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

View File

@@ -1,333 +1,671 @@
"""
WLD 3분 BB 시뮬레이션.
기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도.
수수료 반영, 레짐/필터 조합 비교 지원.
python simulation_1h.py # discovered_rules HTML 차트 (기본)
python simulation_1h.py discover # 모든 봉 특징 탐색 → discovered_rules.json
python simulation_1h.py compare # 9종 조합 수익률 순위
python simulation_1h.py mtf # 봉별 BB 비교 + MTF 시뮬
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
import pandas as pd
import yfinance as yf
import plotly.graph_objs as go
from plotly import subplots
import plotly.io as pio
from datetime import datetime
pio.renderers.default = 'browser'
from plotly import subplots
from config import *
pio.renderers.default = "browser"
from config import (
BUY_COOLDOWN_SEC,
COIN_NAME,
ENTRY_INTERVAL,
SELL_COOLDOWN_SEC,
SIM_INITIAL_CASH_KRW,
SIM_MIN_ORDER_KRW,
SYMBOL,
TRADING_FEE_RATE,
TREND_INTERVAL_1D,
TREND_INTERVAL_1H,
)
from monitor import Monitor
import strategy
@dataclass
class SimTrade:
dt: pd.Timestamp
action: str
signal: str
price: float
krw: float
fee: float
quantity: float
pnl: float | None
cash_after: float
total_asset: float
@dataclass
class SimResult:
config_name: str
trades: list[SimTrade]
initial_cash: float
final_cash: float
final_coin_qty: float
final_price: float
realized_pnl: float
total_fees: float
final_asset: float
total_return_pct: float
trade_count: int
win_count: int
def run_backtest(
df_3m: pd.DataFrame,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
config_name: str = "",
initial_cash: float = SIM_INITIAL_CASH_KRW,
min_order_krw: float = SIM_MIN_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> SimResult:
"""신호 순서대로 현물 매수/매도 시뮬레이션 (수수료 차감)."""
cash = float(initial_cash)
coin_qty = 0.0
cost_basis = 0.0
realized_pnl = 0.0
total_fees = 0.0
win_count = 0
trades: list[SimTrade] = []
last_buy_ts: pd.Timestamp | None = None
last_sell_ts: pd.Timestamp | None = None
signals = df_3m[df_3m["point"] == 1].sort_index()
for ts, row in signals.iterrows():
price = float(row["Close"])
action = str(row.get("action", ""))
signal_name = str(row.get("signal", ""))
if price <= 0:
continue
trend_at = str(row.get("trend", "")) or strategy.get_trend_at(df_1d, df_1h, ts)
if trend_at not in ("up", "down", "range"):
trend_at = strategy.get_trend_at(df_1d, df_1h, ts)
if action == "buy":
if last_buy_ts is not None:
if (ts - last_buy_ts).total_seconds() < BUY_COOLDOWN_SEC:
continue
buy_krw = float(
strategy.get_buy_amount(SYMBOL, signal_name, price, trend_at)
)
buy_krw = max(min_order_krw, min(buy_krw, cash))
fee = buy_krw * fee_rate
total_cost = buy_krw + fee
if buy_krw < min_order_krw or cash < total_cost:
continue
qty = buy_krw / price
cash -= total_cost
total_fees += fee
cost_basis += buy_krw
coin_qty += qty
last_buy_ts = ts
trades.append(
SimTrade(
dt=ts,
action="매수",
signal=signal_name,
price=price,
krw=buy_krw,
fee=fee,
quantity=qty,
pnl=None,
cash_after=cash,
total_asset=cash + coin_qty * price,
)
)
continue
if action == "sell":
if coin_qty <= 0:
continue
if last_sell_ts is not None:
if (ts - last_sell_ts).total_seconds() < SELL_COOLDOWN_SEC:
continue
ratio = strategy.get_sell_ratio(SYMBOL, signal_name)
sell_qty = min(coin_qty * ratio, coin_qty)
sell_krw = sell_qty * price
if sell_krw < min_order_krw:
if coin_qty * price < min_order_krw:
continue
sell_qty = coin_qty
sell_krw = sell_qty * price
fee = sell_krw * fee_rate
net = sell_krw - fee
avg_cost = cost_basis / coin_qty
sold_cost = avg_cost * sell_qty
pnl = net - sold_cost
cash += net
total_fees += fee
cost_basis -= sold_cost
coin_qty -= sell_qty
realized_pnl += pnl
if pnl > 0:
win_count += 1
if coin_qty < 1e-12:
coin_qty = 0.0
cost_basis = 0.0
last_sell_ts = ts
trades.append(
SimTrade(
dt=ts,
action="매도",
signal=signal_name,
price=price,
krw=sell_krw,
fee=fee,
quantity=sell_qty,
pnl=pnl,
cash_after=cash,
total_asset=cash + coin_qty * price,
)
)
final_price = float(df_3m["Close"].iloc[-1])
final_asset = cash + coin_qty * final_price
sell_trades = sum(1 for t in trades if t.action == "매도")
return SimResult(
config_name=config_name,
trades=trades,
initial_cash=initial_cash,
final_cash=cash,
final_coin_qty=coin_qty,
final_price=final_price,
realized_pnl=realized_pnl,
total_fees=total_fees,
final_asset=final_asset,
total_return_pct=(final_asset - initial_cash) / initial_cash * 100
if initial_cash > 0
else 0.0,
trade_count=len(trades),
win_count=win_count if sell_trades else 0,
)
def print_backtest_report(result: SimResult) -> None:
fee_pct = TRADING_FEE_RATE * 100
print("\n" + "=" * 80)
print(
f"[{result.config_name}] 시작 {result.initial_cash:,.0f}원 | "
f"최소주문 {SIM_MIN_ORDER_KRW:,.0f}원 | 수수료 {fee_pct:.3f}%/쪽"
)
print("=" * 80)
if not result.trades:
print("체결 없음")
else:
print(
f"{'일시':<18} {'구분':<4} {'신호':<22} {'가격':>9} {'금액':>10} "
f"{'수수료':>8} {'수익':>10}"
)
print("-" * 80)
for t in result.trades:
pnl_s = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
print(
f"{t.dt.strftime('%Y-%m-%d %H:%M'):<18} {t.action:<4} {t.signal:<22} "
f"{t.price:>9,.2f} {t.krw:>10,.0f} {t.fee:>8,.0f} {pnl_s:>10}"
)
print("-" * 80)
sells = sum(1 for t in result.trades if t.action == "매도")
win_rate = result.win_count / sells * 100 if sells else 0.0
print(f"거래 횟수: {result.trade_count} (매도 {sells}회) | 승률: {win_rate:.1f}%")
print(f"수수료 합계: {result.total_fees:,.0f}")
print(f"실현 손익(수수료 반영): {result.realized_pnl:+,.0f}")
print(
f"최종 자산: {result.final_asset:,.0f}원 | "
f"총수익: {result.final_asset - result.initial_cash:+,.0f}"
f"({result.total_return_pct:+.2f}%)"
)
print("=" * 80)
def run_comparison(df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame) -> None:
"""기법 조합별 수익률 비교 (수수료 포함)."""
print(f"\n{'='*80}")
print(f"전략 조합 비교 — {SYMBOL} 3분 | {df_3m.index[0]} ~ {df_3m.index[-1]}")
print(f"시작 {SIM_INITIAL_CASH_KRW:,}원 | 수수료 {TRADING_FEE_RATE*100:.3f}%/매수·매도")
print(f"{'='*80}")
print(
f"{'순위':<4} {'조합':<22} {'수익률':>9} {'최종자산':>12} "
f"{'거래':>6} {'승률':>7} {'수수료':>10}"
)
print("-" * 80)
rows: list[tuple[SimResult, strategy.StrategyConfig]] = []
for cfg in strategy.comparison_presets():
df_sig = strategy.annotate_signals(
SYMBOL,
df_3m.copy(),
simulation=True,
df_1h=df_1h,
df_1d=df_1d,
config=cfg,
)
res = run_backtest(df_sig, df_1d, df_1h, config_name=cfg.name)
rows.append((res, cfg))
rows.sort(key=lambda x: x[0].total_return_pct, reverse=True)
for rank, (res, cfg) in enumerate(rows, 1):
sells = sum(1 for t in res.trades if t.action == "매도")
wr = res.win_count / sells * 100 if sells else 0.0
print(
f"{rank:<4} {res.config_name:<22} {res.total_return_pct:>+8.2f}% "
f"{res.final_asset:>12,.0f} {res.trade_count:>6} {wr:>6.1f}% "
f"{res.total_fees:>10,.0f}"
)
best_res, best_cfg = rows[0]
print("-" * 80)
print(f"1위: {best_cfg.name} ({best_res.total_return_pct:+.2f}%)")
print(
"실거래 적용: strategy.ACTIVE_CONFIG 를 1위 조합으로 맞추세요 "
"(현재 ACTIVE_CONFIG.name=%s)" % strategy.ACTIVE_CONFIG.name
)
print(f"{'='*80}\n")
class Simulation:
def render_plotly(self, symbol: str, interval_minutes: int, data: pd.DataFrame, inverseData: pd.DataFrame) -> None:
fig = subplots.make_subplots(
rows=3, cols=1,
subplot_titles=("캔들", "이격도/거래량", "장기 이격도"),
shared_xaxes=True, horizontal_spacing=0.03, vertical_spacing=0.03,
row_heights=[0.6, 0.2, 0.2]
)
# Row 1: 캔들 + 이동평균 + 볼린저
fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name='캔들'), row=1, col=1)
for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
if ma_col in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data[ma_col], name=ma_col, mode='lines', line=dict(color=color, width=1)), row=1, col=1)
if 'Lower' in data.columns and 'Upper' in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data['Lower'], name='볼린저 하단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
fig.add_trace(go.Scatter(x=data.index, y=data['Upper'], name='볼린저 상단', mode='lines', line=dict(color='grey', width=1, dash='dot')), row=1, col=1)
# 매수 포인트
for sig, color in [('movingaverage','red'),('deviation40','orange'),('Deviation720','blue'),('deviation1440','purple'),('fall_6p','black')]:
pts = data[(data['point']==1) & (data['signal']==sig)]
if len(pts)>0:
fig.add_trace(go.Scatter(x=pts.index, y=pts['Close'], mode='markers', name=f'{sig} 매수', marker=dict(color=color, size=8, symbol='circle')), row=1, col=1)
# 매도 포인트: inverseData의 buy 신호 중 fall_6p, deviation40만 일반 그래프 가격축에 매도로 표시
inv_sell_pts = inverseData[(inverseData['point']==1) & (inverseData['signal'].isin(['deviation40','fall_6p']))]
if len(inv_sell_pts)>0:
idx = inv_sell_pts.index.intersection(data.index)
if len(idx)>0:
fig.add_trace(
go.Scatter(
x=idx,
y=data.loc[idx, 'Close'],
mode='markers',
name='매도',
marker=dict(color='orange', size=10, symbol='triangle-down')
),
row=1, col=1
)
# Row 2: 이격도 + 거래량
for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
if dev_col in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=dev_col, mode='lines', line=dict(color=color, width=width)), row=2, col=1)
if 'Volume' in data.columns:
fig.add_trace(go.Bar(x=data.index, y=data['Volume'], name='거래량', marker_color='lightgray', opacity=0.5), row=2, col=1)
# Row 3: 장기 이격도 및 기준선
for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
if dev_col in data.columns:
fig.add_trace(go.Scatter(x=data.index, y=data[dev_col], name=f'{dev_col}(장기)', mode='lines', line=dict(color=color, width=2)), row=3, col=1)
for h, color in [(90,'red'),(95,'green'),(100,'black')]:
fig.add_hline(y=h, line_width=1, line_dash='dash', line_color=color, row=3, col=1)
# ----------------- 인버스용 트레이스 (초기 숨김) -----------------
n_orig = len(fig.data)
# Row 1: 캔들/MA/볼린저 (inverseData)
fig.add_trace(go.Candlestick(x=inverseData.index, open=inverseData['Open'], high=inverseData['High'], low=inverseData['Low'], close=inverseData['Close'], name='캔들(인버스)', showlegend=True, visible=False), row=1, col=1)
for ma_col, color in [('MA5','red'),('MA20','blue'),('MA40','green'),('MA120','purple'),('MA200','brown'),('MA240','darkred'),('MA720','cyan'),('MA1440','magenta')]:
if ma_col in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[ma_col], name=f'{ma_col}(인버스)', mode='lines', line=dict(color=color, width=1), showlegend=True, visible=False), row=1, col=1)
if 'Lower' in inverseData.columns and 'Upper' in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Lower'], name='볼린저 하단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData['Upper'], name='볼린저 상단(인버스)', mode='lines', line=dict(color='grey', width=1, dash='dot'), showlegend=True, visible=False), row=1, col=1)
# 인버스 매수 포인트: fall_6p, deviation40만 표시
for sig, color in [('deviation40','orange'),('fall_6p','black')]:
pts_inv = inverseData[(inverseData['point']==1) & (inverseData['signal']==sig)]
if len(pts_inv)>0:
fig.add_trace(go.Scatter(x=pts_inv.index, y=inverseData.loc[pts_inv.index,'Close'], mode='markers', name=f'{sig} 매수(인버스)', marker=dict(color=color, size=8, symbol='circle'), showlegend=True, visible=False), row=1, col=1)
# 인버스 보기에서의 매도 포인트: 일반 그래프의 매수를 인버스 그래프의 매도로 표시 (모든 매수 신호 반영)
normal_to_inv_sell = data[(data['point']==1)]
if len(normal_to_inv_sell) > 0:
idx2 = normal_to_inv_sell.index.intersection(inverseData.index)
if len(idx2) > 0:
fig.add_trace(
go.Scatter(
x=idx2,
y=inverseData.loc[idx2, 'Close'],
mode='markers',
name='매도(일반→인버스)',
marker=dict(color='orange', size=10, symbol='triangle-down'),
showlegend=True,
visible=False
),
row=1, col=1
)
# Row 2: 이격도 + 거래량 (inverseData)
for dev_col, color, width in [('Deviation5','red',1),('Deviation20','blue',1),('Deviation40','green',2),('Deviation120','purple',1),('Deviation200','brown',1),('Deviation720','darkred',2),('Deviation720','cyan',1),('Deviation1440','magenta',1)]:
if dev_col in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(인버스)', mode='lines', line=dict(color=color, width=width), showlegend=True, visible=False), row=2, col=1)
if 'Volume' in inverseData.columns:
fig.add_trace(go.Bar(x=inverseData.index, y=inverseData['Volume'], name='거래량(인버스)', marker_color='lightgray', opacity=0.5, showlegend=True, visible=False), row=2, col=1)
# Row 3: 장기 이격도 (inverseData)
for dev_col, color in [('Deviation720','darkred'),('Deviation1440','magenta')]:
if dev_col in inverseData.columns:
fig.add_trace(go.Scatter(x=inverseData.index, y=inverseData[dev_col], name=f'{dev_col}(장기-인버스)', mode='lines', line=dict(color=color, width=2), showlegend=True, visible=False), row=3, col=1)
n_total = len(fig.data)
n_inv = n_total - n_orig
visible_orig = [True]*n_orig + [False]*n_inv
visible_inv = [False]*n_orig + [True]*n_inv
legendtitle_orig = {'text': '일반 그래프'}
legendtitle_inv = {'text': '인버스 그래프'}
fig.update_layout(
height=1000,
margin=dict(t=180, l=40, r=240, b=40),
title=dict(
text=f"{symbol}, {interval_minutes} 분봉, ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})",
x=0.5,
xanchor='center',
y=0.995,
yanchor='top',
pad=dict(t=10, b=12)
),
xaxis_rangeslider_visible=False,
xaxis1_rangeslider_visible=False,
xaxis2_rangeslider_visible=False,
legend=dict(orientation='v', yref='paper', yanchor='top', y=1.0, xref='paper', xanchor='left', x=1.02, title=legendtitle_orig),
dragmode='zoom',
updatemenus=[dict(
type='buttons',
direction='left',
x=0.0,
xanchor='left',
y=1.11,
yanchor='top',
pad=dict(t=0, r=10, b=0, l=0),
buttons=[
dict(
label='',
method='update',
args=[
{'visible': visible_orig},
{
'legend': {'title': legendtitle_orig},
'xaxis.autorange': True,
'xaxis2.autorange': True,
'xaxis3.autorange': True,
'yaxis.autorange': True,
'yaxis2.autorange': True,
'yaxis3.autorange': True,
}
],
execute=True
),
dict(
label='인버스',
method='update',
args=[
{'visible': visible_inv},
{'legend': {'title': legendtitle_inv, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
],
args2=[
{'visible': visible_orig},
{'legend': {'title': legendtitle_orig, 'orientation': 'v', 'y': 1.0, 'yanchor': 'top', 'x': 1.02, 'xanchor': 'left'}}
],
execute=True
),
]
)]
)
fig.update_xaxes(title_text='시간', row=3, col=1)
fig.update_yaxes(title_text='가격 (KRW)', row=1, col=1)
fig.update_yaxes(title_text='이격도/거래량', row=2, col=1)
fig.update_yaxes(title_text='장기 이격도', row=3, col=1)
fig.show(config={'scrollZoom': True, 'displaylogo': False})
def __init__(self) -> None:
self.monitor = Monitor()
self.INTERVAL_MAP = {
60: "60m",
240: "4h",
}
self.monitor = Monitor(cooldown_file=None)
def detect_turnaround_signal(self, symbol, data, interval=0, params=None):
if len(data) < 7:
return None
current_data = data.iloc[-1]
if current_data.get('point', 0) == 1:
return {
'alert': True,
'details': f"매수신호: {current_data.get('signal', 'unknown')}"
}
return {'alert': False, 'details': "매수신호 없음"}
def load_mtf(self, symbol: str):
df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D)
df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H)
df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL)
def fetch_price_history(self, symbol: str, interval_minutes: int, days: int = 30) -> pd.DataFrame:
if symbol in KR_COINS:
bong_count = 3000
return self.monitor.get_coin_more_data(symbol, interval_minutes, bong_count=bong_count)
if interval_minutes not in self.INTERVAL_MAP:
raise ValueError("interval must be 60 or 240")
interval_str = self.INTERVAL_MAP[interval_minutes]
df = yf.download(
tickers=symbol,
period=f"{days}d",
interval=interval_str,
progress=False,
if df_1d is None or df_1d.empty:
df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500)
if df_1h is None or df_1h.empty:
df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000)
if df_3m is None or df_3m.empty:
df_3m = self.monitor.get_coin_more_data(
symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True
)
df_1d = self.monitor.calculate_technical_indicators(df_1d)
df_1h = self.monitor.calculate_technical_indicators(df_1h)
df_3m = self.monitor.calculate_technical_indicators(df_3m)
return df_1d, df_1h, df_3m
def render_plotly(self, df_3m: pd.DataFrame, trend: str, result: SimResult) -> None:
cfg = strategy.ACTIVE_CONFIG.name
summary = (
f"[{cfg}] 시작 {result.initial_cash:,.0f} | 최종 {result.final_asset:,.0f} | "
f"{result.total_return_pct:+.2f}% | 수수료 {result.total_fees:,.0f}"
)
if df.empty:
raise RuntimeError("No data fetched. Check symbol or interval support.")
return df
fig = subplots.make_subplots(
rows=3,
cols=1,
subplot_titles=(
f"{COIN_NAME} 3분 BB — {trend}",
"RSI / BB폭(%)",
summary,
),
shared_xaxes=False,
vertical_spacing=0.06,
row_heights=[0.5, 0.18, 0.32],
specs=[[{"type": "xy"}], [{"type": "xy"}], [{"type": "table"}]],
)
fig.add_trace(
go.Candlestick(
x=df_3m.index,
open=df_3m["Open"],
high=df_3m["High"],
low=df_3m["Low"],
close=df_3m["Close"],
name="캔들",
showlegend=False,
),
row=1,
col=1,
)
for col, color in [("MA", "blue"), ("Upper", "gray"), ("Lower", "gray")]:
if col in df_3m.columns:
fig.add_trace(
go.Scatter(
x=df_3m.index,
y=df_3m[col],
name=col,
line=dict(color=color, dash="dot" if col != "MA" else "solid"),
showlegend=False,
),
row=1,
col=1,
)
def analyze_bottom_period(self, symbol: str, interval_minutes: int, days: int = 90):
data = self.fetch_price_history(symbol, interval_minutes, days)
data = self.monitor.calculate_technical_indicators(data)
data = self.monitor.annotate_signals(symbol, data, simulation=True)
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
print(f"총 데이터 수: {len(data)}")
bottom_start = pd.Timestamp('2025-06-22')
bottom_end = pd.Timestamp('2025-07-09')
bottom_data = data[(data.index >= bottom_start) & (data.index <= bottom_end)]
if len(bottom_data) == 0:
print("저점 기간 데이터가 없습니다.")
return None, []
print(f"\n저점 기간 데이터: {bottom_data.index[0]} ~ {bottom_data.index[-1]}")
print(f"저점 기간 데이터 수: {len(bottom_data)}")
print("\n=== 저점 기간 기술적 지표 분석 ===")
min_price = bottom_data['Low'].min()
max_price = bottom_data['High'].max()
avg_price = bottom_data['Close'].mean()
print(f"최저가: {min_price:.4f}")
print(f"최고가: {max_price:.4f}")
print(f"평균가: {avg_price:.4f}")
print(f"가격 변동폭: {((max_price - min_price) / min_price * 100):.2f}%")
bb_lower_min = bottom_data['Lower'].min()
bb_upper_max = bottom_data['Upper'].max()
print(f"\n볼린저 밴드 분석:")
print(f"하단 밴드 최저: {bb_lower_min:.4f}")
print(f"상단 밴드 최고: {bb_upper_max:.4f}")
volume_avg = bottom_data['Volume'].mean()
volume_max = bottom_data['Volume'].max()
print(f"\n거래량 분석:")
print(f"평균 거래량: {volume_avg:.0f}")
print(f"최대 거래량: {volume_max:.0f}")
actual_bottom_idx = bottom_data['Low'].idxmin()
actual_bottom_price = bottom_data.loc[actual_bottom_idx, 'Low']
actual_bottom_date = actual_bottom_idx
print(f"\n실제 저점:")
print(f"날짜: {actual_bottom_date}")
print(f"가격: {actual_bottom_price:.4f}")
print(f"볼린저 하단 대비: {((actual_bottom_price - bottom_data.loc[actual_bottom_idx, 'Lower']) / bottom_data.loc[actual_bottom_idx, 'Lower'] * 100):.2f}%")
print(f"\n=== 매수 신호 분석 ===")
bottom_alerts = bottom_data[bottom_data['point'] == 1]
alerts = [(idx, row['Close']) for idx, row in bottom_alerts.iterrows()]
print(f"저점 기간 매수 신호 수: {len(alerts)}")
if alerts:
print("매수 신호 발생 시점:")
for date, price in alerts:
print(f" {date}: {price:.4f}")
return bottom_data, alerts
buy_trades = [t for t in result.trades if t.action == "매수"]
sell_trades = [t for t in result.trades if t.action == "매도"]
fig.add_trace(
go.Scatter(
x=[t.dt for t in buy_trades],
y=[t.price for t in buy_trades],
mode="markers",
name="매수",
legendgroup="trades",
showlegend=True,
marker=dict(
color="#22c55e",
size=11,
symbol="triangle-up",
line=dict(width=1, color="#166534"),
),
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=[t.dt for t in sell_trades],
y=[t.price for t in sell_trades],
mode="markers",
name="매도",
legendgroup="trades",
showlegend=True,
marker=dict(
color="#ef4444",
size=11,
symbol="triangle-down",
line=dict(width=1, color="#991b1b"),
),
),
row=1,
col=1,
)
if "RSI" in df_3m.columns:
fig.add_trace(
go.Scatter(
x=df_3m.index,
y=df_3m["RSI"],
name="RSI",
showlegend=False,
),
row=2,
col=1,
)
if "BB_Width" in df_3m.columns:
fig.add_trace(
go.Scatter(
x=df_3m.index,
y=df_3m["BB_Width"],
name="BB폭%",
showlegend=False,
),
row=2,
col=1,
)
if result.trades:
cells = [
[t.dt.strftime("%Y-%m-%d %H:%M") for t in result.trades],
[t.action for t in result.trades],
[t.signal for t in result.trades],
[f"{t.price:,.2f}" for t in result.trades],
[f"{t.krw:,.0f}" for t in result.trades],
[f"{t.fee:,.0f}" for t in result.trades],
[f"{t.pnl:+,.0f}" if t.pnl is not None else "-" for t in result.trades],
[f"{t.total_asset:,.0f}" for t in result.trades],
]
else:
cells = [["-"] * 8]
fig.add_trace(
go.Table(
header=dict(
values=[
"일시",
"구분",
"신호",
"가격",
"금액",
"수수료",
"수익",
"총자산",
],
fill_color="#e8e8e8",
),
cells=dict(values=cells),
),
row=3,
col=1,
)
fig.update_layout(
height=1100,
title=f"{SYMBOL} BB 타이밍 시뮬 (범례 클릭: 매수/매도 표시 토글)",
margin=dict(l=50, r=140, t=80, b=40),
dragmode="zoom",
legend=dict(
orientation="v",
yanchor="top",
y=0.99,
xanchor="left",
x=1.01,
bgcolor="rgba(255,255,255,0.9)",
bordercolor="#cccccc",
borderwidth=1,
font=dict(size=12),
title=dict(text="체결 (클릭 토글)", side="top"),
itemclick="toggle",
itemdoubleclick="toggleothers",
),
)
# Y축 고정·rangeslider 해제 → 세로 드래그/박스줌·휠 줌 가능
fig.update_xaxes(
rangeslider_visible=False,
fixedrange=False,
row=1,
col=1,
)
fig.update_xaxes(fixedrange=False, row=2, col=1)
fig.update_yaxes(
title_text="가격 (KRW)",
fixedrange=False,
scaleanchor=None,
scaleratio=None,
row=1,
col=1,
)
fig.update_yaxes(
fixedrange=False,
scaleanchor=None,
scaleratio=None,
row=2,
col=1,
)
fig.show(
config={
"scrollZoom": True,
"displaylogo": False,
"doubleClick": "reset",
"modeBarButtonsToAdd": ["zoom2d", "pan2d", "resetScale2d"],
}
)
def run_simulation(self, symbol: str, interval_minutes: int, days: int = 30):
data = self.fetch_price_history(symbol, interval_minutes)
def load_all_frames(self) -> dict[int, pd.DataFrame]:
"""discovered 규칙용 전 간격 로드."""
from mtf_bb import load_frames_from_db
inverseData = self.monitor.inverse_data(data)
inverseData = self.monitor.annotate_signals(symbol, inverseData, simulation=True)
return load_frames_from_db(self.monitor, SYMBOL)
data = self.monitor.calculate_technical_indicators(data)
data = self.monitor.annotate_signals(symbol, data, simulation=True)
print(f"데이터 기간: {data.index[0]} ~ {data.index[-1]}")
print(f"총 데이터 수: {len(data)}")
alerts = []
for i in range(len(data)):
if data['point'].iloc[i] == 1:
alerts.append((data.index[i], data['Close'].iloc[i]))
print(f"\n총 매수 신호 수: {len(alerts)}")
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
print(f" - MA 신호: {ma_signals}")
print(f" - Dev40 신호: {dev40_signals}")
print(f" - Dev240 신호: {dev240_signals}")
print(f" - Dev1440 신호: {dev1440_signals}")
def _run_one_strategy(
self,
name: str,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
df_3m: pd.DataFrame,
cfg: strategy.StrategyConfig,
frames: dict | None = None,
) -> tuple[pd.DataFrame, SimResult, int]:
"""한 전략으로 신호·백테스트. 반환: (df, result, 신호수)."""
df_sig = strategy.annotate_signals(
SYMBOL,
df_3m.copy(),
simulation=True,
df_1h=df_1h,
df_1d=df_1d,
config=cfg,
frames=frames,
)
n_sig = int((df_sig["point"] == 1).sum())
res = run_backtest(df_sig, df_1d, df_1h, config_name=name)
return df_sig, res, n_sig
# Plotly 기반 시각화로 전환
self.render_plotly(symbol, interval_minutes, data, inverseData)
def run(self, config: strategy.StrategyConfig | None = None) -> SimResult:
"""기본 BB vs 탐색 규칙 중 수익률·신호가 있는 쪽을 HTML에 표시."""
df_1d, df_1h, df_3m = self.load_mtf(SYMBOL)
trend = strategy.get_trend(df_1d, df_1h)
print(f"추세(최신): {trend}")
print(f"3분: {df_3m.index[0]} ~ {df_3m.index[-1]} ({len(df_3m)}봉)")
cfg_base = strategy.StrategyConfig(
name="01_기본_BB만",
use_discovered_rules=False,
use_regime_switch=False,
use_rsi_filter=False,
use_volume_filter=False,
use_squeeze_filter=False,
use_stop_loss=False,
)
df_base, res_base, n_base = self._run_one_strategy(
cfg_base.name, df_1d, df_1h, df_3m, cfg_base
)
print(f"\n[기본 BB] 신호 {n_base} | 수익 {res_base.total_return_pct:+.2f}% | 거래 {res_base.trade_count}")
candidates: list[tuple[str, pd.DataFrame, SimResult, int]] = [
(cfg_base.name, df_base, res_base, n_base),
]
try:
from rule_discovery import load_rules
rules = load_rules()
frames = self.load_all_frames()
if rules and frames:
cfg_disc = strategy.StrategyConfig(
name=rules.name,
use_discovered_rules=True,
use_regime_switch=False,
use_rsi_filter=False,
use_volume_filter=False,
use_squeeze_filter=False,
use_stop_loss=False,
)
df_disc, res_disc, n_disc = self._run_one_strategy(
cfg_disc.name, df_1d, df_1h, df_3m, cfg_disc, frames=frames
)
print(
f"[탐색 규칙] 신호 {n_disc} | 수익 {res_disc.total_return_pct:+.2f}% "
f"| 거래 {res_disc.trade_count}"
)
print(f" 매수: {rules.buy_all} | OR: {rules.buy_any}")
print(f" 매도: {rules.sell_all} | 손절: {rules.sell_stop}")
if n_disc > 0 and res_disc.trade_count > 0:
candidates.append((cfg_disc.name, df_disc, res_disc, n_disc))
except Exception as e:
print(f"[탐색 규칙] 스킵: {e}")
# 신호·거래 있는 후보 중 수익률 최대
valid = [c for c in candidates if c[3] > 0 and c[2].trade_count > 0]
if not valid:
valid = candidates
name, df_plot, result, n_sig = max(valid, key=lambda c: c[2].total_return_pct)
print(f"\n>>> HTML 적용: {name} (신호 {n_sig}, 거래 {result.trade_count}, {result.total_return_pct:+.2f}%)")
sigs = df_plot[df_plot["point"] == 1]
if len(sigs):
print(sigs["action"].value_counts().to_string())
print_backtest_report(result)
self.render_plotly(df_plot, trend, result)
return result
def run_mtf_analysis() -> None:
"""봉별 BB 백테스트 비교, 정책 저장, MTF 시뮬 차트."""
from mtf_bb import apply_policy, load_frames_from_db, run_interval_comparison, save_policy
monitor = Monitor()
policy, _ = run_interval_comparison(monitor)
save_policy(policy)
apply_policy(policy)
frames = load_frames_from_db(monitor, SYMBOL)
df_1d = frames.get(TREND_INTERVAL_1D)
if df_1d is None or df_1d.empty:
df_1d = frames[ENTRY_INTERVAL]
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1h is None or df_1h.empty:
df_1h = frames[ENTRY_INTERVAL]
cfg = strategy.StrategyConfig(
name="MTF_BB",
use_mtf=True,
use_regime_switch=strategy.ACTIVE_CONFIG.use_regime_switch,
use_rsi_filter=False,
use_volume_filter=False,
use_squeeze_filter=False,
use_stop_loss=True,
)
df_sig = strategy.annotate_mtf_signals(SYMBOL, frames, df_1d, df_1h, policy, cfg)
trend = strategy.get_trend(df_1d, df_1h)
print(f"\nMTF 시뮬 ({policy.name}) | 추세: {trend}")
result = run_backtest(df_sig, df_1d, df_1h, config_name=policy.name)
print_backtest_report(result)
Simulation().render_plotly(df_sig, trend, result)
def run_discover() -> None:
"""모든 봉·캔들 특징으로 최적 규칙 탐색 후 JSON 저장."""
from rule_discovery import discover_rules, load_frames, save_rules
monitor = Monitor(cooldown_file=None)
frames = load_frames(monitor)
rules = discover_rules(frames)
save_rules(rules)
print(f"\n저장: discovered_rules.json")
print("HTML 차트: python simulation_1h.py")
def main() -> None:
sim = Simulation()
if len(sys.argv) > 1 and sys.argv[1] == "discover":
run_discover()
return
if len(sys.argv) > 1 and sys.argv[1] == "mtf":
run_mtf_analysis()
return
df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL)
if len(sys.argv) > 1 and sys.argv[1] == "compare":
run_comparison(df_1d, df_1h, df_3m)
return
sim.run()
if __name__ == "__main__":
sim = Simulation()
interval = 60
days = 90
target_coins = KR_COINS
#target_coins = ['XRP']
show_graphs = True
for symbol in target_coins:
print(f"\n=== {symbol} 저점 기간 분석 시작 ===")
try:
bottom_data, alerts = sim.analyze_bottom_period(symbol, interval, days)
print(f"\n=== {symbol} 전체 기간 시뮬레이션 ===")
if show_graphs:
sim.run_simulation(symbol, interval, days)
else:
data = sim.fetch_price_history(symbol, interval, days)
inverseData = sim.monitor.inverse_data(data)
inverseData = sim.monitor.annotate_signals(symbol, inverseData, simulation=True)
data = sim.monitor.calculate_technical_indicators(data)
data = sim.monitor.annotate_signals(symbol, data, simulation=True)
total_signals = len(data[data['point'] == 1])
ma_signals = len(data[(data['point'] == 1) & (data['signal'] == 'movingaverage')])
dev40_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation40')])
dev240_signals = len(data[(data['point'] == 1) & (data['signal'] == 'Deviation720')])
dev1440_signals = len(data[(data['point'] == 1) & (data['signal'] == 'deviation1440')])
print(f"총 매수 신호: {total_signals}")
print(f" - MA 신호: {ma_signals}")
print(f" - Dev40 신호: {dev40_signals}")
print(f" - Dev240 신호: {dev240_signals}")
print(f" - Dev1440 신호: {dev1440_signals}")
except Exception as e:
print(f"Error analyzing {symbol}: {str(e)}")
main()