GT MTF 프로필·캘리브레이션과 04 매칭/시뮬/실거래 파이프라인을 추가한다.

3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프,
walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 11:27:50 +09:00
parent b52d61b777
commit 2cb67c42b3
47 changed files with 5956 additions and 209 deletions

View File

@@ -0,0 +1,92 @@
"""
05 규칙 알림 텔레그램 메시지 포맷.
"""
from __future__ import annotations
from typing import Any
from config import COIN_NAME, MONITOR_ALERT_KRW_AMOUNT, SYMBOL
def _fmt_krw(value: float) -> str:
"""원화 금액 표시."""
if value >= 100:
return f"{value:,.0f}"
if value >= 1:
return f"{value:,.2f}"
return f"{value:,.4f}"
def _fmt_price(value: float) -> str:
"""코인 단가 표시."""
if value >= 100:
return f"{value:,.0f}"
if value >= 10:
return f"{value:,.2f}"
if value >= 1:
return f"{value:,.3f}"
return f"{value:,.4f}"
def _holding_qty(balances: dict[str, dict[str, float]], symbol: str) -> float:
"""
잔고 dict에서 코인 보유 수량을 반환합니다.
Args:
balances: load_balances_dict() 결과.
symbol: 통화 코드 (예: WLD).
Returns:
보유 수량 (없으면 0).
"""
info = balances.get(symbol) or {}
return float(info.get("balance") or 0.0)
def build_rule_alert_message(
hit: dict[str, Any],
balances: dict[str, dict[str, float]] | None = None,
) -> str:
"""
규칙 발화 알림 본문을 만듭니다.
매수: MONITOR_ALERT_KRW_AMOUNT 기준 수량·금액.
매도: 보유 수량(잔고 조회 가능 시) × 가격 = 금액, 없으면 참고 금액 기준.
Args:
hit: evaluate_live_rules 항목 (side, rule_id, dt, close).
balances: 빗썸 잔고 dict. None이면 매도도 참고 금액 기준.
Returns:
텔레그램 메시지 문자열.
"""
side = str(hit.get("side", "")).upper()
close = float(hit["close"])
rule_id = hit.get("rule_id", "")
dt = hit.get("dt", "")
qty_basis = ""
if side == "SELL" and balances is not None:
qty = _holding_qty(balances, SYMBOL)
amount = qty * close
qty_basis = "보유 기준"
elif side == "BUY":
amount = float(MONITOR_ALERT_KRW_AMOUNT)
qty = amount / close if close > 0 else 0.0
qty_basis = "참고 매수 규모"
else:
amount = float(MONITOR_ALERT_KRW_AMOUNT)
qty = amount / close if close > 0 else 0.0
qty_basis = "참고 규모(잔고 미조회)"
lines = [
f"[DeepCoin {side}] {COIN_NAME}",
f"규칙: {rule_id}",
f"시각: {dt}",
f"가격: {_fmt_price(close)}",
f"수량: {qty:,.4f} {SYMBOL} ({qty_basis})",
f"금액: {_fmt_krw(amount)}",
"※ 알림만 전송, 자동 주문 없음",
]
return "\n".join(lines)

View File

@@ -0,0 +1,280 @@
"""
ground_truth·시뮬 등 차트 HTML 공통 레이아웃·스타일.
"""
from __future__ import annotations
from typing import Any
CHART_REPORT_CSS = """
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }
h1 { font-size: 1.35rem; }
h2 { font-size: 1.1rem; margin-top: 28px; }
.meta { color: #475569; font-size: 0.9rem; }
.note { background: #f1f5f9; border: 1px solid #cbd5e1; padding: 10px; border-radius: 6px;
color: #334155; font-size: 0.9rem; line-height: 1.5; }
.go { font-size: 1.25rem; font-weight: 700; }
.go-pass { color: #16a34a; }
.go-fail { color: #dc2626; }
.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; }
.chart-wrap { background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }
.legend-box { font-size:0.85rem; color:#475569; margin-bottom:10px; line-height: 1.6; }
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; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.criteria { background:#fff; border:1px solid #e2e8f0; border-radius:8px;
padding:12px 16px; margin:12px 0; }
.criteria h3 { margin: 0 0 8px; font-size: 1rem; }
.criteria ul { margin: 6px 0 0 18px; padding: 0; }
.criteria li { margin: 4px 0; color: #334155; }
.criteria .kind { color: #64748b; font-size: 0.85rem; }
.table-scroll { max-height: 480px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
.pass { color: #16a34a; font-weight: 600; }
.fail { color: #dc2626; font-weight: 600; }
.cards-group-title { font-size: 0.82rem; color: #475569; margin: 14px 0 6px; font-weight: 600; }
"""
def initial_change_pct(pnl: dict[str, Any]) -> float:
"""
초기 금액 대비 총보유자산 증감율(%)을 계산합니다.
Args:
pnl: initial_cash_krw, final_asset_krw (또는 pnl_pct) 포함 dict.
Returns:
증감율 %.
"""
if pnl.get("pnl_pct") is not None:
return float(pnl["pnl_pct"])
initial = float(pnl.get("initial_cash_krw") or 0)
final = float(pnl.get("final_asset_krw") or 0)
if initial <= 0:
return 0.0
return (final - initial) / initial * 100.0
def pnl_cards_html(pnl: dict[str, Any], trade_label: str, trade_count: int) -> str:
"""
GT·시뮬 HTML 공통 자산 요약 카드 (총보유자산·초기 대비 증감율).
Args:
pnl: simulate_truth_portfolio 또는 simulate_fixed_order_portfolio 결과.
trade_label: 타점 라벨(예: 정답 타점, 시뮬 체결).
trade_count: 타점 건수.
Returns:
card div HTML 연속 문자열.
"""
if pnl.get("initial_cash_krw") is None:
return card_html(trade_label, f"{trade_count}")
change_pct = initial_change_pct(pnl)
out = card_html(trade_label, f"{trade_count}")
out += card_html("초기 금액", f"{pnl['initial_cash_krw']:,.0f}")
out += card_html("총보유자산", f"{pnl['final_asset_krw']:,.0f}")
out += card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
out += card_html("수수료", f"{pnl['total_fees_krw']:,.0f}")
if pnl.get("holding_qty", 0) > 0:
out += card_html(
"미청산",
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
)
return out
def market_cards_html(close_last: float, bb_pos_txt: str) -> str:
"""
종가·BB %B 카드.
Args:
close_last: 종가.
bb_pos_txt: BB %B 표시 문자열.
Returns:
card HTML.
"""
return card_html("종가", f"{close_last:,.2f}") + card_html("BB %B", bb_pos_txt)
def card_html(label: str, value: str) -> str:
"""
요약 카드 HTML 한 칸.
Args:
label: 라벨.
value: 값(HTML 허용).
Returns:
div.card 문자열.
"""
return f'<div class="card"><span>{label}</span><b>{value}</b></div>'
def wrap_chart_report_page(
page_title: str,
heading: str,
meta_line: str,
note_html: str,
legend_html: str,
cards_html: str,
chart_html: str,
sections_html: str,
) -> str:
"""
Plotly 차트·테이블을 ground_truth와 동일 스타일 페이지로 감쌉니다.
Args:
page_title: document title.
heading: h1.
meta_line: 기간·추세 등.
note_html: 안내 박스.
legend_html: 차트 범례 설명.
cards_html: .cards 내부 HTML.
chart_html: plotly embed.
sections_html: h2·테이블·criteria 등 본문 하단.
Returns:
전체 HTML 문서.
"""
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>{page_title}</title>
<style>{CHART_REPORT_CSS}</style>
</head>
<body>
<h1>{heading}</h1>
<p class="meta">{meta_line}</p>
{note_html}
<div class="legend-box">{legend_html}</div>
<div class="cards">{cards_html}</div>
<div class="chart-wrap">{chart_html}</div>
{sections_html}
</body>
</html>"""
_COL_LABELS: dict[str, str] = {
"m3_bb_pos": "3분 BB %B",
"m15_bb_pos": "15분 BB %B",
"m30_bb_pos": "30분 BB %B",
"m3_RSI": "3분 RSI",
"m15_RSI": "15분 RSI",
"m30_RSI": "30분 RSI",
"m3_stoch_k": "3분 Stoch %K",
"m15_stoch_k": "15분 Stoch %K",
}
def _col_label(col: str) -> str:
"""지표 컬럼 한글 라벨."""
return _COL_LABELS.get(col, col)
def _format_condition(cond: dict[str, Any]) -> str:
"""
규칙 조건 dict를 읽기 쉬운 문자열로 변환.
Args:
cond: {col, op, lo, hi, value}.
Returns:
한 줄 설명.
"""
col = _col_label(str(cond.get("col", "")))
op = cond.get("op", "")
if op == "between":
return f"{col} ∈ [{cond.get('lo'):.4g}, {cond.get('hi'):.4g}]"
if op == "lte":
return f"{col}{cond.get('value'):.4g}"
if op == "gte":
return f"{col}{cond.get('value'):.4g}"
if op == "lt":
return f"{col} < {cond.get('value'):.4g}"
if op == "gt":
return f"{col} > {cond.get('value'):.4g}"
return f"{col} {op} {cond}"
_KIND_LABELS: dict[str, str] = {
"contrast": "대조 — GT 매수·매도 프로필 대비 반대 구간 (m3 중심)",
"compound_tight": "복합 타이트 — GT 프로필 상위 3지표 AND 동시 충족",
"compound": "복합 TOP3 — GT 프로필 상위 3지표 AND",
"atomic": "단일 — GT 프로필 1지표",
"wide": "완화 — 프로필 외곽 구간",
}
def format_rule_kind(kind: str) -> str:
"""규칙 kind 한글 설명."""
return _KIND_LABELS.get(kind, kind)
def rule_criteria_html(rule: dict[str, Any]) -> str:
"""
monitor_rule 1개의 매칭 기준 블록 HTML.
Args:
rule: matched_rules 내 rule dict.
Returns:
.criteria 블록 HTML.
"""
rid = rule.get("rule_id", "")
side = "매수" if rule.get("side") == "buy" else "매도"
kind = rule.get("kind", "")
conds = rule.get("conditions") or []
items = "".join(f"<li>{_format_condition(c)}</li>" for c in conds)
m = rule.get("metrics", {}).get("all", {})
hold = rule.get("metrics", {}).get("holdout", {})
return f"""
<div class="criteria">
<h3>{rid} <span class="kind">({side} · {format_rule_kind(kind)})</span></h3>
<p class="meta">발화 시 3분봉 종가·8TF 지표가 아래를 <b>모두</b> 만족하면 {side} 신호.</p>
<ul>{items or '<li>조건 없음</li>'}</ul>
<p class="meta">전구간 EV {m.get('ev_pct', '-')}%% · holdout EV {hold.get('ev_pct', '-')}%% ·
holdout PF {hold.get('profit_factor', '-')}</p>
</div>"""
def go_no_go_table_html(checks: list[dict[str, Any]], go: bool) -> str:
"""
Go/No-Go 검증 테이블 HTML.
Args:
checks: go_no_go.checks.
go: 종합 판정.
Returns:
section HTML.
"""
flag = "go-pass" if go else "go-fail"
label = "GO" if go else "NO-GO"
rows = []
for c in checks:
mark = "PASS" if c.get("pass") else "FAIL"
cls = "pass" if c.get("pass") else "fail"
rows.append(
f"<tr><td>{c.get('rule_id')}</td><td>{c.get('side')}</td>"
f'<td class="{cls}">{mark}</td>'
f'<td class="num">{c.get("holdout_ev")}</td>'
f'<td class="num">{c.get("holdout_pf")}</td>'
f'<td class="num">{c.get("wf_positive_ratio")}</td>'
f'<td class="num">{c.get("fee_stress_ev")}</td></tr>'
)
body = "\n".join(rows) if rows else "<tr><td colspan='7'>없음</td></tr>"
return f"""
<h2>Go/No-Go (monitor_rules)</h2>
<p class="go {flag}">종합 판정: {label}</p>
<table>
<thead><tr><th>규칙</th><th>side</th><th>판정</th><th>holdout EV%</th>
<th>holdout PF</th><th>WF 양수월</th><th>수수료 2x EV%</th></tr></thead>
<tbody>{body}</tbody>
</table>"""

191
deepcoin/ops/live_trader.py Normal file
View File

@@ -0,0 +1,191 @@
"""
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
"""
from __future__ import annotations
import json
import time
from datetime import date, datetime
from pathlib import Path
from typing import Any
from config import (
COIN_NAME,
LIVE_COOLDOWN_MIN,
LIVE_DAILY_KRW_MAX,
LIVE_DAILY_LOSS_LIMIT_KRW,
LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
LIVE_TRADING_ENABLED,
SYMBOL,
)
from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.ops.alert_message import build_rule_alert_message
from deepcoin.ops.monitor import Monitor
from deepcoin.paths import LIVE_TRADES_LOG
class LiveTrader(Monitor):
"""
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 주문 없음(드라이런 로그만).
"""
def __init__(self) -> None:
"""Monitor 초기화, 일별 카운터 비움."""
super().__init__(cooldown_file=None)
self._rule_last_unix: dict[str, float] = {}
self._day: str = ""
self._day_spent_krw: float = 0.0
self._day_trades: int = 0
self._day_pnl_krw: float = 0.0
def _reset_day_if_needed(self) -> None:
"""날짜 변경 시 일별 한도 카운터 초기화."""
today = date.today().isoformat()
if today != self._day:
self._day = today
self._day_spent_krw = 0.0
self._day_trades = 0
self._day_pnl_krw = 0.0
def _append_log(self, record: dict[str, Any]) -> None:
"""
live_trades.jsonl에 한 줄 append.
Args:
record: 로그 dict.
"""
LIVE_TRADES_LOG.parent.mkdir(parents=True, exist_ok=True)
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _can_trade(self, rule_id: str) -> tuple[bool, str]:
"""
일·쿨다운·손실 한도 검사.
Args:
rule_id: 규칙 ID.
Returns:
(허용 여부, 사유).
"""
self._reset_day_if_needed()
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
return False, "일 최대 거래 수 초과"
if self._day_spent_krw + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
return False, "일 주문 한도 초과"
if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW):
return False, "일 손실 한도 초과"
last = self._rule_last_unix.get(rule_id, 0.0)
if time.time() - last < LIVE_COOLDOWN_MIN * 60:
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
return True, ""
def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]:
"""
매수·매도 주문 실행 또는 드라이런.
Args:
hit: evaluate_live_rules 항목.
Returns:
로그용 결과 dict.
"""
side = hit["side"]
price = float(hit["close"])
amount_krw = float(LIVE_ORDER_KRW)
record: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="seconds"),
"rule_id": hit["rule_id"],
"side": side,
"signal_dt": hit["dt"],
"price": price,
"amount_krw": amount_krw,
"live_enabled": LIVE_TRADING_ENABLED,
"ok": False,
"message": "",
}
if not LIVE_TRADING_ENABLED:
record["message"] = "dry_run (LIVE_TRADING_ENABLED=0)"
record["ok"] = True
return record
try:
if side == "buy":
ok = self.buyCoinMarket(SYMBOL, int(amount_krw), count=None)
record["ok"] = bool(ok)
record["message"] = "buyCoinMarket" if ok else "buy failed"
elif side == "sell":
bal = self.load_balances_dict().get(SYMBOL, {})
qty = float(bal.get("balance") or 0)
if qty <= 0:
record["message"] = "보유 없음"
else:
ok = self.sellCoinMarket(SYMBOL, int(price), qty)
record["ok"] = bool(ok)
record["message"] = f"sell qty={qty}" if ok else "sell failed"
else:
record["message"] = f"unknown side {side}"
except Exception as exc:
record["message"] = str(exc)
if record["ok"]:
self._day_spent_krw += amount_krw
self._day_trades += 1
self._rule_last_unix[hit["rule_id"]] = time.time()
return record
def run_once(self) -> None:
"""1회: 규칙 평가 → (허용 시) 주문 → 텔레그램."""
rules = load_monitor_rules()
print(
f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} "
f"{COIN_NAME} live={'ON' if LIVE_TRADING_ENABLED else 'OFF'} "
f"rules={len(rules)}"
)
if not rules:
print(" monitor_rules 없음")
return
fired = evaluate_live_rules(rules)
balances = None
try:
balances = self.load_balances_dict()
except Exception:
pass
if not fired:
print(" 발화 없음")
return
for hit in fired:
rid = hit["rule_id"]
ok, reason = self._can_trade(rid)
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
if not ok:
print(f" skip: {reason}")
continue
log = self._execute_order(hit)
self._append_log(log)
print(f" order: {log['message']} ok={log['ok']}")
msg = build_rule_alert_message(hit, balances)
if log["ok"]:
msg += f"\n[체결] {log['message']}"
else:
msg += f"\n[실패] {log['message']}"
self._send_coin_msg(msg)
def run_loop(self, sleep_sec: int) -> None:
"""
상시 루프.
Args:
sleep_sec: 대기 초.
"""
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
while True:
self.run_once()
time.sleep(sleep_sec)

View File

@@ -1,19 +1,34 @@
"""
WLD(월드코인) 실시간 모니터 — BB·일목 위치·추세 출력 (자동 매매 없음).
WLD(월드코인) 실시간 모니터 — BB·일목·04 매칭 규칙 알림 (자동 매매 없음).
"""
from __future__ import annotations
from datetime import datetime
import time
from config import COIN_NAME, MONITOR_LOOP_SLEEP_SEC, SYMBOL
from config import COIN_NAME, MONITOR_ALERT_COOLDOWN_MIN, MONITOR_LOOP_SLEEP_SEC, SYMBOL
from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.ops.alert_message import build_rule_alert_message
from deepcoin.ops.monitor import Monitor
class MonitorCoin(Monitor):
"""WLD 시장 상태 주기 출력."""
"""WLD 시장 상태·매칭 규칙 주기 출력."""
def __init__(self, cooldown_file: str | None = None, *, check_rules: bool = True) -> None:
"""
Args:
cooldown_file: 매매 쿨다운 JSON 경로.
check_rules: True면 04 active_rules 평가·알림.
"""
super().__init__(cooldown_file=cooldown_file)
self.check_rules = check_rules
self._last_alert_unix: dict[str, float] = {}
def monitor_wld(self) -> None:
"""전 봉 BB·일목·추세를 콘솔에 출력합니다."""
"""전 봉 BB·일목·추세 및 규칙 발화를 출력합니다."""
print(
"[{}] {} ({})".format(
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
@@ -22,9 +37,49 @@ class MonitorCoin(Monitor):
)
)
self.process_wld_market_status(SYMBOL)
if self.check_rules:
self._check_matched_rules()
def _check_matched_rules(self) -> None:
"""04 monitor_rules 최신 봉 평가 후 쿨다운 내 중복 알림 방지."""
rules = load_monitor_rules()
if not rules:
print(" [04 규칙] monitor_rules 없음 — scripts/04_match_rules.py 실행")
return
try:
fired = evaluate_live_rules(rules)
except Exception as exc:
print(f" [04 규칙] 평가 오류: {exc}")
return
if not fired:
print(f" [04 규칙] 발화 없음 (감시 {len(rules)}개)")
return
balances: dict | None = None
try:
balances = self.load_balances_dict()
except Exception:
balances = None
cooldown_sec = MONITOR_ALERT_COOLDOWN_MIN * 60
now = time.time()
for hit in fired:
rid = hit["rule_id"]
preview = build_rule_alert_message(hit, balances).replace("\n", " | ")
print(f" [04] {preview}")
last = self._last_alert_unix.get(rid, 0.0)
if now - last < cooldown_sec:
print(f" [04] 쿨다운 skip {rid} ({MONITOR_ALERT_COOLDOWN_MIN}분)")
continue
self._last_alert_unix[rid] = now
self._send_coin_msg(build_rule_alert_message(hit, balances))
def run_schedule(self) -> None:
"""MONITOR_LOOP_SLEEP_SEC 간격으로 상태를 출력합니다."""
rules = load_monitor_rules()
names = ", ".join(r["rule_id"] for r in rules) or "(없음)"
print(
f"05 모니터 시작 · 감시 {len(rules)}개 ({names}) · "
f"주기 {MONITOR_LOOP_SLEEP_SEC}초 · 알림쿨다운 {MONITOR_ALERT_COOLDOWN_MIN}"
)
while True:
self.monitor_wld()
time.sleep(MONITOR_LOOP_SLEEP_SEC)

View File

@@ -42,6 +42,7 @@ from deepcoin.common.indicators import apply_bar_indicators, disparity_column, g
from deepcoin.ops.monitor import Monitor
from deepcoin.data.mtf_bb import interval_label, load_frames_from_db
from deepcoin.ops.chart_report import wrap_chart_report_page
from deepcoin.paths import CHART_BB_HTML, CHART_TRUTH_HTML, resolve_ground_truth_file
OUTPUT_HTML = CHART_BB_HTML
@@ -69,6 +70,49 @@ def _marker_sizes(trades: list[dict], action: str) -> list[float]:
]
def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
"""
시뮬(규칙) 매수·매도 마커 — 원형, GT 삼각형과 구분.
Args:
fig: plotly Figure.
trades: dt, action, price, forward_ret_pct, rule_id 키.
row: subplot row.
"""
for action, color, label in [
("buy", "#059669", "시뮬 매수"),
("sell", "#b91c1c", "시뮬 매도"),
]:
pts = [t for t in trades if t.get("action") == action]
if not pts:
continue
fig.add_trace(
go.Scatter(
x=[pd.Timestamp(t["dt"]) for t in pts],
y=[float(t["price"]) for t in pts],
mode="markers",
name=label,
legendgroup=label,
marker=dict(
symbol="circle",
size=9,
color=color,
line=dict(width=1, color="#fff"),
opacity=0.75,
),
hovertext=[
f"{label}<br>{t['dt'][:16]}<br>₩{float(t['price']):,.0f}"
f"<br>leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%"
f"<br>{t.get('rule_id', '')}"
for t in pts
],
hovertemplate="%{hovertext}<extra></extra>",
),
row=row,
col=1,
)
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
"""정답 매수·매도 마커 (삼각형 크기 = 비중)."""
for action, color, symbol, label in [
@@ -112,8 +156,12 @@ def build_chart_html(
interval_min: int = ENTRY_INTERVAL,
note: str = "",
truth_trades: list[dict] | None = None,
sim_trades: list[dict] | None = None,
title_suffix: str = "BB 차트",
pnl_summary: dict | None = None,
legend_html: str | None = None,
footer_sections: str | None = None,
cards_html: str | None = None,
) -> str:
"""BB·이격도·RSI·MACD·스토캐스틱·거래량 차트 HTML."""
df = apply_bar_indicators(df.copy())
@@ -191,6 +239,8 @@ def build_chart_html(
if truth_trades:
_add_truth_markers(fig, truth_trades, row=1)
if sim_trades:
_add_sim_markers(fig, sim_trades, row=1)
disp_row = 2
for i, p in enumerate(DISPARITY_PERIODS):
@@ -340,7 +390,8 @@ def build_chart_html(
last_price=close_last,
)
trade_rows = ""
if truth_trades:
trade_table = footer_sections or ""
if footer_sections is None and truth_trades:
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio_steps
steps = simulate_truth_portfolio_steps(
@@ -385,14 +436,13 @@ def build_chart_html(
<td><b>{total_s}</b>{hold_s}</td>
<td>{t.get('memo', '')}</td>
</tr>"""
trade_table = ""
if truth_trades:
if not trade_table and truth_trades:
if not trade_rows:
trade_rows = "<tr><td colspan='6'>타점 없음</td></tr>"
mark_note = ""
if pnl.get("mark_price"):
mark_note = (
f" 상단 최종 자산은 미청산 포함 종가 ₩{pnl['mark_price']:,.0f} 평가."
f" 총보유자산(미청산 포함)은 종가 ₩{pnl['mark_price']:,.0f} 평가."
)
trade_table = f"""
<h2>정답 타점 (ground_truth)</h2>
@@ -405,54 +455,43 @@ def build_chart_html(
pnl_cards = ""
if truth_trades and pnl.get("initial_cash_krw") is not None:
pnl_cards = f"""
<div class="card"><span>시작</span><b>₩{pnl['initial_cash_krw']:,.0f}</b></div>
<div class="card"><span>최종 자산</span><b>₩{pnl['final_asset_krw']:,.0f}</b></div>
<div class="card"><span>수익금</span><b>₩{pnl['pnl_krw']:+,.0f}</b></div>
<div class="card"><span>수익률</span><b>{pnl['pnl_pct']:+.2f}%</b></div>
<div class="card"><span>수수료</span><b>₩{pnl['total_fees_krw']:,.0f}</b></div>"""
if pnl.get("holding_qty", 0) > 0:
pnl_cards += f"""
<div class="card"><span>미청산</span><b>{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})</b></div>"""
from deepcoin.ops.chart_report import card_html, initial_change_pct
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>{SYMBOL} {title_suffix}</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; }}
.note {{ background: #f1f5f9; border: 1px solid #cbd5e1; padding: 10px; border-radius: 6px; color: #334155; }}
.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; }}
.chart-wrap {{ background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }}
.legend-box {{ font-size:0.85rem; color:#475569; margin-bottom:10px; }}
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; }}
</style>
</head>
<body>
<h1>{COIN_NAME} ({SYMBOL}) {title_suffix}</h1>
<p class="meta">추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}</p>
{note_html}
<div class="legend-box">▲ 매수 · ▼ 매도 — 삼각형이 클수록 비중이 큽니다.</div>
<div class="cards">
change_pct = initial_change_pct(pnl)
pnl_cards = (
card_html("초기 금액", f"{pnl['initial_cash_krw']:,.0f}")
+ card_html("총보유자산", f"{pnl['final_asset_krw']:,.0f}")
+ card_html("초기 대비 증감율", f"{change_pct:+.2f}%")
+ card_html("수수료", f"{pnl['total_fees_krw']:,.0f}")
)
if pnl.get("holding_qty", 0) > 0:
pnl_cards += card_html(
"미청산",
f"{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})",
)
default_legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 크기 = 비중.<br>"
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = monitor_rules holdout 발화."
)
if cards_html:
cards_inner = cards_html
else:
cards_inner = f"""
<div class="card"><span>종가</span><b>₩{close_last:,.2f}</b></div>
<div class="card"><span>BB %B</span><b>{bb_pos_txt}</b></div>
<div class="card"><span>정답 타점</span><b>{len(truth_trades) if truth_trades else 0}건</b></div>
{pnl_cards}
</div>
<div class="chart-wrap">{chart_html}</div>
{trade_table}
</body>
</html>"""
{pnl_cards}"""
return wrap_chart_report_page(
page_title=f"{SYMBOL} {title_suffix}",
heading=f"{COIN_NAME} ({SYMBOL}) {title_suffix}",
meta_line=f"추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}",
note_html=note_html,
legend_html=legend_html or default_legend,
cards_html=cards_inner,
chart_html=chart_html,
sections_html=trade_table,
)
def _frames_to_mtf(