40만 원 기준 시뮬·dry-run 정합 및 hybrid 체결 엔진 통합.

초기 자금 GT_INITIAL_CASH_KRW=400000과 원화 한도 비율(알림·LIVE_ORDER·일한도·손실한도)을 맞추고, dry-run/live 체결을 sim_causal_hybrid(replay)와 동일 경로로 통합한다. 시뮬 리포트 갱신, Phase C 슈퍼바이저·매수매도 리허설 스크립트를 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-03 11:31:24 +09:00
parent b9ee241d14
commit c6888c9228
24 changed files with 7707 additions and 6253 deletions

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Phase A: live_trader dry-run·hybrid tier 사이징·한도 점검."""
"""Phase A: live_trader dry-run·sim_causal_hybrid(06) 정합·한도 점검."""
from __future__ import annotations
@@ -11,13 +11,18 @@ runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
from config import ( # noqa: E402
CHART_LOOKBACK_DAYS,
GT_INITIAL_CASH_KRW,
GT_SIGNAL_CAUSAL,
LIVE_COOLDOWN_MIN,
LIVE_DAILY_KRW_MAX,
LIVE_DAILY_LOSS_LIMIT_KRW,
LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
LIVE_TRADING_ENABLED,
MATCH_LIVE_CACHE_SEC,
MATCH_PRIMARY_INTERVAL,
MONITOR_ALERT_KRW_AMOUNT,
MONITOR_LOOP_SLEEP_SEC,
SIM_PRIMARY_SIZING,
SYMBOL,
TRADING_FEE_RATE,
@@ -30,8 +35,11 @@ from deepcoin.ground_truth.gt_model import leg_entry_weights
from deepcoin.matching.position_sizing import compute_buy_amount_krw
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled
from deepcoin.ops.hybrid_sim_execution import replay_paper_portfolio
from deepcoin.ops.live_trader import LiveTrader
from deepcoin.ops.monitor import Monitor
from deepcoin.ops.paper_portfolio import PaperPortfolio
from deepcoin.matching.position_sizing import load_ev_wf_approved_rule_ids
def _plan_with_dd(
@@ -87,6 +95,12 @@ def check_config() -> list[str]:
f"loss_limit={LIVE_DAILY_LOSS_LIMIT_KRW:,} "
f"cooldown={LIVE_COOLDOWN_MIN}min"
)
print(
f" 06 루프: sleep={MONITOR_LOOP_SLEEP_SEC}s · "
f"live_eval_cache={MATCH_LIVE_CACHE_SEC}s · bar={MATCH_PRIMARY_INTERVAL}m"
)
print(" 체결 엔진: sim_causal_hybrid (hybrid_sim_execution)")
print(f" GT_INITIAL_CASH_KRW=₩{GT_INITIAL_CASH_KRW:,}")
rules = load_monitor_rules()
print(f" monitor_rules={[r['rule_id'] for r in rules]}")
if not GT_SIGNAL_CAUSAL:
@@ -102,15 +116,57 @@ def check_config() -> list[str]:
return issues
def check_capital_alignment() -> list[str]:
"""
초기 자금 40만 원 기준 원화 한도·알림 비율 점검 (100만 시대 ×0.4).
Returns:
불일치 시 이슈 문자열 목록.
"""
issues: list[str] = []
_print_header("1b. 초기 자금·비율 (40만 원)")
ic = int(GT_INITIAL_CASH_KRW)
expected = {
"GT_INITIAL_CASH_KRW": 400_000,
"MONITOR_ALERT_KRW_AMOUNT": int(ic * 0.10),
"LIVE_ORDER_KRW": int(ic * 0.10),
"LIVE_DAILY_LOSS_LIMIT_KRW": int(ic * 0.05),
"LIVE_DAILY_KRW_MAX": int(ic * 10),
}
actual = {
"GT_INITIAL_CASH_KRW": ic,
"MONITOR_ALERT_KRW_AMOUNT": int(MONITOR_ALERT_KRW_AMOUNT),
"LIVE_ORDER_KRW": int(LIVE_ORDER_KRW),
"LIVE_DAILY_LOSS_LIMIT_KRW": int(LIVE_DAILY_LOSS_LIMIT_KRW),
"LIVE_DAILY_KRW_MAX": int(LIVE_DAILY_KRW_MAX),
}
for key, exp in expected.items():
got = actual[key]
ok = got == exp
mark = "OK" if ok else "WARN"
print(f" [{mark}] {key}={got:,} (기대 {exp:,})")
if not ok:
issues.append(f"{key}={got:,} ≠ 기대 {exp:,}")
paper = PaperPortfolio.load()
if int(paper.cash_krw) != ic and paper.qty < 1e-12:
issues.append(
f"paper 현금 ₩{paper.cash_krw:,.0f} ≠ 초기 ₩{ic:,} (보유 없을 때)"
)
elif int(getattr(paper, "initial_cash_krw", 0) or paper.cash_krw) != ic:
print(f" [INFO] paper 운용 중 (cash=₩{paper.cash_krw:,.0f})")
return issues
def check_tier_sizing(df) -> list[str]:
"""hybrid vs conviction tier 금액 비교 (enhanced=False가 primary)."""
issues: list[str] = []
_print_header("2. hybrid tier 사이징 (시나리오)")
price = 487.0
ic = int(GT_INITIAL_CASH_KRW)
scenarios = [
("신규·소형DD(1%)", 1_000_000, 0.0, 1.0, {}),
("신규·대형DD(6%)", 1_000_000, 0.0, 6.0, {}),
("복리·과거large leg", 5_000_000, 2000.0, 3.0, {"completed_leg_ret": {1: 25.0}}),
("신규·소형DD(1%)", ic, 0.0, 1.0, {}),
("신규·대형DD(6%)", ic, 0.0, 6.0, {}),
("복리·과거large leg", ic * 5, 2000.0, 3.0, {"completed_leg_ret": {1: 25.0}}),
]
for label, cash, qty, dd, extra in scenarios:
hybrid_amt = _plan_with_dd(
@@ -138,9 +194,35 @@ def check_tier_sizing(df) -> list[str]:
return issues
def check_paper_replay(df) -> list[str]:
"""paper signal_history → 시뮬 replay 잔고 일치."""
issues: list[str] = []
_print_header("3. paper 시뮬 replay")
paper = PaperPortfolio.load()
hist = paper.signal_history
print(f" signal_history={len(hist)} · saved cash=₩{paper.cash_krw:,.0f} qty={paper.qty:.4f}")
if not hist:
print(" (이력 없음 — 신규 dry-run)")
return issues
approved = load_ev_wf_approved_rule_ids()
replayed, _ = replay_paper_portfolio(hist, df, approved_buy_rules=approved)
cash_diff = abs(replayed.cash_krw - paper.cash_krw)
qty_diff = abs(replayed.qty - paper.qty)
print(
f" replay cash=₩{replayed.cash_krw:,.0f} qty={replayed.qty:.4f} "
f"(Δcash={cash_diff:,.0f} Δqty={qty_diff:.6f})"
)
if cash_diff > 1.0 or qty_diff > 1e-6:
issues.append(
"paper_portfolio.json 과 sim replay 불일치 — "
"signal_history 갱신 후 06 --once 1회 권장"
)
return issues
def check_live_limits() -> None:
"""시뮬 대비 실거래 일한도 영향 안내."""
_print_header("3. 실거래 한도 vs hybrid tier")
_print_header("4. 실거래 한도 vs hybrid tier")
st = LivePositionState()
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
@@ -153,7 +235,7 @@ def check_live_limits() -> None:
planned = st.plan_buy_amount_krw(
str(df.index[-1]) if df is not None and not df.empty else "2026-06-01 12:00:00",
price,
1_000_000,
float(GT_INITIAL_CASH_KRW),
0.0,
df,
enhanced=False,
@@ -172,7 +254,7 @@ def check_live_limits() -> None:
def check_live_eval() -> None:
"""현재 시점 규칙 발화."""
_print_header("4. 현재 발화 (live_eval)")
_print_header("5. 현재 발화 (live_eval)")
fired = evaluate_live_rules(force_refresh=True)
if not fired:
print(" 발화 없음 (정상 — 신호 대기)")
@@ -183,7 +265,7 @@ def check_live_eval() -> None:
def run_dryrun_once() -> None:
"""06 1회 dry-run."""
_print_header("5. 06_execute_live --once (dry-run)")
_print_header("6. 06_execute_live --once (dry-run)")
LiveTrader().run_once()
@@ -199,7 +281,7 @@ def write_verification_report(issues: list[str], out_path: Path) -> None:
"",
"## Plan (목적)",
"",
"- hybrid primary(`enhanced=False`) live_trader 경로가 시뮬과 정합인지 확인",
"- 06 dry-run/live 체결이 `hybrid_sim_execution`(sim_causal_hybrid)과 정합인지 확인",
"- conviction tier(`enhanced=True`) 미사용 확인",
"- 실거래 한도가 hybrid tier와 어떻게 상호작용하는지 기록",
"",
@@ -247,6 +329,7 @@ def main() -> int:
"""Phase A 검증 실행."""
print("[06_verify] Phase A dry-run 검증 시작")
issues = check_config()
issues.extend(check_capital_alignment())
mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
df = frames.get(MATCH_PRIMARY_INTERVAL)
@@ -254,6 +337,7 @@ def main() -> int:
issues.append("3m OHLC 없음 — 01_download 필요")
else:
issues.extend(check_tier_sizing(df))
issues.extend(check_paper_replay(df))
check_live_limits()
check_live_eval()
run_dryrun_once()

View File

@@ -10,6 +10,7 @@ Phase C dry-run 종료 후 모의 수익률(참고) 집계.
"""
from __future__ import annotations
import argparse
import json
import runpy
from datetime import datetime
@@ -28,7 +29,12 @@ from config import ( # noqa: E402
)
from deepcoin.matching.label_outcomes import _forward_ret_vectorized # noqa: E402
from deepcoin.ops.monitor import Monitor # noqa: E402
from deepcoin.paths import PAPER_FIRES_LOG, PAPER_WEEKLY_REPORT_JSON # noqa: E402
from deepcoin.ops.paper_portfolio import PaperPortfolio # noqa: E402
from deepcoin.paths import ( # noqa: E402
PAPER_FIRES_LOG,
PAPER_WEEKLY_REPORT_JSON,
PHASE_C_DAILY_DIR,
)
_FEE_PCT = TRADING_FEE_RATE * 2 * 100
@@ -87,12 +93,22 @@ def attach_forward_returns(fires: pd.DataFrame, close_df: pd.DataFrame) -> pd.Da
return fires
def summarize(fires: pd.DataFrame) -> dict:
"""집계 dict."""
def summarize(fires: pd.DataFrame, *, report_kind: str = "daily") -> dict:
"""
집계 dict.
Args:
fires: forward_ret_pct 포함 발화 DataFrame.
report_kind: daily | final.
Returns:
JSON 직렬화 가능 dict.
"""
traded = fires[fires["would_trade"] == True] # noqa: E712
with_ret = traded[traded["forward_ret_pct"].notna()]
out: dict = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"report_kind": report_kind,
"symbol": SYMBOL,
"forward_bars": MATCH_FORWARD_BARS,
"fee_round_trip_pct": _FEE_PCT,
@@ -101,10 +117,17 @@ def summarize(fires: pd.DataFrame) -> dict:
"skipped_count": int(len(fires) - len(traded)),
"labeled_count": int(len(with_ret)),
"note": (
"모의 forward 수익률. 실계좌·hybrid 복리 PnL 아님. "
"매수·매도 leg 미결합 단순 합산."
"forward %는 발화별 참고 지표. "
"총보유금액(equity)은 paper_portfolio 모의 체결 기준."
),
}
if not fires.empty and "ts" in fires.columns:
out["log_from"] = str(fires["ts"].min())
out["log_to"] = str(fires["ts"].max())
buy_n = int((traded["side"] == "buy").sum()) if not traded.empty else 0
sell_n = int((traded["side"] == "sell").sum()) if not traded.empty else 0
out["buy_fires"] = buy_n
out["sell_fires"] = sell_n
if not with_ret.empty:
out["mean_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].mean()), 4)
out["sum_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].sum()), 4)
@@ -125,13 +148,25 @@ def summarize(fires: pd.DataFrame) -> dict:
return out
def main() -> None:
"""paper_fires 로드 → forward % → 리포트 저장."""
fires = load_paper_fires(PAPER_FIRES_LOG)
def build_phase_c_report(
fires_path: Path | None = None,
*,
report_kind: str = "daily",
) -> tuple[dict, pd.DataFrame]:
"""
paper_fires 로드 → forward % → 리포트 dict.
Args:
fires_path: jsonl 경로 (기본 PAPER_FIRES_LOG).
report_kind: daily | final.
Returns:
(report, fires_with_returns) — 발화 없으면 ({}, empty DataFrame).
"""
path = fires_path or PAPER_FIRES_LOG
fires = load_paper_fires(path)
if fires.empty:
print(f"[07] 발화 로그 없음: {PAPER_FIRES_LOG}")
print(" Phase C 기간 06_execute_live.py (LIVE=0) 상시 실행 후 재시도")
return
return {}, fires
mon = Monitor(cooldown_file=None)
df = mon.read_candles_from_db(SYMBOL, MATCH_PRIMARY_INTERVAL, max_rows=50000)
@@ -141,24 +176,172 @@ def main() -> None:
df = df.set_index(pd.to_datetime(df["datetime"]))
fires = attach_forward_returns(fires, df)
report = summarize(fires)
PAPER_WEEKLY_REPORT_JSON.parent.mkdir(parents=True, exist_ok=True)
PAPER_WEEKLY_REPORT_JSON.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
report = summarize(fires, report_kind=report_kind)
print(f"[07] 저장: {PAPER_WEEKLY_REPORT_JSON}")
print(f" 기간 로그: {fires['ts'].min()} ~ {fires['ts'].max()}")
print(f" 발화 {report['total_signals']} · 체결가정(would_trade) {report['would_trade_count']}")
mark = float(df["Close"].iloc[-1]) if not df.empty and "Close" in df.columns else 0.0
paper = PaperPortfolio.load()
report["paper_portfolio"] = paper.summary(mark)
return report, fires
def format_report_text(report: dict) -> str:
"""사람이 읽기 쉬운 요약 텍스트."""
kind = report.get("report_kind", "daily")
title = "Phase C 최종 보고" if kind == "final" else "Phase C 중간 보고"
lines = [
f"=== {title} ({report.get('generated_at', '')}) ===",
f"심볼: {report.get('symbol', '')}",
]
pf = report.get("paper_portfolio") or {}
if pf:
lines.extend(
[
"--- 모의 계좌 (dry-run, 빗썸 잔고 미사용) ---",
f"초기 자금: ₩{pf.get('initial_cash_krw', 0):,.0f}",
f"현금: ₩{pf.get('cash_krw', 0):,.0f} · "
f"보유 {pf.get('qty', 0):.4f} {report.get('symbol', '')} "
f"(평가단가 ₩{pf.get('mark_price', 0):,.0f})",
f"코인 평가: ₩{pf.get('coin_value_krw', 0):,.0f}",
f"총보유금액: ₩{pf.get('equity_krw', 0):,.0f} "
f"(손익 ₩{pf.get('pnl_krw', 0):+,.0f} / {pf.get('pnl_pct', 0):+.2f}%)",
]
)
lines.append(
f"발화 합계: {report.get('total_signals', 0)} "
f"(체결 {report.get('would_trade_count', 0)}, "
f"매수 {report.get('buy_fires', 0)} / 매도 {report.get('sell_fires', 0)})"
)
if "log_from" in report:
lines.append(f"로그 구간: {report['log_from']} ~ {report['log_to']}")
if "sum_forward_ret_pct" in report:
print(
f" 모의 forward 합산: {report['sum_forward_ret_pct']}% "
lines.append(
f"모의 forward 합산: {report['sum_forward_ret_pct']}% "
f"(평균 {report['mean_forward_ret_pct']}%, "
f"{MATCH_FORWARD_BARS}봉 후, 참고용)"
f"{report.get('forward_bars')}봉 후, 참고용)"
)
else:
print(" forward 라벨 가능 건 없음 (봉 데이터 부족 또는 발화 없음)")
lines.append("모의 forward: 라벨 가능 건 없음 (봉·발화 부족)")
lines.append(report.get("note", ""))
return "\n".join(lines)
def write_report_outputs(
report: dict,
*,
json_path: Path | None = None,
text_path: Path | None = None,
) -> None:
"""JSON·텍스트 리포트 저장."""
if json_path:
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if text_path:
text_path.parent.mkdir(parents=True, exist_ok=True)
text_path.write_text(format_report_text(report), encoding="utf-8")
def append_verification_log(report: dict, verification_md: Path) -> None:
"""live_verification 일별 표 해당 날짜 행 갱신."""
if not verification_md.is_file():
return
text = verification_md.read_text(encoding="utf-8")
iso = report.get("generated_at", "")[:10]
try:
y, m, d = iso.split("-")
day_label = f"{int(m)}/{int(d)}"
except ValueError:
return
buy = report.get("buy_fires", 0)
sell = report.get("sell_fires", 0)
pf = report.get("paper_portfolio") or {}
equity = pf.get("equity_krw", "-")
pnl_pct = pf.get("pnl_pct", "-")
kind = report.get("report_kind", "daily")
memo = "C 최종" if kind == "final" else "중간보고"
row = (
f"| {day_label} | Y | - | Y | {buy} | {sell} | "
f"총₩{equity} ({pnl_pct}%) {memo} |"
)
marker = "### 일별 기록"
if marker not in text:
return
head, table = text.split(marker, 1)
lines = table.splitlines()
new_lines: list[str] = []
replaced = False
for line in lines:
if line.startswith(f"| {day_label} |"):
new_lines.append(row)
replaced = True
else:
new_lines.append(line)
if not replaced:
new_lines.append(row)
verification_md.write_text(head + marker + "\n".join(new_lines), encoding="utf-8")
def run_report(
*,
report_kind: str = "daily",
stamp: str | None = None,
update_verification: bool = True,
) -> dict:
"""
리포트 생성·저장·콘솔 출력.
Args:
report_kind: daily | final.
stamp: 파일명용 타임스탬프 (기본 now).
update_verification: live_verification md 갱신 여부.
Returns:
report dict (빈 dict 가능).
"""
report, fires = build_phase_c_report(report_kind=report_kind)
if not report:
print(f"[07] 발화 로그 없음: {PAPER_FIRES_LOG}")
print(" Phase C 기간 06_execute_live.py (LIVE=0) 상시 실행 후 재시도")
return {}
stamp = stamp or datetime.now().strftime("%Y%m%d_%H%M")
daily_dir = PHASE_C_DAILY_DIR
write_report_outputs(
report,
json_path=daily_dir / f"report_{stamp}_{report_kind}.json",
text_path=daily_dir / f"report_{stamp}_{report_kind}.txt",
)
write_report_outputs(report, json_path=PAPER_WEEKLY_REPORT_JSON)
if update_verification:
append_verification_log(
report,
Path(__file__).resolve().parents[1]
/ "docs/05_ops/live_verification_20260601.md",
)
print(format_report_text(report))
print(f"[07] JSON: {PAPER_WEEKLY_REPORT_JSON}")
print(f"[07] 일별: {daily_dir}/report_{stamp}_{report_kind}.*")
return report
def main() -> None:
"""CLI: paper_fires → forward % → 리포트 저장."""
parser = argparse.ArgumentParser(description="Phase C 모의 forward % 집계")
parser.add_argument(
"--kind",
choices=("daily", "final"),
default="daily",
help="daily=중간, final=금요일 최종",
)
parser.add_argument("--no-verification-md", action="store_true")
args = parser.parse_args()
run_report(
report_kind=args.kind,
update_verification=not args.no_verification_md,
)
if __name__ == "__main__":

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Phase C 슈퍼바이저: 06 dry-run 상시 + 매일 22:00 중간보고 + 금요일 22:00 최종 후 종료.
사용:
python scripts/08_phase_c_supervisor.py
python scripts/08_phase_c_supervisor.py --end-date 2026-06-05 --report-hour 22
"""
from __future__ import annotations
import argparse
import runpy
import signal
import subprocess
import sys
import time
from datetime import date, datetime, timedelta
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
runpy.run_path(str(_ROOT / "scripts" / "_bootstrap.py"))
from config import LIVE_TRADING_ENABLED # noqa: E402
from deepcoin.paths import ( # noqa: E402
PHASE_C_SUPERVISOR_LOG,
PHASE_C_SUPERVISOR_PID,
)
_DEFAULT_PY = "/Users/dsyoon/opt/anaconda3/envs/coin/bin/python"
_REPORT_WINDOW_MIN = 5
def _log(msg: str) -> None:
"""슈퍼바이저 로그 (파일; nohup 시 stdout 중복 방지)."""
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
PHASE_C_SUPERVISOR_LOG.parent.mkdir(parents=True, exist_ok=True)
with PHASE_C_SUPERVISOR_LOG.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def _run_script(py: str, name: str, *args: str) -> int:
"""scripts/ 하위 스크립트 실행."""
cmd = [py, str(_ROOT / "scripts" / name), *args]
_log(f"실행: {' '.join(cmd)}")
proc = subprocess.run(cmd, cwd=str(_ROOT), check=False)
return proc.returncode
def _in_report_window(now: datetime, hour: int) -> bool:
"""보고 시각(시) 직후 REPORT_WINDOW_MIN 분 이내."""
return now.hour == hour and now.minute < _REPORT_WINDOW_MIN
def _stop_child(proc: subprocess.Popen[bytes] | None) -> None:
"""06 자식 프로세스 종료."""
if proc is None or proc.poll() is not None:
return
_log(f"06 종료 요청 pid={proc.pid}")
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
_log("06 종료 완료")
def _write_pid() -> None:
"""슈퍼바이저 PID 기록."""
PHASE_C_SUPERVISOR_PID.parent.mkdir(parents=True, exist_ok=True)
PHASE_C_SUPERVISOR_PID.write_text(str(os_getpid()), encoding="utf-8")
def os_getpid() -> int:
"""현재 PID."""
import os
return os.getpid()
def _daily_pipeline(py: str, *, final: bool) -> None:
"""다운로드 → verify → 07 보고."""
_run_script(py, "01_download.py")
_run_script(py, "06_verify_live_dryrun.py")
kind = "final" if final else "daily"
_run_script(py, "07_phase_c_paper_report.py", "--kind", kind)
def main() -> int:
"""
Phase C 슈퍼바이저 메인.
Returns:
종료 코드 0=정상, 1=설정 오류.
"""
parser = argparse.ArgumentParser(description="Phase C dry-run 슈퍼바이저")
parser.add_argument(
"--end-date",
type=lambda s: date.fromisoformat(s),
default=date(2026, 6, 5),
help="최종 보고·종료일 (금요일, ISO)",
)
parser.add_argument("--report-hour", type=int, default=22, help="일일 보고 시각(시, 24h)")
parser.add_argument(
"--py",
default=_DEFAULT_PY,
help="Python 실행 파일 (coin conda)",
)
args = parser.parse_args()
if LIVE_TRADING_ENABLED:
_log("오류: LIVE_TRADING_ENABLED=1 — Phase C는 0 이어야 합니다.")
return 1
_write_pid()
reported: set[date] = set()
py = args.py
end: date = args.end_date
hour: int = args.report_hour
_log(
f"Phase C 슈퍼바이저 시작 · end={end} · 보고 {hour}:00 KST · LIVE=0"
)
child = subprocess.Popen(
[py, str(_ROOT / "scripts" / "06_execute_live.py")],
cwd=str(_ROOT),
)
_log(f"06 dry-run 기동 pid={child.pid}")
try:
while True:
now = datetime.now()
today = now.date()
if child.poll() is not None:
_log(f"06 비정상 종료 code={child.returncode} — 재기동")
child = subprocess.Popen(
[py, str(_ROOT / "scripts" / "06_execute_live.py")],
cwd=str(_ROOT),
)
if _in_report_window(now, hour) and today not in reported:
if today <= end:
reported.add(today)
is_final = today == end
_log(
f"{'최종' if is_final else '중간'} 보고 시작 ({today})"
)
_daily_pipeline(py, final=is_final)
if is_final:
_log("금요일 최종 보고 완료 — Phase C dry-run 종료")
break
if today > end and today not in reported:
_log("종료일 경과 — 최종 보고(미실시 시) 후 종료")
_daily_pipeline(py, final=True)
break
time.sleep(60)
except KeyboardInterrupt:
_log("KeyboardInterrupt — 종료")
finally:
_stop_child(child)
if PHASE_C_SUPERVISOR_PID.is_file():
PHASE_C_SUPERVISOR_PID.unlink(missing_ok=True)
_log("슈퍼바이저 종료")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Phase C: 슈퍼바이저(06 상시 + 22시 보고 + 금요일 종료). 백그라운드 기동용.
set -e
cd "$(dirname "$0")/.."
PY="${PY:-/Users/dsyoon/opt/anaconda3/envs/coin/bin/python}"
LOG="${LOG:-data/ops/phase_c_supervisor.log}"
PIDFILE="${PIDFILE:-data/ops/phase_c_supervisor.pid}"
if [[ -f "$PIDFILE" ]]; then
old=$(cat "$PIDFILE")
if kill -0 "$old" 2>/dev/null; then
echo "이미 실행 중 (pid=$old). 중복 기동하지 않습니다."
exit 0
fi
fi
mkdir -p data/ops
nohup "$PY" -u scripts/08_phase_c_supervisor.py >> "$LOG" 2>&1 &
disown
echo $! > "$PIDFILE"
echo "Phase C 슈퍼바이저 기동 pid=$(cat "$PIDFILE")"
echo "로그: $LOG"

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
40만 원 기준 매수·매도 최종 리허설 (DB 없이 synthetic + paper replay).
"""
from __future__ import annotations
import runpy
import sys
from pathlib import Path
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
import pandas as pd
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
from deepcoin.matching.position_sizing import load_ev_wf_approved_rule_ids
from deepcoin.ops.hybrid_sim_execution import (
hit_key,
plan_live_hit,
replay_paper_portfolio,
sort_hits_sim_order,
)
from deepcoin.ops.paper_portfolio import PaperPortfolio
def _mini_ohlc() -> pd.DataFrame:
"""drawdown 계산용 최소 3m OHLC."""
idx = pd.date_range("2026-06-01 09:00:00", periods=200, freq="3min")
close = pd.Series([500.0 - i * 0.1 for i in range(200)], index=idx, dtype=float)
return pd.DataFrame(
{"Open": close, "High": close + 2, "Low": close - 2, "Close": close},
index=idx,
)
def test_sort_buy_before_sell() -> None:
"""동일 시각 buy·sell → buy 먼저."""
hits = [
{"dt": "2026-06-01 12:00:00", "rule_id": "sell_mtf_cross_all_tf", "side": "sell", "close": 500.0},
{"dt": "2026-06-01 12:00:00", "rule_id": "buy_compound_tight", "side": "buy", "close": 500.0},
]
ordered = sort_hits_sim_order(hits)
assert ordered[0]["side"] == "buy", ordered
print(" [OK] 동일 시각 buy → sell 순서")
def test_sell_without_holdings() -> None:
"""보유 없이 매도만 → 모의 보유 없음."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
hist = [
{
"dt": "2026-06-01 12:00:00",
"rule_id": "sell_mtf_cross_all_tf",
"side": "sell",
"close": 500.0,
}
]
paper, results = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)
key = hit_key(hist[0])
res = results[key]
assert not res.ok and "보유 없음" in res.message, res
assert paper.qty < 1e-9 and paper.cash_krw == float(GT_INITIAL_CASH_KRW)
print(" [OK] 보유 없음 매도 스킵")
def test_buy_then_partial_sell() -> None:
"""매수 후 분할 매도 1회."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
dt_buy = str(ohlc.index[50])
dt_sell = str(ohlc.index[80])
price_buy = float(ohlc.loc[ohlc.index[50], "Close"])
price_sell = float(ohlc.loc[ohlc.index[80], "Close"])
hist = [
{
"dt": dt_buy,
"rule_id": "buy_compound_tight",
"side": "buy",
"close": price_buy,
},
{
"dt": dt_sell,
"rule_id": "sell_mtf_cross_all_tf",
"side": "sell",
"close": price_sell,
},
]
paper, results = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)
buy_res = results[hit_key(hist[0])]
sell_res = results[hit_key(hist[1])]
assert buy_res.ok, buy_res.message
assert paper.qty > 0 or sell_res.ok, (paper.qty, sell_res)
if sell_res.ok:
assert sell_res.sell_qty > 0 and sell_res.amount_krw > 0
assert paper.cash_krw > float(GT_INITIAL_CASH_KRW) * 0.5
print(
f" [OK] 매수 ₩{buy_res.amount_krw:,.0f} → 매도 "
f"ok={sell_res.ok} qty={sell_res.sell_qty:.4f} 현금=₩{paper.cash_krw:,.0f}"
)
def test_unapproved_buy_excluded_from_sizing() -> None:
"""EV/WF 미포함 매수는 hybrid 배분 입력에서 제외."""
ohlc = _mini_ohlc()
hist = [
{
"dt": str(ohlc.index[60]),
"rule_id": "buy_fake_rule",
"side": "buy",
"close": 500.0,
},
]
approved = {"buy_compound_tight"}
sized_hist = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)[0]
assert sized_hist.qty < 1e-9
print(" [OK] 미승인 매수 규칙 → 체결 없음")
def test_plan_live_matches_replay() -> None:
"""plan_live_hit == replay 마지막 건."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
hist = []
hit = {
"dt": str(ohlc.index[70]),
"rule_id": "buy_compound_tight",
"side": "buy",
"close": float(ohlc["Close"].iloc[70]),
}
plan = plan_live_hit(hist, hit, ohlc, approved_buy_rules=approved)
hist.append(hit)
_, results = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)
replay_res = results[hit_key(hit)]
assert plan.amount_krw == replay_res.amount_krw, (plan, replay_res)
assert plan.ok == replay_res.ok
print(f" [OK] plan_live_hit ≡ replay (₩{plan.amount_krw:,.0f})")
def test_initial_cash_400k_large_buy() -> None:
"""40만·대형 DD 시 매수액 ≤ 가용현금."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
hit = {
"dt": str(ohlc.index[100]),
"rule_id": "buy_compound_tight",
"side": "buy",
"close": float(ohlc["Close"].iloc[100]),
}
plan = plan_live_hit([], hit, ohlc, approved_buy_rules=approved)
assert plan.ok
assert 0 < plan.amount_krw <= GT_INITIAL_CASH_KRW
fee = plan.amount_krw * TRADING_FEE_RATE
assert plan.amount_krw + fee <= GT_INITIAL_CASH_KRW + 1
print(f" [OK] 40만 대형 tier 매수 ₩{plan.amount_krw:,.0f} (≤{GT_INITIAL_CASH_KRW:,})")
def test_paper_apply_buy_insufficient() -> None:
"""현금 부족 시 apply_buy 실패."""
p = PaperPortfolio()
p.cash_krw = 10_000.0
ok = p.apply_buy(50_000, 500.0, leg_id=1)
assert not ok
print(" [OK] 현금 부족 매수 거부")
def main() -> int:
"""리허설 실행."""
print(f"[리허설] GT_INITIAL_CASH_KRW=₩{GT_INITIAL_CASH_KRW:,}")
print(f" approved buys: {load_ev_wf_approved_rule_ids()}")
fails = 0
tests = [
test_sort_buy_before_sell,
test_sell_without_holdings,
test_buy_then_partial_sell,
test_unapproved_buy_excluded_from_sizing,
test_plan_live_matches_replay,
test_initial_cash_400k_large_buy,
test_paper_apply_buy_insufficient,
]
for fn in tests:
try:
fn()
except AssertionError as e:
print(f" [FAIL] {fn.__name__}: {e}")
fails += 1
except Exception as e:
print(f" [ERROR] {fn.__name__}: {e}")
fails += 1
print(f"\n[결과] {'PASS' if fails == 0 else f'FAIL ({fails})'}")
return 1 if fails else 0
if __name__ == "__main__":
raise SystemExit(main())