refactor: GT·시뮬·운영 3축 정리 및 hybrid 실거래 정합

Phase C/dry-run·미사용 모듈·재생성 HTML을 제거하고, 운영 체결을
sim_causal_hybrid와 동일한 hybrid 로직으로 통합한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xavis
2026-06-03 23:50:28 +09:00
parent a16c942be4
commit d7848df6f7
85 changed files with 177180 additions and 196131 deletions

View File

@@ -1,7 +1,7 @@
"""
시뮬 sim_causal_hybrid 와 동일 체결 엔진 (build_monitor_hybrid_sized_trades).
dry-run·live(06) 모두 발화 이력 → hybrid 배분 → amount_krw·수량 적용.
live(06) plan_live_hit: 발화 이력 → hybrid 배분 → amount_krw·수량 (인과, 현금·보유 제약).
"""
from __future__ import annotations
@@ -11,10 +11,17 @@ from typing import Any
import pandas as pd
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
from config import (
CHART_LOOKBACK_DAYS,
GT_INITIAL_CASH_KRW,
LIVE_HYBRID_BOOTSTRAP_FIRES,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
from deepcoin.ops.paper_portfolio import PaperPortfolio
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.paths import MATCHING_FIRE_OUTCOMES
@dataclass
@@ -29,6 +36,190 @@ class SimTradeResult:
leg_id: int | None = None
def bootstrap_monitor_signals_from_outcomes(
*,
end_dt: str | None = None,
lookback_days: int | None = None,
) -> list[dict[str, Any]]:
"""
04 fire_outcomes 에서 monitor 규칙 발화를 로드 (시뮬 all_monitor 와 동일 입력).
Args:
end_dt: 이 시각 이전만 포함 (None=전체).
lookback_days: CHART_LOOKBACK_DAYS 대신 사용할 일수.
Returns:
{dt, rule_id, side, close} 리스트 (시각순).
"""
import pandas as pd
path = MATCHING_FIRE_OUTCOMES
if not path.is_file():
return []
monitor_ids = {r["rule_id"] for r in load_monitor_rules()}
if not monitor_ids:
return []
df = pd.read_csv(path)
if df.empty or "rule_id" not in df.columns:
return []
sub = df[df["rule_id"].isin(monitor_ids)].copy()
if sub.empty:
return []
sub["dt"] = sub["dt"].astype(str)
if end_dt:
sub = sub[sub["dt"] <= str(end_dt)]
if lookback_days is not None and lookback_days > 0:
end_ts = pd.to_datetime(sub["dt"].max()) if end_dt is None else pd.to_datetime(end_dt)
start = end_ts - pd.Timedelta(days=int(lookback_days))
sub = sub[pd.to_datetime(sub["dt"]) >= start]
elif lookback_days is None and CHART_LOOKBACK_DAYS > 0:
end_ts = pd.to_datetime(sub["dt"].max())
start = end_ts - pd.Timedelta(days=int(CHART_LOOKBACK_DAYS))
sub = sub[pd.to_datetime(sub["dt"]) >= start]
rows: list[dict[str, Any]] = []
for _, r in sub.iterrows():
rows.append(
{
"dt": str(r["dt"]),
"rule_id": str(r["rule_id"]),
"side": str(r["side"]),
"close": float(r["close"]),
}
)
return sort_hits_sim_order(rows)
def merge_signal_histories(
*histories: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
발화 이력 병합 (dt+rule_id+side 기준 중복 제거, 시뮬 정렬).
Args:
*histories: 신호 dict 리스트들.
Returns:
병합·정렬된 리스트.
"""
seen: set[tuple[str, str, str]] = set()
merged: list[dict[str, Any]] = []
for hist in histories:
for h in hist:
key = hit_key(h)
if key in seen:
continue
seen.add(key)
merged.append(
{
"dt": key[0],
"rule_id": key[1],
"side": key[2],
"close": float(h["close"]),
}
)
return sort_hits_sim_order(merged)
def build_live_signal_history(
persisted: list[dict[str, Any]] | None = None,
*,
bootstrap_fires: bool | None = None,
) -> list[dict[str, Any]]:
"""
운영 hybrid 이력: fire_outcomes 부트스트랩 + live 저장분 병합.
Args:
persisted: live_signal_history.json signals.
bootstrap_fires: fire_outcomes 부트스트랩 여부. None이면 config.
Returns:
sim_causal_hybrid 입력과 동일 형식의 이력.
"""
use_boot = (
LIVE_HYBRID_BOOTSTRAP_FIRES if bootstrap_fires is None else bootstrap_fires
)
parts: list[list[dict[str, Any]]] = []
if use_boot:
boot = bootstrap_monitor_signals_from_outcomes()
if boot:
parts.append(boot)
if persisted:
parts.append(persisted)
if not parts:
return []
return merge_signal_histories(*parts)
class HybridSimPortfolio:
"""
hybrid 배분 결과를 현금·보유 수량에 적용 (시뮬·live plan 공용, API 미사용).
"""
def __init__(self) -> None:
"""초기 현금만 보유."""
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
self.qty: float = 0.0
self.qty_by_leg: dict[int, float] = {}
self.sell_leg: int | None = None
self.sell_base_qty: float = 0.0
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
"""
매수 체결 (가용 현금·수수료 범위).
Args:
amount_krw: 매수 원화.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if amount_krw <= 0 or price <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
if self.cash_krw < amount_krw + fee:
return False
self.cash_krw -= amount_krw + fee
bought = amount_krw / price
self.qty += bought
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def apply_sell(self, amount_krw: float, sell_qty: float, price: float, leg_id: int) -> bool:
"""
매도 체결 (보유 수량 필요).
Args:
amount_krw: 매도 원화(총액).
sell_qty: 매도 수량.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if sell_qty <= 0 or amount_krw <= 0:
return False
leg_qty = self.qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
return False
fee = amount_krw * TRADING_FEE_RATE
self.cash_krw += amount_krw - fee
leg_qty -= sell_qty
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
self.qty = max(self.qty - sell_qty, 0.0)
if self.qty < 1e-12:
self.qty = 0.0
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
self.qty_by_leg.pop(leg_id, None)
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def hit_key(hit: dict[str, Any]) -> tuple[str, str, str]:
"""발화 고유 키 (dt, rule_id, side)."""
return (str(hit["dt"]), str(hit["rule_id"]), str(hit["side"]))
@@ -55,14 +246,17 @@ def sort_hits_sim_order(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _signals_for_hybrid(
signal_history: list[dict[str, Any]],
*,
approved_buy_rules: set[str] | None,
approved_buy_rules: set[str] | None = None,
) -> list[dict[str, Any]]:
"""
hybrid 배분용 신호 목록 (EV/WF 미통과 매수 제외).
hybrid 배분용 신호 목록.
sim_causal_hybrid 와 동일하려면 approved_buy_rules=None (monitor 전체 발화).
운영에서 추가로 EV/WF 매수만 허용하려면 rule_id 집합을 넘깁니다.
Args:
signal_history: {dt, rule_id, side, close}.
approved_buy_rules: 허용 매수 rule_id.
approved_buy_rules: None=필터 없음. set 이면 해당 매수 rule_id.
Returns:
시뮬 입력 trade dict 리스트.
@@ -71,7 +265,11 @@ def _signals_for_hybrid(
for h in sort_hits_sim_order(signal_history):
side = str(h["side"])
rid = str(h["rule_id"])
if side == "buy" and approved_buy_rules is not None and rid not in approved_buy_rules:
if (
side == "buy"
and approved_buy_rules is not None
and rid not in approved_buy_rules
):
continue
out.append(
{
@@ -118,29 +316,19 @@ def size_monitor_signals(
return sized
def _find_sized_trade(sized: list[dict[str, Any]], hit: dict[str, Any]) -> dict[str, Any] | None:
"""sized 목록에서 발화 1건 조회."""
dt, rid, side = hit_key(hit)
for t in sized:
action = str(t.get("action", t.get("side", "")))
if str(t.get("dt")) == dt and str(t.get("rule_id", "")) == rid and action == side:
return t
return None
def replay_paper_portfolio(
def replay_hybrid_signals(
signal_history: list[dict[str, Any]],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> tuple[PaperPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
) -> tuple[HybridSimPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
"""
신호 이력 전체를 시뮬 엔진으로 재생 → 모의 계좌(GT_INITIAL_CASH_KRW) 상태.
신호 이력 전체를 hybrid 배분·체결 규칙으로 재생 (simulate_portfolio_steps 동일).
Args:
signal_history: Phase C 누적 발화.
signal_history: 누적 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: EV/WF 통과 매수 규칙.
approved_buy_rules: None=시뮬 동일. set 이면 매수 rule_id 필터.
Returns:
(portfolio, hit_key → SimTradeResult).
@@ -148,67 +336,97 @@ def replay_paper_portfolio(
sized = size_monitor_signals(
signal_history, ohlc_df, approved_buy_rules=approved_buy_rules
)
paper = PaperPortfolio()
paper.cash_krw = float(GT_INITIAL_CASH_KRW)
paper.qty = 0.0
paper.qty_by_leg = {}
portfolio = HybridSimPortfolio()
results: dict[tuple[str, str, str], SimTradeResult] = {}
fee_rate = TRADING_FEE_RATE
current_leg: int | None = None
leg_budget = 0.0
leg_sell_idxs: dict[int, list[int]] = {}
for i, t in enumerate(sized):
lid = int(t.get("leg_id", 0))
if str(t.get("action", t.get("side"))) == "sell":
leg_sell_idxs.setdefault(lid, []).append(i)
sell_leg: int | None = None
sell_base_qty = 0.0
for i, t in enumerate(sized):
side = str(t.get("action", t.get("side", "")))
for t in sorted(sized, key=lambda x: x["dt"]):
action = str(t.get("action", t.get("side", "")))
price = float(t["price"])
if price <= 0:
continue
dt = str(t["dt"])
rid = str(t.get("rule_id", ""))
leg_id = int(t.get("leg_id", 0))
hit = {"dt": dt, "rule_id": rid, "side": side, "close": price}
weight = float(t.get("weight", 1.0))
hit = {"dt": dt, "rule_id": rid, "side": action, "close": price}
key = hit_key(hit)
amount = float(t.get("amount_krw") or 0)
if side == "buy":
if action == "buy":
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
amount = min(
float(ak),
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
)
else:
if leg_id != current_leg:
current_leg = leg_id
leg_budget = portfolio.cash_krw
amount = min(
leg_budget * weight,
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
)
if amount <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매수 스킵(현금·tier)"
)
continue
ok = paper.apply_buy(amount, price, leg_id)
msg = f"paper_buy sim leg={leg_id}{amount:,.0f}" if ok else "paper_buy 실패"
results[key] = SimTradeResult(
hit, amount, 0.0, ok, msg, leg_id=leg_id
fee = amount * fee_rate
portfolio.cash_krw -= amount + fee
bought = amount / price
portfolio.qty += bought
portfolio.qty_by_leg[leg_id] = (
portfolio.qty_by_leg.get(leg_id, 0.0) + bought
)
portfolio.sell_leg = None
portfolio.sell_base_qty = 0.0
results[key] = SimTradeResult(
hit,
amount,
0.0,
True,
f"sim_buy leg={leg_id}{amount:,.0f}",
leg_id=leg_id,
)
sell_leg = None
continue
leg_qty = paper.qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "모의 보유 없음")
continue
if amount <= 0:
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "시뮬 매도 스킵")
if action == "sell" and portfolio.qty > 0:
leg_qty = portfolio.qty_by_leg.get(leg_id, portfolio.qty)
if portfolio.sell_leg != leg_id:
portfolio.sell_leg = leg_id
portfolio.sell_base_qty = leg_qty
sell_qty = resolve_sell_qty(
t, leg_qty, price, portfolio.sell_base_qty, weight
)
if sell_qty <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매도 스킵"
)
continue
gross = sell_qty * price
fee = gross * fee_rate
portfolio.cash_krw += gross - fee
leg_qty -= sell_qty
portfolio.qty_by_leg[leg_id] = max(leg_qty, 0.0)
portfolio.qty = max(portfolio.qty - sell_qty, 0.0)
if portfolio.qty < 1e-12:
portfolio.qty = 0.0
results[key] = SimTradeResult(
hit,
gross,
sell_qty,
True,
f"sim_sell qty={sell_qty:.4f}{gross:,.0f}",
leg_id=leg_id,
)
continue
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
rem = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
is_last = bool(rem) and i == rem[-1]
sell_qty = leg_qty if is_last else amount / price if price > 0 else 0.0
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "보유 없음")
ok = paper.apply_sell(amount, sell_qty, price, leg_id)
msg = f"paper_sell sim qty={sell_qty:.4f}{amount:,.0f}" if ok else "paper_sell 실패"
results[key] = SimTradeResult(
hit, amount, sell_qty, ok, msg, leg_id=leg_id
)
return paper, results
return portfolio, results
def plan_live_hit(
@@ -228,7 +446,7 @@ def plan_live_hit(
approved_buy_rules: 매수 허용.
Returns:
SimTradeResult (dry-run replay_paper_portfolio 와 동일).
SimTradeResult.
"""
if ohlc_df is None or getattr(ohlc_df, "empty", True):
return SimTradeResult(hit, 0.0, 0.0, False, "OHLC 없음")
@@ -246,7 +464,7 @@ def plan_live_hit(
"close": float(hit["close"]),
}
)
_, results = replay_paper_portfolio(
_, results = replay_hybrid_signals(
hist, ohlc_df, approved_buy_rules=approved_buy_rules
)
res = results.get((dt, rid, side))

View File

@@ -1,7 +1,10 @@
"""
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
dry-run·live 체결 배분: 시뮬 sim_causal_hybrid 와 동일 (hybrid_sim_execution).
체결 배분: 시뮬 sim_causal_hybrid 와 동일
- fire_outcomes 부트스트랩 + hybrid_sim_execution.plan_live_hit
- enhanced=False, hybrid DD tier, EV/WF 매수 필터
LIVE_TRADING_ENABLED=1 필수.
"""
from __future__ import annotations
@@ -9,16 +12,15 @@ from __future__ import annotations
import json
import time
from datetime import date, datetime
from pathlib import Path
from typing import Any
from config import (
CHART_LOOKBACK_DAYS,
COIN_NAME,
GT_INITIAL_CASH_KRW,
LIVE_COOLDOWN_MIN,
LIVE_DAILY_KRW_MAX,
LIVE_DAILY_LOSS_LIMIT_KRW,
LIVE_HYBRID_BOOTSTRAP_FIRES,
LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
LIVE_TRADING_ENABLED,
@@ -27,64 +29,48 @@ from config import (
TRADING_FEE_RATE,
)
from deepcoin.data.mtf_bb import load_frames_from_db
from deepcoin.ground_truth.ground_truth import load_ground_truth
from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.position_sizing import (
load_ev_wf_approved_rule_ids,
top_leg_ids_by_forward_return,
)
from deepcoin.ops.alert_message import build_rule_alert_message
from deepcoin.ops.hybrid_sim_execution import (
SimTradeResult,
build_live_signal_history,
hit_key,
plan_live_hit,
replay_paper_portfolio,
sort_hits_sim_order,
)
from deepcoin.ops.monitor import Monitor
from deepcoin.ops.paper_portfolio import PaperPortfolio
from deepcoin.paths import (
LIVE_SIGNAL_HISTORY_JSON,
LIVE_TRADES_LOG,
PAPER_FIRES_LOG,
resolve_ground_truth_file,
)
class LiveTrader(Monitor):
"""
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 모의(sim hybrid)만.
규칙 발화 시 빗썸 실주문. 배분은 시뮬 sim_causal_hybrid 와 동일 엔진.
"""
def __init__(self) -> None:
"""Monitor 초기화, 일별 카운터 비움."""
"""Monitor 초기화, hybrid 이력·일별 카운터."""
if not LIVE_TRADING_ENABLED:
raise RuntimeError(
"LIVE_TRADING_ENABLED=0 — 실거래만 지원합니다. "
".env 에 LIVE_TRADING_ENABLED=1 설정 후 재기동하세요."
)
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
self._gt_trades: list[dict] = []
self._large_legs: set[int] = set()
self._approved_rules: set[str] = set()
self._position_state = LivePositionState.load()
self._paper = PaperPortfolio.load() if not LIVE_TRADING_ENABLED else None
self._live_signal_history: list[dict[str, Any]] = []
self._ohlc_df = None
self._load_sizing_context()
if self._paper_mode and self._paper.signal_history:
self._resync_paper_from_sim()
if LIVE_TRADING_ENABLED:
self._live_signal_history = self._load_live_signal_history()
self._persisted_ops_signals: list[dict[str, Any]] = self._load_persisted_signals()
self._live_signal_history = self._init_signal_history()
self._load_ohlc_df()
@property
def _paper_mode(self) -> bool:
"""dry-run: 모의 계좌·시뮬 hybrid 체결."""
return not LIVE_TRADING_ENABLED and self._paper is not None
def _load_live_signal_history(self) -> list[dict[str, Any]]:
"""live 시뮬 정합용 발화 이력."""
def _load_persisted_signals(self) -> list[dict[str, Any]]:
"""live_signal_history.json."""
if not LIVE_SIGNAL_HISTORY_JSON.is_file():
return []
try:
@@ -93,12 +79,28 @@ class LiveTrader(Monitor):
except (json.JSONDecodeError, OSError):
return []
def _save_live_signal_history(self) -> None:
"""live 발화 이력 저장."""
def _init_signal_history(self) -> list[dict[str, Any]]:
"""
시뮬과 동일 hybrid 입력: fire_outcomes 부트스트랩 + 운영 저장분.
Returns:
병합된 발화 이력.
"""
merged = build_live_signal_history(self._persisted_ops_signals)
n_boot = max(len(merged) - len(self._persisted_ops_signals), 0)
print(
f"[06] hybrid 이력: total={len(merged)} "
f"(bootstrap={'on' if LIVE_HYBRID_BOOTSTRAP_FIRES else 'off'}, "
f"from_fires~{n_boot}, ops_persisted={len(self._persisted_ops_signals)})"
)
return merged
def _save_persisted_ops_signals(self) -> None:
"""운영 체결분만 저장 (fire_outcomes 부트스트랩은 재로드)."""
LIVE_SIGNAL_HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True)
LIVE_SIGNAL_HISTORY_JSON.write_text(
json.dumps(
{"signals": self._live_signal_history[-2000:]},
{"signals": self._persisted_ops_signals[-2000:]},
ensure_ascii=False,
indent=2,
),
@@ -106,7 +108,7 @@ class LiveTrader(Monitor):
)
def _live_signal_seen(self, hit: dict[str, Any]) -> bool:
"""live 이력에 동일 봉 발화가 있는지."""
"""이력에 동일 봉 발화가 있는지."""
dt, rid, side = hit_key(hit)
return any(
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
@@ -114,35 +116,18 @@ class LiveTrader(Monitor):
)
def _append_live_signal(self, hit: dict[str, Any]) -> None:
"""live 발화 이력 추가."""
"""체결 성공 발화를 전체 이력·운영 저장분에 추가."""
if self._live_signal_seen(hit):
return
self._live_signal_history.append(
{
"dt": str(hit["dt"]),
"rule_id": str(hit["rule_id"]),
"side": str(hit["side"]),
"close": float(hit["close"]),
}
)
def _balances_for_trading(self) -> dict[str, dict[str, float]] | None:
"""
dry-run: paper_portfolio만. live: 빗썸 API.
"""
if self._paper_mode:
return self._paper.balances_dict()
try:
return self.load_balances_dict()
except Exception:
return None
def _load_sizing_context(self) -> None:
"""GT leg·EV/WF 통과 규칙 캐시."""
gt = load_ground_truth(resolve_ground_truth_file()) or {}
self._gt_trades = gt.get("trades") or []
self._large_legs = top_leg_ids_by_forward_return(self._gt_trades)
self._approved_rules = load_ev_wf_approved_rule_ids()
row = {
"dt": str(hit["dt"]),
"rule_id": str(hit["rule_id"]),
"side": str(hit["side"]),
"close": float(hit["close"]),
}
self._live_signal_history.append(row)
if not any(hit_key(s) == hit_key(row) for s in self._persisted_ops_signals):
self._persisted_ops_signals.append(row)
def _reset_day_if_needed(self) -> None:
"""날짜 변경 시 일별 한도 카운터 초기화."""
@@ -159,42 +144,21 @@ class LiveTrader(Monitor):
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _append_paper_fire(
self,
hit: dict[str, Any],
planned_krw: float,
would_trade: bool,
skip_reason: str = "",
order_log: dict[str, Any] | None = None,
) -> None:
"""Phase C paper_fires.jsonl."""
PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True)
row = {
"ts": datetime.now().isoformat(timespec="seconds"),
"signal_dt": hit.get("dt"),
"rule_id": hit.get("rule_id"),
"side": hit.get("side"),
"close": float(hit.get("close") or 0),
"planned_krw": round(float(planned_krw), 0),
"would_trade": bool(would_trade),
"skip_reason": skip_reason or "",
"live_enabled": bool(LIVE_TRADING_ENABLED),
"order_message": (order_log or {}).get("message", ""),
"sizing": "sim_causal_hybrid",
}
with PAPER_FIRES_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
"""
쿨다운(1봉=3분) + live 일한도. dry-run은 일한도만 생략.
쿨다운(1봉=3분) + 일한도·손실한도·거래횟수.
Args:
rule_id: 규칙 ID.
planned_krw: 예정 매수 원화.
Returns:
(허용 여부, 거절 사유).
"""
self._reset_day_if_needed()
last = self._rule_last_unix.get(rule_id, 0.0)
if time.time() - last < LIVE_COOLDOWN_MIN * 60:
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
if self._paper_mode:
return True, ""
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
return False, "일 최대 거래 수 초과"
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
@@ -212,42 +176,81 @@ class LiveTrader(Monitor):
except Exception:
self._ohlc_df = None
def _resync_paper_from_sim(self) -> None:
"""기존 paper 잔고를 sim_causal_hybrid replay 로 맞춤."""
def _sim_plan(self, hit: dict[str, Any]) -> SimTradeResult:
"""시뮬 hybrid 배분 1건 (누적 이력·인과, 현금·보유 제약)."""
if self._ohlc_df is None:
self._load_ohlc_df()
if self._ohlc_df is None or getattr(self._ohlc_df, "empty", True):
return
replayed, _ = replay_paper_portfolio(
self._paper.signal_history,
self._ohlc_df,
approved_buy_rules=self._approved_rules,
)
self._paper.cash_krw = replayed.cash_krw
self._paper.qty = replayed.qty
self._paper.qty_by_leg = dict(replayed.qty_by_leg)
self._paper.current_leg_id = replayed.current_leg_id
self._paper.save()
def _sim_plan(self, hit: dict[str, Any]) -> Any:
"""시뮬 hybrid 배분 1건."""
if self._ohlc_df is None:
self._load_ohlc_df()
if self._paper_mode:
hist = list(self._paper.signal_history)
else:
hist = list(self._live_signal_history)
return plan_live_hit(
hist,
list(self._live_signal_history),
hit,
self._ohlc_df,
approved_buy_rules=self._approved_rules,
approved_buy_rules=None,
)
def _execute_live_order(self, hit: dict[str, Any], plan: Any) -> dict[str, Any]:
"""실거래: 시뮬 planned 금액·수량으로 API 주문."""
@staticmethod
def _cap_plan_to_exchange(plan: SimTradeResult, hit: dict[str, Any], balances: dict) -> SimTradeResult:
"""
시뮬 planned 금액을 거래소 가용 잔고 이내로 제한.
Args:
plan: hybrid 시뮬 배분 결과.
hit: 발화.
balances: load_balances_dict() 결과.
Returns:
조정된 SimTradeResult.
"""
sym = balances.get(SYMBOL, {})
price = float(hit["close"])
side = hit["side"]
if not plan.ok or plan.amount_krw <= 0:
return plan
if side == "buy":
krw = float(sym.get("krw") or 0)
max_buy = max(krw / (1.0 + TRADING_FEE_RATE) - 1.0, 0.0)
capped = min(float(plan.amount_krw), max_buy)
if capped <= 0:
return SimTradeResult(
plan.hit, 0.0, 0.0, False, "거래소 현금 부족(시뮬 대비)"
)
if capped < plan.amount_krw - 1.0:
return SimTradeResult(
plan.hit,
round(capped, 0),
0.0,
True,
f"sim_buy capped ₩{capped:,.0f} (plan ₩{plan.amount_krw:,.0f})",
leg_id=plan.leg_id,
)
return plan
held = float(sym.get("balance") or 0)
if held <= 0:
return SimTradeResult(plan.hit, 0.0, 0.0, False, "거래소 보유 없음")
sell_qty = min(float(plan.sell_qty), held)
if sell_qty <= 0:
return SimTradeResult(plan.hit, 0.0, 0.0, False, "매도 수량 0")
gross = round(sell_qty * price, 0)
if sell_qty < plan.sell_qty - 1e-8:
return SimTradeResult(
plan.hit,
gross,
sell_qty,
True,
f"sim_sell capped qty={sell_qty:.4f}",
leg_id=plan.leg_id,
)
return plan
def _execute_live_order(
self, hit: dict[str, Any], plan: SimTradeResult, balances: dict
) -> dict[str, Any]:
"""실거래: 시뮬 plan(잔고 cap)으로 API 주문."""
side = hit["side"]
price = float(hit["close"])
plan = self._cap_plan_to_exchange(plan, hit, balances)
record: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="seconds"),
"rule_id": hit["rule_id"],
@@ -269,29 +272,15 @@ class LiveTrader(Monitor):
record["ok"] = bool(ok)
record["message"] = "buyCoinMarket" if ok else "buy failed"
elif side == "sell":
bal = self.load_balances_dict().get(SYMBOL, {})
held = float(bal.get("balance") or 0)
if held <= 0:
record["message"] = "보유 없음"
else:
sell_qty = min(float(plan.sell_qty), held)
if sell_qty <= 0:
record["message"] = "매도 수량 0"
else:
gross = sell_qty * price
record["amount_krw"] = round(gross, 0)
ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
record["ok"] = bool(ok)
record["sell_qty"] = sell_qty
record["message"] = (
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
)
if record["ok"] and live_sizing_enabled():
fee = gross * TRADING_FEE_RATE
self._position_state.record_sell(
gross, fee, full_close=(sell_qty >= held * 0.999)
)
self._position_state.save()
sell_qty = float(plan.sell_qty)
gross = sell_qty * price
record["amount_krw"] = round(gross, 0)
ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
record["ok"] = bool(ok)
record["sell_qty"] = sell_qty
record["message"] = (
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
)
else:
record["message"] = f"unknown side {side}"
except Exception as exc:
@@ -302,85 +291,22 @@ class LiveTrader(Monitor):
self._day_spent_krw += spent
self._day_trades += 1
self._rule_last_unix[hit["rule_id"]] = time.time()
if live_sizing_enabled() and side == "buy":
fee = spent * TRADING_FEE_RATE
self._position_state.record_buy(hit["dt"], price, spent, fee)
self._position_state.save()
self._append_live_signal(hit)
self._save_live_signal_history()
self._save_persisted_ops_signals()
return record
def _process_paper_batch(self, new_hits: list[dict[str, Any]]) -> None:
"""
dry-run: 신규 발화를 이력에 넣고 시뮬 전체 재생 후 알림.
"""
if not new_hits:
return
if self._ohlc_df is None:
self._load_ohlc_df()
for hit in new_hits:
self._paper.append_signal(hit)
replayed, results = replay_paper_portfolio(
self._paper.signal_history,
self._ohlc_df,
approved_buy_rules=self._approved_rules,
)
self._paper.cash_krw = replayed.cash_krw
self._paper.qty = replayed.qty
self._paper.qty_by_leg = dict(replayed.qty_by_leg)
self._paper.current_leg_id = replayed.current_leg_id
for hit in new_hits:
key = hit_key(hit)
res = results.get(key)
if res is None:
self._paper.mark_processed(hit["rule_id"], hit["dt"])
continue
log = {
"ok": res.ok,
"message": res.message,
"amount_krw": res.amount_krw,
"sell_qty": res.sell_qty,
}
self._append_paper_fire(
hit, res.amount_krw, res.ok, "" if res.ok else res.message, log
)
self._paper.mark_processed(hit["rule_id"], hit["dt"])
print(f" [{hit['side']}] {hit['rule_id']} @ {hit['dt']}")
print(f" order: {res.message} ok={res.ok}")
if not res.ok:
continue
self._rule_last_unix[hit["rule_id"]] = time.time()
post_balances = self._paper.balances_dict()
msg = build_rule_alert_message(
hit,
post_balances,
trade_krw=res.amount_krw,
trade_qty=res.sell_qty if hit["side"] == "sell" else None,
)
sym = post_balances.get(SYMBOL, {})
msg += (
f"\n[모의잔고·체결후] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
)
msg += f"\n[체결] {res.message}"
self._send_coin_msg(msg)
self._paper.save()
def run_once(self) -> None:
"""1회: 규칙 평가 → 시뮬 hybrid 체결 → 텔레그램."""
"""1회: 규칙 평가 → hybrid 배분 → 빗썸 주문 → 텔레그램."""
from deepcoin.data.ops_sync import ensure_ops_candles
ensure_ops_candles()
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)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
f"{COIN_NAME} LIVE rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
)
if not rules:
print(" monitor_rules 없음")
print(" monitor_rules 없음 — scripts/04_match_rules.py 실행")
return
fired = evaluate_live_rules(rules, force_refresh=True)
@@ -388,28 +314,14 @@ class LiveTrader(Monitor):
print(" 발화 없음")
return
if self._paper_mode:
print(
f" [paper] 현금 ₩{self._paper.cash_krw:,.0f} · "
f"보유 {self._paper.qty:.4f} {SYMBOL} "
f"(초기 ₩{GT_INITIAL_CASH_KRW:,.0f})"
)
try:
balances = self.load_balances_dict()
except Exception:
balances = {}
new_paper_hits: list[dict[str, Any]] = []
for hit in sort_hits_sim_order(fired):
rid = hit["rule_id"]
if self._paper_mode and self._paper.already_processed(rid, hit["dt"]):
print(f" [{hit['side']}] {rid} @ {hit['dt']} (이미 처리)")
continue
if LIVE_TRADING_ENABLED and self._live_signal_seen(hit):
continue
if hit["side"] == "buy" and rid not in self._approved_rules:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(" skip: EV/WF 미통과 규칙")
if self._paper_mode:
self._append_paper_fire(hit, 0.0, False, "EV/WF 미통과 규칙")
self._paper.mark_processed(rid, hit["dt"])
if self._live_signal_seen(hit):
continue
plan_preview = self._sim_plan(hit)
@@ -417,24 +329,23 @@ class LiveTrader(Monitor):
if not ok:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(f" skip: {reason}")
if self._paper_mode:
self._append_paper_fire(
hit, plan_preview.amount_krw, False, reason
)
self._paper.mark_processed(rid, hit["dt"])
continue
if self._paper_mode:
new_paper_hits.append(hit)
if not plan_preview.ok:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(f" skip: {plan_preview.message}")
continue
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
log = self._execute_live_order(hit, plan_preview)
log = self._execute_live_order(hit, plan_preview, balances)
self._append_log(log)
print(f" order: {log['message']} ok={log['ok']}")
if not log["ok"]:
continue
balances = self._balances_for_trading()
try:
balances = self.load_balances_dict()
except Exception:
balances = None
msg = build_rule_alert_message(
hit,
balances,
@@ -444,23 +355,15 @@ class LiveTrader(Monitor):
if balances:
sym = balances.get(SYMBOL, {})
msg += (
f"\n[잔고] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
f"\n[잔고] 현금 {float(sym.get('krw', 0)):,.0f} · "
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
)
msg += f"\n[체결] {log['message']}"
self._send_coin_msg(msg)
if self._paper_mode and new_paper_hits:
self._process_paper_batch(new_paper_hits)
def run_loop(self, sleep_sec: int) -> None:
"""상시 루프."""
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
while True:
self.run_once()
time.sleep(sleep_sec)
def _fmt_paper_krw(value: float) -> str:
"""원화 표시."""
return f"{float(value):,.0f}"

View File

@@ -17,6 +17,11 @@ import numpy as np
import os
from config import *
from deepcoin.data.candle_intervals import (
candle_api_segment,
interval_display_label,
pagination_step,
)
class Monitor(HTS):
"""WLD 코인 데이터·지표·시장 상태 출력."""
@@ -303,27 +308,14 @@ class Monitor(HTS):
) -> pd.DataFrame | None:
base = BITHUMB_API_URL.rstrip("/")
count = BITHUMB_API_CANDLE_COUNT
segment = candle_api_segment(interval)
for attempt in range(retries):
try:
path = f"/v1/candles/{segment}"
if to is None:
if interval >= DAILY_INTERVAL_MIN:
url = f"{base}/v1/candles/days?market=KRW-{symbol}&count={count}"
else:
url = (
f"{base}/v1/candles/minutes/{interval}"
f"?market=KRW-{symbol}&count={count}"
)
url = f"{base}{path}?market=KRW-{symbol}&count={count}"
else:
if interval >= DAILY_INTERVAL_MIN:
url = (
f"{base}/v1/candles/days?market=KRW-{symbol}"
f"&count={count}&to={to}"
)
else:
url = (
f"{base}/v1/candles/minutes/{interval}"
f"?market=KRW-{symbol}&count={count}&to={to}"
)
url = f"{base}{path}?market=KRW-{symbol}&count={count}&to={to}"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
@@ -383,10 +375,10 @@ class Monitor(HTS):
print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
break
if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
label = "일봉" if interval >= 1440 else f"{interval}"
label = interval_display_label(interval)
print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}")
time.sleep(MONITOR_SLEEP_BETWEEN_CHUNKS_SEC)
to = to - relativedelta(minutes=interval * MONITOR_API_CHUNK_BARS)
to = to - pagination_step(interval, MONITOR_API_CHUNK_BARS)
if data is None or data.empty:
return pd.DataFrame()
data = data.set_index("datetime")
@@ -407,6 +399,12 @@ class Monitor(HTS):
Returns:
LIMIT에 넣을 최대 행 수.
"""
from config import MONTH_INTERVAL_MIN, WEEK_INTERVAL_MIN
if interval == WEEK_INTERVAL_MIN:
return max(lookback_days // 7 + 10, DB_ROW_MIN_DAILY_BARS)
if interval == MONTH_INTERVAL_MIN:
return max(lookback_days // 30 + 6, DB_ROW_MIN_DAILY_BARS)
if interval >= DAILY_INTERVAL_MIN:
return max(
lookback_days + DB_ROW_DAILY_PADDING_DAYS,
@@ -425,7 +423,7 @@ class Monitor(HTS):
"""
API로 받은 봉을 coins.db에 증분 INSERT합니다 (01_download.append_data와 동일).
dry-run·05·06·live_eval이 load_frames_from_db 할 때마다 최신 봉이 쌓입니다.
05·06·live_eval이 load_frames_from_db 할 때마다 최신 봉이 쌓입니다.
Returns:
(추가 행 수, 스킵 행 수)
@@ -520,9 +518,10 @@ class Monitor(HTS):
self, symbol: str, interval: int, db_max_rows: int | None = None
) -> pd.DataFrame:
"""
WLD 시세: API 최신 봉 + coins.db 과거 봉 + 1분봉 최신 1개를 합칩니다.
WLD 시세: API 최신 봉 + coins.db 과거 봉 합칩니다.
MONITOR_PERSIST_CANDLES=1 이면 API 청크를 즉시 coins.db에 INSERT합니다.
1분봉은 다운로드·병합하지 않습니다.
"""
data = self.get_coin_data(symbol, interval)
if data is None or data.empty:
@@ -530,11 +529,6 @@ class Monitor(HTS):
self.persist_api_candles_to_db(symbol, interval, data)
data_1 = self.get_coin_data(symbol, interval=1)
if data_1 is not None and not data_1.empty:
data_1 = data_1.copy()
data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
row_limit = DB_READ_LIMIT_DEFAULT if db_max_rows is None else int(db_max_rows)
saved_data = self.read_candles_from_db(
symbol, interval, max_rows=row_limit
@@ -542,8 +536,6 @@ class Monitor(HTS):
parts = [data]
if saved_data is not None and not saved_data.empty:
parts.append(saved_data)
if data_1 is not None and not data_1.empty:
parts.append(data_1.iloc[[-1]])
merged = pd.concat(parts, ignore_index=True)
merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")

View File

@@ -1,337 +0,0 @@
"""
Phase C dry-run 모의 포트폴리오 — 시뮬 allocate_order_amounts_chronological 와 동일 현금·보유 규칙.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from config import (
GT_INITIAL_CASH_KRW,
GT_MAX_SELLS_PER_LEG,
GT_MIN_ORDER_KRW,
SYMBOL,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
from deepcoin.ground_truth.gt_model import leg_exit_weights
from deepcoin.paths import PAPER_FIRES_LOG, PAPER_PORTFOLIO_JSON
class PaperPortfolio:
"""
dry-run 전용 현금·코인 보유 (초기 GT_INITIAL_CASH_KRW, 실거래 API 미사용).
"""
def __init__(self) -> None:
"""빈 모의 계좌."""
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
self.qty: float = 0.0
self.qty_by_leg: dict[int, float] = {}
self.current_leg_id: int = 0
self.sell_leg: int | None = None
self.sell_base_qty: float = 0.0
self.sells_done_by_leg: dict[int, int] = {}
self.processed_signals: list[str] = []
self.signal_history: list[dict[str, Any]] = []
@classmethod
def load(cls, path: Path | None = None) -> PaperPortfolio:
"""
디스크에서 복원. 없으면 GT_INITIAL_CASH_KRW(기본 40만 원).
Args:
path: JSON 경로.
Returns:
PaperPortfolio.
"""
p = path or PAPER_PORTFOLIO_JSON
st = cls()
if not p.is_file():
return st
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return st
st.cash_krw = float(data.get("cash_krw", GT_INITIAL_CASH_KRW))
st.qty = float(data.get("qty") or 0.0)
st.qty_by_leg = {int(k): float(v) for k, v in (data.get("qty_by_leg") or {}).items()}
st.current_leg_id = int(data.get("current_leg_id") or 0)
st.sell_leg = data.get("sell_leg")
if st.sell_leg is not None:
st.sell_leg = int(st.sell_leg)
st.sell_base_qty = float(data.get("sell_base_qty") or 0.0)
st.sells_done_by_leg = {
int(k): int(v) for k, v in (data.get("sells_done_by_leg") or {}).items()
}
st.processed_signals = list(data.get("processed_signals") or [])[-500:]
st.signal_history = list(data.get("signal_history") or [])[-2000:]
if not st.signal_history:
st._rebuild_signal_history_from_fires()
return st
def _rebuild_signal_history_from_fires(self) -> None:
"""
구버전 paper(이력 없음) → paper_fires.jsonl 에서 would_trade 복원.
"""
if not PAPER_FIRES_LOG.is_file():
return
seen: set[tuple[str, str, str]] = set()
rows: list[dict[str, Any]] = []
try:
for line in PAPER_FIRES_LOG.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
row = json.loads(line)
if not row.get("would_trade"):
continue
dt = str(row.get("signal_dt") or "")
rid = str(row.get("rule_id") or "")
side = str(row.get("side") or "")
if not dt or not rid or not side:
continue
key = (dt, rid, side)
if key in seen:
continue
seen.add(key)
rows.append(
{
"dt": dt,
"rule_id": rid,
"side": side,
"close": float(row.get("close") or 0),
}
)
except (json.JSONDecodeError, OSError, TypeError, ValueError):
return
self.signal_history = rows[-2000:]
def append_signal(self, hit: dict[str, Any]) -> None:
"""
시뮬 재생용 발화 이력 추가 (dt·rule_id·side·close).
Args:
hit: evaluate_live_rules 항목.
"""
row = {
"dt": str(hit["dt"]),
"rule_id": str(hit["rule_id"]),
"side": str(hit["side"]),
"close": float(hit["close"]),
}
key = self.signal_key(row["rule_id"], row["dt"])
if key in self.processed_signals:
return
if any(
s["dt"] == row["dt"]
and s["rule_id"] == row["rule_id"]
and s["side"] == row["side"]
for s in self.signal_history
):
return
self.signal_history.append(row)
def save(self, path: Path | None = None) -> None:
"""상태 저장."""
p = path or PAPER_PORTFOLIO_JSON
p.parent.mkdir(parents=True, exist_ok=True)
payload = {
"cash_krw": round(self.cash_krw, 0),
"qty": self.qty,
"qty_by_leg": {str(k): round(v, 8) for k, v in self.qty_by_leg.items()},
"current_leg_id": self.current_leg_id,
"sell_leg": self.sell_leg,
"sell_base_qty": round(self.sell_base_qty, 8),
"sells_done_by_leg": self.sells_done_by_leg,
"processed_signals": self.processed_signals[-500:],
"signal_history": self.signal_history[-2000:],
"initial_cash_krw": GT_INITIAL_CASH_KRW,
"sizing_engine": "sim_causal_hybrid",
}
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def balances_dict(self) -> dict[str, dict[str, float]]:
"""live_trader·alert용 잔고 dict."""
return {
SYMBOL: {
"balance": self.qty,
"available_krw": self.cash_krw,
"krw": self.cash_krw,
}
}
def signal_key(self, rule_id: str, signal_dt: str) -> str:
"""동일 봉 중복 발화 방지 키."""
return f"{rule_id}|{signal_dt}"
def already_processed(self, rule_id: str, signal_dt: str) -> bool:
"""이미 체결·스킵 처리한 신호인지."""
return self.signal_key(rule_id, signal_dt) in self.processed_signals
def mark_processed(self, rule_id: str, signal_dt: str) -> None:
"""신호 처리 완료 표시."""
key = self.signal_key(rule_id, signal_dt)
if key not in self.processed_signals:
self.processed_signals.append(key)
def active_leg_id(self) -> int | None:
"""보유 수량이 있는 leg_id (없으면 None)."""
for lid, q in sorted(self.qty_by_leg.items()):
if q > 1e-12:
return lid
return None
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
"""
모의 매수 체결.
Args:
amount_krw: 매수 원화.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if amount_krw <= 0 or price <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
if self.cash_krw < amount_krw + fee:
return False
self.cash_krw -= amount_krw + fee
bought = amount_krw / price
self.qty += bought
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
self.current_leg_id = leg_id
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def plan_sell(
self,
price: float,
leg_id: int | None = None,
) -> tuple[float, float, str]:
"""
분할 매도 규모 (시뮬 leg_exit_weights·GT_MAX_SELLS_PER_LEG).
Args:
price: 체결가.
leg_id: 대상 leg. None이면 active_leg_id.
Returns:
(amount_krw, sell_qty, skip_reason). skip_reason 비어 있으면 체결 가능.
"""
lid = leg_id if leg_id is not None else self.active_leg_id()
if lid is None:
return 0.0, 0.0, "모의 보유 없음"
leg_qty = self.qty_by_leg.get(lid, 0.0)
if leg_qty <= 1e-12:
return 0.0, 0.0, "모의 보유 없음"
if self.sell_leg != lid:
self.sell_leg = lid
self.sell_base_qty = leg_qty
n_sells = max(1, int(GT_MAX_SELLS_PER_LEG))
weights = leg_exit_weights(n_sells)
idx = self.sells_done_by_leg.get(lid, 0)
is_last = idx >= len(weights) - 1
if is_last:
sell_qty = leg_qty
gross = sell_qty * price
else:
weight = float(weights[idx])
trade = {"amount_krw": None, "weight": weight}
sell_qty = resolve_sell_qty(
trade, leg_qty, price, self.sell_base_qty, weight
)
gross = sell_qty * price
if gross < GT_MIN_ORDER_KRW and leg_qty * price >= GT_MIN_ORDER_KRW:
gross = GT_MIN_ORDER_KRW
sell_qty = min(leg_qty, gross / price)
if gross <= 0 or sell_qty <= 0:
return 0.0, 0.0, "모의 매도 규모 0"
return round(gross, 0), sell_qty, ""
def apply_sell(
self,
amount_krw: float,
sell_qty: float,
price: float,
leg_id: int,
) -> bool:
"""
모의 매도 체결.
Args:
amount_krw: 매도 원화(총액).
sell_qty: 매도 수량.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if sell_qty <= 0 or amount_krw <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
self.cash_krw += amount_krw - fee
leg_qty = self.qty_by_leg.get(leg_id, 0.0) - sell_qty
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
self.qty = max(self.qty - sell_qty, 0.0)
if self.qty < 1e-12:
self.qty = 0.0
self.sells_done_by_leg[leg_id] = self.sells_done_by_leg.get(leg_id, 0) + 1
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
self.qty_by_leg.pop(leg_id, None)
self.sell_leg = None
self.sell_base_qty = 0.0
self.sells_done_by_leg.pop(leg_id, None)
return True
def equity_krw(self, mark_price: float) -> float:
"""
총보유금액 = 현금 + 코인 평가(시세).
Args:
mark_price: 평가 단가.
Returns:
원화 합계.
"""
return float(self.cash_krw) + float(self.qty) * float(mark_price)
def summary(self, mark_price: float) -> dict[str, Any]:
"""
dry-run 모의 계좌 스냅샷 (빗썸 잔고와 무관).
Args:
mark_price: 최신 종가 등 평가 단가.
Returns:
initial·cash·qty·equity·pnl dict.
"""
initial = float(GT_INITIAL_CASH_KRW)
equity = self.equity_krw(mark_price)
pnl = equity - initial
pnl_pct = (pnl / initial * 100.0) if initial > 0 else 0.0
coin_value = float(self.qty) * float(mark_price)
return {
"initial_cash_krw": round(initial, 0),
"cash_krw": round(self.cash_krw, 0),
"qty": round(self.qty, 8),
"mark_price": round(mark_price, 4),
"coin_value_krw": round(coin_value, 0),
"equity_krw": round(equity, 0),
"pnl_krw": round(pnl, 0),
"pnl_pct": round(pnl_pct, 4),
"source": "paper_portfolio.json (dry-run only, not Bithumb)",
}

View File

@@ -1,9 +1,7 @@
"""
WLD 볼린저 밴드 차트.
Ground Truth 차트 HTML (05_chart_truth).
python scripts/05_chart_bb.py
python scripts/05_chart_truth.py
python scripts/02_ground_truth.py
"""
from __future__ import annotations
@@ -44,12 +42,11 @@ 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
from deepcoin.paths import CHART_TRUTH_HTML, resolve_ground_truth_file
OUTPUT_HTML = CHART_BB_HTML
TRUTH_HTML = CHART_TRUTH_HTML
GROUND_TRUTH_PATH = resolve_ground_truth_file()
REPORT_DIR = CHART_BB_HTML.parent
REPORT_DIR = CHART_TRUTH_HTML.parent
def interval_chart_label(interval_min: int) -> str:
@@ -606,19 +603,34 @@ def load_chart_frames() -> dict[int, pd.DataFrame] | None:
return frames
def run_ground_truth_chart(open_browser: bool = True) -> Path:
def run_ground_truth_chart(
open_browser: bool = True,
*,
from_json: bool = True,
) -> Path:
"""
정답 타점을 생성·저장하고 마커가 포함된 HTML 차트를 만듭니다.
정답 타점 마커가 포함된 HTML 차트를 만듭니다.
Args:
open_browser: True면 브라우저로 HTML을 엽니다.
from_json: True면 기존 ground_truth_trades.json 을 사용합니다.
False면 DB에서 GT를 재생성합니다.
Returns:
HTML 파일 경로.
"""
from deepcoin.ground_truth.ground_truth import run_from_db
from deepcoin.ground_truth.ground_truth import load_ground_truth, run_from_db
data = run_from_db()
gt_path = resolve_ground_truth_file()
if from_json:
data = load_ground_truth(gt_path)
if not data:
print(f"GT JSON 없음({gt_path}) — DB에서 재생성합니다.")
data = run_from_db()
else:
print(f"GT JSON 로드: {gt_path}")
else:
data = run_from_db()
frames = load_chart_frames()
if frames is None:
raise RuntimeError("차트 데이터 로드 실패")
@@ -645,74 +657,12 @@ def run_ground_truth_chart(open_browser: bool = True) -> Path:
return TRUTH_HTML
def run_chart(open_browser: bool = True) -> Path:
"""
3분봉 BB 차트 HTML을 생성합니다.
Args:
open_browser: True면 기본 브라우저로 HTML을 엽니다.
Returns:
저장된 HTML 경로.
"""
frames = load_chart_frames()
if frames is None:
raise RuntimeError("차트 데이터 로드 실패")
df_1d, df_1h, df_3m = _frames_to_mtf(frames)
trend = get_trend(df_1d, df_1h)
df_chart = apply_bar_indicators(df_3m)
print(f"\n추세(참고): {trend}")
print(f"3분: {df_chart.index[0]} ~ {df_chart.index[-1]} ({len(df_chart)}봉)")
html = build_chart_html(
df_chart,
trend,
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 print_usage() -> None:
print(
"""
DeepCoin simulation.py
python simulation.py
WLD 3분봉 BB 차트 → docs/charts/wld_bb_chart.html
python simulation.py truth
정답 타점 생성 → ground_truth_trades.json
차트 → docs/02_ground_truth/wld_ground_truth_chart.html
"""
)
def main() -> None:
"""05_chart_truth CLI 진입 (미사용 시 no-op)."""
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
print_usage()
print("GT 차트: python scripts/05_chart_truth.py")
return
if len(sys.argv) > 1 and sys.argv[1] in ("truth", "ground-truth", "gt"):
print("=" * 60)
print("정답 타점 생성 + 차트")
print("=" * 60)
run_ground_truth_chart()
print("\n완료.")
return
if len(sys.argv) > 1:
print(f"알 수 없는 옵션: {sys.argv[1]}\n")
print_usage()
return
print("=" * 60)
print("WLD BB 차트 (매매 전략 없음)")
print("=" * 60)
run_chart()
print("\n완료.")
run_ground_truth_chart(open_browser=False)
if __name__ == "__main__":