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:
@@ -1185,7 +1185,7 @@ def simulate_truth_portfolio(
|
||||
|
||||
Args:
|
||||
trades: JSON trades 또는 TradePoint 리스트.
|
||||
initial_cash: 시작 원화 (기본 100만).
|
||||
initial_cash: 시작 원화 (기본 GT_INITIAL_CASH_KRW, 40만).
|
||||
fee_rate: 매수·매도 각각 적용 수수료율.
|
||||
last_price: 미청산 평가용 종가. None이면 마지막 체결가.
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ def build_simulation_report(
|
||||
use_amount_krw=True,
|
||||
)
|
||||
|
||||
# 전기간 monitor 규칙 — 100만원에서 복리 (holdout만 X)
|
||||
# 전기간 monitor 규칙 — GT_INITIAL_CASH_KRW에서 복리 (holdout만 X)
|
||||
all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)]
|
||||
if not all_monitor.empty:
|
||||
sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor))
|
||||
|
||||
@@ -47,16 +47,21 @@ def _holding_qty(balances: dict[str, dict[str, float]], symbol: str) -> float:
|
||||
def build_rule_alert_message(
|
||||
hit: dict[str, Any],
|
||||
balances: dict[str, dict[str, float]] | None = None,
|
||||
*,
|
||||
trade_krw: float | None = None,
|
||||
trade_qty: float | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
규칙 발화 알림 본문을 만듭니다.
|
||||
|
||||
매수: MONITOR_ALERT_KRW_AMOUNT 기준 수량·금액.
|
||||
매도: 보유 수량(잔고 조회 가능 시) × 가격 = 금액, 없으면 참고 금액 기준.
|
||||
trade_krw·trade_qty가 있으면 실제(모의) 체결 규모를 표시합니다.
|
||||
없으면 매수는 MONITOR_ALERT_KRW_AMOUNT, 매도는 보유×가격(또는 참고 금액).
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목 (side, rule_id, dt, close).
|
||||
balances: 빗썸 잔고 dict. None이면 매도도 참고 금액 기준.
|
||||
balances: 잔고 dict (모의·실거래).
|
||||
trade_krw: 체결 원화(모의·실거래 planned/executed).
|
||||
trade_qty: 체결 수량(매도 시).
|
||||
|
||||
Returns:
|
||||
텔레그램 메시지 문자열.
|
||||
@@ -67,14 +72,25 @@ def build_rule_alert_message(
|
||||
dt = hit.get("dt", "")
|
||||
|
||||
qty_basis = ""
|
||||
if side == "SELL" and balances is not None:
|
||||
if trade_krw is not None and trade_krw > 0:
|
||||
amount = float(trade_krw)
|
||||
if trade_qty is not None and trade_qty > 0:
|
||||
qty = float(trade_qty)
|
||||
qty_basis = "체결 기준"
|
||||
elif close > 0:
|
||||
qty = amount / close
|
||||
qty_basis = "체결 기준(원화→수량)"
|
||||
else:
|
||||
qty = 0.0
|
||||
qty_basis = "체결 기준"
|
||||
elif side == "SELL" and balances is not None:
|
||||
qty = _holding_qty(balances, SYMBOL)
|
||||
amount = qty * close
|
||||
qty_basis = "보유 기준"
|
||||
qty_basis = "보유 기준(체결 전)"
|
||||
elif side == "BUY":
|
||||
amount = float(MONITOR_ALERT_KRW_AMOUNT)
|
||||
qty = amount / close if close > 0 else 0.0
|
||||
qty_basis = "참고 매수 규모"
|
||||
qty_basis = "참고 매수 규모(알림용)"
|
||||
else:
|
||||
amount = float(MONITOR_ALERT_KRW_AMOUNT)
|
||||
qty = amount / close if close > 0 else 0.0
|
||||
|
||||
255
deepcoin/ops/hybrid_sim_execution.py
Normal file
255
deepcoin/ops/hybrid_sim_execution.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
시뮬 sim_causal_hybrid 와 동일 체결 엔진 (build_monitor_hybrid_sized_trades).
|
||||
|
||||
dry-run·live(06) 모두 발화 이력 → hybrid 배분 → amount_krw·수량 적용.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
|
||||
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
|
||||
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
|
||||
from deepcoin.ops.paper_portfolio import PaperPortfolio
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimTradeResult:
|
||||
"""단일 발화에 대한 시뮬 배분·체결 결과."""
|
||||
|
||||
hit: dict[str, Any]
|
||||
amount_krw: float
|
||||
sell_qty: float
|
||||
ok: bool
|
||||
message: str
|
||||
leg_id: int | None = None
|
||||
|
||||
|
||||
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"]))
|
||||
|
||||
|
||||
def sort_hits_sim_order(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
시뮬·allocate 순서: 시각순, 동일 시각이면 buy → sell.
|
||||
|
||||
Args:
|
||||
hits: evaluate_live_rules 발화.
|
||||
|
||||
Returns:
|
||||
정렬된 리스트.
|
||||
"""
|
||||
side_rank = {"buy": 0, "sell": 1}
|
||||
|
||||
def _key(h: dict[str, Any]) -> tuple:
|
||||
return (str(h["dt"]), side_rank.get(str(h["side"]), 9), str(h["rule_id"]))
|
||||
|
||||
return sorted(hits, key=_key)
|
||||
|
||||
|
||||
def _signals_for_hybrid(
|
||||
signal_history: list[dict[str, Any]],
|
||||
*,
|
||||
approved_buy_rules: set[str] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
hybrid 배분용 신호 목록 (EV/WF 미통과 매수 제외).
|
||||
|
||||
Args:
|
||||
signal_history: {dt, rule_id, side, close}.
|
||||
approved_buy_rules: 허용 매수 rule_id.
|
||||
|
||||
Returns:
|
||||
시뮬 입력 trade dict 리스트.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
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:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"dt": str(h["dt"]),
|
||||
"side": side,
|
||||
"close": float(h["close"]),
|
||||
"rule_id": rid,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def size_monitor_signals(
|
||||
signal_history: list[dict[str, Any]],
|
||||
ohlc_df: pd.DataFrame,
|
||||
*,
|
||||
approved_buy_rules: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
시뮬과 동일 hybrid tier 배분 (amount_krw·weight·leg_id).
|
||||
|
||||
Args:
|
||||
signal_history: 누적 발화.
|
||||
ohlc_df: 3m OHLC.
|
||||
approved_buy_rules: 매수 허용 규칙.
|
||||
|
||||
Returns:
|
||||
sized trade dict 리스트 (시각순).
|
||||
"""
|
||||
rows = _signals_for_hybrid(signal_history, approved_buy_rules=approved_buy_rules)
|
||||
if not rows:
|
||||
return []
|
||||
fires = pd.DataFrame(rows)
|
||||
dd = load_hybrid_dd_params()
|
||||
sized, _stats = build_monitor_hybrid_sized_trades(
|
||||
fires,
|
||||
ohlc_df,
|
||||
enhanced=False,
|
||||
initial_cash=float(GT_INITIAL_CASH_KRW),
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
dd_large_pct=dd.get("dd_large_pct"),
|
||||
dd_medium_pct=dd.get("dd_medium_pct"),
|
||||
)
|
||||
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(
|
||||
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]]:
|
||||
"""
|
||||
신호 이력 전체를 시뮬 엔진으로 재생 → 모의 계좌(GT_INITIAL_CASH_KRW) 상태.
|
||||
|
||||
Args:
|
||||
signal_history: Phase C 누적 발화.
|
||||
ohlc_df: 3m OHLC.
|
||||
approved_buy_rules: EV/WF 통과 매수 규칙.
|
||||
|
||||
Returns:
|
||||
(portfolio, hit_key → SimTradeResult).
|
||||
"""
|
||||
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 = {}
|
||||
results: dict[tuple[str, str, str], SimTradeResult] = {}
|
||||
|
||||
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", "")))
|
||||
price = float(t["price"])
|
||||
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}
|
||||
key = hit_key(hit)
|
||||
amount = float(t.get("amount_krw") or 0)
|
||||
|
||||
if side == "buy":
|
||||
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
|
||||
)
|
||||
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, "시뮬 매도 스킵")
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
def plan_live_hit(
|
||||
signal_history: list[dict[str, Any]],
|
||||
hit: dict[str, Any],
|
||||
ohlc_df: pd.DataFrame,
|
||||
*,
|
||||
approved_buy_rules: set[str] | None = None,
|
||||
) -> SimTradeResult:
|
||||
"""
|
||||
live: 누적 이력 + 신규 발화 1건 — replay 와 동일 sell_qty·amount.
|
||||
|
||||
Args:
|
||||
signal_history: 기존 이력(신규 hit 미포함).
|
||||
hit: 이번 발화.
|
||||
ohlc_df: 3m OHLC.
|
||||
approved_buy_rules: 매수 허용.
|
||||
|
||||
Returns:
|
||||
SimTradeResult (dry-run replay_paper_portfolio 와 동일).
|
||||
"""
|
||||
if ohlc_df is None or getattr(ohlc_df, "empty", True):
|
||||
return SimTradeResult(hit, 0.0, 0.0, False, "OHLC 없음")
|
||||
dt, rid, side = hit_key(hit)
|
||||
hist = list(signal_history)
|
||||
if not any(
|
||||
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
|
||||
for s in hist
|
||||
):
|
||||
hist.append(
|
||||
{
|
||||
"dt": dt,
|
||||
"rule_id": rid,
|
||||
"side": side,
|
||||
"close": float(hit["close"]),
|
||||
}
|
||||
)
|
||||
_, results = replay_paper_portfolio(
|
||||
hist, ohlc_df, approved_buy_rules=approved_buy_rules
|
||||
)
|
||||
res = results.get((dt, rid, side))
|
||||
if res is not None:
|
||||
return res
|
||||
return SimTradeResult(hit, 0.0, 0.0, False, "시뮬 배분 없음")
|
||||
@@ -1,5 +1,7 @@
|
||||
"""
|
||||
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
|
||||
|
||||
dry-run·live 체결 배분: 시뮬 sim_causal_hybrid 와 동일 (hybrid_sim_execution).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,6 +15,7 @@ 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,
|
||||
@@ -29,20 +32,29 @@ 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 (
|
||||
compute_buy_amount_krw,
|
||||
live_buy_asset_pct_scale,
|
||||
load_ev_wf_approved_rule_ids,
|
||||
top_leg_ids_by_forward_return,
|
||||
)
|
||||
from deepcoin.paths import resolve_ground_truth_file
|
||||
from deepcoin.ops.alert_message import build_rule_alert_message
|
||||
from deepcoin.ops.hybrid_sim_execution import (
|
||||
hit_key,
|
||||
plan_live_hit,
|
||||
replay_paper_portfolio,
|
||||
sort_hits_sim_order,
|
||||
)
|
||||
from deepcoin.ops.monitor import Monitor
|
||||
from deepcoin.paths import LIVE_TRADES_LOG, PAPER_FIRES_LOG
|
||||
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 이면 주문 없음(드라이런 로그만).
|
||||
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 모의(sim hybrid)만.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -57,8 +69,73 @@ class LiveTrader(Monitor):
|
||||
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()
|
||||
|
||||
@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 시뮬 정합용 발화 이력."""
|
||||
if not LIVE_SIGNAL_HISTORY_JSON.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(LIVE_SIGNAL_HISTORY_JSON.read_text(encoding="utf-8"))
|
||||
return list(data.get("signals") or [])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
def _save_live_signal_history(self) -> None:
|
||||
"""live 발화 이력 저장."""
|
||||
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:]},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
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
|
||||
for s in self._live_signal_history
|
||||
)
|
||||
|
||||
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 통과 규칙 캐시."""
|
||||
@@ -77,12 +154,7 @@ class LiveTrader(Monitor):
|
||||
self._day_pnl_krw = 0.0
|
||||
|
||||
def _append_log(self, record: dict[str, Any]) -> None:
|
||||
"""
|
||||
live_trades.jsonl에 한 줄 append.
|
||||
|
||||
Args:
|
||||
record: 로그 dict.
|
||||
"""
|
||||
"""live_trades.jsonl append."""
|
||||
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")
|
||||
@@ -95,11 +167,7 @@ class LiveTrader(Monitor):
|
||||
skip_reason: str = "",
|
||||
order_log: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Phase C dry-run: 모든 발화·스킵 사유·모의 금액을 paper_fires.jsonl에 기록.
|
||||
|
||||
금요일 `07_phase_c_paper_report.py`로 forward 수익률(참고) 집계.
|
||||
"""
|
||||
"""Phase C paper_fires.jsonl."""
|
||||
PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
@@ -112,21 +180,21 @@ class LiveTrader(Monitor):
|
||||
"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]:
|
||||
"""
|
||||
일·쿨다운·손실 한도 검사.
|
||||
|
||||
Args:
|
||||
rule_id: 규칙 ID.
|
||||
|
||||
Returns:
|
||||
(허용 여부, 사유).
|
||||
쿨다운(1봉=3분) + live 일한도. dry-run은 일한도만 생략.
|
||||
"""
|
||||
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)
|
||||
@@ -134,147 +202,103 @@ class LiveTrader(Monitor):
|
||||
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 _load_ohlc_df(self) -> None:
|
||||
"""drawdown tier용 3m OHLC 캐시."""
|
||||
"""drawdown tier용 3m OHLC."""
|
||||
try:
|
||||
frames = load_frames_from_db(self, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
|
||||
self._ohlc_df = frames.get(MATCH_PRIMARY_INTERVAL)
|
||||
except Exception:
|
||||
self._ohlc_df = None
|
||||
|
||||
def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float:
|
||||
"""
|
||||
총자산·현금·EV/WF·leg 티어로 매수 원화 산출.
|
||||
|
||||
GT_SIGNAL_CAUSAL=1 이면 시뮬 sim_primary(hybrid, enhanced=False)와 동일 tier·weight.
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목.
|
||||
|
||||
Returns:
|
||||
매수 원화.
|
||||
"""
|
||||
rid = hit["rule_id"]
|
||||
if rid not in self._approved_rules:
|
||||
return 0.0
|
||||
price = float(hit["close"])
|
||||
cash = 0.0
|
||||
qty = 0.0
|
||||
try:
|
||||
bal = self.load_balances_dict()
|
||||
sym = bal.get(SYMBOL, {})
|
||||
cash = float(sym.get("available_krw") or sym.get("krw") or 0)
|
||||
qty = float(sym.get("balance") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
if live_sizing_enabled():
|
||||
if self._ohlc_df is None:
|
||||
self._load_ohlc_df()
|
||||
return self._position_state.plan_buy_amount_krw(
|
||||
hit["dt"],
|
||||
price,
|
||||
cash,
|
||||
qty,
|
||||
self._ohlc_df,
|
||||
enhanced=False,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
)
|
||||
scale = live_buy_asset_pct_scale(
|
||||
rid,
|
||||
hit["dt"],
|
||||
self._gt_trades,
|
||||
approved_rules=self._approved_rules,
|
||||
large_legs=self._large_legs,
|
||||
def _resync_paper_from_sim(self) -> None:
|
||||
"""기존 paper 잔고를 sim_causal_hybrid replay 로 맞춤."""
|
||||
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,
|
||||
)
|
||||
return compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
1.0,
|
||||
1.0,
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
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,
|
||||
hit,
|
||||
self._ohlc_df,
|
||||
approved_buy_rules=self._approved_rules,
|
||||
)
|
||||
|
||||
def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
매수·매도 주문 실행 또는 드라이런.
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목.
|
||||
|
||||
Returns:
|
||||
로그용 결과 dict.
|
||||
"""
|
||||
def _execute_live_order(self, hit: dict[str, Any], plan: Any) -> dict[str, Any]:
|
||||
"""실거래: 시뮬 planned 금액·수량으로 API 주문."""
|
||||
side = hit["side"]
|
||||
price = float(hit["close"])
|
||||
if side == "buy":
|
||||
amount_krw = self._resolve_buy_amount_krw(hit)
|
||||
if amount_krw <= 0:
|
||||
return {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"rule_id": hit["rule_id"],
|
||||
"side": side,
|
||||
"signal_dt": hit["dt"],
|
||||
"price": price,
|
||||
"amount_krw": 0,
|
||||
"live_enabled": LIVE_TRADING_ENABLED,
|
||||
"ok": False,
|
||||
"message": "매수 스킵(EV/WF·leg·현금)",
|
||||
}
|
||||
else:
|
||||
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,
|
||||
"amount_krw": plan.amount_krw,
|
||||
"live_enabled": True,
|
||||
"ok": False,
|
||||
"message": "",
|
||||
"message": plan.message,
|
||||
"sizing": "sim_causal_hybrid",
|
||||
}
|
||||
|
||||
if not LIVE_TRADING_ENABLED:
|
||||
record["message"] = "dry_run (LIVE_TRADING_ENABLED=0)"
|
||||
record["ok"] = True
|
||||
if not plan.ok:
|
||||
return record
|
||||
|
||||
try:
|
||||
if side == "buy":
|
||||
ok = self.buyCoinMarket(SYMBOL, int(amount_krw), count=None)
|
||||
ok = self.buyCoinMarket(SYMBOL, int(plan.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:
|
||||
held = float(bal.get("balance") or 0)
|
||||
if held <= 0:
|
||||
record["message"] = "보유 없음"
|
||||
else:
|
||||
gross = qty * price
|
||||
record["amount_krw"] = round(gross, 0)
|
||||
ok = self.sellCoinMarket(SYMBOL, int(price), qty)
|
||||
record["ok"] = bool(ok)
|
||||
record["message"] = f"sell qty={qty}" 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=True
|
||||
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"
|
||||
)
|
||||
self._position_state.save()
|
||||
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()
|
||||
else:
|
||||
record["message"] = f"unknown side {side}"
|
||||
except Exception as exc:
|
||||
record["message"] = str(exc)
|
||||
|
||||
if record["ok"]:
|
||||
spent = float(record.get("amount_krw") or amount_krw)
|
||||
spent = float(record.get("amount_krw") or plan.amount_krw)
|
||||
self._day_spent_krw += spent
|
||||
self._day_trades += 1
|
||||
self._rule_last_unix[hit["rule_id"]] = time.time()
|
||||
@@ -282,74 +306,158 @@ class LiveTrader(Monitor):
|
||||
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()
|
||||
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회: 규칙 평가 → (허용 시) 주문 → 텔레그램."""
|
||||
"""1회: 규칙 평가 → 시뮬 hybrid 체결 → 텔레그램."""
|
||||
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)}"
|
||||
f"rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
|
||||
)
|
||||
if not rules:
|
||||
print(" monitor_rules 없음")
|
||||
return
|
||||
|
||||
fired = evaluate_live_rules(rules)
|
||||
balances = None
|
||||
try:
|
||||
balances = self.load_balances_dict()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fired = evaluate_live_rules(rules, force_refresh=True)
|
||||
if not fired:
|
||||
print(" 발화 없음")
|
||||
return
|
||||
|
||||
for hit in fired:
|
||||
if self._paper_mode:
|
||||
print(
|
||||
f" [paper] 현금 ₩{self._paper.cash_krw:,.0f} · "
|
||||
f"보유 {self._paper.qty:.4f} {SYMBOL} "
|
||||
f"(초기 ₩{GT_INITIAL_CASH_KRW:,.0f})"
|
||||
)
|
||||
|
||||
new_paper_hits: list[dict[str, Any]] = []
|
||||
for hit in sort_hits_sim_order(fired):
|
||||
rid = hit["rule_id"]
|
||||
if hit["side"] == "buy" and hit["rule_id"] not in self._approved_rules:
|
||||
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 미통과 규칙")
|
||||
self._append_paper_fire(
|
||||
hit, 0.0, False, "EV/WF 미통과 규칙"
|
||||
)
|
||||
if self._paper_mode:
|
||||
self._append_paper_fire(hit, 0.0, False, "EV/WF 미통과 규칙")
|
||||
self._paper.mark_processed(rid, hit["dt"])
|
||||
continue
|
||||
planned = (
|
||||
self._resolve_buy_amount_krw(hit)
|
||||
if hit["side"] == "buy"
|
||||
else float(LIVE_ORDER_KRW)
|
||||
)
|
||||
ok, reason = self._can_trade(rid, planned)
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
|
||||
plan_preview = self._sim_plan(hit)
|
||||
ok, reason = self._can_trade(rid, plan_preview.amount_krw)
|
||||
if not ok:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
print(f" skip: {reason}")
|
||||
self._append_paper_fire(hit, planned, False, 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 hit["side"] == "buy" and planned <= 0:
|
||||
print(" skip: 매수금액 0")
|
||||
self._append_paper_fire(hit, 0.0, False, "매수금액 0")
|
||||
|
||||
if self._paper_mode:
|
||||
new_paper_hits.append(hit)
|
||||
continue
|
||||
log = self._execute_order(hit)
|
||||
self._append_paper_fire(hit, planned, True, "", log)
|
||||
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
log = self._execute_live_order(hit, plan_preview)
|
||||
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']}"
|
||||
if not log["ok"]:
|
||||
continue
|
||||
balances = self._balances_for_trading()
|
||||
msg = build_rule_alert_message(
|
||||
hit,
|
||||
balances,
|
||||
trade_krw=float(log.get("amount_krw") or 0),
|
||||
trade_qty=float(log.get("sell_qty") or 0) or None,
|
||||
)
|
||||
if balances:
|
||||
sym = balances.get(SYMBOL, {})
|
||||
msg += (
|
||||
f"\n[잔고] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
|
||||
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
|
||||
)
|
||||
msg += f"\n[체결] {log['message']}"
|
||||
self._send_coin_msg(msg)
|
||||
|
||||
def run_loop(self, sleep_sec: int) -> None:
|
||||
"""
|
||||
상시 루프.
|
||||
if self._paper_mode and new_paper_hits:
|
||||
self._process_paper_batch(new_paper_hits)
|
||||
|
||||
Args:
|
||||
sleep_sec: 대기 초.
|
||||
"""
|
||||
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}"
|
||||
|
||||
337
deepcoin/ops/paper_portfolio.py
Normal file
337
deepcoin/ops/paper_portfolio.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
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)",
|
||||
}
|
||||
@@ -54,7 +54,12 @@ MATCHING_GT_COMPARISON_HTML = DOCS_MATCHING / "gt_comparison_report.html"
|
||||
|
||||
LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl"
|
||||
PAPER_FIRES_LOG = OPS_STATE_DIR / "paper_fires.jsonl"
|
||||
PAPER_PORTFOLIO_JSON = OPS_STATE_DIR / "paper_portfolio.json"
|
||||
LIVE_SIGNAL_HISTORY_JSON = OPS_STATE_DIR / "live_signal_history.json"
|
||||
PAPER_WEEKLY_REPORT_JSON = DOCS_OPS / "phase_c_paper_report.json"
|
||||
PHASE_C_DAILY_DIR = DOCS_OPS / "phase_c_daily"
|
||||
PHASE_C_SUPERVISOR_LOG = OPS_STATE_DIR / "phase_c_supervisor.log"
|
||||
PHASE_C_SUPERVISOR_PID = OPS_STATE_DIR / "phase_c_supervisor.pid"
|
||||
|
||||
CHART_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html"
|
||||
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
|
||||
|
||||
Reference in New Issue
Block a user