3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프, walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
7.1 KiB
Python
232 lines
7.1 KiB
Python
"""
|
|
04-3: 규칙 발화별 성과 라벨링 (GT leg 청산 + forward 폴백).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from config import (
|
|
CHART_LOOKBACK_DAYS,
|
|
MATCH_FORWARD_BARS,
|
|
MATCH_LABEL_MODE,
|
|
MATCH_MAX_HOLD_DAYS,
|
|
MATCH_PRIMARY_INTERVAL,
|
|
SYMBOL,
|
|
TRADING_FEE_RATE,
|
|
)
|
|
from deepcoin.data.mtf_bb import load_frames_from_db
|
|
from deepcoin.matching.gt_schedule import load_gt_trade_events
|
|
from deepcoin.ops.monitor import Monitor
|
|
|
|
_NS_PER_DAY = 86_400 * 1_000_000_000
|
|
|
|
|
|
def _forward_ret_vectorized(
|
|
fire_ts_ns: np.ndarray,
|
|
c0: np.ndarray,
|
|
close_ts_ns: np.ndarray,
|
|
close_px: np.ndarray,
|
|
side: np.ndarray,
|
|
n_bars: int,
|
|
fee_pct: float,
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
"""
|
|
고정 N봉 forward 수익률(벡터화, 루프 최소).
|
|
|
|
Args:
|
|
fire_ts_ns: 발화 시각(ns).
|
|
c0: 발화가.
|
|
close_ts_ns, close_px: 주간격 종가 시계열.
|
|
side: buy | sell.
|
|
n_bars: forward 봉 수.
|
|
fee_pct: 왕복 수수료 %p.
|
|
|
|
Returns:
|
|
(ret_pct, valid_mask).
|
|
"""
|
|
ret = np.full(len(fire_ts_ns), np.nan, dtype=float)
|
|
valid = np.zeros(len(fire_ts_ns), dtype=bool)
|
|
for i in range(len(fire_ts_ns)):
|
|
idx = np.searchsorted(close_ts_ns, fire_ts_ns[i], side="right") - 1
|
|
if idx < 0:
|
|
continue
|
|
end = idx + n_bars
|
|
if end >= len(close_px):
|
|
continue
|
|
c_entry = c0[i]
|
|
c_exit = float(close_px[end])
|
|
if side[i] == "buy":
|
|
ret[i] = (c_exit / c_entry - 1.0) * 100.0 - fee_pct
|
|
else:
|
|
ret[i] = (c_entry / c_exit - 1.0) * 100.0 - fee_pct
|
|
valid[i] = True
|
|
return ret, valid
|
|
|
|
|
|
def _leg_gt_ret_vectorized(
|
|
fire_ts_ns: np.ndarray,
|
|
c0: np.ndarray,
|
|
side: np.ndarray,
|
|
buy_ts: np.ndarray,
|
|
buy_px: np.ndarray,
|
|
sell_ts: np.ndarray,
|
|
sell_px: np.ndarray,
|
|
max_hold_days: int,
|
|
fee_pct: float,
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
"""
|
|
GT 이벤트 기준 leg 청산 수익률.
|
|
|
|
매수 발화: 다음 GT 매도까지 보유.
|
|
매도 발화: 직전 GT 매수가 대비 청산 수익.
|
|
|
|
Args:
|
|
fire_ts_ns, c0, side: 발화 배열.
|
|
buy_ts, buy_px, sell_ts, sell_px: GT 이벤트.
|
|
max_hold_days: 최대 보유 일수.
|
|
fee_pct: 왕복 수수료 %p.
|
|
|
|
Returns:
|
|
(ret_pct, valid_mask, hold_days).
|
|
"""
|
|
n = len(fire_ts_ns)
|
|
ret = np.full(n, np.nan, dtype=float)
|
|
valid = np.zeros(n, dtype=bool)
|
|
hold_days = np.full(n, np.nan, dtype=float)
|
|
max_hold_ns = max_hold_days * _NS_PER_DAY
|
|
|
|
buy_m = side == "buy"
|
|
if buy_m.any() and len(sell_ts) > 0:
|
|
t_b = fire_ts_ns[buy_m]
|
|
idx = np.searchsorted(sell_ts, t_b, side="right")
|
|
ok = idx < len(sell_ts)
|
|
if ok.any():
|
|
i_ok = np.where(buy_m)[0][ok]
|
|
exit_ns = sell_ts[idx[ok]]
|
|
delta = exit_ns - t_b[ok]
|
|
within = (delta > 0) & (delta <= max_hold_ns)
|
|
if within.any():
|
|
ii = i_ok[within]
|
|
exit_px = sell_px[idx[ok][within]]
|
|
entry = c0[ii]
|
|
ret[ii] = (exit_px / entry - 1.0) * 100.0 - fee_pct
|
|
valid[ii] = True
|
|
hold_days[ii] = delta[within] / _NS_PER_DAY
|
|
|
|
sell_m = side == "sell"
|
|
if sell_m.any() and len(buy_ts) > 0:
|
|
t_s = fire_ts_ns[sell_m]
|
|
idx = np.searchsorted(buy_ts, t_s, side="left") - 1
|
|
ok = idx >= 0
|
|
if ok.any():
|
|
i_ok = np.where(sell_m)[0][ok]
|
|
entry_ns = buy_ts[idx[ok]]
|
|
delta = t_s[ok] - entry_ns
|
|
within = (delta > 0) & (delta <= max_hold_ns)
|
|
if within.any():
|
|
ii = i_ok[within]
|
|
entry_px = buy_px[idx[ok][within]]
|
|
exit_p = c0[ii]
|
|
ret[ii] = (exit_p / entry_px - 1.0) * 100.0 - fee_pct
|
|
valid[ii] = True
|
|
hold_days[ii] = delta[within] / _NS_PER_DAY
|
|
|
|
return ret, valid, hold_days
|
|
|
|
|
|
def label_fire_outcomes(
|
|
fires: pd.DataFrame,
|
|
frames: dict[int, pd.DataFrame] | None = None,
|
|
) -> pd.DataFrame:
|
|
"""
|
|
각 발화의 성과를 라벨링합니다.
|
|
|
|
MATCH_LABEL_MODE=leg_gt: GT 다음 매도/직전 매수 기준.
|
|
미충족 분은 forward N봉으로 폴백.
|
|
|
|
Args:
|
|
fires: rule_fires.
|
|
frames: OHLCV (폴백용).
|
|
|
|
Returns:
|
|
fire_outcomes (+ forward_ret_pct, label_method, hold_days, ...).
|
|
"""
|
|
if fires.empty:
|
|
return fires.copy()
|
|
|
|
fee_pct = TRADING_FEE_RATE * 2 * 100
|
|
fts = pd.to_datetime(fires["dt"])
|
|
fire_ts_ns = fts.values.astype("datetime64[ns]").astype(np.int64)
|
|
c0 = fires["close"].astype(float).values
|
|
side = fires["side"].astype(str).values
|
|
|
|
buy_ts, buy_px, sell_ts, sell_px = load_gt_trade_events()
|
|
label_method = np.full(len(fires), "", dtype=object)
|
|
hold_days = np.full(len(fires), np.nan, dtype=float)
|
|
|
|
ret = np.full(len(fires), np.nan, dtype=float)
|
|
|
|
if MATCH_LABEL_MODE == "leg_gt":
|
|
ret, valid, hd = _leg_gt_ret_vectorized(
|
|
fire_ts_ns,
|
|
c0,
|
|
side,
|
|
buy_ts,
|
|
buy_px,
|
|
sell_ts,
|
|
sell_px,
|
|
MATCH_MAX_HOLD_DAYS,
|
|
fee_pct,
|
|
)
|
|
label_method[valid] = "leg_gt"
|
|
hold_days = hd
|
|
|
|
need_fb = ~np.isfinite(ret)
|
|
if need_fb.any():
|
|
if frames is None:
|
|
mon = Monitor(cooldown_file=None)
|
|
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
|
|
if need_fb.any() and frames is not None:
|
|
raw = frames.get(MATCH_PRIMARY_INTERVAL)
|
|
if raw is not None and not raw.empty:
|
|
px = raw.copy()
|
|
if not isinstance(px.index, pd.DatetimeIndex):
|
|
px.index = pd.to_datetime(px.index)
|
|
px = px.sort_index()
|
|
col = "close" if "close" in px.columns else "Close"
|
|
close_px = px[col].astype(float).values
|
|
close_ts_ns = px.index.astype(np.int64).values
|
|
fb_ret, fb_ok = _forward_ret_vectorized(
|
|
fire_ts_ns[need_fb],
|
|
c0[need_fb],
|
|
close_ts_ns,
|
|
close_px,
|
|
side[need_fb],
|
|
MATCH_FORWARD_BARS,
|
|
fee_pct,
|
|
)
|
|
ret[need_fb] = np.where(fb_ok, fb_ret, np.nan)
|
|
fb_idx = np.where(need_fb)[0][fb_ok]
|
|
label_method[fb_idx] = f"forward_{MATCH_FORWARD_BARS}"
|
|
|
|
out = fires.copy()
|
|
out["forward_ret_pct"] = np.round(ret, 4)
|
|
out["win"] = (ret > 0).astype(int)
|
|
out["label_method"] = label_method
|
|
out["hold_days"] = np.round(hold_days, 2)
|
|
out["forward_bars"] = MATCH_FORWARD_BARS
|
|
out = out[np.isfinite(out["forward_ret_pct"])].reset_index(drop=True)
|
|
|
|
leg_n = int((out["label_method"] == "leg_gt").sum())
|
|
fb_n = int(out["label_method"].astype(str).str.startswith("forward").sum())
|
|
print(
|
|
f"[04-3] 성과 라벨: {len(out):,}건 "
|
|
f"(mode={MATCH_LABEL_MODE}, leg_gt={leg_n:,}, forward폴백={fb_n:,}, "
|
|
f"수수료 {fee_pct:.3f}%p)"
|
|
)
|
|
return out
|