GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.

타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를
position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 16:11:49 +09:00
parent 2cb67c42b3
commit 5842cc9fa3
14 changed files with 2073 additions and 182 deletions

View File

@@ -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