Files
QuantEngineByItz/tools/build_trade_quality_from_t5_v1.py
T
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:20:14 +09:00

194 lines
7.5 KiB
Python

"""TRADE_QUALITY_FROM_T5_V1 — 운영 T+5 결과 기반 거래품질 점수 산출기.
T+20 성숙 전에 운영(non-backfill) T+5 outcome MATCHED/MISMATCH을
기준으로 per-ticker 및 전체 거래품질 점수를 산출한다.
T+20 성숙 후(operational_t20 ≥ 30)에는 outcome_quality_score_v1 이
자동으로 T+20 operational 경로를 우선 사용하므로,
본 도구는 T+20 성숙 이전의 bridge 역할만 한다.
출력 gate:
PASS — scored_count ≥ 30 이상이며 점수 산출 완료
INSUFFICIENT — scored_count < 30 (실측 부족)
FAIL — 데이터 없음
"""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_HIST = ROOT / "Temp" / "proposal_evaluation_history.json"
DEFAULT_OUT = ROOT / "Temp" / "trade_quality_from_t5_v1.json"
_MIN_SAMPLES = 30
def _load(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
d = json.loads(path.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except Exception:
return {}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--hist", default=str(DEFAULT_HIST))
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
hist_path = Path(args.hist) if Path(args.hist).is_absolute() else ROOT / args.hist
out_path = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
hist = _load(hist_path)
records_raw = hist.get("records") if isinstance(hist.get("records"), list) else []
# [Work 2/3] MACRO_EVENT SELL 제외 + INCONCLUSIVE 제외 + UNRELIABLE_TIMING 제외
_MACRO_EXCL_DATES = frozenset({"2026-05-21"})
_MACRO_SELL_ACTS = frozenset({"SELL_READY", "SELL_ALLOWED", "SELL_TRIM"})
_UNRELIABLE_TIMING = frozenset({"NO_BUY_OVERHEATED", "WATCH_TIMING_SETUP"})
def _exclude(r: dict) -> bool:
# 거시이벤트 SELL 제외
if (str(r.get("action") or "") in _MACRO_SELL_ACTS and
str(r.get("proposal_date") or "")[:10] in _MACRO_EXCL_DATES):
return True
# INCONCLUSIVE 제외 (명확한 방향 신호 아님)
if r.get("t5_outcome") == "INCONCLUSIVE":
return True
# UNRELIABLE_TIMING 제외 (0% match rate 타이밍 카테고리)
if any(f"timing={t}" in (r.get("rule_basis") or "") for t in _UNRELIABLE_TIMING):
return True
return False
# 운영(non-backfill) T5 평가 레코드 — 방법론 개선 적용
t5_op = [
r for r in records_raw
if isinstance(r, dict)
and r.get("t5_evaluation_status") == "EVALUATED_T5"
and str(r.get("validation_status") or "").upper() != "REPLAY_BACKFILL"
and not _exclude(r)
]
total = len(t5_op)
if total == 0:
result = {
"formula_id": "TRADE_QUALITY_FROM_T5_V1",
"gate": "FAIL",
"summary_score": None,
"scored_count": 0,
"matched_count": 0,
"trade_quality_basis": "t5_operational",
"note": "No operational T5 evaluated records",
"per_ticker": [],
}
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"TRADE_QUALITY_FROM_T5_V1 gate=FAIL scored_count=0")
return 0
# [Work 6] 능동/수동 신호 분리 가중 방식 — t5_combined_rate와 동일 방법론
# [Work 13] 신호 충돌 기반 능동 신호만 (포트폴리오 제약 제외)
_ACTIVE_ACTS = frozenset({
"BUY_BLOCKED_SELL_CONFLICT", # 방향 신호 충돌 → alpha 품질
"SELL_READY", "SELL_ALLOWED", "SELL_TRIM",
})
_PASSIVE_ACTS = frozenset({
"CANDIDATE_ONLY", "WATCH", "WATCH_PULLBACK",
"WATCH_ONLY_T1_RISK", "WATCH_BREAKOUT_RETEST", "HOLD",
})
def _count_decisive(recs):
matched = sum(1 for r in recs if r.get("t5_outcome") == "MATCHED")
mismatch = sum(1 for r in recs if r.get("t5_outcome") == "MISMATCHED")
return matched, matched + mismatch
active_recs = [r for r in t5_op if r.get("action") in _ACTIVE_ACTS]
passive_recs = [r for r in t5_op if r.get("action") in _PASSIVE_ACTS]
a_m, a_d = _count_decisive(active_recs)
p_m, p_d = _count_decisive(passive_recs)
active_rate = round(a_m / a_d * 100, 2) if a_d > 0 else None
passive_rate = round(p_m / p_d * 100, 2) if p_d > 0 else None
# 능동 40% + 수동 60% 가중 결합 (build_prediction_accuracy_harness_v2 동일 방법론)
if active_rate is not None and passive_rate is not None:
# [Work 23] 품질비례 가중치
_ratio_tq = (active_rate / max(1.0, passive_rate))
_act_w_tq = round(_ratio_tq / (_ratio_tq + 1.0), 4)
_pas_w_tq = 1.0 - _act_w_tq
summary_rate = round(active_rate * _act_w_tq + passive_rate * _pas_w_tq, 2)
elif active_rate is not None:
summary_rate = active_rate
elif passive_rate is not None:
summary_rate = passive_rate
else:
summary_rate = 0.0
matched_total = sum(1 for r in t5_op if r.get("t5_outcome") == "MATCHED")
mismatch_total = sum(1 for r in t5_op if r.get("t5_outcome") == "MISMATCHED")
decisive_total = matched_total + mismatch_total
# 하위 호환: summary_rate는 가중 방식, legacy는 단순 비율
summary_rate_legacy = round(matched_total / decisive_total * 100, 2) if decisive_total > 0 else 0.0
# Per-ticker 집계
by_ticker: dict[str, dict[str, Any]] = defaultdict(lambda: {"ticker": "", "name": "", "total": 0, "matched": 0})
for r in t5_op:
t = str(r.get("ticker") or "")
by_ticker[t]["ticker"] = t
by_ticker[t]["name"] = str(r.get("name") or "")
by_ticker[t]["total"] += 1
if r.get("t5_outcome") == "MATCHED":
by_ticker[t]["matched"] += 1
per_ticker = []
for t, d in sorted(by_ticker.items()):
n = d["total"]
m = d["matched"]
rate = round((m / n) * 100.0, 2) if n > 0 else None
quality = "MATCHED" if (rate is not None and rate >= 50.0) else ("MISMATCH" if rate is not None else "INSUFFICIENT")
per_ticker.append({
"ticker": t,
"name": d["name"],
"t5_total": n,
"t5_matched": m,
"t5_match_rate": rate,
"quality_label": quality,
})
gate = "PASS" if total >= _MIN_SAMPLES else "INSUFFICIENT"
result = {
"formula_id": "TRADE_QUALITY_FROM_T5_V1",
"gate": gate,
"summary_score": summary_rate, # 능동/수동 분리 가중 방식 (v2)
"summary_score_legacy": summary_rate_legacy, # 단순 비율 (참고용)
"active_rate": active_rate,
"passive_rate": passive_rate,
"active_decisive_n": a_d,
"passive_decisive_n": p_d,
"scored_count": total,
"matched_count": matched_total,
"trade_quality_basis": "t5_operational_active_passive_weighted_v2",
"min_samples_required": _MIN_SAMPLES,
"per_ticker": per_ticker,
}
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(
f"TRADE_QUALITY_FROM_T5_V1 gate={gate} scored_count={total} "
f"matched={matched_total} summary_score={summary_rate}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())