9개 간격(1~1440분) BB·일목 위치 특징을 3분 타임라인에 맞춰 분석하고, discover로 매수·매도 규칙을 찾은 뒤 HTML 차트에 해당 체결만 표시한다. simulation_1h.py를 simulation.py로 변경했으며, 파라미터 없이 실행하면 analyze→discover→차트가 한 번에 수행된다. Co-authored-by: Cursor <cursoragent@cursor.com>
663 lines
21 KiB
Python
663 lines
21 KiB
Python
"""
|
|
WLD 볼린저밴드 MTF 전략.
|
|
|
|
기본 타이밍 (모든 봉 동일):
|
|
- 매수: 하단 밴드 상향 돌파 (prev_close <= Lower, close > Lower)
|
|
- 매도: 상단 밴드 상향 돌파 (prev_close < Upper, close >= Upper)
|
|
|
|
여러 봉(3·10·15·30·60·240·1440분)의 BB 상태를 비교해
|
|
실행 봉·확인 봉을 정한 뒤 매매합니다 (MtfBbPolicy).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from config import (
|
|
BB_MIN_WIDTH_PCT,
|
|
DEFAULT_BUY_KRW,
|
|
ENTRY_INTERVAL,
|
|
RANGE_BUY_KRW,
|
|
RSI_BUY_MAX,
|
|
RSI_PERIOD,
|
|
TREND_RANGE_MA_GAP_PCT,
|
|
VOLUME_BUY_RATIO,
|
|
)
|
|
|
|
Trend = Literal["up", "down", "range"]
|
|
Action = Literal["buy", "sell"]
|
|
|
|
# 기본 신호
|
|
SIGNAL_BUY_LOWER = "bb_base_buy_lower"
|
|
SIGNAL_SELL_UPPER = "bb_base_sell_upper"
|
|
SIGNAL_SELL_STOP = "bb_base_sell_stop"
|
|
|
|
BUY_SIGNALS = {SIGNAL_BUY_LOWER}
|
|
SELL_SIGNALS = {SIGNAL_SELL_UPPER, SIGNAL_SELL_STOP}
|
|
|
|
# 봉별 BB 상태 (분석·확인용)
|
|
STATE_SQUEEZE = "squeeze"
|
|
STATE_CROSS_UP_LOWER = "cross_up_lower"
|
|
STATE_CROSS_UP_UPPER = "cross_up_upper"
|
|
STATE_CROSS_DOWN_LOWER = "cross_down_lower"
|
|
STATE_BELOW_LOWER = "below_lower"
|
|
STATE_ABOVE_UPPER = "above_upper"
|
|
STATE_INSIDE = "inside"
|
|
|
|
BUY_TRIGGER_STATE = STATE_CROSS_UP_LOWER
|
|
SELL_TRIGGER_STATE = STATE_CROSS_UP_UPPER
|
|
STOP_TRIGGER_STATE = STATE_CROSS_DOWN_LOWER
|
|
|
|
# 확인 봉에서 매수 허용/차단 상태
|
|
BUY_CONFIRM_OK = {
|
|
STATE_INSIDE,
|
|
STATE_BELOW_LOWER,
|
|
STATE_CROSS_UP_LOWER,
|
|
STATE_SQUEEZE,
|
|
}
|
|
BUY_CONFIRM_BLOCK = {
|
|
STATE_CROSS_UP_UPPER,
|
|
STATE_ABOVE_UPPER,
|
|
STATE_CROSS_DOWN_LOWER,
|
|
}
|
|
SELL_CONFIRM_OK = {
|
|
STATE_INSIDE,
|
|
STATE_ABOVE_UPPER,
|
|
STATE_CROSS_UP_UPPER,
|
|
STATE_CROSS_UP_LOWER,
|
|
STATE_BELOW_LOWER,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class StrategyConfig:
|
|
"""전략 조합 설정 (시뮬 비교·실거래 공용)."""
|
|
|
|
name: str = "default"
|
|
use_mtf: bool = True
|
|
use_regime_switch: bool = True
|
|
use_rsi_filter: bool = True
|
|
rsi_buy_max: float = RSI_BUY_MAX
|
|
use_volume_filter: bool = False
|
|
volume_ratio: float = VOLUME_BUY_RATIO
|
|
use_squeeze_filter: bool = True
|
|
use_stop_loss: bool = True
|
|
allow_buy_in_up: bool = True
|
|
allow_buy_in_range: bool = True
|
|
allow_buy_in_down: bool = False
|
|
use_discovered_rules: bool = False
|
|
|
|
|
|
# HTML 시뮬: discovered_rules.json (python simulation.py discover)
|
|
ACTIVE_CONFIG = StrategyConfig(
|
|
name="discovered_best",
|
|
use_discovered_rules=True,
|
|
use_mtf=False,
|
|
use_regime_switch=False,
|
|
use_rsi_filter=False,
|
|
use_volume_filter=False,
|
|
use_squeeze_filter=False,
|
|
use_stop_loss=False,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class MtfBbPolicy:
|
|
"""
|
|
봉 간격별 BB 비교 후 적용할 매매 정책.
|
|
|
|
buy_interval / sell_interval: 실제 주문 트리거 봉
|
|
buy_confirm_intervals: 매수 시 함께 봐야 할 상위(긴) 봉
|
|
"""
|
|
|
|
buy_interval: int = 3
|
|
sell_interval: int = 3
|
|
buy_confirm_intervals: tuple[int, ...] = (60, 1440)
|
|
sell_confirm_intervals: tuple[int, ...] = (60,)
|
|
name: str = "default_mtf"
|
|
|
|
|
|
ACTIVE_MTF_POLICY = MtfBbPolicy(
|
|
name="3분실행_60·일봉확인",
|
|
buy_interval=3,
|
|
sell_interval=3,
|
|
buy_confirm_intervals=(60, 1440),
|
|
sell_confirm_intervals=(60,),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class TradeSignal:
|
|
"""실시간 1회 매매 신호."""
|
|
|
|
action: Action
|
|
signal: str
|
|
close: float
|
|
trend: Trend
|
|
|
|
|
|
def prepare_entry_df(data: pd.DataFrame) -> pd.DataFrame:
|
|
"""3분봉 보조 지표 추가."""
|
|
df = data.copy()
|
|
delta = df["Close"].diff()
|
|
gain = delta.where(delta > 0, 0.0).rolling(RSI_PERIOD).mean()
|
|
loss = (-delta.where(delta < 0, 0.0)).rolling(RSI_PERIOD).mean()
|
|
rs = gain / loss.replace(0, np.nan)
|
|
df["RSI"] = 100 - (100 / (1 + rs))
|
|
df["VolMA5"] = df["Volume"].rolling(5).mean()
|
|
ma = df["MA"].replace(0, np.nan)
|
|
df["BB_Width"] = (df["Upper"] - df["Lower"]) / ma * 100
|
|
return df
|
|
|
|
|
|
def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
|
|
"""일봉·1시간봉 기준 추세."""
|
|
if len(df_1d) < 20 or len(df_1h) < 40:
|
|
return "range"
|
|
|
|
d_close = float(df_1d["Close"].iloc[-1])
|
|
d_ma20 = float(df_1d["MA20"].iloc[-1])
|
|
h_close = float(df_1h["Close"].iloc[-1])
|
|
h_ma20 = float(df_1h["MA20"].iloc[-1])
|
|
h_ma40 = float(df_1h["MA40"].iloc[-1])
|
|
|
|
if h_ma40 == 0:
|
|
return "range"
|
|
|
|
ma_gap_pct = abs(h_ma20 - h_ma40) / h_ma40 * 100
|
|
if ma_gap_pct < TREND_RANGE_MA_GAP_PCT:
|
|
return "range"
|
|
|
|
if d_close > d_ma20 and h_ma20 > h_ma40 and h_close > h_ma20:
|
|
return "up"
|
|
if d_close < d_ma20 and h_ma20 < h_ma40 and h_close < h_ma20:
|
|
return "down"
|
|
return "range"
|
|
|
|
|
|
def get_trend_at(
|
|
df_1d: pd.DataFrame, df_1h: pd.DataFrame, ts: pd.Timestamp
|
|
) -> Trend:
|
|
"""특정 시점까지의 데이터로 추세 판별 (백테스트용)."""
|
|
d = df_1d[df_1d.index <= ts]
|
|
h = df_1h[df_1h.index <= ts]
|
|
if d.empty or h.empty:
|
|
return "range"
|
|
return get_trend(d, h)
|
|
|
|
|
|
def _bb_squeeze(bb_width: float) -> bool:
|
|
return bb_width < BB_MIN_WIDTH_PCT
|
|
|
|
|
|
def classify_bb_state_at(
|
|
df: pd.DataFrame,
|
|
index_pos: int,
|
|
cfg: StrategyConfig | None = None,
|
|
) -> str:
|
|
"""
|
|
한 봉의 볼린저밴드 상태를 분류합니다.
|
|
|
|
Returns:
|
|
cross_up_lower, cross_up_upper, cross_down_lower, below_lower,
|
|
above_upper, inside, squeeze
|
|
"""
|
|
cfg = cfg or ACTIVE_CONFIG
|
|
if index_pos < 0:
|
|
index_pos = len(df) + index_pos
|
|
if index_pos < 1 or index_pos >= len(df):
|
|
return STATE_INSIDE
|
|
|
|
close = float(df["Close"].iloc[index_pos])
|
|
prev_close = float(df["Close"].iloc[index_pos - 1])
|
|
lower = float(df["Lower"].iloc[index_pos])
|
|
prev_lower = float(df["Lower"].iloc[index_pos - 1])
|
|
upper = float(df["Upper"].iloc[index_pos])
|
|
prev_upper = float(df["Upper"].iloc[index_pos - 1])
|
|
bb_width = float(df["BB_Width"].iloc[index_pos]) if "BB_Width" in df.columns else 999.0
|
|
|
|
if cfg.use_squeeze_filter and _bb_squeeze(bb_width):
|
|
return STATE_SQUEEZE
|
|
if prev_close <= prev_lower and close > lower:
|
|
return STATE_CROSS_UP_LOWER
|
|
if prev_close < prev_upper and close >= upper:
|
|
return STATE_CROSS_UP_UPPER
|
|
if prev_close >= prev_lower and close < lower:
|
|
return STATE_CROSS_DOWN_LOWER
|
|
if close < lower:
|
|
return STATE_BELOW_LOWER
|
|
if close > upper:
|
|
return STATE_ABOVE_UPPER
|
|
return STATE_INSIDE
|
|
|
|
|
|
def get_latest_bb_state(df: pd.DataFrame, cfg: StrategyConfig | None = None) -> str:
|
|
"""마지막 봉의 BB 상태."""
|
|
df = prepare_entry_df(df)
|
|
if len(df) < 2:
|
|
return STATE_INSIDE
|
|
return classify_bb_state_at(df, -1, cfg)
|
|
|
|
|
|
def _allow_buy(trend: Trend, cfg: StrategyConfig) -> bool:
|
|
"""레짐·MTF에 따른 매수 허용 여부."""
|
|
if cfg.use_regime_switch:
|
|
if trend == "down":
|
|
return cfg.allow_buy_in_down
|
|
if trend == "range":
|
|
return cfg.allow_buy_in_range
|
|
if trend == "up":
|
|
return cfg.allow_buy_in_up
|
|
if cfg.use_mtf and trend == "down":
|
|
return False
|
|
return True
|
|
|
|
|
|
def evaluate_interval_bb(
|
|
symbol: str,
|
|
df_entry: pd.DataFrame,
|
|
config: StrategyConfig | None = None,
|
|
) -> TradeSignal | None:
|
|
"""
|
|
단일 봉 간격에서 BB 기본 규칙만으로 신호 1건.
|
|
|
|
매도 우선 → 매수.
|
|
"""
|
|
cfg = config or ACTIVE_CONFIG
|
|
df = prepare_entry_df(df_entry)
|
|
if len(df) < 22:
|
|
return None
|
|
|
|
state = classify_bb_state_at(df, -1, cfg)
|
|
close = float(df["Close"].iloc[-1])
|
|
trend: Trend = "range"
|
|
|
|
if cfg.use_stop_loss and state == STATE_CROSS_DOWN_LOWER:
|
|
return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend)
|
|
if state == STATE_CROSS_UP_UPPER:
|
|
return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend)
|
|
if state == STATE_CROSS_UP_LOWER:
|
|
if cfg.use_rsi_filter:
|
|
rsi = float(df["RSI"].iloc[-1])
|
|
if not np.isnan(rsi) and rsi > cfg.rsi_buy_max:
|
|
return None
|
|
if cfg.use_volume_filter:
|
|
vol = float(df["Volume"].iloc[-1])
|
|
vol_ma = float(df["VolMA5"].iloc[-1])
|
|
if vol_ma > 0 and vol < vol_ma * cfg.volume_ratio:
|
|
return None
|
|
return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend)
|
|
return None
|
|
|
|
|
|
def _confirm_buy(states: dict[int, str], policy: MtfBbPolicy) -> bool:
|
|
"""상위 봉 상태가 매수에 적합한지."""
|
|
for iv in policy.buy_confirm_intervals:
|
|
st = states.get(iv)
|
|
if st is None:
|
|
return False
|
|
if st in BUY_CONFIRM_BLOCK:
|
|
return False
|
|
if st not in BUY_CONFIRM_OK and st != STATE_SQUEEZE:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _confirm_sell(states: dict[int, str], policy: MtfBbPolicy) -> bool:
|
|
"""상위 봉 상태가 매도에 적합한지."""
|
|
for iv in policy.sell_confirm_intervals:
|
|
st = states.get(iv)
|
|
if st is None:
|
|
continue
|
|
if st in {STATE_CROSS_DOWN_LOWER, STATE_BELOW_LOWER}:
|
|
return False
|
|
return True
|
|
|
|
|
|
def evaluate_mtf_bb(
|
|
symbol: str,
|
|
frames: dict[int, pd.DataFrame],
|
|
df_1d: pd.DataFrame,
|
|
df_1h: pd.DataFrame,
|
|
policy: MtfBbPolicy | None = None,
|
|
config: StrategyConfig | None = None,
|
|
) -> TradeSignal | None:
|
|
"""
|
|
여러 봉의 BB 상태를 비교해 최종 매수/매도 1건을 결정합니다.
|
|
|
|
1) 각 봉 최신 BB 상태 분류
|
|
2) policy.sell_interval 에서 손절/상단돌파 → 매도
|
|
3) policy.buy_interval 에서 하단돌파 + 확인봉 OK → 매수
|
|
4) 일봉·1시간 추세로 하락장 매수 차단 (config)
|
|
"""
|
|
cfg = config or ACTIVE_CONFIG
|
|
policy = policy or ACTIVE_MTF_POLICY
|
|
if policy.buy_interval not in frames or policy.sell_interval not in frames:
|
|
return None
|
|
|
|
states = {iv: get_latest_bb_state(frames[iv], cfg) for iv in frames}
|
|
trend = get_trend(df_1d, df_1h)
|
|
|
|
sell_df = prepare_entry_df(frames[policy.sell_interval])
|
|
sell_close = float(sell_df["Close"].iloc[-1])
|
|
sell_state = states[policy.sell_interval]
|
|
|
|
if cfg.use_stop_loss and sell_state == STATE_CROSS_DOWN_LOWER:
|
|
if not policy.sell_confirm_intervals or _confirm_sell(states, policy):
|
|
return TradeSignal("sell", SIGNAL_SELL_STOP, sell_close, trend)
|
|
|
|
if sell_state == STATE_CROSS_UP_UPPER:
|
|
if not policy.sell_confirm_intervals or _confirm_sell(states, policy):
|
|
return TradeSignal("sell", SIGNAL_SELL_UPPER, sell_close, trend)
|
|
|
|
buy_state = states[policy.buy_interval]
|
|
if buy_state != STATE_CROSS_UP_LOWER:
|
|
return None
|
|
if not _allow_buy(trend, cfg):
|
|
return None
|
|
if not _confirm_buy(states, policy):
|
|
return None
|
|
|
|
buy_close = float(frames[policy.buy_interval]["Close"].iloc[-1])
|
|
sig = evaluate_interval_bb(symbol, frames[policy.buy_interval], cfg)
|
|
if sig and sig.action == "buy":
|
|
return TradeSignal("buy", sig.signal, buy_close, trend)
|
|
return None
|
|
|
|
|
|
def evaluate(
|
|
symbol: str,
|
|
df_entry: pd.DataFrame,
|
|
df_1h: pd.DataFrame,
|
|
df_1d: pd.DataFrame,
|
|
config: StrategyConfig | None = None,
|
|
trend_override: Trend | None = None,
|
|
frames: dict[int, pd.DataFrame] | None = None,
|
|
policy: MtfBbPolicy | None = None,
|
|
) -> TradeSignal | None:
|
|
"""
|
|
매매 신호 1건.
|
|
|
|
frames 가 있으면 evaluate_mtf_bb, 없으면 df_entry 단일 봉 BB.
|
|
"""
|
|
if frames:
|
|
return evaluate_mtf_bb(symbol, frames, df_1d, df_1h, policy, config)
|
|
cfg = config or ACTIVE_CONFIG
|
|
trend = trend_override if trend_override else get_trend(df_1d, df_1h)
|
|
sig = evaluate_interval_bb(symbol, df_entry, cfg)
|
|
if sig is None:
|
|
return None
|
|
if sig.action == "buy" and not _allow_buy(trend, cfg):
|
|
return None
|
|
return TradeSignal(sig.action, sig.signal, sig.close, trend)
|
|
|
|
|
|
def annotate_interval_signals(
|
|
symbol: str,
|
|
data: pd.DataFrame,
|
|
simulation: bool | None = None,
|
|
config: StrategyConfig | None = None,
|
|
) -> pd.DataFrame:
|
|
"""단일 봉 간격 전체에 BB 신호 기록 (봉별 백테스트용)."""
|
|
cfg = config or ACTIVE_CONFIG
|
|
df = prepare_entry_df(data)
|
|
df["signal"] = ""
|
|
df["point"] = 0
|
|
df["action"] = ""
|
|
df["bb_state"] = ""
|
|
|
|
for i in range(21, len(df)):
|
|
st = classify_bb_state_at(df, i, cfg)
|
|
df.at[df.index[i], "bb_state"] = st
|
|
sig = evaluate_interval_bb(symbol, df.iloc[: i + 1], cfg)
|
|
if sig:
|
|
df.at[df.index[i], "signal"] = sig.signal
|
|
df.at[df.index[i], "point"] = 1
|
|
df.at[df.index[i], "action"] = sig.action
|
|
|
|
if not simulation:
|
|
sig = evaluate_interval_bb(symbol, df, cfg)
|
|
if sig:
|
|
df.at[df.index[-1], "signal"] = sig.signal
|
|
df.at[df.index[-1], "point"] = 1
|
|
df.at[df.index[-1], "action"] = sig.action
|
|
df.at[df.index[-1], "bb_state"] = get_latest_bb_state(df, cfg)
|
|
|
|
return df
|
|
|
|
|
|
def _slice_frames_at(frames: dict[int, pd.DataFrame], ts: pd.Timestamp) -> dict[int, pd.DataFrame]:
|
|
"""시점 ts 이하 봉만 남긴 frames."""
|
|
out: dict[int, pd.DataFrame] = {}
|
|
for iv, df in frames.items():
|
|
part = df[df.index <= ts]
|
|
if len(part) >= 22:
|
|
out[iv] = part
|
|
return out
|
|
|
|
|
|
def annotate_mtf_signals(
|
|
symbol: str,
|
|
frames: dict[int, pd.DataFrame],
|
|
df_1d: pd.DataFrame,
|
|
df_1h: pd.DataFrame,
|
|
policy: MtfBbPolicy | None = None,
|
|
config: StrategyConfig | None = None,
|
|
) -> pd.DataFrame:
|
|
"""실행 봉(buy_interval) 타임라인에 MTF BB 신호 기록."""
|
|
policy = policy or ACTIVE_MTF_POLICY
|
|
cfg = config or ACTIVE_CONFIG
|
|
if policy.buy_interval not in frames:
|
|
raise ValueError(f"buy_interval {policy.buy_interval} 데이터 없음")
|
|
|
|
df = prepare_entry_df(frames[policy.buy_interval].copy())
|
|
df["signal"] = ""
|
|
df["point"] = 0
|
|
df["action"] = ""
|
|
df["trend"] = ""
|
|
df["bb_state"] = ""
|
|
|
|
for i in range(21, len(df)):
|
|
ts = df.index[i]
|
|
sliced = _slice_frames_at(frames, ts)
|
|
if policy.buy_interval not in sliced:
|
|
continue
|
|
df.at[ts, "bb_state"] = get_latest_bb_state(sliced[policy.buy_interval], cfg)
|
|
trend_at = get_trend_at(df_1d, df_1h, ts)
|
|
sig = evaluate_mtf_bb(symbol, sliced, df_1d, df_1h, policy, cfg)
|
|
if sig:
|
|
df.at[ts, "signal"] = sig.signal
|
|
df.at[ts, "point"] = 1
|
|
df.at[ts, "action"] = sig.action
|
|
df.at[ts, "trend"] = sig.trend
|
|
|
|
return df
|
|
|
|
|
|
def evaluate_discovered_live(
|
|
symbol: str,
|
|
frames: dict[int, pd.DataFrame],
|
|
df_1d: pd.DataFrame,
|
|
df_1h: pd.DataFrame,
|
|
balances: dict,
|
|
) -> TradeSignal | None:
|
|
"""
|
|
최신 3분 봉 시점에서 discovered_rules + 전 봉 BB·일목 조합으로 신호 1건.
|
|
"""
|
|
from candle_features import build_master_feature_matrix
|
|
from rule_discovery import buy_mask, load_rules, rules_have_buy, sell_mask
|
|
|
|
rules = load_rules()
|
|
if rules is None or not rules_have_buy(rules):
|
|
return None
|
|
|
|
matrix = build_master_feature_matrix(frames)
|
|
if len(matrix) < 22:
|
|
return None
|
|
last = matrix.iloc[[-1]]
|
|
ts = last.index[-1]
|
|
close = float(last["Close"].iloc[-1])
|
|
trend = get_trend_at(df_1d, df_1h, ts)
|
|
|
|
position = float(balances.get(symbol, {}).get("balance", 0) or 0)
|
|
if position >= 1.0:
|
|
if rules.sell_stop and sell_mask(last, rules, stop=True)[0]:
|
|
return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend)
|
|
if sell_mask(last, rules, stop=False)[0]:
|
|
return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend)
|
|
return None
|
|
|
|
if buy_mask(last, rules)[0]:
|
|
return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend)
|
|
return None
|
|
|
|
|
|
def annotate_discovered_signals(
|
|
symbol: str,
|
|
frames: dict[int, pd.DataFrame],
|
|
df_1d: pd.DataFrame,
|
|
df_1h: pd.DataFrame,
|
|
rules=None,
|
|
data: pd.DataFrame | None = None,
|
|
) -> pd.DataFrame:
|
|
"""탐색된 다봉·캔들 규칙으로 3분 타임라인(전체 봉)에 신호 기록."""
|
|
from candle_features import build_master_feature_matrix
|
|
from rule_discovery import generate_trade_events, load_rules, rules_have_buy
|
|
|
|
rule_set = rules or load_rules()
|
|
if rule_set is None or not rules_have_buy(rule_set):
|
|
raise FileNotFoundError(
|
|
"discovered_rules.json 없거나 매수 규칙이 비어 있습니다. "
|
|
"python simulation.py 실행"
|
|
)
|
|
|
|
entry = frames.get(ENTRY_INTERVAL)
|
|
if entry is None or entry.empty:
|
|
raise ValueError("3분봉 데이터가 없습니다.")
|
|
|
|
matrix = build_master_feature_matrix(frames).iloc[21:].copy()
|
|
df = prepare_entry_df(data if data is not None else entry)
|
|
df["signal"] = ""
|
|
df["point"] = 0
|
|
df["action"] = ""
|
|
df["trend"] = ""
|
|
|
|
for ts, action, sig in generate_trade_events(matrix, rule_set):
|
|
if ts not in df.index:
|
|
continue
|
|
trend_at = get_trend_at(df_1d, df_1h, ts)
|
|
df.at[ts, "signal"] = sig
|
|
df.at[ts, "point"] = 1
|
|
df.at[ts, "action"] = action
|
|
df.at[ts, "trend"] = trend_at
|
|
|
|
return df
|
|
|
|
|
|
def annotate_signals(
|
|
symbol: str,
|
|
data: pd.DataFrame,
|
|
simulation: bool | None = None,
|
|
df_1h: pd.DataFrame | None = None,
|
|
df_1d: pd.DataFrame | None = None,
|
|
config: StrategyConfig | None = None,
|
|
frames: dict[int, pd.DataFrame] | None = None,
|
|
) -> pd.DataFrame:
|
|
"""3분봉 구간 전체에 signal/point/action/trend 기록."""
|
|
cfg = config or ACTIVE_CONFIG
|
|
if cfg.use_discovered_rules and frames:
|
|
h1d = df_1d if df_1d is not None and not df_1d.empty else data
|
|
h1h = df_1h if df_1h is not None and not df_1h.empty else data
|
|
return annotate_discovered_signals(symbol, frames, h1d, h1h, data=data)
|
|
df = prepare_entry_df(data)
|
|
df["signal"] = ""
|
|
df["point"] = 0
|
|
df["action"] = ""
|
|
df["trend"] = ""
|
|
|
|
htf_1h = df_1h if df_1h is not None else df
|
|
htf_1d = df_1d if df_1d is not None else df
|
|
|
|
for i in range(21, len(df)):
|
|
ts = df.index[i]
|
|
trend_at = get_trend_at(htf_1d, htf_1h, ts) if simulation else get_trend(htf_1d, htf_1h)
|
|
sig = evaluate(
|
|
symbol,
|
|
df.iloc[: i + 1],
|
|
htf_1h,
|
|
htf_1d,
|
|
config=cfg,
|
|
trend_override=trend_at if simulation else None,
|
|
)
|
|
if sig:
|
|
df.at[df.index[i], "signal"] = sig.signal
|
|
df.at[df.index[i], "point"] = 1
|
|
df.at[df.index[i], "action"] = sig.action
|
|
df.at[df.index[i], "trend"] = sig.trend
|
|
|
|
if not simulation:
|
|
live = evaluate(symbol, df, htf_1h, htf_1d, config=cfg)
|
|
if live:
|
|
df.at[df.index[-1], "signal"] = live.signal
|
|
df.at[df.index[-1], "point"] = 1
|
|
df.at[df.index[-1], "action"] = live.action
|
|
df.at[df.index[-1], "trend"] = live.trend
|
|
|
|
return df
|
|
|
|
|
|
def comparison_presets() -> list[StrategyConfig]:
|
|
"""기법 조합 비교용 프리셋."""
|
|
return [
|
|
StrategyConfig(name="01_기본_BB만", use_mtf=False, use_regime_switch=False,
|
|
use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=False),
|
|
StrategyConfig(name="02_기본+손절", use_mtf=False, use_regime_switch=False,
|
|
use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
|
|
StrategyConfig(name="03_기본+MTF", use_mtf=True, use_regime_switch=False,
|
|
use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
|
|
StrategyConfig(name="04_기본+MTF+스퀴즈", use_mtf=True, use_regime_switch=False,
|
|
use_rsi_filter=False, use_squeeze_filter=True, use_stop_loss=True),
|
|
StrategyConfig(name="05_기본+MTF+RSI", use_mtf=True, use_regime_switch=False,
|
|
use_rsi_filter=True, use_squeeze_filter=False, use_stop_loss=True),
|
|
StrategyConfig(name="06_기본+MTF+거래량", use_mtf=True, use_regime_switch=False,
|
|
use_rsi_filter=False, use_volume_filter=True, volume_ratio=1.1,
|
|
use_squeeze_filter=False, use_stop_loss=True),
|
|
StrategyConfig(name="07_레짐스위치", use_mtf=True, use_regime_switch=True,
|
|
use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
|
|
StrategyConfig(name="08_레짐+RSI+스퀴즈", use_mtf=True, use_regime_switch=True,
|
|
use_rsi_filter=True, use_squeeze_filter=True, use_stop_loss=True),
|
|
StrategyConfig(name="09_풀필터", use_mtf=True, use_regime_switch=True,
|
|
use_rsi_filter=True, use_volume_filter=True, volume_ratio=1.1,
|
|
use_squeeze_filter=True, use_stop_loss=True),
|
|
]
|
|
|
|
|
|
def get_signal_action(signal: str) -> Action | None:
|
|
if signal in BUY_SIGNALS:
|
|
return "buy"
|
|
if signal in SELL_SIGNALS:
|
|
return "sell"
|
|
return None
|
|
|
|
|
|
def get_buy_amount(symbol: str, signal: str, close: float, trend: Trend = "up") -> int:
|
|
if trend == "range":
|
|
return RANGE_BUY_KRW
|
|
return DEFAULT_BUY_KRW
|
|
|
|
|
|
def get_sell_ratio(symbol: str, signal: str) -> float:
|
|
return 1.0
|
|
|
|
|
|
def allowed_inverse_sell_signals() -> set[str]:
|
|
return set()
|
|
|
|
|
|
def should_double_buy(symbol: str, signal: str, data: pd.DataFrame) -> bool:
|
|
return False
|