Files
QuantEngineByItz/tools/build_alpha_feedback_loop_v2.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

239 lines
11 KiB
Python

"""
build_alpha_feedback_loop_v2.py
목적: proposal_evaluation_history T5 운영 데이터를 분석해
PA1 팩터 가중치 조정 권고를 생성한다.
기존 ALPHA_FEEDBACK_LOOP_V1은 T20 데이터만 사용해 DATA_INSUFFICIENT.
V2는 T5 운영 데이터(≥10건)로 즉시 동작한다.
AGENTS.md AFL 원칙: "권고만 출력, 자동 적용 금지"
→ 이 도구는 recommended_adjustments를 생성하지만 자동으로 settings를 수정하지 않는다.
→ 사용자 승인 후 settings 시트에서 수동 반영.
출력: Temp/alpha_feedback_loop_v2.json
"""
from __future__ import annotations
import argparse
import json
import statistics
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT_PATH = ROOT / "Temp" / "alpha_feedback_loop_v2.json"
_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"})
_MIN_SAMPLES = 10
def _load(p: Path) -> dict:
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
def _exclude(r: dict) -> bool:
if (str(r.get("action") or "") in _MACRO_SELL_ACTS and
str(r.get("proposal_date") or "")[:10] in _MACRO_EXCL_DATES):
return True
if r.get("t5_outcome") == "INCONCLUSIVE":
return True
if any(f"timing={t}" in (r.get("rule_basis") or "") for t in _UNRELIABLE_TIMING):
return True
return False
def _parse_rule(rb: str) -> dict:
rb = rb or ""
return {p.split("=")[0]: p.split("=")[1] for p in rb.split("|") if "=" in p}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--hist", default=str(ROOT / "Temp" / "proposal_evaluation_history.json"))
ap.add_argument("--out", default=str(OUT_PATH))
args = ap.parse_args()
hist = _load(Path(args.hist))
recs_raw = hist.get("records") or []
op_t5 = [
r for r in recs_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)
]
if len(op_t5) < _MIN_SAMPLES:
result = {
"formula_id": "ALPHA_FEEDBACK_LOOP_V2",
"status": "DATA_INSUFFICIENT",
"cases_analyzed": len(op_t5),
"recommended_adjustments": [],
}
Path(args.out).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"ALPHA_FEEDBACK_LOOP_V2: DATA_INSUFFICIENT (n={len(op_t5)} < {_MIN_SAMPLES})")
return 0
# ── 조건 컴포넌트별 T5 성과 분석 ─────────────────────────────────────────
component_stats: dict[str, dict] = defaultdict(lambda: {"total": 0, "matched": 0})
for r in op_t5:
rb = _parse_rule(r.get("rule_basis"))
matched = r.get("t5_outcome") == "MATCHED"
for key in ["quality", "timing", "t1", "sell_conflict"]:
val = rb.get(key)
if val:
k = f"{key}={val}"
component_stats[k]["total"] += 1
if matched:
component_stats[k]["matched"] += 1
# ── 능동/수동 분리 성과 ──────────────────────────────────────────────────
_ACTIVE = frozenset({"BUY_BLOCKED_SELL_CONFLICT", "BUY_BLOCKED_PORTFOLIO_GUARD",
"BUY_BLOCKED_TRIM_REQUIRED", "SELL_READY"})
_PASSIVE = frozenset({"CANDIDATE_ONLY", "WATCH", "WATCH_PULLBACK", "HOLD"})
def _rate(recs):
m = sum(1 for r in recs if r.get("t5_outcome") == "MATCHED")
mm = sum(1 for r in recs if r.get("t5_outcome") == "MISMATCHED")
n = m + mm
return round(m / n * 100, 2) if n > 0 else None, n
active_recs = [r for r in op_t5 if r.get("action") in _ACTIVE]
passive_recs = [r for r in op_t5 if r.get("action") in _PASSIVE]
active_rate, active_n = _rate(active_recs)
passive_rate, passive_n = _rate(passive_recs)
# ── PA1 팩터 효과 추정 ───────────────────────────────────────────────────
# 현재 PA1 가중치 읽기
json_path = ROOT / "GatherTradingData.json"
jdata = _load(json_path)
settings = jdata.get("data", {}).get("settings", {})
pa1_current = {k.replace("pa1_w_", ""): v
for k, v in (settings.items() if isinstance(settings, dict) else {}.items())
if k.startswith("pa1_w_")}
thesis_f = ["pullback_entry", "flow_strong", "rs_leader", "volume_confirm", "rsi_healthy", "brt_leader"]
anti_f = ["chase_risk", "distribution", "foreign_sell", "rsi_overbought", "usd_krw_weak", "stale_position"]
thesis_sum = sum(pa1_current.get(f, 0) for f in thesis_f)
anti_sum = sum(pa1_current.get(f, 0) for f in anti_f)
# ── 권고 생성 ────────────────────────────────────────────────────────────
recommendations = []
# 1. sell_pass 정확도 기반 antithesis 조정
sell_recs = [r for r in op_t5 if r.get("action") in ("SELL_READY", "SELL_ALLOWED")]
sell_rate, sell_n = _rate(sell_recs)
if sell_rate is not None and sell_rate < 50 and sell_n >= 5:
# sell 정확도가 낮다 → antithesis가 지나치게 강하다
# → antithesis 일부 완화, thesis 강화 권고
recommendations.append({
"factor": "antithesis_balance",
"current_ratio": round(anti_sum / max(1, thesis_sum), 2),
"target_ratio": "2.0~3.0x",
"action": "antithesis 일부 완화 + thesis 강화",
"details": {
"pa1_w_usd_krw_weak": {"current": pa1_current.get("usd_krw_weak", 40), "recommended": 15},
"pa1_w_stale_position": {"current": pa1_current.get("stale_position", 40), "recommended": 20},
"pa1_w_flow_strong": {"current": pa1_current.get("flow_strong", 5), "recommended": 15},
"pa1_w_pullback_entry": {"current": pa1_current.get("pullback_entry", 5), "recommended": 15},
},
"rationale": (
f"SELL 신호 정확도={sell_rate:.1f}%(n={sell_n}) < 50% - "
f"antithesis {anti_sum}pt가 thesis {thesis_sum}pt의 {anti_sum/max(1,thesis_sum):.1f}x로 "
f"지나치게 강해 모든 종목이 획일적 EXIT 신호를 받음. "
f"usd_krw_weak/stale_position은 종목 차별화에 기여하지 않으므로 완화."
),
})
else:
recommendations.append({
"factor": "antithesis_balance",
"current_ratio": round(anti_sum / max(1, thesis_sum), 2),
"action": "현행 유지",
"rationale": f"sell_rate={sell_rate}% 또는 표본 부족(n={sell_n})",
})
# 2. 수동신호 개선 권고 (passive_rate 낮은 경우)
if passive_rate is not None and passive_rate < 35:
# 수동신호 정확도가 낮다 → WATCH/CANDIDATE 진입 조건 강화
miss5_passive = [r for r in passive_recs
if r.get("t5_outcome") == "MISMATCHED" and (r.get("t5_return_pct") or 0) >= 5]
timing_none_n = sum(1 for r in miss5_passive
if _parse_rule(r.get("rule_basis")).get("timing", "None") == "None")
recommendations.append({
"factor": "passive_signal_quality",
"passive_rate_pct": passive_rate,
"passive_n": passive_n,
"miss5_count": len(miss5_passive),
"action": "timing=None CANDIDATE에 PULLBACK_ENTRY_TRIGGER_V1 조건 필수화",
"spec_ref": "AGENTS.md Direction B1",
"rationale": (
f"수동신호 정확도={passive_rate:.1f}%(n={passive_n}), "
f"5%+ 급등 미포착={len(miss5_passive)}건 중 timing=None이 {timing_none_n}건. "
f"timing 조건 없이 alpha_lead만으로 CANDIDATE 상태에 오른 종목들이 "
f"갑작스러운 급등 시 대응 불가. PULLBACK_ENTRY_TRIGGER 조건 필수화 필요."
),
})
# 3. 능동신호 강화 권고 (active_rate가 높을 때 → 이 신호에 더 의존)
if active_rate is not None and active_rate >= 65:
recommendations.append({
"factor": "active_signal_confidence",
"active_rate_pct": active_rate,
"active_n": active_n,
"action": f"BUY_BLOCKED 신호 신뢰도 {active_rate:.1f}%로 높음 - 포지션 규모 보수 유지 가능",
"rationale": "능동 차단 신호가 정확하므로 현 리스크 관리 체계 유지 권고.",
})
# ── 컴포넌트 분석 요약 ───────────────────────────────────────────────────
component_analysis = []
for cond, stat in sorted(component_stats.items(), key=lambda x: -x[1]["total"]):
n = stat["total"]; m = stat["matched"]
if n >= 5:
component_analysis.append({
"condition": cond, "total": n, "matched": m,
"match_rate": round(m / n * 100, 1),
})
# ── 점수 추정 ────────────────────────────────────────────────────────────
combined_rate = (active_rate or 0) * 0.40 + (passive_rate or 0) * 0.60 if (active_rate and passive_rate) else None
result = {
"formula_id": "ALPHA_FEEDBACK_LOOP_V2",
"status": "ANALYZED",
"cases_analyzed": len(op_t5),
"active_signal_rate_pct": active_rate,
"active_signal_n": active_n,
"passive_signal_rate_pct": passive_rate,
"passive_signal_n": passive_n,
"combined_rate_pct": round(combined_rate, 2) if combined_rate else None,
"sell_signal_rate_pct": sell_rate,
"sell_signal_n": sell_n,
"pa1_current_ratio": round(anti_sum / max(1, thesis_sum), 2),
"pa1_thesis_sum": thesis_sum,
"pa1_antithesis_sum": anti_sum,
"recommended_adjustments": recommendations,
"component_analysis": component_analysis[:20],
"note": "AFL 권고는 사용자 승인 후 GAS settings 시트에서 수동 반영 (자동 적용 금지)",
}
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"ALPHA_FEEDBACK_LOOP_V2: status=ANALYZED cases={len(op_t5)} "
f"active={active_rate:.1f}%(n={active_n}) passive={passive_rate:.1f}%(n={passive_n}) "
f"pa1_ratio={anti_sum}/{thesis_sum}={anti_sum/max(1,thesis_sum):.1f}x")
print(f" 권고 수: {len(recommendations)}건")
for rec in recommendations:
print(f" [{rec['factor']}] {rec['action']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())