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>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user