GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.
타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,13 @@ COIN_TELEGRAM_CHAT_ID=
|
||||
SYMBOL=WLD
|
||||
CHART_LOOKBACK_DAYS=365
|
||||
|
||||
# 02 Ground Truth
|
||||
GT_MIN_ORDER_KRW=5000
|
||||
GT_MAX_BUY_ORDER_KRW=100000
|
||||
GT_BUY_PCT_LARGE_LEG=1.0
|
||||
GT_BUY_PCT_SMALL_LEG=0.05
|
||||
GT_LARGE_LEG_TOP_PCT=0.2
|
||||
|
||||
# 04 매칭
|
||||
MATCH_LABEL_MODE=leg_gt
|
||||
MATCH_HOLDOUT_RATIO=0.15
|
||||
@@ -24,6 +31,8 @@ MONITOR_ALERT_KRW_AMOUNT=100000
|
||||
# 3 실거래 (오픈 시에만 1)
|
||||
LIVE_TRADING_ENABLED=0
|
||||
LIVE_ORDER_KRW=100000
|
||||
LIVE_BUY_PCT_LARGE=1.0
|
||||
LIVE_BUY_PCT_SMALL=0.05
|
||||
LIVE_DAILY_KRW_MAX=300000
|
||||
LIVE_COOLDOWN_MIN=180
|
||||
LIVE_MAX_TRADES_PER_DAY=10
|
||||
|
||||
@@ -202,6 +202,11 @@ GT_SELL_SPLIT_GAP_PCT = _getenv_float("GT_SELL_SPLIT_GAP_PCT", "2.5")
|
||||
GT_MARKER_SIZE_MIN = _getenv_int("GT_MARKER_SIZE_MIN", "10")
|
||||
GT_MARKER_SIZE_MAX = _getenv_int("GT_MARKER_SIZE_MAX", "32")
|
||||
GT_INITIAL_CASH_KRW = _getenv_int("GT_INITIAL_CASH_KRW", "1000000")
|
||||
GT_MIN_ORDER_KRW = _getenv_int("GT_MIN_ORDER_KRW", "5000")
|
||||
GT_MAX_BUY_ORDER_KRW = _getenv_int("GT_MAX_BUY_ORDER_KRW", "100000")
|
||||
GT_BUY_PCT_LARGE_LEG = _getenv_float("GT_BUY_PCT_LARGE_LEG", "1.0")
|
||||
GT_BUY_PCT_SMALL_LEG = _getenv_float("GT_BUY_PCT_SMALL_LEG", "0.05")
|
||||
GT_LARGE_LEG_TOP_PCT = _getenv_float("GT_LARGE_LEG_TOP_PCT", "0.2")
|
||||
TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005")
|
||||
|
||||
# --- 모니터 / API 수집 ---
|
||||
@@ -335,6 +340,8 @@ LIVE_TRADING_ENABLED = _getenv("LIVE_TRADING_ENABLED", "0").strip() in (
|
||||
"yes",
|
||||
)
|
||||
LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "100000")
|
||||
LIVE_BUY_PCT_LARGE = _getenv_float("LIVE_BUY_PCT_LARGE", "1.0")
|
||||
LIVE_BUY_PCT_SMALL = _getenv_float("LIVE_BUY_PCT_SMALL", "0.05")
|
||||
LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "300000")
|
||||
LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "180")
|
||||
LIVE_MAX_TRADES_PER_DAY = _getenv_int("LIVE_MAX_TRADES_PER_DAY", "10")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,12 @@ from config import (
|
||||
GT_BUY_BB_MAX,
|
||||
GT_BUY_MIN_BARS,
|
||||
GT_BUY_MIN_SWING_PCT,
|
||||
GT_BUY_PCT_LARGE_LEG,
|
||||
GT_BUY_PCT_SMALL_LEG,
|
||||
GT_INITIAL_CASH_KRW,
|
||||
GT_LARGE_LEG_TOP_PCT,
|
||||
GT_MIN_ORDER_KRW,
|
||||
GT_MAX_BUY_ORDER_KRW,
|
||||
GT_MAX_BUYS_PER_LEG,
|
||||
GT_MAX_ROUND_TRIPS,
|
||||
TRADING_FEE_RATE,
|
||||
@@ -68,6 +73,7 @@ class TradePoint:
|
||||
price: float
|
||||
memo: str
|
||||
weight: float = 1.0
|
||||
amount_krw: float | None = None
|
||||
leg_id: int = 0
|
||||
bb_pos: float | None = None
|
||||
rsi: float | None = None
|
||||
@@ -844,6 +850,12 @@ def generate_ground_truth(
|
||||
)
|
||||
|
||||
trade_dicts = order_trades_leg_block(trades)
|
||||
trade_dicts, alloc_stats = allocate_gt_order_amounts(
|
||||
trade_dicts,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
min_order_krw=GT_MIN_ORDER_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
)
|
||||
last_close = float(df["Close"].iloc[-1])
|
||||
pnl = simulate_truth_portfolio(
|
||||
trade_dicts,
|
||||
@@ -859,8 +871,13 @@ def generate_ground_truth(
|
||||
)
|
||||
_validate_leg_portfolio(trade_dicts, last_close)
|
||||
|
||||
from deepcoin.ground_truth.gt_model import default_model, model_to_dict
|
||||
|
||||
gt_model = model_to_dict(default_model())
|
||||
|
||||
return {
|
||||
"name": "ground_truth_split_buy_peak_sell",
|
||||
"model": gt_model,
|
||||
"method": method,
|
||||
"symbol": SYMBOL,
|
||||
"interval_min": ENTRY_INTERVAL,
|
||||
@@ -893,12 +910,23 @@ def generate_ground_truth(
|
||||
"unrealized_pnl_krw": round(
|
||||
float(pnl.get("pnl_krw", 0)) - float(pnl_realized.get("pnl_krw", 0)), 0
|
||||
),
|
||||
"execution_order": "leg_block",
|
||||
"execution_order": (
|
||||
"chronological"
|
||||
if any(float(t.get("amount_krw") or 0) > 0 for t in trade_dicts)
|
||||
else "leg_block"
|
||||
),
|
||||
"order_amount_min_krw": GT_MIN_ORDER_KRW,
|
||||
"order_amount_max_buy_krw": GT_MAX_BUY_ORDER_KRW,
|
||||
"buy_pct_large_leg": GT_BUY_PCT_LARGE_LEG,
|
||||
"buy_pct_small_leg": GT_BUY_PCT_SMALL_LEG,
|
||||
"large_leg_top_pct": GT_LARGE_LEG_TOP_PCT,
|
||||
**alloc_stats,
|
||||
},
|
||||
"note": (
|
||||
"저점 분할 매수(비중=삼각형), 고점 1~2회 매도. "
|
||||
"체결 순서=leg별 매수→매도(시각순 아님). 기간말 leg는 종가 청산. "
|
||||
"summary.pnl_pct는 미청산 포함 종가 평가, realized_pnl_pct는 체결만 반영."
|
||||
"매수=총자산×최적비중×티어(상위 leg 대형·그 외 소형), "
|
||||
f"현금 한도·최소 ₩{GT_MIN_ORDER_KRW:,}. "
|
||||
"체결 순서=chronological. summary.pnl_pct는 미청산 포함 종가 평가."
|
||||
),
|
||||
"trades": trade_dicts,
|
||||
}
|
||||
@@ -921,18 +949,6 @@ def _validate_leg_portfolio(
|
||||
steps = simulate_truth_portfolio_steps(trade_dicts)
|
||||
if not steps:
|
||||
return
|
||||
leg_ids = sorted({int(s["leg_id"]) for s in steps})
|
||||
for lid in leg_ids:
|
||||
leg_steps = [s for s in steps if int(s["leg_id"]) == lid]
|
||||
sells = [s for s in leg_steps if s["action"] == "sell"]
|
||||
if not sells:
|
||||
continue
|
||||
last_sell = sells[-1]
|
||||
if float(last_sell["holding_qty"]) > 1e-4:
|
||||
raise ValueError(
|
||||
f"leg#{lid} 마지막 매도 후 보유 잔존 qty={last_sell['holding_qty']} "
|
||||
"(leg 블록 체결·매도 비중 합 검토 필요)"
|
||||
)
|
||||
final = steps[-1]
|
||||
if float(final["holding_qty"]) > 1e-2:
|
||||
raise ValueError(
|
||||
@@ -943,6 +959,203 @@ def _validate_leg_portfolio(
|
||||
raise ValueError("종가 평가 후에도 미청산 보유가 남음")
|
||||
|
||||
|
||||
def allocate_gt_order_amounts(
|
||||
trades: list[dict[str, Any]],
|
||||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||||
min_order_krw: float = GT_MIN_ORDER_KRW,
|
||||
max_buy_krw: float = GT_MAX_BUY_ORDER_KRW,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""
|
||||
GT 각 타점에 amount_krw를 시각순·총자산·비중(최적 매수율)으로 배분합니다.
|
||||
|
||||
매수: 총보유자산 × (leg 비중 share × 티어 스케일), 상한=가용 현금.
|
||||
leg 상위 GT_LARGE_LEG_TOP_PCT는 GT_BUY_PCT_LARGE_LEG, 그 외는 GT_BUY_PCT_SMALL_LEG.
|
||||
매도 후 현금 증가분은 다음 매수부터 자동 반영(시각순 복리).
|
||||
|
||||
Args:
|
||||
trades: trade dict 리스트(시각순 정렬 전).
|
||||
initial_cash: 초기 현금.
|
||||
min_order_krw: 매수·매도 최소 원화 금액.
|
||||
max_buy_krw: 매수 1회 상한(가용 현금·비중 배분 후 캡).
|
||||
fee_rate: 수수료율.
|
||||
|
||||
Returns:
|
||||
(동일 dict 참조, amount_krw 채움), alloc_stats 요약.
|
||||
"""
|
||||
from deepcoin.matching.position_sizing import (
|
||||
compute_buy_amount_krw,
|
||||
leg_asset_pct_scale,
|
||||
top_leg_ids_by_forward_return,
|
||||
)
|
||||
|
||||
chron = sorted(trades, key=lambda x: x["dt"])
|
||||
large_legs = top_leg_ids_by_forward_return(chron)
|
||||
leg_buy_idxs: dict[int, list[int]] = {}
|
||||
leg_sell_idxs: dict[int, list[int]] = {}
|
||||
for i, t in enumerate(chron):
|
||||
lid = int(t.get("leg_id", 0))
|
||||
if t["action"] == "buy":
|
||||
leg_buy_idxs.setdefault(lid, []).append(i)
|
||||
elif t["action"] == "sell":
|
||||
leg_sell_idxs.setdefault(lid, []).append(i)
|
||||
|
||||
cash = float(initial_cash)
|
||||
qty = 0.0
|
||||
qty_by_leg: dict[int, float] = {}
|
||||
sell_leg: int | None = None
|
||||
sell_base_qty = 0.0
|
||||
buy_executed = 0
|
||||
buy_skipped = 0
|
||||
sell_executed = 0
|
||||
sell_skipped = 0
|
||||
buy_amounts: list[float] = []
|
||||
|
||||
for i, t in enumerate(chron):
|
||||
price = float(t["price"])
|
||||
if price <= 0:
|
||||
continue
|
||||
leg_id = int(t.get("leg_id", 0))
|
||||
weight = float(t.get("weight", 1.0))
|
||||
|
||||
if t["action"] == "buy":
|
||||
rem = [j for j in leg_buy_idxs.get(leg_id, []) if j >= i]
|
||||
w_sum = sum(float(chron[j].get("weight", 1.0)) for j in rem)
|
||||
w_share = (
|
||||
weight / w_sum if w_sum > 0 else 1.0 / max(len(rem), 1)
|
||||
)
|
||||
scale = leg_asset_pct_scale(leg_id, large_legs)
|
||||
amount = compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
weight,
|
||||
w_sum,
|
||||
asset_pct_scale=scale,
|
||||
min_order_krw=min_order_krw,
|
||||
fee_rate=fee_rate,
|
||||
)
|
||||
if amount <= 0:
|
||||
t["amount_krw"] = 0
|
||||
buy_skipped += 1
|
||||
continue
|
||||
t["amount_krw"] = amount
|
||||
fee = amount * fee_rate
|
||||
cash -= amount + fee
|
||||
bought_qty = amount / price
|
||||
qty += bought_qty
|
||||
qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought_qty
|
||||
buy_executed += 1
|
||||
buy_amounts.append(amount)
|
||||
sell_leg = None
|
||||
|
||||
elif t["action"] == "sell":
|
||||
leg_qty = qty_by_leg.get(leg_id, 0.0)
|
||||
if leg_qty <= 1e-12:
|
||||
sell_skipped += 1
|
||||
continue
|
||||
if sell_leg != leg_id:
|
||||
sell_leg = leg_id
|
||||
sell_base_qty = leg_qty
|
||||
rem_sells = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
|
||||
is_last_leg_sell = bool(rem_sells) and i == rem_sells[-1]
|
||||
if is_last_leg_sell:
|
||||
sell_qty = leg_qty
|
||||
gross = sell_qty * price
|
||||
else:
|
||||
gross = sell_base_qty * weight * price
|
||||
if gross >= min_order_krw:
|
||||
gross = max(min_order_krw, gross)
|
||||
gross = min(gross, leg_qty * price)
|
||||
if gross <= 0:
|
||||
sell_skipped += 1
|
||||
continue
|
||||
if not is_last_leg_sell:
|
||||
sell_qty = gross / price
|
||||
else:
|
||||
sell_qty = leg_qty
|
||||
t["amount_krw"] = round(gross, 0)
|
||||
fee = gross * fee_rate
|
||||
cash += gross - fee
|
||||
leg_qty -= sell_qty
|
||||
qty_by_leg[leg_id] = max(leg_qty, 0.0)
|
||||
qty = max(qty - sell_qty, 0.0)
|
||||
if qty < 1e-12:
|
||||
qty = 0.0
|
||||
sell_executed += 1
|
||||
|
||||
stats: dict[str, Any] = {
|
||||
"buy_executed": buy_executed,
|
||||
"buy_skipped": buy_skipped,
|
||||
"sell_executed": sell_executed,
|
||||
"sell_skipped": sell_skipped,
|
||||
"buy_total_krw": round(sum(buy_amounts), 0),
|
||||
"large_leg_count": len(large_legs),
|
||||
"large_leg_top_pct": GT_LARGE_LEG_TOP_PCT,
|
||||
}
|
||||
if buy_amounts:
|
||||
stats["buy_amount_avg_krw"] = round(sum(buy_amounts) / len(buy_amounts), 0)
|
||||
stats["buy_amount_min_krw"] = round(min(buy_amounts), 0)
|
||||
stats["buy_amount_max_krw"] = round(max(buy_amounts), 0)
|
||||
return trades, stats
|
||||
|
||||
|
||||
def _resolve_sell_qty(
|
||||
t: dict[str, Any],
|
||||
qty: float,
|
||||
price: float,
|
||||
sell_base_qty: float,
|
||||
weight: float,
|
||||
) -> float:
|
||||
"""
|
||||
매도 수량: amount_krw가 보유 전량에 가깝으면 전량, 아니면 weight 비중.
|
||||
|
||||
Args:
|
||||
t: trade dict.
|
||||
qty: 현재 보유 수량.
|
||||
price: 체결가.
|
||||
sell_base_qty: leg 첫 매도 시점 보유량.
|
||||
weight: 매도 비중.
|
||||
|
||||
Returns:
|
||||
매도 수량.
|
||||
"""
|
||||
if qty <= 0 or price <= 0:
|
||||
return 0.0
|
||||
ak = t.get("amount_krw")
|
||||
if ak is not None and float(ak) > 0:
|
||||
gross_cap = float(ak)
|
||||
if gross_cap >= qty * price * 0.999:
|
||||
return qty
|
||||
return min(qty, gross_cap / price)
|
||||
return min(sell_base_qty * weight, qty)
|
||||
|
||||
|
||||
def _trade_buy_amount(
|
||||
t: dict[str, Any],
|
||||
cash: float,
|
||||
leg_budget: float,
|
||||
current_leg: int | None,
|
||||
leg_id: int,
|
||||
fee_rate: float,
|
||||
) -> tuple[float, float, int | None]:
|
||||
"""
|
||||
매수 체결 원화: amount_krw 우선, 없으면 leg_budget*weight.
|
||||
|
||||
Returns:
|
||||
(amount, new_leg_budget, new_current_leg).
|
||||
"""
|
||||
weight = float(t.get("weight", 1.0))
|
||||
if t.get("amount_krw") is not None and float(t["amount_krw"]) > 0:
|
||||
amount = min(float(t["amount_krw"]), max(cash / (1.0 + fee_rate), 0.0))
|
||||
return amount, leg_budget, current_leg
|
||||
if leg_id != current_leg:
|
||||
current_leg = leg_id
|
||||
leg_budget = cash
|
||||
amount = leg_budget * weight
|
||||
return amount, leg_budget, current_leg
|
||||
|
||||
|
||||
def order_trades_leg_block(
|
||||
trades: list[TradePoint] | list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -996,8 +1209,12 @@ def _truth_simulation_rows(
|
||||
Returns:
|
||||
dict 행 리스트.
|
||||
"""
|
||||
if chronological:
|
||||
return order_trades_chronological(trades)
|
||||
rows = [t if isinstance(t, dict) else asdict(t) for t in trades]
|
||||
use_chrono = chronological or any(
|
||||
float(r.get("amount_krw") or 0) > 0 for r in rows
|
||||
)
|
||||
if use_chrono:
|
||||
return sorted(rows, key=lambda x: x["dt"])
|
||||
return order_trades_leg_block(trades)
|
||||
|
||||
|
||||
@@ -1037,7 +1254,9 @@ def simulate_truth_portfolio_steps(
|
||||
current_leg = leg_id
|
||||
leg_budget = cash
|
||||
sell_leg = None
|
||||
amount = leg_budget * weight
|
||||
amount, leg_budget, current_leg = _trade_buy_amount(
|
||||
t, cash, leg_budget, current_leg, leg_id, fee_rate
|
||||
)
|
||||
if amount <= 0:
|
||||
continue
|
||||
fee = amount * fee_rate
|
||||
@@ -1054,7 +1273,7 @@ def simulate_truth_portfolio_steps(
|
||||
if leg_id != sell_leg:
|
||||
sell_leg = leg_id
|
||||
sell_base_qty = qty
|
||||
sell_qty = min(sell_base_qty * weight, qty)
|
||||
sell_qty = _resolve_sell_qty(t, qty, price, sell_base_qty, weight)
|
||||
if sell_qty <= 0:
|
||||
continue
|
||||
gross = sell_qty * price
|
||||
@@ -1071,6 +1290,7 @@ def simulate_truth_portfolio_steps(
|
||||
"action": action,
|
||||
"price": price,
|
||||
"weight": weight,
|
||||
"amount_krw": t.get("amount_krw"),
|
||||
"leg_id": leg_id,
|
||||
"cash_krw": round(cash, 0),
|
||||
"holding_qty": round(qty, 4),
|
||||
@@ -1130,7 +1350,9 @@ def simulate_truth_portfolio(
|
||||
current_leg = leg_id
|
||||
leg_budget = cash
|
||||
sell_leg = None
|
||||
amount = leg_budget * weight
|
||||
amount, leg_budget, current_leg = _trade_buy_amount(
|
||||
t, cash, leg_budget, current_leg, leg_id, fee_rate
|
||||
)
|
||||
if amount <= 0:
|
||||
continue
|
||||
fee = amount * fee_rate
|
||||
@@ -1148,7 +1370,7 @@ def simulate_truth_portfolio(
|
||||
if leg_id != sell_leg:
|
||||
sell_leg = leg_id
|
||||
sell_base_qty = qty
|
||||
sell_qty = min(sell_base_qty * weight, qty)
|
||||
sell_qty = _resolve_sell_qty(t, qty, price, sell_base_qty, weight)
|
||||
if sell_qty <= 0:
|
||||
continue
|
||||
gross = sell_qty * price
|
||||
|
||||
228
deepcoin/ground_truth/gt_model.py
Normal file
228
deepcoin/ground_truth/gt_model.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Ground Truth 타점·비중·자본 배분 모델 (일반화 명세).
|
||||
|
||||
타점 생성(ground_truth.py)과 자본 배분(position_sizing.py)의 공통 언어.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from config import (
|
||||
GT_BUY_BB_MAX,
|
||||
GT_BUY_MIN_BARS,
|
||||
GT_BUY_MIN_SWING_PCT,
|
||||
GT_BUY_PCT_LARGE_LEG,
|
||||
GT_BUY_PCT_SMALL_LEG,
|
||||
GT_LARGE_LEG_TOP_PCT,
|
||||
GT_MAX_BUYS_PER_LEG,
|
||||
GT_MAX_SELLS_PER_LEG,
|
||||
GT_MIN_ORDER_KRW,
|
||||
GT_SELL_SPLIT_GAP_PCT,
|
||||
GT_SELECTION_MODE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GtEntrySpec:
|
||||
"""
|
||||
매수 타점(leg 내 분할) 규칙.
|
||||
|
||||
Attributes:
|
||||
pivot_kind: ZigZag 저점(trough).
|
||||
price_field: 체결가 = 봉 Low.
|
||||
weight_rule: 저가일수록 큰 비중 (1/price 정규화).
|
||||
max_per_leg: leg당 최대 매수 횟수.
|
||||
min_bars_gap: 분할 매수 최소 봉 간격.
|
||||
"""
|
||||
|
||||
pivot_kind: str = "trough"
|
||||
price_field: str = "Low"
|
||||
weight_rule: str = "inverse_price_normalized"
|
||||
max_per_leg: int = GT_MAX_BUYS_PER_LEG
|
||||
min_bars_gap: int = GT_BUY_MIN_BARS
|
||||
bb_filter: str = f"bb_pos <= {GT_BUY_BB_MAX}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GtExitSpec:
|
||||
"""
|
||||
매도 타점(leg 내 분할) 규칙.
|
||||
|
||||
Attributes:
|
||||
pivot_kind: major swing 고점(peak).
|
||||
price_field: 체결가 = 봉 High.
|
||||
split_weights: 2회 분할 시 (65%, 35%).
|
||||
split_gap_pct: 2차 고점 인정 최소 괴리(%).
|
||||
"""
|
||||
|
||||
pivot_kind: str = "peak"
|
||||
price_field: str = "High"
|
||||
split_weights: tuple[float, float] = (0.65, 0.35)
|
||||
split_gap_pct: float = GT_SELL_SPLIT_GAP_PCT
|
||||
max_per_leg: int = GT_MAX_SELLS_PER_LEG
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GtCapitalSpec:
|
||||
"""
|
||||
체결 원화(amount_krw) 배분 규칙.
|
||||
|
||||
Attributes:
|
||||
buy_formula: 총자산 × 최적매수율, 상한=가용현금.
|
||||
optimal_buy_rate: leg 내 남은 weight 비중.
|
||||
large_leg_top_pct: 수익률 상위 leg 비율.
|
||||
pct_large: 상위 leg 총자산 배분 스케일.
|
||||
pct_small: 그 외 leg 스케일.
|
||||
min_order_krw: 최소 체결 원화.
|
||||
"""
|
||||
|
||||
buy_formula: str = "min(total_asset * w_share * tier_scale, cash/(1+fee))"
|
||||
optimal_buy_rate: str = "weight / sum(remaining_buy_weights_in_leg)"
|
||||
large_leg_top_pct: float = GT_LARGE_LEG_TOP_PCT
|
||||
pct_large: float = GT_BUY_PCT_LARGE_LEG
|
||||
pct_small: float = GT_BUY_PCT_SMALL_LEG
|
||||
min_order_krw: float = float(GT_MIN_ORDER_KRW)
|
||||
sell_formula: str = "leg_qty * sell_weight * price (last sell = full leg_qty)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundTruthModel:
|
||||
"""
|
||||
GT 전체 모델 (타점 + 비중 + 자본).
|
||||
|
||||
Attributes:
|
||||
selection_mode: 타점 생성 모드.
|
||||
entry: 매수 명세.
|
||||
exit: 매도 명세.
|
||||
capital: 자본 배분 명세.
|
||||
leg: leg 정의.
|
||||
"""
|
||||
|
||||
selection_mode: str = GT_SELECTION_MODE
|
||||
entry: GtEntrySpec = field(default_factory=GtEntrySpec)
|
||||
exit: GtExitSpec = field(default_factory=GtExitSpec)
|
||||
capital: GtCapitalSpec = field(default_factory=GtCapitalSpec)
|
||||
leg_definition: str = (
|
||||
"이전 고점 매도 ~ 다음 고점 매도 구간 = leg_id; "
|
||||
"기간말 잔여 구간은 마지막 leg"
|
||||
)
|
||||
execution_order_chrono: str = (
|
||||
"amount_krw 배분·summary.pnl_pct = 시각순 체결(매도 후 현금 → 다음 매수 반영)"
|
||||
)
|
||||
execution_order_leg_block: str = (
|
||||
"JSON 저장 순서 = leg별 매수 전량 → 매도 전량 (차트·테이블 leg 정합)"
|
||||
)
|
||||
|
||||
|
||||
def default_model() -> GroundTruthModel:
|
||||
"""현재 config 기준 GT 모델."""
|
||||
return GroundTruthModel()
|
||||
|
||||
|
||||
def compute_buy_weights_inverse_price(prices: list[float]) -> list[float]:
|
||||
"""
|
||||
저점 매수 비중: score_i = 1/price_i → 합=1 정규화.
|
||||
|
||||
Args:
|
||||
prices: leg 내 매수 후보 가격.
|
||||
|
||||
Returns:
|
||||
weight 리스트 (합 ≈ 1).
|
||||
"""
|
||||
if not prices:
|
||||
return []
|
||||
scores = [1.0 / max(p, 1e-9) for p in prices]
|
||||
s = sum(scores)
|
||||
return [x / s for x in scores] if s > 0 else [1.0 / len(prices)] * len(prices)
|
||||
|
||||
|
||||
def sell_split_weights(n_sells: int) -> list[float]:
|
||||
"""
|
||||
leg 매도 비중.
|
||||
|
||||
Args:
|
||||
n_sells: 매도 횟수(1 또는 2).
|
||||
|
||||
Returns:
|
||||
weight 리스트.
|
||||
"""
|
||||
spec = GtExitSpec()
|
||||
if n_sells >= 2:
|
||||
return list(spec.split_weights)
|
||||
return [1.0]
|
||||
|
||||
|
||||
def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
JSON·리포트용 모델 dict.
|
||||
|
||||
Args:
|
||||
model: None이면 default.
|
||||
|
||||
Returns:
|
||||
직렬화 dict.
|
||||
"""
|
||||
m = model or default_model()
|
||||
return {
|
||||
"selection_mode": m.selection_mode,
|
||||
"leg_definition": m.leg_definition,
|
||||
"entry": {
|
||||
"pivot": m.entry.pivot_kind,
|
||||
"price": m.entry.price_field,
|
||||
"weight_rule": m.entry.weight_rule,
|
||||
"weight_formula": "w_i = (1/price_i) / sum(1/price_j)",
|
||||
"max_buys_per_leg": m.entry.max_per_leg,
|
||||
"min_bars_between_buys": m.entry.min_bars_gap,
|
||||
"bb_filter": m.entry.bb_filter,
|
||||
},
|
||||
"exit": {
|
||||
"pivot": m.exit.pivot_kind,
|
||||
"price": m.exit.price_field,
|
||||
"weight_rule": "fixed_split_or_full",
|
||||
"weights_two_sell": list(m.exit.split_weights),
|
||||
"split_gap_pct": m.exit.split_gap_pct,
|
||||
"max_sells_per_leg": m.exit.max_per_leg,
|
||||
},
|
||||
"capital": {
|
||||
"buy": m.capital.buy_formula,
|
||||
"optimal_buy_rate": m.capital.optimal_buy_rate,
|
||||
"large_leg_top_pct": m.capital.large_leg_top_pct,
|
||||
"pct_large_leg": m.capital.pct_large,
|
||||
"pct_small_leg": m.capital.pct_small,
|
||||
"min_order_krw": m.capital.min_order_krw,
|
||||
"sell": m.capital.sell_formula,
|
||||
},
|
||||
"execution": {
|
||||
"chrono": m.execution_order_chrono,
|
||||
"leg_block_json": m.execution_order_leg_block,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def summarize_leg_weights(trades: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""
|
||||
leg별 매수·매도 비중 합 검증용 요약.
|
||||
|
||||
Args:
|
||||
trades: GT trade dict.
|
||||
|
||||
Returns:
|
||||
leg_id → {buy_sum, sell_sum, n_buy, n_sell}.
|
||||
"""
|
||||
legs: dict[int, dict[str, Any]] = {}
|
||||
for t in trades:
|
||||
lid = int(t.get("leg_id", 0))
|
||||
legs.setdefault(
|
||||
lid,
|
||||
{"buy_sum": 0.0, "sell_sum": 0.0, "n_buy": 0, "n_sell": 0},
|
||||
)
|
||||
w = float(t.get("weight", 0))
|
||||
if t.get("action") == "buy":
|
||||
legs[lid]["buy_sum"] += w
|
||||
legs[lid]["n_buy"] += 1
|
||||
elif t.get("action") == "sell":
|
||||
legs[lid]["sell_sum"] += w
|
||||
legs[lid]["n_sell"] += 1
|
||||
return legs
|
||||
@@ -15,9 +15,39 @@ from config import (
|
||||
LIVE_ORDER_KRW,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.matching.position_sizing import (
|
||||
attach_dynamic_buy_amounts,
|
||||
load_sizing_context_from_gt,
|
||||
)
|
||||
|
||||
|
||||
def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
|
||||
def _planned_order_krw(
|
||||
t: dict[str, Any],
|
||||
order_krw: float,
|
||||
sizing_mode: str,
|
||||
) -> float:
|
||||
"""
|
||||
체결 계획 원화: amount_krw 우선 또는 고정.
|
||||
|
||||
Args:
|
||||
t: trade dict.
|
||||
order_krw: 고정 1회 금액.
|
||||
sizing_mode: fixed | amount_krw.
|
||||
|
||||
Returns:
|
||||
계획 원화.
|
||||
"""
|
||||
ak = t.get("amount_krw")
|
||||
if sizing_mode == "amount_krw" or (ak is not None and float(ak) > 0):
|
||||
return float(ak or 0)
|
||||
return float(order_krw)
|
||||
|
||||
|
||||
def select_capped_fires(
|
||||
fires: pd.DataFrame,
|
||||
*,
|
||||
use_dynamic_sizing: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
일한도·회수 제한으로 체결 가능한 발화만 남깁니다.
|
||||
|
||||
@@ -29,20 +59,58 @@ def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
if fires.empty:
|
||||
return fires
|
||||
gt_trades, large_legs, approved = load_sizing_context_from_gt()
|
||||
df = fires.sort_values("dt").copy()
|
||||
df["ts"] = pd.to_datetime(df["dt"])
|
||||
df["day"] = df["ts"].dt.date.astype(str)
|
||||
cash = float(GT_INITIAL_CASH_KRW)
|
||||
qty = 0.0
|
||||
taken: list[pd.DataFrame] = []
|
||||
for _, day_grp in df.groupby("day", sort=True):
|
||||
spent = 0.0
|
||||
n_trades = 0
|
||||
idxs: list[Any] = []
|
||||
for idx, _row in day_grp.iterrows():
|
||||
for idx, row in day_grp.iterrows():
|
||||
if n_trades >= LIVE_MAX_TRADES_PER_DAY:
|
||||
break
|
||||
if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
|
||||
break
|
||||
spent += LIVE_ORDER_KRW
|
||||
side = row["side"]
|
||||
price = float(row["close"])
|
||||
if side == "buy" and use_dynamic_sizing:
|
||||
from deepcoin.matching.position_sizing import (
|
||||
compute_buy_amount_krw,
|
||||
live_buy_asset_pct_scale,
|
||||
)
|
||||
|
||||
scale = live_buy_asset_pct_scale(
|
||||
str(row["rule_id"]),
|
||||
str(row["dt"]),
|
||||
gt_trades,
|
||||
approved_rules=approved,
|
||||
large_legs=large_legs,
|
||||
)
|
||||
planned = compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
1.0,
|
||||
1.0,
|
||||
asset_pct_scale=scale,
|
||||
)
|
||||
else:
|
||||
planned = float(LIVE_ORDER_KRW)
|
||||
if side == "buy":
|
||||
if spent + planned > LIVE_DAILY_KRW_MAX:
|
||||
break
|
||||
if planned <= 0:
|
||||
continue
|
||||
fee = planned * TRADING_FEE_RATE
|
||||
cash -= planned + fee
|
||||
qty += planned / price if price > 0 else 0.0
|
||||
spent += planned
|
||||
elif side == "sell" and qty > 0:
|
||||
gross = qty * price
|
||||
cash += gross * (1.0 - TRADING_FEE_RATE)
|
||||
qty = 0.0
|
||||
n_trades += 1
|
||||
idxs.append(idx)
|
||||
if idxs:
|
||||
@@ -52,7 +120,11 @@ def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
|
||||
return pd.concat(taken, ignore_index=True)
|
||||
|
||||
|
||||
def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]:
|
||||
def fires_to_trade_list(
|
||||
fires: pd.DataFrame,
|
||||
*,
|
||||
apply_dynamic_sizing: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환.
|
||||
|
||||
@@ -73,25 +145,65 @@ def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]:
|
||||
"forward_ret_pct": float(r.get("forward_ret_pct", 0)),
|
||||
}
|
||||
)
|
||||
if apply_dynamic_sizing and rows:
|
||||
gt_trades, large_legs, approved = load_sizing_context_from_gt()
|
||||
attach_dynamic_buy_amounts(
|
||||
rows,
|
||||
gt_trades=gt_trades,
|
||||
approved_rules=approved,
|
||||
large_legs=large_legs,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def simulate_sized_portfolio(
|
||||
trades: list[dict[str, Any]],
|
||||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
last_price: float | None = None,
|
||||
fallback_order_krw: float = LIVE_ORDER_KRW,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
trade.amount_krw(총자산 비율 배분) 기준 포트폴리오 시뮬.
|
||||
|
||||
Args:
|
||||
trades: 시간순 trade dict (amount_krw 권장).
|
||||
initial_cash: 시작 현금.
|
||||
fee_rate: 수수료율.
|
||||
last_price: 미청산 평가 종가.
|
||||
fallback_order_krw: amount_krw 없을 때 1회 금액.
|
||||
|
||||
Returns:
|
||||
simulate_truth_portfolio와 동일 키 구조.
|
||||
"""
|
||||
return simulate_fixed_order_portfolio(
|
||||
trades,
|
||||
order_krw=fallback_order_krw,
|
||||
initial_cash=initial_cash,
|
||||
fee_rate=fee_rate,
|
||||
last_price=last_price,
|
||||
sizing_mode="amount_krw",
|
||||
)
|
||||
|
||||
|
||||
def simulate_fixed_order_portfolio(
|
||||
trades: list[dict[str, Any]],
|
||||
order_krw: float = LIVE_ORDER_KRW,
|
||||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
last_price: float | None = None,
|
||||
sizing_mode: str = "fixed",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
매 체결마다 고정 원화 금액으로 매수·매도한 뒤 총평가·수익률을 계산합니다.
|
||||
포트폴리오 시뮬 (고정 원화 또는 trade.amount_krw).
|
||||
|
||||
Args:
|
||||
trades: 시간순 {dt, action, price}.
|
||||
order_krw: 1회 매수·매도 금액(원).
|
||||
trades: 시간순 {dt, action, price, amount_krw?}.
|
||||
order_krw: sizing_mode=fixed 일 때 1회 금액(원).
|
||||
initial_cash: 시작 현금.
|
||||
fee_rate: 수수료율.
|
||||
last_price: 미청산 평가 종가.
|
||||
sizing_mode: 'fixed' | 'amount_krw' (없으면 order_krw).
|
||||
|
||||
Returns:
|
||||
simulate_truth_portfolio와 동일 키 구조.
|
||||
@@ -110,7 +222,8 @@ def simulate_fixed_order_portfolio(
|
||||
last_trade_price = price
|
||||
|
||||
if action == "buy":
|
||||
amount = min(order, max(cash / (1.0 + fee_rate), 0.0))
|
||||
planned = _planned_order_krw(t, order, sizing_mode)
|
||||
amount = min(planned, max(cash / (1.0 + fee_rate), 0.0))
|
||||
if amount <= 0:
|
||||
continue
|
||||
fee = amount * fee_rate
|
||||
@@ -119,7 +232,11 @@ def simulate_fixed_order_portfolio(
|
||||
qty += amount / price
|
||||
|
||||
elif action == "sell" and qty > 0:
|
||||
sell_qty = min(qty, order / price)
|
||||
planned = _planned_order_krw(t, order, sizing_mode)
|
||||
if planned >= qty * price * 0.999:
|
||||
sell_qty = qty
|
||||
else:
|
||||
sell_qty = min(qty, planned / price)
|
||||
if sell_qty <= 0:
|
||||
continue
|
||||
gross = sell_qty * price
|
||||
@@ -148,6 +265,7 @@ def simulate_fixed_order_portfolio(
|
||||
"mark_price": round(mark_price, 2),
|
||||
"fee_rate": fee_rate,
|
||||
"order_krw": round(order, 0),
|
||||
"sizing_mode": sizing_mode,
|
||||
"trade_count": len(trades),
|
||||
}
|
||||
|
||||
@@ -157,6 +275,7 @@ def simulate_fixed_order_portfolio_steps(
|
||||
order_krw: float = LIVE_ORDER_KRW,
|
||||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
sizing_mode: str = "fixed",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
체결마다 현금·보유·총평가 스냅샷 (GT 테이블용).
|
||||
@@ -166,6 +285,7 @@ def simulate_fixed_order_portfolio_steps(
|
||||
order_krw: 1회 체결 원화.
|
||||
initial_cash: 시작 현금.
|
||||
fee_rate: 수수료율.
|
||||
sizing_mode: fixed | amount_krw.
|
||||
|
||||
Returns:
|
||||
step dict 리스트.
|
||||
@@ -182,7 +302,8 @@ def simulate_fixed_order_portfolio_steps(
|
||||
continue
|
||||
|
||||
if action == "buy":
|
||||
amount = min(order, max(cash / (1.0 + fee_rate), 0.0))
|
||||
planned = _planned_order_krw(t, order, sizing_mode)
|
||||
amount = min(planned, max(cash / (1.0 + fee_rate), 0.0))
|
||||
if amount <= 0:
|
||||
continue
|
||||
fee = amount * fee_rate
|
||||
@@ -190,7 +311,11 @@ def simulate_fixed_order_portfolio_steps(
|
||||
qty += amount / price
|
||||
|
||||
elif action == "sell" and qty > 0:
|
||||
sell_qty = min(qty, order / price)
|
||||
planned = _planned_order_krw(t, order, sizing_mode)
|
||||
if planned >= qty * price * 0.999:
|
||||
sell_qty = qty
|
||||
else:
|
||||
sell_qty = min(qty, planned / price)
|
||||
if sell_qty <= 0:
|
||||
continue
|
||||
gross = sell_qty * price
|
||||
@@ -207,6 +332,7 @@ def simulate_fixed_order_portfolio_steps(
|
||||
"price": price,
|
||||
"rule_id": t.get("rule_id", ""),
|
||||
"forward_ret_pct": t.get("forward_ret_pct"),
|
||||
"amount_krw": t.get("amount_krw"),
|
||||
"cash_krw": round(cash, 0),
|
||||
"holding_qty": round(qty, 4),
|
||||
"total_asset_krw": round(cash + qty * price, 0),
|
||||
|
||||
378
deepcoin/matching/position_sizing.py
Normal file
378
deepcoin/matching/position_sizing.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
총자산 대비 최적 매수율(비중) · 현금 한도 · leg 상위·EV/WF 통과 대형 매수.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import (
|
||||
GT_BUY_PCT_LARGE_LEG,
|
||||
GT_BUY_PCT_SMALL_LEG,
|
||||
GT_INITIAL_CASH_KRW,
|
||||
GT_LARGE_LEG_TOP_PCT,
|
||||
GT_MIN_ORDER_KRW,
|
||||
LIVE_BUY_PCT_LARGE,
|
||||
LIVE_BUY_PCT_SMALL,
|
||||
MATCH_GT_TOLERANCE_MIN,
|
||||
SIM_FEE_STRESS_MULT,
|
||||
SIM_GO_MIN_HOLDOUT_EV,
|
||||
SIM_GO_MIN_HOLDOUT_PF,
|
||||
SIM_GO_WF_POSITIVE_RATIO,
|
||||
SIM_WALK_FORWARD_MIN_MONTHS,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.matching.load_rules import load_matched_rules
|
||||
from deepcoin.paths import MATCHING_FIRE_OUTCOMES, MATCHING_MATCHED_RULES
|
||||
|
||||
|
||||
def portfolio_totals(
|
||||
cash: float,
|
||||
qty: float,
|
||||
price: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
총보유자산·코인평가·가용현금(=총자산-평가액)을 계산합니다.
|
||||
|
||||
Args:
|
||||
cash: 현금.
|
||||
qty: 보유 수량.
|
||||
price: 평가·체결가.
|
||||
|
||||
Returns:
|
||||
(total_asset_krw, holding_value_krw, cash_krw).
|
||||
"""
|
||||
holding = qty * price
|
||||
total = cash + holding
|
||||
return total, holding, cash
|
||||
|
||||
|
||||
def optimal_weight_share(weight: float, weight_sum_remaining: float) -> float:
|
||||
"""
|
||||
leg 내 남은 매수 비중 대비 이번 체결 최적 매수율(0~1).
|
||||
|
||||
Args:
|
||||
weight: 이번 타점 weight.
|
||||
weight_sum_remaining: 동일 leg 남은 매수 weight 합.
|
||||
|
||||
Returns:
|
||||
비중 비율.
|
||||
"""
|
||||
if weight_sum_remaining > 0:
|
||||
return weight / weight_sum_remaining
|
||||
return 1.0
|
||||
|
||||
|
||||
def compute_buy_amount_krw(
|
||||
cash: float,
|
||||
qty: float,
|
||||
price: float,
|
||||
weight: float,
|
||||
weight_sum_remaining: float,
|
||||
*,
|
||||
asset_pct_scale: float,
|
||||
min_order_krw: float = GT_MIN_ORDER_KRW,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
) -> float:
|
||||
"""
|
||||
총자산 × (최적 매수율 × scale)을 목표로, 가용 현금을 넘지 않게 매수 원화를 산출합니다.
|
||||
|
||||
Args:
|
||||
cash: 현금.
|
||||
qty: 보유 수량.
|
||||
price: 체결가.
|
||||
weight: 타점 비중.
|
||||
weight_sum_remaining: leg 내 남은 매수 weight 합.
|
||||
asset_pct_scale: leg·규칙 티어(대형/소형) 스케일.
|
||||
min_order_krw: 최소 주문 원화.
|
||||
fee_rate: 수수료율.
|
||||
|
||||
Returns:
|
||||
매수 원화(0이면 미체결).
|
||||
"""
|
||||
if price <= 0:
|
||||
return 0.0
|
||||
total_asset, _, _ = portfolio_totals(cash, qty, price)
|
||||
budget = max(cash / (1.0 + fee_rate), 0.0)
|
||||
opt_rate = optimal_weight_share(weight, weight_sum_remaining) * asset_pct_scale
|
||||
target = total_asset * opt_rate
|
||||
amount = min(target, budget)
|
||||
if budget >= min_order_krw and 0 < amount < min_order_krw:
|
||||
amount = min(min_order_krw, budget)
|
||||
return round(max(amount, 0.0), 0)
|
||||
|
||||
|
||||
def top_leg_ids_by_forward_return(
|
||||
trades: list[dict[str, Any]],
|
||||
top_pct: float = GT_LARGE_LEG_TOP_PCT,
|
||||
) -> set[int]:
|
||||
"""
|
||||
leg별 최대 forward_return 기준 상위 n% leg_id 집합.
|
||||
|
||||
Args:
|
||||
trades: GT trade dict.
|
||||
top_pct: 상위 비율(0~1).
|
||||
|
||||
Returns:
|
||||
대형 매수 leg_id set.
|
||||
"""
|
||||
leg_ret: dict[int, float] = {}
|
||||
for t in trades:
|
||||
if t.get("action") != "sell":
|
||||
continue
|
||||
lid = int(t.get("leg_id", 0))
|
||||
ret = float(t.get("forward_return_pct") or 0.0)
|
||||
leg_ret[lid] = max(leg_ret.get(lid, 0.0), ret)
|
||||
if not leg_ret:
|
||||
return set()
|
||||
ranked = sorted(leg_ret.items(), key=lambda x: x[1], reverse=True)
|
||||
n = max(1, int(len(ranked) * top_pct + 0.999999))
|
||||
return {lid for lid, _ in ranked[:n]}
|
||||
|
||||
|
||||
def leg_asset_pct_scale(leg_id: int, large_legs: set[int]) -> float:
|
||||
"""
|
||||
leg 티어에 따른 총자산 대비 매수 스케일.
|
||||
|
||||
Args:
|
||||
leg_id: leg 번호.
|
||||
large_legs: 상위 leg 집합.
|
||||
|
||||
Returns:
|
||||
GT_BUY_PCT_LARGE_LEG 또는 GT_BUY_PCT_SMALL_LEG.
|
||||
"""
|
||||
if leg_id in large_legs:
|
||||
return float(GT_BUY_PCT_LARGE_LEG)
|
||||
return float(GT_BUY_PCT_SMALL_LEG)
|
||||
|
||||
|
||||
def _parse_dt(dt: str) -> datetime:
|
||||
return datetime.fromisoformat(str(dt).replace("Z", "+00:00")[:19])
|
||||
|
||||
|
||||
def nearest_gt_leg_id(
|
||||
dt: str,
|
||||
gt_trades: list[dict[str, Any]],
|
||||
tolerance_min: int = MATCH_GT_TOLERANCE_MIN,
|
||||
) -> int | None:
|
||||
"""
|
||||
시각에 가장 가까운 GT trade의 leg_id (매수 우선).
|
||||
|
||||
Args:
|
||||
dt: 발화 시각.
|
||||
gt_trades: GT trades.
|
||||
tolerance_min: 허용 분.
|
||||
|
||||
Returns:
|
||||
leg_id 또는 None.
|
||||
"""
|
||||
if not gt_trades:
|
||||
return None
|
||||
t0 = _parse_dt(dt)
|
||||
best_buy: int | None = None
|
||||
best_buy_min = float(tolerance_min) + 1.0
|
||||
best_any: int | None = None
|
||||
best_any_min = float(tolerance_min) + 1.0
|
||||
for t in gt_trades:
|
||||
try:
|
||||
t1 = _parse_dt(t["dt"])
|
||||
except ValueError:
|
||||
continue
|
||||
delta = abs((t0 - t1).total_seconds()) / 60.0
|
||||
if delta > tolerance_min:
|
||||
continue
|
||||
lid = int(t.get("leg_id", 0))
|
||||
if t.get("action") == "buy" and delta < best_buy_min:
|
||||
best_buy_min = delta
|
||||
best_buy = lid
|
||||
if delta < best_any_min:
|
||||
best_any_min = delta
|
||||
best_any = lid
|
||||
return best_buy if best_buy is not None else best_any
|
||||
|
||||
|
||||
_APPROVED_RULES_CACHE: set[str] | None = None
|
||||
|
||||
|
||||
def load_ev_wf_approved_rule_ids(
|
||||
matched_path: Path | None = None,
|
||||
outcomes_path: Path | None = None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
holdout EV·PF, walk-forward, 수수료 스트레스를 모두 통과한 rule_id.
|
||||
|
||||
Args:
|
||||
matched_path: matched_rules.json.
|
||||
outcomes_path: fire_outcomes.csv.
|
||||
|
||||
Returns:
|
||||
통과 rule_id set. 산출 불가 시 monitor_rules 전체 fallback.
|
||||
"""
|
||||
global _APPROVED_RULES_CACHE
|
||||
if _APPROVED_RULES_CACHE is not None:
|
||||
return set(_APPROVED_RULES_CACHE)
|
||||
|
||||
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
|
||||
from deepcoin.matching.simulation import (
|
||||
evaluate_go_no_go,
|
||||
simulate_live_order_cap,
|
||||
walk_forward_by_month,
|
||||
walk_forward_summary,
|
||||
)
|
||||
|
||||
mp = matched_path or MATCHING_MATCHED_RULES
|
||||
op = outcomes_path or MATCHING_FIRE_OUTCOMES
|
||||
matched = load_matched_rules(mp)
|
||||
rules = matched.get("monitor_rules") or []
|
||||
if not rules or not op.is_file():
|
||||
return {r["rule_id"] for r in rules}
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import MATCH_FEE_RATE
|
||||
|
||||
outcomes = pd.read_csv(op)
|
||||
outcomes["split"] = _split_train_valid_holdout(outcomes)
|
||||
wf_sum = walk_forward_summary(walk_forward_by_month(outcomes))
|
||||
fee_stress: dict[str, Any] = {}
|
||||
for rid in outcomes["rule_id"].unique():
|
||||
sub = outcomes[outcomes["rule_id"] == rid]
|
||||
from deepcoin.matching.simulation import _fee_adjust_ret
|
||||
|
||||
adj = _fee_adjust_ret(sub["forward_ret_pct"], SIM_FEE_STRESS_MULT)
|
||||
fee_stress[rid] = _rule_metrics(sub.assign(forward_ret_pct=adj))
|
||||
monitor_ids = {r["rule_id"] for r in rules}
|
||||
live_cap = simulate_live_order_cap(
|
||||
outcomes, rule_ids=monitor_ids, holdout_only=True
|
||||
)
|
||||
go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
|
||||
passed = {c["rule_id"] for c in go.get("checks", []) if c.get("pass")}
|
||||
if passed:
|
||||
_APPROVED_RULES_CACHE = passed
|
||||
return passed
|
||||
fallback = monitor_ids
|
||||
_APPROVED_RULES_CACHE = fallback
|
||||
return fallback
|
||||
|
||||
|
||||
def live_buy_asset_pct_scale(
|
||||
rule_id: str,
|
||||
dt: str,
|
||||
gt_trades: list[dict[str, Any]],
|
||||
*,
|
||||
approved_rules: set[str],
|
||||
large_legs: set[int],
|
||||
) -> float:
|
||||
"""
|
||||
실거래·시뮬 매수: EV/WF 통과 규칙 + leg 상위만 대형 비율.
|
||||
|
||||
Args:
|
||||
rule_id: 규칙 ID.
|
||||
dt: 체결 시각.
|
||||
gt_trades: GT trades.
|
||||
approved_rules: 통과 rule_id.
|
||||
large_legs: 상위 leg.
|
||||
|
||||
Returns:
|
||||
LIVE_BUY_PCT_LARGE 또는 LIVE_BUY_PCT_SMALL(또는 0에 가까운 소형).
|
||||
"""
|
||||
if rule_id not in approved_rules:
|
||||
return float(LIVE_BUY_PCT_SMALL)
|
||||
lid = nearest_gt_leg_id(dt, gt_trades)
|
||||
if lid is not None and lid in large_legs:
|
||||
return float(LIVE_BUY_PCT_LARGE)
|
||||
return float(LIVE_BUY_PCT_SMALL)
|
||||
|
||||
|
||||
def attach_dynamic_buy_amounts(
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
gt_trades: list[dict[str, Any]],
|
||||
approved_rules: set[str] | None = None,
|
||||
large_legs: set[int] | None = None,
|
||||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||||
default_weight: float = 1.0,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
시뮬 발화 trade dict에 amount_krw(총자산 비율·현금 한도)를 채웁니다.
|
||||
|
||||
Args:
|
||||
trades: 시간순 {dt, action, price, rule_id, …}.
|
||||
gt_trades: GT leg 매칭용.
|
||||
approved_rules: EV/WF 통과 rule. None이면 전 규칙 대형 허용 안 함.
|
||||
large_legs: 상위 leg. None이면 GT에서 산출.
|
||||
initial_cash: 초기 현금.
|
||||
default_weight: 매수 weight 기본값.
|
||||
fee_rate: 수수료율.
|
||||
|
||||
Returns:
|
||||
amount_krw가 채워진 동일 리스트.
|
||||
"""
|
||||
if large_legs is None:
|
||||
large_legs = top_leg_ids_by_forward_return(gt_trades)
|
||||
if approved_rules is None:
|
||||
approved_rules = set()
|
||||
|
||||
cash = float(initial_cash)
|
||||
qty = 0.0
|
||||
for t in sorted(trades, key=lambda x: x["dt"]):
|
||||
action = t.get("action", t.get("side", ""))
|
||||
price = float(t["price"])
|
||||
if price <= 0:
|
||||
continue
|
||||
if action == "buy":
|
||||
rid = str(t.get("rule_id", ""))
|
||||
scale = live_buy_asset_pct_scale(
|
||||
rid, t["dt"], gt_trades,
|
||||
approved_rules=approved_rules,
|
||||
large_legs=large_legs,
|
||||
)
|
||||
amount = compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
float(t.get("weight", default_weight)),
|
||||
float(t.get("weight", default_weight)),
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=fee_rate,
|
||||
)
|
||||
t["amount_krw"] = amount
|
||||
if amount > 0:
|
||||
fee = amount * fee_rate
|
||||
cash -= amount + fee
|
||||
qty += amount / price
|
||||
elif action == "sell" and qty > 1e-12:
|
||||
gross = qty * price
|
||||
t["amount_krw"] = round(gross, 0)
|
||||
fee = gross * fee_rate
|
||||
cash += gross - fee
|
||||
qty = 0.0
|
||||
return trades
|
||||
|
||||
|
||||
def load_sizing_context_from_gt(
|
||||
gt_path: Path | None = None,
|
||||
) -> tuple[list[dict[str, Any]], set[int], set[str]]:
|
||||
"""
|
||||
GT JSON에서 trades, 상위 leg, EV/WF 통과 rule을 로드합니다.
|
||||
|
||||
Args:
|
||||
gt_path: ground_truth_trades.json.
|
||||
|
||||
Returns:
|
||||
(gt_trades, large_legs, approved_rules).
|
||||
"""
|
||||
from deepcoin.paths import resolve_ground_truth_file
|
||||
|
||||
p = gt_path or resolve_ground_truth_file()
|
||||
trades: list[dict[str, Any]] = []
|
||||
if p.is_file():
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
trades = data.get("trades") or []
|
||||
large = top_leg_ids_by_forward_return(trades)
|
||||
approved = load_ev_wf_approved_rule_ids()
|
||||
return trades, large, approved
|
||||
@@ -12,6 +12,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import (
|
||||
GT_INITIAL_CASH_KRW,
|
||||
LIVE_DAILY_KRW_MAX,
|
||||
LIVE_MAX_TRADES_PER_DAY,
|
||||
LIVE_ORDER_KRW,
|
||||
@@ -28,7 +29,19 @@ from config import (
|
||||
SIM_WALK_FORWARD_MIN_MONTHS,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.ground_truth.ground_truth import (
|
||||
load_ground_truth,
|
||||
order_trades_chronological,
|
||||
simulate_truth_portfolio,
|
||||
)
|
||||
from deepcoin.matching.portfolio_sim import (
|
||||
fires_to_trade_list,
|
||||
select_capped_fires,
|
||||
simulate_fixed_order_portfolio,
|
||||
simulate_sized_portfolio,
|
||||
)
|
||||
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
|
||||
from deepcoin.paths import resolve_ground_truth_file
|
||||
from deepcoin.paths import (
|
||||
ANALYSIS_GT_CALIBRATION_JSON,
|
||||
MATCHING_FIRE_OUTCOMES,
|
||||
@@ -109,12 +122,19 @@ def walk_forward_summary(wf_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
|
||||
def simulate_live_order_cap(
|
||||
outcomes: pd.DataFrame,
|
||||
*,
|
||||
rule_ids: set[str] | None = None,
|
||||
holdout_only: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
1회·일 한도·슬리피지 가정으로 체결 가능한 발화만 집계.
|
||||
|
||||
Args:
|
||||
outcomes: fire_outcomes.
|
||||
outcomes: fire_outcomes (split 컬럼 있으면 holdout 필터 가능).
|
||||
rule_ids: None이면 전 규칙, 지정 시 해당 rule만.
|
||||
holdout_only: True면 split==holdout 만.
|
||||
|
||||
Returns:
|
||||
규칙별·전체 요약.
|
||||
@@ -122,12 +142,27 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
|
||||
if outcomes.empty:
|
||||
return {"rules": {}, "note": "발화 없음"}
|
||||
|
||||
df = outcomes.sort_values("dt").copy()
|
||||
df = outcomes
|
||||
if holdout_only and "split" in df.columns:
|
||||
df = df[df["split"] == "holdout"]
|
||||
if rule_ids is not None:
|
||||
df = df[df["rule_id"].isin(rule_ids)]
|
||||
df = df.sort_values("dt").copy()
|
||||
df["ts"] = pd.to_datetime(df["dt"])
|
||||
df["day"] = df["ts"].dt.date.astype(str)
|
||||
slip = LIVE_SLIPPAGE_PCT
|
||||
taken_rows: list[pd.DataFrame] = []
|
||||
|
||||
from deepcoin.matching.position_sizing import (
|
||||
compute_buy_amount_krw,
|
||||
live_buy_asset_pct_scale,
|
||||
load_sizing_context_from_gt,
|
||||
)
|
||||
|
||||
gt_trades, large_legs, approved = load_sizing_context_from_gt()
|
||||
cash = float(GT_INITIAL_CASH_KRW)
|
||||
qty = 0.0
|
||||
|
||||
for day, day_grp in df.groupby("day", sort=True):
|
||||
spent = 0.0
|
||||
n_trades = 0
|
||||
@@ -135,9 +170,34 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
|
||||
for idx, row in day_grp.iterrows():
|
||||
if n_trades >= LIVE_MAX_TRADES_PER_DAY:
|
||||
break
|
||||
if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
|
||||
break
|
||||
spent += LIVE_ORDER_KRW
|
||||
side = row["side"]
|
||||
price = float(row["close"])
|
||||
if side == "buy":
|
||||
scale = live_buy_asset_pct_scale(
|
||||
str(row["rule_id"]),
|
||||
str(row["dt"]),
|
||||
gt_trades,
|
||||
approved_rules=approved,
|
||||
large_legs=large_legs,
|
||||
)
|
||||
planned = compute_buy_amount_krw(
|
||||
cash, qty, price, 1.0, 1.0, asset_pct_scale=scale
|
||||
)
|
||||
else:
|
||||
planned = float(LIVE_ORDER_KRW)
|
||||
if side == "buy":
|
||||
if planned <= 0:
|
||||
continue
|
||||
if spent + planned > LIVE_DAILY_KRW_MAX:
|
||||
break
|
||||
fee = planned * TRADING_FEE_RATE
|
||||
cash -= planned + fee
|
||||
qty += planned / price if price > 0 else 0.0
|
||||
spent += planned
|
||||
elif side == "sell" and qty > 0:
|
||||
gross = qty * price
|
||||
cash += gross * (1.0 - TRADING_FEE_RATE)
|
||||
qty = 0.0
|
||||
n_trades += 1
|
||||
taken_idx.append(idx)
|
||||
if taken_idx:
|
||||
@@ -164,6 +224,7 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
|
||||
"order_krw": LIVE_ORDER_KRW,
|
||||
"daily_krw_max": LIVE_DAILY_KRW_MAX,
|
||||
"slippage_pct": slip,
|
||||
"sizing": "total_asset_pct_ev_wf_large_leg",
|
||||
},
|
||||
"taken_count": int(len(taken)),
|
||||
"total_count": int(len(df)),
|
||||
@@ -267,9 +328,35 @@ def build_simulation_report(
|
||||
sub.assign(forward_ret_pct=adj)
|
||||
)
|
||||
|
||||
live_cap = simulate_live_order_cap(outcomes)
|
||||
monitor_ids = {r["rule_id"] for r in matched.get("monitor_rules", [])}
|
||||
live_cap = simulate_live_order_cap(
|
||||
outcomes, rule_ids=monitor_ids, holdout_only=True
|
||||
)
|
||||
go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
|
||||
|
||||
portfolio_compare: dict[str, Any] = {}
|
||||
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
|
||||
gt_trades = gt_data.get("trades") or []
|
||||
mark = (gt_data.get("summary") or {}).get("mark_price")
|
||||
if gt_trades:
|
||||
portfolio_compare["ground_truth_chrono"] = simulate_truth_portfolio(
|
||||
order_trades_chronological(gt_trades),
|
||||
last_price=float(mark) if mark else None,
|
||||
)
|
||||
holdout = outcomes[
|
||||
outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout")
|
||||
]
|
||||
capped = select_capped_fires(holdout)
|
||||
if not capped.empty:
|
||||
portfolio_compare["sim_sized"] = simulate_sized_portfolio(
|
||||
fires_to_trade_list(capped, apply_dynamic_sizing=True),
|
||||
last_price=float(mark) if mark else None,
|
||||
)
|
||||
portfolio_compare["sim_fixed_order"] = simulate_fixed_order_portfolio(
|
||||
fires_to_trade_list(capped, apply_dynamic_sizing=False),
|
||||
last_price=float(mark) if mark else None,
|
||||
)
|
||||
|
||||
gt_portfolio: dict[str, Any] = {}
|
||||
if ANALYSIS_GT_CALIBRATION_JSON.is_file():
|
||||
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
|
||||
@@ -301,6 +388,8 @@ def build_simulation_report(
|
||||
"fee_stress_by_rule": fee_stress,
|
||||
"live_order_cap_sim": live_cap,
|
||||
"go_no_go": go,
|
||||
"portfolio_compare": portfolio_compare,
|
||||
"gt_model": gt_data.get("model"),
|
||||
"monitor_rules": matched.get("monitor_rules", []),
|
||||
"gt_portfolio_calibration": gt_portfolio,
|
||||
"criteria": {
|
||||
|
||||
@@ -20,6 +20,7 @@ from config import (
|
||||
)
|
||||
from deepcoin.ground_truth.ground_truth import (
|
||||
load_ground_truth,
|
||||
order_trades_chronological,
|
||||
simulate_truth_portfolio,
|
||||
simulate_truth_portfolio_steps,
|
||||
)
|
||||
@@ -28,6 +29,7 @@ from deepcoin.matching.portfolio_sim import (
|
||||
select_capped_fires,
|
||||
simulate_fixed_order_portfolio,
|
||||
simulate_fixed_order_portfolio_steps,
|
||||
simulate_sized_portfolio,
|
||||
)
|
||||
from deepcoin.matching.select_rules import _split_train_valid_holdout
|
||||
from deepcoin.ops.chart_report import (
|
||||
@@ -36,6 +38,7 @@ from deepcoin.ops.chart_report import (
|
||||
market_cards_html,
|
||||
pnl_cards_html,
|
||||
rule_criteria_html,
|
||||
stacked_summary_cards_html,
|
||||
wrap_chart_report_page,
|
||||
)
|
||||
from deepcoin.ops.simulation import build_chart_html, load_chart_frames, _frames_to_mtf
|
||||
@@ -194,9 +197,11 @@ def _summary_cards_html(
|
||||
bb_txt: str,
|
||||
gt_trades: list[dict[str, Any]],
|
||||
gt_pnl: dict[str, Any],
|
||||
sim_pnl: dict[str, Any],
|
||||
sim_sized_pnl: dict[str, Any],
|
||||
sim_fixed_pnl: dict[str, Any],
|
||||
sim_trade_count: int,
|
||||
go_flag: bool,
|
||||
model_note: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄).
|
||||
@@ -206,26 +211,43 @@ def _summary_cards_html(
|
||||
bb_txt: BB %B.
|
||||
gt_trades: GT trades.
|
||||
gt_pnl: GT 포트폴리오 요약.
|
||||
sim_pnl: 시뮬 포트폴리오 요약.
|
||||
sim_sized_pnl: 총자산%·EV/WF·leg 시뮬 요약.
|
||||
sim_fixed_pnl: 고정 ₩/회 baseline.
|
||||
sim_trade_count: 체결 가정 발화 수.
|
||||
go_flag: Go/No-Go.
|
||||
model_note: GT 모델 한 줄 요약.
|
||||
|
||||
Returns:
|
||||
cards HTML.
|
||||
"""
|
||||
go_cls = "go-pass" if go_flag else "go-fail"
|
||||
gt_row = (
|
||||
'<p class="cards-group-title">정답 (ground_truth) — 분할 비중·leg 체결</p>'
|
||||
+ market_cards_html(close_last, bb_txt)
|
||||
+ pnl_cards_html(gt_pnl, "정답 타점", len(gt_trades))
|
||||
gt_sub = (
|
||||
"저점 분할매수(1/price 비중) · 고점 65/35% 매도 · "
|
||||
"총자산×비중×leg티어 · 시각순 복리"
|
||||
)
|
||||
sim_row = (
|
||||
'<p class="cards-group-title">시뮬 (monitor_rules · holdout · '
|
||||
f"1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도) — "
|
||||
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span></p>'
|
||||
+ pnl_cards_html(sim_pnl, "시뮬 체결", sim_trade_count)
|
||||
if model_note:
|
||||
gt_sub = model_note
|
||||
gt_cards = market_cards_html(close_last, bb_txt) + pnl_cards_html(
|
||||
gt_pnl, "정답 GT", len(gt_trades)
|
||||
)
|
||||
sim_sized_title = (
|
||||
"시뮬·총자산% (EV/WF·leg상위) — "
|
||||
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span>'
|
||||
)
|
||||
sim_fixed_title = f"시뮬·고정 ₩{LIVE_ORDER_KRW:,}/회 (비교)"
|
||||
return (
|
||||
'<div class="summary-cards">'
|
||||
+ stacked_summary_cards_html(gt_sub, gt_cards)
|
||||
+ stacked_summary_cards_html(
|
||||
sim_sized_title,
|
||||
pnl_cards_html(sim_sized_pnl, "시뮬(비율)", sim_trade_count),
|
||||
)
|
||||
+ stacked_summary_cards_html(
|
||||
sim_fixed_title,
|
||||
pnl_cards_html(sim_fixed_pnl, "시뮬(고정)", sim_trade_count),
|
||||
)
|
||||
+ "</div>"
|
||||
)
|
||||
return gt_row + sim_row
|
||||
|
||||
|
||||
def build_simulation_page_html(
|
||||
@@ -294,56 +316,57 @@ def build_simulation_page_html(
|
||||
elif gt_summary.get("mark_price"):
|
||||
close_val = float(gt_summary["mark_price"])
|
||||
|
||||
sim_trades = fires_to_trade_list(capped)
|
||||
gt_pnl = {}
|
||||
gt_summary_pnl = gt_data.get("summary") or {}
|
||||
if gt_summary_pnl.get("pnl_krw") is not None and gt_summary_pnl.get(
|
||||
"execution_order"
|
||||
) == "leg_block":
|
||||
gt_pnl = {
|
||||
k: gt_summary_pnl[k]
|
||||
for k in (
|
||||
"initial_cash_krw",
|
||||
"final_asset_krw",
|
||||
"pnl_pct",
|
||||
"total_fees_krw",
|
||||
"holding_qty",
|
||||
"holding_value_krw",
|
||||
"mark_price",
|
||||
"cash_krw",
|
||||
)
|
||||
if k in gt_summary_pnl
|
||||
}
|
||||
elif gt_trades:
|
||||
sim_trades_sized = fires_to_trade_list(capped, apply_dynamic_sizing=True)
|
||||
sim_trades_fixed = fires_to_trade_list(capped, apply_dynamic_sizing=False)
|
||||
|
||||
gt_pnl: dict[str, Any] = {}
|
||||
if gt_trades:
|
||||
gt_chron = order_trades_chronological(gt_trades)
|
||||
gt_pnl = simulate_truth_portfolio(
|
||||
gt_trades,
|
||||
gt_chron,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
last_price=close_val if close_val else None,
|
||||
)
|
||||
|
||||
sim_pnl = simulate_fixed_order_portfolio(
|
||||
sim_trades,
|
||||
mark = close_val if close_val else None
|
||||
sim_sized_pnl = simulate_sized_portfolio(
|
||||
sim_trades_sized,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
last_price=mark,
|
||||
)
|
||||
sim_fixed_pnl = simulate_fixed_order_portfolio(
|
||||
sim_trades_fixed,
|
||||
order_krw=LIVE_ORDER_KRW,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
last_price=close_val if close_val else None,
|
||||
last_price=mark,
|
||||
sizing_mode="fixed",
|
||||
)
|
||||
sim_steps = simulate_fixed_order_portfolio_steps(
|
||||
sim_trades,
|
||||
sim_trades_sized,
|
||||
order_krw=LIVE_ORDER_KRW,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
sizing_mode="amount_krw",
|
||||
)
|
||||
gt_steps = (
|
||||
simulate_truth_portfolio_steps(
|
||||
gt_trades,
|
||||
order_trades_chronological(gt_trades),
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
)
|
||||
if gt_trades
|
||||
else []
|
||||
)
|
||||
model = gt_data.get("model") or {}
|
||||
model_note = (
|
||||
f"mode={model.get('selection_mode', 'split_buy_peak_sell')} · "
|
||||
f"매수비중=1/price · 매도=65/35%"
|
||||
if model
|
||||
else ""
|
||||
)
|
||||
|
||||
criteria_blocks = "".join(rule_criteria_html(r) for r in monitor_rules)
|
||||
go_table = go_no_go_table_html(go.get("checks", []), go_flag)
|
||||
@@ -355,7 +378,7 @@ def build_simulation_page_html(
|
||||
|
||||
sim_table = f"""
|
||||
<h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2>
|
||||
<p class="meta">1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도·최대 거래수 적용 후 체결 순 포트폴리오.
|
||||
<p class="meta">총자산×최적비중·현금한도·EV/WF통과·leg상위 대형 매수. 일한도·최대 거래수 적용.
|
||||
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
@@ -367,7 +390,7 @@ def build_simulation_page_html(
|
||||
|
||||
gt_table = f"""
|
||||
<h2>정답 타점 (ground_truth)</h2>
|
||||
<p class="meta">삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}</p>
|
||||
<p class="meta">삼각형 크기 = GT 체결 금액. 매수 분할·매도 leg 반영.{_mark_note(close_val)}</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
|
||||
@@ -390,7 +413,7 @@ def build_simulation_page_html(
|
||||
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
|
||||
)
|
||||
legend = (
|
||||
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 비중.<br>"
|
||||
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 체결 금액.<br>"
|
||||
"● <b>시뮬</b> — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서."
|
||||
)
|
||||
if frames is not None:
|
||||
@@ -405,7 +428,15 @@ def build_simulation_page_html(
|
||||
)
|
||||
|
||||
cards = _summary_cards_html(
|
||||
close_val, bb_txt, gt_trades, gt_pnl, sim_pnl, len(capped), go_flag
|
||||
close_val,
|
||||
bb_txt,
|
||||
gt_trades,
|
||||
gt_pnl,
|
||||
sim_sized_pnl,
|
||||
sim_fixed_pnl,
|
||||
len(capped),
|
||||
go_flag,
|
||||
model_note=model_note,
|
||||
)
|
||||
|
||||
if frames is not None:
|
||||
@@ -415,6 +446,7 @@ def build_simulation_page_html(
|
||||
note=note,
|
||||
truth_trades=gt_trades,
|
||||
sim_trades=_fires_to_chart_trades(holdout_fires),
|
||||
# 차트 마커는 holdout 전체; 카드·테이블은 일한도 capped
|
||||
title_suffix="1단계 시뮬레이션 (monitor · holdout)",
|
||||
legend_html=legend,
|
||||
footer_sections=sections,
|
||||
|
||||
@@ -37,7 +37,13 @@ CHART_REPORT_CSS = """
|
||||
.table-scroll { max-height: 480px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.pass { color: #16a34a; font-weight: 600; }
|
||||
.fail { color: #dc2626; font-weight: 600; }
|
||||
.cards-group-title { font-size: 0.82rem; color: #475569; margin: 14px 0 6px; font-weight: 600; }
|
||||
.summary-cards { margin: 16px 0; }
|
||||
.summary-cards .cards-row-block { display: block; width: 100%; margin-bottom: 14px; }
|
||||
.summary-cards .cards-row-block:last-child { margin-bottom: 0; }
|
||||
.cards-group-title {
|
||||
font-size: 0.82rem; color: #475569; margin: 0 0 8px; font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -116,6 +122,28 @@ def card_html(label: str, value: str) -> str:
|
||||
return f'<div class="card"><span>{label}</span><b>{value}</b></div>'
|
||||
|
||||
|
||||
def stacked_summary_cards_html(
|
||||
title: str,
|
||||
cards_inner: str,
|
||||
) -> str:
|
||||
"""
|
||||
제목 한 줄 + 카드 flex 한 줄을 세로 블록으로 묶습니다.
|
||||
|
||||
Args:
|
||||
title: cards-group-title 텍스트(HTML 허용).
|
||||
cards_inner: .cards 안에 넣을 card div 문자열.
|
||||
|
||||
Returns:
|
||||
cards-row-block HTML.
|
||||
"""
|
||||
return (
|
||||
'<div class="cards-row-block">'
|
||||
f'<p class="cards-group-title">{title}</p>'
|
||||
f'<div class="cards">{cards_inner}</div>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def wrap_chart_report_page(
|
||||
page_title: str,
|
||||
heading: str,
|
||||
@@ -135,7 +163,7 @@ def wrap_chart_report_page(
|
||||
meta_line: 기간·추세 등.
|
||||
note_html: 안내 박스.
|
||||
legend_html: 차트 범례 설명.
|
||||
cards_html: .cards 내부 HTML.
|
||||
cards_html: .cards 내부 HTML 또는 .summary-cards 블록 전체.
|
||||
chart_html: plotly embed.
|
||||
sections_html: h2·테이블·criteria 등 본문 하단.
|
||||
|
||||
@@ -154,7 +182,7 @@ def wrap_chart_report_page(
|
||||
<p class="meta">{meta_line}</p>
|
||||
{note_html}
|
||||
<div class="legend-box">{legend_html}</div>
|
||||
<div class="cards">{cards_html}</div>
|
||||
{cards_html if "summary-cards" in cards_html else f'<div class="cards">{cards_html}</div>'}
|
||||
<div class="chart-wrap">{chart_html}</div>
|
||||
{sections_html}
|
||||
</body>
|
||||
|
||||
@@ -19,9 +19,18 @@ from config import (
|
||||
LIVE_ORDER_KRW,
|
||||
LIVE_TRADING_ENABLED,
|
||||
SYMBOL,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.ground_truth.ground_truth import load_ground_truth
|
||||
from deepcoin.matching.live_eval import evaluate_live_rules
|
||||
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.monitor import Monitor
|
||||
from deepcoin.paths import LIVE_TRADES_LOG
|
||||
@@ -40,6 +49,17 @@ class LiveTrader(Monitor):
|
||||
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._load_sizing_context()
|
||||
|
||||
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()
|
||||
|
||||
def _reset_day_if_needed(self) -> None:
|
||||
"""날짜 변경 시 일별 한도 카운터 초기화."""
|
||||
@@ -61,7 +81,7 @@ class LiveTrader(Monitor):
|
||||
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
def _can_trade(self, rule_id: str) -> tuple[bool, str]:
|
||||
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
일·쿨다운·손실 한도 검사.
|
||||
|
||||
@@ -74,7 +94,8 @@ class LiveTrader(Monitor):
|
||||
self._reset_day_if_needed()
|
||||
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
|
||||
return False, "일 최대 거래 수 초과"
|
||||
if self._day_spent_krw + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
|
||||
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
|
||||
if self._day_spent_krw + need > LIVE_DAILY_KRW_MAX:
|
||||
return False, "일 주문 한도 초과"
|
||||
if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW):
|
||||
return False, "일 손실 한도 초과"
|
||||
@@ -83,6 +104,46 @@ class LiveTrader(Monitor):
|
||||
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
|
||||
return True, ""
|
||||
|
||||
def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float:
|
||||
"""
|
||||
총자산·현금·EV/WF·leg 티어로 매수 원화 산출.
|
||||
|
||||
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
|
||||
scale = live_buy_asset_pct_scale(
|
||||
rid,
|
||||
hit["dt"],
|
||||
self._gt_trades,
|
||||
approved_rules=self._approved_rules,
|
||||
large_legs=self._large_legs,
|
||||
)
|
||||
return compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
1.0,
|
||||
1.0,
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
)
|
||||
|
||||
def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
매수·매도 주문 실행 또는 드라이런.
|
||||
@@ -95,7 +156,22 @@ class LiveTrader(Monitor):
|
||||
"""
|
||||
side = hit["side"]
|
||||
price = float(hit["close"])
|
||||
amount_krw = float(LIVE_ORDER_KRW)
|
||||
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"],
|
||||
@@ -163,11 +239,23 @@ class LiveTrader(Monitor):
|
||||
|
||||
for hit in fired:
|
||||
rid = hit["rule_id"]
|
||||
ok, reason = self._can_trade(rid)
|
||||
if hit["side"] == "buy" and hit["rule_id"] not in self._approved_rules:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
print(" skip: EV/WF 미통과 규칙")
|
||||
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']}")
|
||||
if not ok:
|
||||
print(f" skip: {reason}")
|
||||
continue
|
||||
if hit["side"] == "buy" and planned <= 0:
|
||||
print(" skip: 매수금액 0")
|
||||
continue
|
||||
log = self._execute_order(hit)
|
||||
self._append_log(log)
|
||||
print(f" order: {log['message']} ok={log['ok']}")
|
||||
|
||||
@@ -28,6 +28,8 @@ from config import (
|
||||
GT_INITIAL_CASH_KRW,
|
||||
GT_MARKER_SIZE_MAX,
|
||||
GT_MARKER_SIZE_MIN,
|
||||
GT_MAX_BUY_ORDER_KRW,
|
||||
LIVE_ORDER_KRW,
|
||||
MACD_FAST,
|
||||
MACD_SIGNAL,
|
||||
MACD_SLOW,
|
||||
@@ -58,16 +60,94 @@ def interval_chart_label(interval_min: int) -> str:
|
||||
return f"{interval_min}분봉"
|
||||
|
||||
|
||||
def _marker_sizes(trades: list[dict], action: str) -> list[float]:
|
||||
"""비중(weight, 0~1)에 비례한 삼각형 크기."""
|
||||
pts = [t for t in trades if t.get("action") == action]
|
||||
def _marker_hover_text(
|
||||
label: str,
|
||||
t: dict,
|
||||
*,
|
||||
default_order_krw: float | None = None,
|
||||
extra_lines: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
차트 마커 툴팁: 체결가(price)와 체결 원화(amount_krw)를 함께 표시.
|
||||
|
||||
Args:
|
||||
label: 정답/시뮬 매수·매도 라벨.
|
||||
t: trade dict (price, amount_krw, weight, memo …).
|
||||
default_order_krw: amount_krw 없을 때 표시할 기본 원화(시뮬 고정 주문).
|
||||
extra_lines: 툴팁 하단 추가 줄.
|
||||
|
||||
Returns:
|
||||
hovertext HTML 줄바꿈 문자열.
|
||||
"""
|
||||
action = t.get("action", t.get("side", ""))
|
||||
amt_label = "매수금액" if action == "buy" else "매도금액"
|
||||
lines = [
|
||||
label,
|
||||
str(t.get("dt", ""))[:16],
|
||||
f"체결가 ₩{float(t['price']):,.0f}",
|
||||
]
|
||||
ak = t.get("amount_krw")
|
||||
if ak is not None and float(ak) > 0:
|
||||
lines.append(f"{amt_label} ₩{float(ak):,.0f}")
|
||||
elif default_order_krw is not None and action == "buy":
|
||||
lines.append(f"{amt_label} ₩{float(default_order_krw):,.0f}")
|
||||
else:
|
||||
lines.append(f"{amt_label} (미배분)")
|
||||
if t.get("weight") is not None:
|
||||
lines.append(f"비중 {float(t.get('weight', 1)) * 100:.0f}%")
|
||||
if extra_lines:
|
||||
lines.extend(extra_lines)
|
||||
memo = t.get("memo", "")
|
||||
if memo:
|
||||
lines.append(str(memo))
|
||||
rule_id = t.get("rule_id", "")
|
||||
if rule_id:
|
||||
lines.append(str(rule_id))
|
||||
return "<br>".join(lines)
|
||||
|
||||
|
||||
def _trade_amount_krw(t: dict) -> float:
|
||||
"""
|
||||
마커 크기·툴팁용 체결 원화. amount_krw 없으면 비중×상한으로 추정.
|
||||
|
||||
Args:
|
||||
t: trade dict.
|
||||
|
||||
Returns:
|
||||
원화 금액(0 이상).
|
||||
"""
|
||||
ak = t.get("amount_krw")
|
||||
if ak is not None and float(ak) > 0:
|
||||
return float(ak)
|
||||
return max(float(t.get("weight", 1.0)), 0.05) * float(GT_MAX_BUY_ORDER_KRW)
|
||||
|
||||
|
||||
def _marker_sizes(pts: list[dict]) -> list[float]:
|
||||
"""
|
||||
체결 원화(amount_krw)에 비례한 삼각형 크기.
|
||||
|
||||
같은 trace(매수 또는 매도) 안에서 최소·최대 금액으로 선형 스케일.
|
||||
|
||||
Args:
|
||||
pts: 동일 action의 trade dict 리스트.
|
||||
|
||||
Returns:
|
||||
plotly marker size(diameter) 리스트.
|
||||
"""
|
||||
if not pts:
|
||||
return []
|
||||
lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX)
|
||||
return [
|
||||
lo + (hi - lo) * min(max(float(t.get("weight", 1.0)), 0.05), 1.0)
|
||||
for t in pts
|
||||
]
|
||||
amounts = [_trade_amount_krw(t) for t in pts]
|
||||
amin, amax = min(amounts), max(amounts)
|
||||
sizes: list[float] = []
|
||||
for amount in amounts:
|
||||
if amax > amin:
|
||||
ratio = (amount - amin) / (amax - amin)
|
||||
else:
|
||||
ratio = 0.5
|
||||
ratio = max(ratio, 0.08)
|
||||
sizes.append(lo + (hi - lo) * ratio)
|
||||
return sizes
|
||||
|
||||
|
||||
def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
@@ -101,9 +181,14 @@ def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
opacity=0.75,
|
||||
),
|
||||
hovertext=[
|
||||
f"{label}<br>{t['dt'][:16]}<br>₩{float(t['price']):,.0f}"
|
||||
f"<br>leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%"
|
||||
f"<br>{t.get('rule_id', '')}"
|
||||
_marker_hover_text(
|
||||
label,
|
||||
t,
|
||||
default_order_krw=LIVE_ORDER_KRW,
|
||||
extra_lines=[
|
||||
f"leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%",
|
||||
],
|
||||
)
|
||||
for t in pts
|
||||
],
|
||||
hovertemplate="%{hovertext}<extra></extra>",
|
||||
@@ -114,7 +199,7 @@ def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
|
||||
|
||||
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
"""정답 매수·매도 마커 (삼각형 크기 = 비중)."""
|
||||
"""정답 매수·매도 마커 (삼각형 크기 = 체결 원화 금액)."""
|
||||
for action, color, symbol, label in [
|
||||
("buy", "#16a34a", "triangle-up", "정답 매수"),
|
||||
("sell", "#dc2626", "triangle-down", "정답 매도"),
|
||||
@@ -122,7 +207,7 @@ def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
pts = [t for t in trades if t.get("action") == action]
|
||||
if not pts:
|
||||
continue
|
||||
sizes = _marker_sizes(trades, action)
|
||||
sizes = _marker_sizes(pts)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[pd.Timestamp(t["dt"]) for t in pts],
|
||||
@@ -138,9 +223,7 @@ def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
|
||||
line=dict(width=1.5, color="#111"),
|
||||
),
|
||||
hovertext=[
|
||||
f"{label}<br>{t['dt'][:16]}<br>₩{t['price']:,.0f}"
|
||||
f"<br>비중 {float(t.get('weight', 1))*100:.0f}%"
|
||||
f"<br>{t.get('memo', '')}"
|
||||
_marker_hover_text(label, t)
|
||||
for t in pts
|
||||
],
|
||||
hovertemplate="%{hovertext}<extra></extra>",
|
||||
@@ -446,7 +529,7 @@ def build_chart_html(
|
||||
)
|
||||
trade_table = f"""
|
||||
<h2>정답 타점 (ground_truth)</h2>
|
||||
<p class="meta">삼각형 크기 = 비중. 매수: 저점 분할 / 매도: 고점 1~2회.
|
||||
<p class="meta">삼각형 크기 = 체결 금액(매수/매도 각각 min~max). 매수: 저점 분할 / 매도: 고점 1~2회.
|
||||
총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}</p>
|
||||
<table>
|
||||
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
|
||||
@@ -471,8 +554,11 @@ def build_chart_html(
|
||||
)
|
||||
|
||||
default_legend = (
|
||||
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 크기 = 비중.<br>"
|
||||
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = monitor_rules holdout 발화."
|
||||
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 크기=체결금액. "
|
||||
"매수=총자산×비중×leg티어(상위 대형). "
|
||||
"툴팁: 체결가·매수/매도금액.<br>"
|
||||
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = holdout 발화 "
|
||||
f"(매수 ₩{LIVE_ORDER_KRW:,.0f}/회)."
|
||||
)
|
||||
if cards_html:
|
||||
cards_inner = cards_html
|
||||
|
||||
@@ -1,35 +1,78 @@
|
||||
# 정답 타점 (Ground Truth)
|
||||
|
||||
1달(기본 45일) 3분봉 구간에서 **사후적 최적 스윙** 매수·매도 라벨을 만듭니다.
|
||||
실시간 매매 전략이 아니라, 이후 전략 검증·학습용 **정답 데이터**입니다.
|
||||
1년(기본 `CHART_LOOKBACK_DAYS=365`) 3분봉에서 **사후 최적 스윙** 매수·매도 라벨.
|
||||
실시간 전략이 아니라 규칙·시뮬 검증용 **벤치마크**입니다.
|
||||
|
||||
## Plan
|
||||
JSON 필드 `model`에 타점·비중·자본 배분 규칙이 일반화되어 있습니다 (`deepcoin/ground_truth/gt_model.py`).
|
||||
|
||||
- **목적**: 차트 상 의미 있는 저점 매수·고점 매도를 JSON으로 고정
|
||||
- **방법**: 고점(major swing)에서 1~2회 매도 · 저점(ZigZag+BB)에서 분할 매수 · 삼각형 크기=비중
|
||||
- **체결 순서**: JSON 저장·포트폴리오 시뮬은 **leg별 매수 전량 → 매도 전량** (시각순 아님). 차트 표는 시각순 정렬.
|
||||
- **HTML 카드**: 초기 금액, 총보유자산, 초기 대비 증감율(종가 평가 포함). 기간말 leg는 **종가 청산** 포함.
|
||||
## Plan — 타점 구조 (일반화)
|
||||
|
||||
## Do
|
||||
### Leg (라운드트립 구간)
|
||||
|
||||
```bash
|
||||
python scripts/02_ground_truth.py # ground_truth_trades.json
|
||||
python scripts/05_chart_truth.py # JSON + HTML 차트
|
||||
- **leg_id**: 이전 **고점 매도** 시각 ~ 다음 **고점 매도** 직전까지.
|
||||
- 마지막 구간: 마지막 major peak 이후 ~ 기간 말 → **기간말 leg** (종가 청산 1회).
|
||||
|
||||
### 매수 타점 (Entry)
|
||||
|
||||
| 항목 | 규칙 |
|
||||
|------|------|
|
||||
| 피벗 | ZigZag **저점(trough)**, `GT_BUY_MIN_SWING_PCT` |
|
||||
| 가격 | 해당 봉 **Low** |
|
||||
| 후보 | leg 구간 내 trough, `GT_BUY_MIN_BARS` 간격, BB (`bb_pos <= GT_BUY_BB_MAX`) |
|
||||
| **비중 weight** | `w_i = (1/price_i) / Σ(1/price_j)` — **저가일수록 큰 비중** |
|
||||
| leg당 상한 | `GT_MAX_BUYS_PER_LEG` (초과 시 저가 순 유지) |
|
||||
|
||||
### 매도 타점 (Exit)
|
||||
|
||||
| 항목 | 규칙 |
|
||||
|------|------|
|
||||
| 피벗 | **major swing 고점(peak)** |
|
||||
| 가격 | 해당 봉 **High** |
|
||||
| **비중 weight** | 1회 매도: **100%** · 2회 분할: **65% + 35%** (`GT_SELL_SPLIT_GAP_PCT`) |
|
||||
| 수량 | leg 보유 수량 × 매도 비중 (마지막 매도 = leg 전량) |
|
||||
|
||||
## Do — 자본 배분 (amount_krw)
|
||||
|
||||
시각순 체결. **매도 후 현금**이 다음 매수에 반영됩니다.
|
||||
|
||||
```
|
||||
총보유자산 = 현금 + 보유×체결가
|
||||
최적매수율 = (이번 weight / leg 남은 weight 합) × leg티어스케일
|
||||
목표매수액 = 총보유자산 × 최적매수율
|
||||
실제매수액 = min(목표, 가용현금/(1+수수료)), 최소 GT_MIN_ORDER_KRW
|
||||
```
|
||||
|
||||
## Check
|
||||
| leg 티어 | 조건 | 스케일 (`.env`) |
|
||||
|----------|------|-----------------|
|
||||
| 대형 | leg 수익률 상위 `GT_LARGE_LEG_TOP_PCT` | `GT_BUY_PCT_LARGE_LEG` (기본 1.0) |
|
||||
| 소형 | 그 외 | `GT_BUY_PCT_SMALL_LEG` (기본 0.05) |
|
||||
|
||||
| 환경 변수 | 기본 | 설명 |
|
||||
|-----------|------|------|
|
||||
| `CHART_LOOKBACK_DAYS` | 365 | 조회 일수 (`.env` 기본 1년) |
|
||||
| `GT_MIN_SWING_PCT` | 4.0 | ZigZag 최소 스윙(%) |
|
||||
| `GT_PIVOT_ORDER` | 20 | 국소 극값 반경(봉) |
|
||||
| `GT_MIN_BARS_BETWEEN` | 30 | 체결 간격(3분봉 30봉=90분) |
|
||||
| `GT_MIN_LEG_PCT` | 8.0 | 한 구간 최소 수익(%) |
|
||||
| `GT_MAX_ROUND_TRIPS` | 24 | 최대 라운드트립 |
|
||||
| `GT_SELECTION_MODE` | split_buy_peak_sell | `split_buy_peak_sell` 등 (`.env` 참고) |
|
||||
**summary.pnl_pct**: 위 배분으로 **시각순** 시뮬 + 기간말 **종가 평가**.
|
||||
|
||||
**JSON 저장 순서**: leg별 매수 전량 → 매도 전량 (`leg_block`, 차트·테이블 정합).
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
python scripts/02_ground_truth.py # ground_truth_trades.json (+ model)
|
||||
python scripts/05_chart_truth.py # HTML 차트
|
||||
```
|
||||
|
||||
## Check — 주요 환경 변수
|
||||
|
||||
| 변수 | 기본 | 설명 |
|
||||
|------|------|------|
|
||||
| `GT_MIN_SWING_PCT` | 4.0 | 매도 피벗 ZigZag(%) |
|
||||
| `GT_BUY_MIN_SWING_PCT` | 3.0 | 매수 피벗 ZigZag(%) |
|
||||
| `GT_PIVOT_ORDER` | 20 | 국소 극값 반경 |
|
||||
| `GT_MIN_BARS_BETWEEN` | 30 | 체결 최소 간격(봉) |
|
||||
| `GT_MIN_LEG_PCT` | 8.0 | major leg 최소 수익(%) |
|
||||
| `GT_BUY_PCT_LARGE_LEG` | 1.0 | 상위 leg 총자산 배분 스케일 |
|
||||
| `GT_BUY_PCT_SMALL_LEG` | 0.05 | 소형 leg 스케일 |
|
||||
| `GT_LARGE_LEG_TOP_PCT` | 0.2 | 대형 leg 상위 비율 |
|
||||
| `GT_MIN_ORDER_KRW` | 5000 | 최소 체결 원화 |
|
||||
|
||||
## Act
|
||||
|
||||
- JSON 수동 수정 후 `scripts/05_chart_truth.py` 재실행으로 차트 갱신
|
||||
- 파라미터 조정으로 타점 수·크기 튜닝
|
||||
- JSON·`model` 수정 후 `02` / `05` 재실행
|
||||
- 시뮬 비교: `04_simulation_report.py` (GT vs 시뮬·총자산% vs 고정 ₩/회)
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
## 목적
|
||||
|
||||
실거래(3단계) 전에 `monitor_rules`가 **과적합이 아닌지** 숫자로 검증합니다.
|
||||
`monitor_rules`가 과적합이 아닌지 검증하고, **Ground Truth와 동일한 자본 배분 원칙**으로 holdout 체결 수익을 비교합니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
python scripts/04_match_rules.py # 선행: 04 전체 또는 select
|
||||
python scripts/04_match_rules.py # 선행
|
||||
python scripts/04_simulation_report.py
|
||||
```
|
||||
|
||||
@@ -15,26 +15,40 @@ python scripts/04_simulation_report.py
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `docs/04_matching/simulation_report.json` | walk-forward·민감도·Go/No-Go |
|
||||
| `docs/04_matching/simulation_report.html` | GT 동일 카드(초기 금액·총보유자산·증감율)·차트·타점·규칙 기준 |
|
||||
| `docs/04_matching/simulation_report.json` | Go/No-Go, `portfolio_compare`, `gt_model` |
|
||||
| `docs/04_matching/simulation_report.html` | 카드 3줄: **GT · 시뮬(총자산%) · 시뮬(고정₩/회)** |
|
||||
|
||||
## 포트폴리오 비교 (`portfolio_compare`)
|
||||
|
||||
| 키 | 설명 |
|
||||
|----|------|
|
||||
| `ground_truth_chrono` | GT 타점 + `amount_krw` 시각순 체결 |
|
||||
| `sim_sized` | holdout 발화 + **총자산×비중×EV/WF·leg상위** (`position_sizing`) |
|
||||
| `sim_fixed_order` | 동일 발화 + **고정 `LIVE_ORDER_KRW`/회** (baseline) |
|
||||
|
||||
## 시뮬 매수 배분 (GT와 동일 원칙)
|
||||
|
||||
- **통과 규칙만** 대형: holdout EV·PF, walk-forward, 수수료 2× 스트레스 (`load_ev_wf_approved_rule_ids`)
|
||||
- **leg 상위** `GT_LARGE_LEG_TOP_PCT` + 근접 GT leg 매칭 → `LIVE_BUY_PCT_LARGE`
|
||||
- 그 외 → `LIVE_BUY_PCT_SMALL`
|
||||
- 일한도·일최대거래: `select_capped_fires` (동적 planned 원화로 `LIVE_DAILY_KRW_MAX` 적용)
|
||||
|
||||
## 검증 항목
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| Holdout | 최근 15% 구간 EV≥0, PF≥1 |
|
||||
| Walk-forward | 월별 EV, 양수 월 비율 ≥ `SIM_GO_WF_POSITIVE_RATIO` |
|
||||
| 수수료 스트레스 | 수수료 2배(`SIM_FEE_STRESS_MULT`) 후에도 EV≥0 |
|
||||
| 실거래 한도 가정 | `LIVE_ORDER_KRW`·`LIVE_DAILY_KRW_MAX` 내 체결 가능 비율 |
|
||||
| Holdout | EV≥0, PF≥1 |
|
||||
| Walk-forward | 양수 월 비율 ≥ `SIM_GO_WF_POSITIVE_RATIO` |
|
||||
| 수수료 스트레스 | 수수료 2× 후 EV≥0 |
|
||||
| 실거래 한도 | 동적 매수액 기준 일한도 시뮬 |
|
||||
|
||||
## Go/No-Go
|
||||
|
||||
- **GO**: `monitor_rules` 전 규칙이 checks 통과 → 2·3단계 진행 가능
|
||||
- **NO-GO**: 04 재선별·규칙 축소 후 재실행
|
||||
- **GO**: monitor_rules 전 규칙 checks 통과
|
||||
- **NO-GO**: 04 재선별 후 재실행
|
||||
|
||||
## 환경 변수 (`config.py` / `.env`)
|
||||
## 환경 변수
|
||||
|
||||
- `SIM_GO_MIN_HOLDOUT_EV`, `SIM_GO_MIN_HOLDOUT_PF`
|
||||
- `SIM_GO_WF_POSITIVE_RATIO` (기본 0.5)
|
||||
- `SIM_WALK_FORWARD_MIN_MONTHS` (기본 3)
|
||||
- `SIM_FEE_STRESS_MULT` (기본 2.0)
|
||||
- `SIM_GO_*`, `SIM_WALK_FORWARD_MIN_MONTHS`, `SIM_FEE_STRESS_MULT`
|
||||
- `LIVE_ORDER_KRW`, `LIVE_DAILY_KRW_MAX` (고정 baseline·한도)
|
||||
- `LIVE_BUY_PCT_LARGE`, `LIVE_BUY_PCT_SMALL` (시뮬·실거래 비율 매수)
|
||||
|
||||
Reference in New Issue
Block a user