ee3e799de1
주요 변경: - 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>
146 lines
5.3 KiB
Python
146 lines
5.3 KiB
Python
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())
|