WLD 전용 BB MTF 전략 및 HTML 시뮬 최적화

- strategy.py, candle_features.py, rule_discovery.py로 다봉 BB·캔들 규칙 탐색
- simulation_1h.py: discover 명령, 기본 BB vs 탐색 규칙 자동 선택, Plotly Y축 줌
- mtf_bb.py, downloader/monitor 정리, 다코인 파일 제거

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-27 19:14:44 +09:00
parent 1c12a6c94a
commit 7d53090034
42 changed files with 2941 additions and 1650 deletions

624
strategy.py Normal file
View File

@@ -0,0 +1,624 @@
"""
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_1h.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 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_1h.py discover 실행"
)
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