9개 간격(1~1440분) BB·일목 위치 특징을 3분 타임라인에 맞춰 분석하고, discover로 매수·매도 규칙을 찾은 뒤 HTML 차트에 해당 체결만 표시한다. simulation_1h.py를 simulation.py로 변경했으며, 파라미터 없이 실행하면 analyze→discover→차트가 한 번에 수행된다. Co-authored-by: Cursor <cursoragent@cursor.com>
856 lines
28 KiB
Python
856 lines
28 KiB
Python
"""
|
|
WLD 3분 BB 시뮬레이션.
|
|
|
|
기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도.
|
|
수수료 반영, 레짐/필터 조합 비교 지원.
|
|
|
|
python simulation.py # analyze → discover → HTML (탐색 규칙 매수·매도)
|
|
python simulation.py analyze # (고급) 조합 분석만
|
|
python simulation.py discover # (고급) 규칙 탐색만
|
|
python simulation.py compare # (고급) 9종 프리셋 비교
|
|
python simulation.py mtf # (고급) 구 MTF BB 정책
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import webbrowser
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import plotly.graph_objs as go
|
|
from plotly.subplots import make_subplots
|
|
|
|
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
|
|
|
|
REPORT_DIR = Path(__file__).resolve().parent / "reports"
|
|
OUTPUT_HTML = REPORT_DIR / "wld_bb_simulation.html"
|
|
|
|
|
|
def interval_chart_label(interval_min: int) -> str:
|
|
"""차트 제목용 봉 라벨."""
|
|
if interval_min >= 1440:
|
|
return "일봉"
|
|
return f"{interval_min}분봉"
|
|
|
|
|
|
def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None:
|
|
"""
|
|
매수·매도 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글).
|
|
simulate_mtf.py 와 동일 스타일.
|
|
"""
|
|
for action, color, symbol, label, text_pos in [
|
|
("매수", "#16a34a", "triangle-up", "매수", "top center"),
|
|
("매도", "#dc2626", "triangle-down", "매도", "bottom center"),
|
|
]:
|
|
pts = [t for t in trades if t.action == action]
|
|
if not pts:
|
|
continue
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=[t.dt for t in pts],
|
|
y=[t.price for t in pts],
|
|
mode="markers+text",
|
|
name=label,
|
|
legendgroup=label,
|
|
text=[label] * len(pts),
|
|
textposition=text_pos,
|
|
textfont=dict(
|
|
size=12,
|
|
color=color,
|
|
family="Malgun Gothic, Arial, sans-serif",
|
|
),
|
|
marker=dict(
|
|
symbol=symbol,
|
|
size=16,
|
|
color=color,
|
|
line=dict(width=2, color="#111"),
|
|
),
|
|
hovertext=[
|
|
f"{label} 체결<br>{t.signal}<br>₩{t.price:,.2f}<br>₩{t.krw:,.0f}"
|
|
for t in pts
|
|
],
|
|
hovertemplate="%{hovertext}<extra></extra>",
|
|
),
|
|
row=row,
|
|
col=1,
|
|
)
|
|
|
|
|
|
def build_simulation_html(
|
|
df: pd.DataFrame,
|
|
result: SimResult,
|
|
trend: str,
|
|
interval_min: int = ENTRY_INTERVAL,
|
|
note: str = "",
|
|
) -> str:
|
|
"""simulate_mtf.py 와 동일 레이아웃의 HTML 리포트."""
|
|
df = strategy.prepare_entry_df(df.copy())
|
|
iv_label = interval_chart_label(interval_min)
|
|
buy_n = sum(1 for t in result.trades if t.action == "매수")
|
|
sell_n = sum(1 for t in result.trades if t.action == "매도")
|
|
pnl_krw = result.final_asset - result.initial_cash
|
|
|
|
summary = {
|
|
"config_name": result.config_name,
|
|
"period_start": str(df.index[0]),
|
|
"period_end": str(df.index[-1]),
|
|
"interval_label": iv_label,
|
|
"trend": trend,
|
|
"signal_count": len(result.trades),
|
|
"buy_signal_count": buy_n,
|
|
"sell_signal_count": sell_n,
|
|
"total_trades": result.trade_count,
|
|
"pnl_krw": round(pnl_krw, 0),
|
|
"pnl_pct": round(result.total_return_pct, 2),
|
|
"total_fees": round(result.total_fees, 0),
|
|
"win_count": result.win_count,
|
|
"note": note,
|
|
}
|
|
|
|
fig = make_subplots(
|
|
rows=3,
|
|
cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.05,
|
|
row_heights=[0.58, 0.2, 0.22],
|
|
subplot_titles=(
|
|
f"{COIN_NAME} ({SYMBOL}) {iv_label} — {result.config_name}",
|
|
"RSI (14)",
|
|
"거래량",
|
|
),
|
|
)
|
|
|
|
fig.add_trace(
|
|
go.Candlestick(
|
|
x=df.index,
|
|
open=df["Open"],
|
|
high=df["High"],
|
|
low=df["Low"],
|
|
close=df["Close"],
|
|
name=f"{iv_label} 캔들",
|
|
increasing_line_color="#ef4444",
|
|
decreasing_line_color="#3b82f6",
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
if "MA" in df.columns:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df.index,
|
|
y=df["MA"],
|
|
name="BB 중심",
|
|
line=dict(color="#64748b", width=1, dash="dot"),
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
if "Upper" in df.columns:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df.index,
|
|
y=df["Upper"],
|
|
name="BB 상단",
|
|
line=dict(color="#94a3b8", width=1),
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
if "Lower" in df.columns:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df.index,
|
|
y=df["Lower"],
|
|
name="BB 하단",
|
|
line=dict(color="#94a3b8", width=1),
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
_add_trade_markers(fig, result.trades, row=1)
|
|
|
|
if not result.trades:
|
|
fig.add_annotation(
|
|
text=(
|
|
f"이 기간에는 체결 신호가 없습니다.<br>"
|
|
f"전략: {result.config_name} | 추세: {trend}"
|
|
),
|
|
xref="paper",
|
|
yref="paper",
|
|
x=0.5,
|
|
y=0.88,
|
|
showarrow=False,
|
|
font=dict(size=14, color="#b45309"),
|
|
bgcolor="#fffbeb",
|
|
bordercolor="#f59e0b",
|
|
borderwidth=1,
|
|
)
|
|
|
|
if "RSI" in df.columns:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df.index,
|
|
y=df["RSI"],
|
|
name="RSI",
|
|
line=dict(color="#7c3aed"),
|
|
),
|
|
row=2,
|
|
col=1,
|
|
)
|
|
fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=2, col=1)
|
|
fig.add_hline(y=50, line_dash="dot", line_color="#d1d5db", row=2, col=1)
|
|
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=df.index,
|
|
y=df["Volume"],
|
|
name="Volume",
|
|
marker_color="#cbd5e1",
|
|
),
|
|
row=3,
|
|
col=1,
|
|
)
|
|
|
|
fig.update_layout(
|
|
height=920,
|
|
template="plotly_white",
|
|
xaxis_rangeslider_visible=False,
|
|
legend=dict(orientation="h", y=1.05, x=0),
|
|
margin=dict(l=60, r=30, t=90, b=40),
|
|
)
|
|
fig.update_yaxes(title_text="가격 (KRW)", row=1, col=1)
|
|
fig.update_yaxes(title_text="RSI", row=2, col=1, range=[0, 100])
|
|
|
|
chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn")
|
|
|
|
trade_rows = ""
|
|
for t in result.trades:
|
|
cls = "buy" if t.action == "매수" else "sell"
|
|
pnl = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
|
|
trade_rows += f"""
|
|
<tr>
|
|
<td>{t.dt}</td>
|
|
<td class="{cls}">{t.action}</td>
|
|
<td>체결</td>
|
|
<td>₩{t.price:,.2f}</td>
|
|
<td>{t.signal}</td>
|
|
<td>₩{t.krw:,.0f}</td>
|
|
<td>₩{t.fee:,.0f}</td>
|
|
<td>{pnl}</td>
|
|
</tr>"""
|
|
if not trade_rows:
|
|
trade_rows = '<tr><td colspan="8">신호 없음</td></tr>'
|
|
|
|
note_html = f"<p class='warn'>{summary['note']}</p>" if summary.get("note") else ""
|
|
sells = summary["sell_signal_count"]
|
|
win_rate = (
|
|
summary["win_count"] / sells * 100 if sells else 0.0
|
|
)
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>{SYMBOL} BB 시뮬레이션</title>
|
|
<style>
|
|
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }}
|
|
h1 {{ font-size: 1.35rem; }}
|
|
.meta {{ color: #475569; font-size: 0.9rem; }}
|
|
.warn {{ background: #fffbeb; border: 1px solid #f59e0b; padding: 10px; border-radius: 6px; color: #92400e; }}
|
|
.cards {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }}
|
|
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; }}
|
|
.card span {{ font-size: 0.75rem; color: #64748b; display: block; }}
|
|
.card b {{ font-size: 1.05rem; }}
|
|
.legend-box {{ background:#fff; border:1px solid #e2e8f0; padding:10px 14px; border-radius:8px; margin-bottom:12px; font-size:0.85rem; }}
|
|
.legend-box span {{ display:inline-block; margin-right:16px; }}
|
|
.dot-buy {{ color:#16a34a; font-weight:700; }}
|
|
.dot-sell {{ color:#dc2626; font-weight:700; }}
|
|
table {{ width:100%; border-collapse:collapse; background:#fff; font-size:0.85rem; }}
|
|
th, td {{ border:1px solid #e2e8f0; padding:8px; text-align:left; }}
|
|
th {{ background:#f1f5f9; }}
|
|
td.buy {{ color:#16a34a; font-weight:600; }}
|
|
td.sell {{ color:#dc2626; font-weight:600; }}
|
|
.chart-wrap {{ background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{COIN_NAME} ({SYMBOL}) BB 시뮬레이션</h1>
|
|
<p class="meta">전략: {summary['config_name']} | 추세: {summary['trend']} | 기간: {summary['period_start']} ~ {summary['period_end']}</p>
|
|
{note_html}
|
|
<div class="legend-box">
|
|
<span class="dot-buy">▲ 매수</span> 범례 클릭 시 마커·라벨 함께 숨김
|
|
<span class="dot-sell">▼ 매도</span> 동일
|
|
</div>
|
|
<div class="cards">
|
|
<div class="card"><span>체결</span><b>{summary['total_trades']} (매수 {summary['buy_signal_count']} / 매도 {summary['sell_signal_count']})</b></div>
|
|
<div class="card"><span>손익</span><b>₩{summary['pnl_krw']:+,.0f} ({summary['pnl_pct']:+.2f}%)</b></div>
|
|
<div class="card"><span>수수료</span><b>₩{summary['total_fees']:,.0f}</b></div>
|
|
<div class="card"><span>승률(매도 기준)</span><b>{win_rate:.1f}%</b></div>
|
|
</div>
|
|
<div class="chart-wrap">{chart_html}</div>
|
|
<h2>신호·체결 내역</h2>
|
|
<table>
|
|
<thead><tr><th>시각</th><th>구분</th><th>상태</th><th>가격</th><th>신호</th><th>금액</th><th>수수료</th><th>손익</th></tr></thead>
|
|
<tbody>{trade_rows}</tbody>
|
|
</table>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@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 __init__(self) -> None:
|
|
self.monitor = Monitor(cooldown_file=None)
|
|
|
|
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)
|
|
|
|
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_plot: pd.DataFrame,
|
|
trend: str,
|
|
result: SimResult,
|
|
interval_min: int = ENTRY_INTERVAL,
|
|
note: str = "",
|
|
open_browser: bool = True,
|
|
) -> Path:
|
|
"""HTML 리포트 저장 (simulate_mtf.py 동일 스타일)."""
|
|
html = build_simulation_html(
|
|
df_plot, result, trend, interval_min=interval_min, note=note
|
|
)
|
|
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
OUTPUT_HTML.write_text(html, encoding="utf-8")
|
|
print(f"HTML: {OUTPUT_HTML}")
|
|
if open_browser:
|
|
webbrowser.open(OUTPUT_HTML.resolve().as_uri())
|
|
return OUTPUT_HTML
|
|
|
|
def load_all_frames(self) -> dict[int, pd.DataFrame]:
|
|
"""discovered 규칙용 전 간격 로드."""
|
|
from mtf_bb import load_frames_from_db
|
|
|
|
return load_frames_from_db(self.monitor, SYMBOL)
|
|
|
|
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
|
|
|
|
def _frames_to_mtf(
|
|
self, frames: dict[int, pd.DataFrame]
|
|
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
"""전 간격 frames에서 1d/1h/3m 추출."""
|
|
df_3m = frames.get(ENTRY_INTERVAL)
|
|
if df_3m is None or df_3m.empty:
|
|
raise ValueError(f"{ENTRY_INTERVAL}분봉 데이터 없음")
|
|
df_1d = frames.get(TREND_INTERVAL_1D)
|
|
if df_1d is None or df_1d.empty:
|
|
df_1d = df_3m
|
|
df_1h = frames.get(TREND_INTERVAL_1H)
|
|
if df_1h is None or df_1h.empty:
|
|
df_1h = df_3m
|
|
return df_1d, df_1h, df_3m
|
|
|
|
def run_discovered_chart(
|
|
self,
|
|
frames: dict[int, pd.DataFrame],
|
|
rules=None,
|
|
) -> SimResult:
|
|
"""
|
|
discovered_rules 매수·매도 규칙만 백테스트하고 HTML에 표시합니다.
|
|
|
|
차트 마커 = 해당 규칙으로 발생한 매수·매도 체결.
|
|
"""
|
|
from rule_discovery import DiscoveredRules, 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 이 없거나 매수 규칙이 비어 있습니다."
|
|
)
|
|
|
|
df_1d, df_1h, df_3m = self._frames_to_mtf(frames)
|
|
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)}봉)")
|
|
print(f"\n[적용 규칙] {rule_set.name}")
|
|
print(f" 매수 AND: {rule_set.buy_all}")
|
|
if rule_set.buy_any:
|
|
print(f" 매수 OR: {rule_set.buy_any}")
|
|
print(f" 매도 AND: {rule_set.sell_all}")
|
|
if rule_set.sell_stop:
|
|
print(f" 손절: {rule_set.sell_stop}")
|
|
|
|
df_sig = strategy.annotate_discovered_signals(
|
|
SYMBOL, frames, df_1d, df_1h, rules=rule_set, data=df_3m
|
|
)
|
|
n_sig = int((df_sig["point"] == 1).sum())
|
|
buy_sig = int((df_sig["action"] == "buy").sum())
|
|
sell_sig = int((df_sig["action"] == "sell").sum())
|
|
print(f"\n규칙 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})")
|
|
|
|
result = run_backtest(df_sig, df_1d, df_1h, config_name=rule_set.name)
|
|
print_backtest_report(result)
|
|
|
|
note = (
|
|
f"매수 규칙: {rule_set.buy_all}"
|
|
+ (f" | OR {rule_set.buy_any}" if rule_set.buy_any else "")
|
|
+ f" | 매도: {rule_set.sell_all}"
|
|
)
|
|
self.render_plotly(df_sig, trend, result, note=note)
|
|
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,
|
|
interval_min=policy.buy_interval,
|
|
note=f"MTF 정책: 매수 {policy.buy_interval}분 / 확인 {policy.buy_confirm_intervals}",
|
|
)
|
|
|
|
|
|
def _load_all_frames_or_exit() -> dict[int, pd.DataFrame] | None:
|
|
"""coins.db 전 간격 로드. 부족 시 None."""
|
|
from rule_discovery import load_frames
|
|
|
|
monitor = Monitor(cooldown_file=None)
|
|
frames = load_frames(monitor)
|
|
if len(frames) < 3:
|
|
print("coins.db 데이터 부족. python downloader.py 실행 후 재시도.")
|
|
return None
|
|
return frames
|
|
|
|
|
|
def run_analyze(frames: dict[int, pd.DataFrame] | None = None) -> None:
|
|
"""전 봉 BB·일목 위치 조합 분석."""
|
|
from combination_analyzer import analyze_combinations, save_report
|
|
|
|
if frames is None:
|
|
print("=== 전 봉 BB·일목 조합 분석 ===")
|
|
frames = _load_all_frames_or_exit()
|
|
if frames is None:
|
|
return
|
|
report = analyze_combinations(frames)
|
|
save_report(report)
|
|
|
|
|
|
def run_discover(frames: dict[int, pd.DataFrame] | None = None):
|
|
"""모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장."""
|
|
from rule_discovery import discover_rules, save_rules
|
|
|
|
if frames is None:
|
|
print("=== 규칙 탐색 (discover) ===")
|
|
frames = _load_all_frames_or_exit()
|
|
if frames is None:
|
|
return None
|
|
rules = discover_rules(frames)
|
|
save_rules(rules)
|
|
print(f"\n저장: discovered_rules.json")
|
|
return rules
|
|
|
|
|
|
def run_full_pipeline() -> None:
|
|
"""
|
|
일반 사용자용 일괄 실행: analyze → discover → HTML.
|
|
|
|
DB 로드는 한 번만 수행합니다.
|
|
"""
|
|
print("=" * 60)
|
|
print("전체 파이프라인: analyze → discover → HTML")
|
|
print("=" * 60)
|
|
frames = _load_all_frames_or_exit()
|
|
if frames is None:
|
|
return
|
|
|
|
print("\n[1/3] 조합 분석 (analyze)")
|
|
run_analyze(frames)
|
|
|
|
print("\n[2/3] 규칙 탐색 (discover)")
|
|
run_discover(frames)
|
|
|
|
print("\n[3/3] 백테스트·HTML 차트 (탐색 규칙 매수·매도)")
|
|
if rules is None:
|
|
print("규칙 탐색 실패 — HTML 생략")
|
|
return
|
|
Simulation().run_discovered_chart(frames, rules=rules)
|
|
print("\n완료.")
|
|
|
|
|
|
def print_usage() -> None:
|
|
print(
|
|
"""
|
|
DeepCoin simulation.py
|
|
|
|
python simulation.py
|
|
analyze + discover + HTML (차트 = discovered_rules 매수·매도)
|
|
|
|
(고급) analyze | discover | compare | mtf
|
|
"""
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
sim = Simulation()
|
|
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
|
|
print_usage()
|
|
return
|
|
if len(sys.argv) == 1 or (len(sys.argv) > 1 and sys.argv[1] in ("all", "chart", "html")):
|
|
if len(sys.argv) > 1 and sys.argv[1] in ("chart", "html"):
|
|
print("참고: chart/html 옵션은 제거되었습니다. python simulation.py 만 사용하세요.\n")
|
|
run_full_pipeline()
|
|
return
|
|
if len(sys.argv) > 1 and sys.argv[1] == "analyze":
|
|
run_analyze()
|
|
return
|
|
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
|
|
if len(sys.argv) > 1 and sys.argv[1] == "compare":
|
|
df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL)
|
|
run_comparison(df_1d, df_1h, df_3m)
|
|
return
|
|
print(f"알 수 없는 옵션: {sys.argv[1]}\n")
|
|
print_usage()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|