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,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_HISTORY = ROOT / "Temp" / "proposal_evaluation_history.json"
|
||||
DEFAULT_OUTCOME = ROOT / "Temp" / "outcome_quality_score_v1.json"
|
||||
DEFAULT_EXEC = ROOT / "Temp" / "execution_quality_harness_v1.json"
|
||||
DEFAULT_PERF = ROOT / "Temp" / "perf_recovery_harness_v1.json"
|
||||
DEFAULT_OUT = ROOT / "Temp" / "operational_outcome_lock_v1.json"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
|
||||
|
||||
def _f(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--history", default=str(DEFAULT_HISTORY))
|
||||
ap.add_argument("--outcome", default=str(DEFAULT_OUTCOME))
|
||||
ap.add_argument("--execution", default=str(DEFAULT_EXEC))
|
||||
ap.add_argument("--perf", default=str(DEFAULT_PERF))
|
||||
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
||||
args = ap.parse_args()
|
||||
|
||||
hp = Path(args.history)
|
||||
op = Path(args.outcome)
|
||||
ep = Path(args.execution)
|
||||
pp = Path(args.perf)
|
||||
out = Path(args.out)
|
||||
if not hp.is_absolute():
|
||||
hp = ROOT / hp
|
||||
if not op.is_absolute():
|
||||
op = ROOT / op
|
||||
if not ep.is_absolute():
|
||||
ep = ROOT / ep
|
||||
if not pp.is_absolute():
|
||||
pp = ROOT / pp
|
||||
if not out.is_absolute():
|
||||
out = ROOT / out
|
||||
|
||||
history = _load(hp)
|
||||
outcome = _load(op)
|
||||
execution = _load(ep)
|
||||
perf = _load(pp)
|
||||
|
||||
records = history.get("records") if isinstance(history.get("records"), list) else []
|
||||
t5_oper = [
|
||||
r for r in records if isinstance(r, dict)
|
||||
and r.get("t5_evaluation_status") == "EVALUATED_T5"
|
||||
and str(r.get("validation_status") or "").upper() != "REPLAY_BACKFILL"
|
||||
]
|
||||
t20_oper = [
|
||||
r for r in records if isinstance(r, dict)
|
||||
and r.get("t20_evaluation_status") == "EVALUATED_T20"
|
||||
and str(r.get("validation_status") or "").upper() != "REPLAY_BACKFILL"
|
||||
]
|
||||
t20_match = len([r for r in t20_oper if str(r.get("t20_outcome") or "") == "MATCHED"])
|
||||
t20_rate = round((t20_match / len(t20_oper)) * 100.0, 2) if t20_oper else 0.0
|
||||
|
||||
oq_score = _f(outcome.get("score"))
|
||||
eq_metrics = (execution.get("metrics") or {}).get("operational_t20") if isinstance((execution.get("metrics") or {}).get("operational_t20"), dict) else {}
|
||||
expectancy = _f(eq_metrics.get("expectancy_pct"))
|
||||
win_rate = _f(eq_metrics.get("win_rate_pct"))
|
||||
late_precision = _f((perf.get("metrics") or {}).get("late_chase_block_precision"), 0.0)
|
||||
value_damage = _f((perf.get("metrics") or {}).get("rebound_sell_value_damage"), 0.0)
|
||||
|
||||
# [Work 29] threshold 현실화
|
||||
# execution_quality_harness_v1.json 미존재 시 EXPECTANCY/WIN_RATE는 "데이터 없음"으로 처리
|
||||
# (실거래 기록 없음 = 알 수 없음, 아닌 0%)
|
||||
_exec_data_available = ep.exists() and bool(eq_metrics)
|
||||
|
||||
reasons: list[str] = []
|
||||
if len(t20_oper) < 30:
|
||||
reasons.append("OPERATIONAL_T20_SAMPLE_LT_30")
|
||||
if t20_rate < 60.0:
|
||||
reasons.append("OPERATIONAL_T20_PASS_LT_60")
|
||||
if oq_score < 60.0:
|
||||
reasons.append("OUTCOME_QUALITY_LT_60")
|
||||
# EXPECTANCY/WIN_RATE: 실거래 T+20 표본이 충분할 때만 체크 (samples>0)
|
||||
_exec_samples = int(_f(eq_metrics.get("samples"), 0.0))
|
||||
if _exec_data_available and _exec_samples >= 10:
|
||||
if expectancy <= 0.1:
|
||||
reasons.append("EXPECTANCY_LE_0_1")
|
||||
if win_rate < 45.0:
|
||||
reasons.append("WIN_RATE_LT_45")
|
||||
# VALUE_DAMAGE: 10% 초과는 실행 차단
|
||||
if value_damage > 10.0:
|
||||
reasons.append("VALUE_DAMAGE_GT_10")
|
||||
if late_precision < 80.0 and late_precision > 0.0:
|
||||
reasons.append("LATE_CHASE_PRECISION_LOW")
|
||||
|
||||
unlock_state = "PERFORMANCE_READY" if not reasons else "WATCH_PENDING_SAMPLE"
|
||||
if any(x in reasons for x in ("OUTCOME_QUALITY_LT_60", "EXPECTANCY_LE_0_1", "WIN_RATE_LT_45", "VALUE_DAMAGE_GT_10")):
|
||||
unlock_state = "NOT_PERFORMANCE_READY"
|
||||
|
||||
result = {
|
||||
"formula_id": "OPERATIONAL_OUTCOME_LOCK_V1",
|
||||
"unlock_state": unlock_state,
|
||||
"reasons": reasons,
|
||||
"metrics": {
|
||||
"operational_t5_count": len(t5_oper),
|
||||
"operational_t20_count": len(t20_oper),
|
||||
"operational_t20_pass_rate": t20_rate,
|
||||
"outcome_quality_score": oq_score,
|
||||
"execution_expectancy_pct": expectancy,
|
||||
"execution_win_rate_pct": win_rate,
|
||||
"late_chase_block_precision": late_precision,
|
||||
"sell_after_rebound_damage_pct": value_damage,
|
||||
},
|
||||
"targets": {
|
||||
"operational_t20_count_min": 30,
|
||||
"operational_t20_pass_rate_min": 60.0,
|
||||
"outcome_quality_score_min": 60.0,
|
||||
"execution_expectancy_pct_min": 0.1,
|
||||
"execution_win_rate_pct_min": 45.0,
|
||||
"sell_after_rebound_damage_pct_max": 10.0,
|
||||
},
|
||||
}
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user