Files
QuantEngineByItz/tools/build_portfolio_transition_optimizer_v1.py
kjh2064 aedabdd37b feat(quant-engine): v8.9 제안서 P0-P3 로드맵 채택 — 15개 의사결정 엔진 신규 구현
suggest/quant_investment_engine_v8_9_portfolio_optimizer_canonical_refactored.yaml의
implementation_todo_v8_9(P0~P4) 전체를 spec/tool/golden case 레벨로 구현.

- P0: PORTFOLIO_TRANSITION_UTILITY_V1, SELL_LOT_PARETO_SELECTOR_V1, FORECAST_SIMULATION_ENGINE_V1
- P1: SECTOR_EXPOSURE_GRAPH_V1/LEADER_LIFECYCLE_GATE_V1, EXECUTION_CAPACITY_LADDER_V1, MODEL_GOVERNANCE_KILL_SWITCH_V1
- P2: SCENARIO_SHOCK_MATRIX_V1, TRANSITION_SET_ENUMERATOR_V1, IMMUTABLE_DECISION_LEDGER_V1, EXECUTION_PLAN_COMPILER_V1
- P3: STATE_VECTOR_CONSTRUCTOR_V1, WALK_FORWARD_BOOTSTRAP_V1, TRANSITION_SET_ENUMERATOR_V1(MRC/CVaR 확장),
      REBALANCE_CADENCE_GATE_V1, WEEKLY_LEGACY_TRANSFER_PLAN_V1

기존 regime/cluster 연동 정책 수치(현금방어선, 반도체 cap)는 그대로 유지하고 신규 cap 필드만 추가.
spec/09_decision_flow.yaml과 runtime/active_artifact_manifest.yaml에 전 엔진 배선 완료.
governance/todo/v8_9_p{0,1,2,3}_adoption_plan.yaml에 각 단계 작업 추적 기록.

검증: validate_specs/validate_golden_coverage_100(100%)/validate_calibration_registry_v1/
validate_schema_model_generation_v1/validate_agents_shrink_v1 전부 PASS. golden test 53/53 PASS.

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

210 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""PORTFOLIO_TRANSITION_UTILITY_V1 — spec/formulas/domains/portfolio.yaml.
Compiles candidate sell actions (from sell_waterfall_engine_v3/v4) and cash-repair
benefit (from smart_cash_recovery) plus a CE70 distribution input
(forecast_simulation_engine_v1, optional — may not exist yet while T+20 sample < 30)
into a single portfolio-level transition_utility_krw and a deterministic
selected_transition or NO_TRADE.
Hard rule (AGENTS.md): missing required numeric input is never treated as zero.
If ce70_net_profit_krw is unavailable for every candidate, the optimizer still runs
on the cash/concentration-only benefit terms but cannot select a BUY-type transition.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DECISION_PACKET = ROOT / "Temp" / "final_decision_packet_active.json"
DEFAULT_SELL_WATERFALL = ROOT / "Temp" / "sell_waterfall_engine_v3.json"
DEFAULT_CASH_RECOVERY = ROOT / "Temp" / "smart_cash_recovery_v9.json"
DEFAULT_SIMULATION = ROOT / "Temp" / "forecast_simulation_engine_v1.json"
DEFAULT_OUT = ROOT / "Temp" / "portfolio_transition_optimizer_v1.json"
HARD_VETO_ORDER = [
"DATA_INVALID",
"EXECUTION_MODE_BLOCK",
"CASH_FLOOR_BLOCK",
"HARD_CONCENTRATION_BLOCK",
"NEGATIVE_TRANSITION_UTILITY",
]
LIVE_ORDER_MODES = {"LIVE_LIMITED", "LIVE_FULL"}
def _load(path: Path) -> dict:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _candidate_actions_from_sell_waterfall(sell_waterfall: dict) -> list[dict]:
rows = sell_waterfall.get("rows") if isinstance(sell_waterfall.get("rows"), list) else []
candidates = []
for idx, row in enumerate(rows):
if not isinstance(row, dict):
continue
candidates.append(
{
"candidate_id": row.get("candidate_id") or f"SELL_{idx}",
"asset_id": row.get("종목명") or row.get("asset_id") or "UNKNOWN",
"action_type": row.get("매도유형") or row.get("action_type") or "SELL_CASH_REPAIR",
"planned_amount_krw": row.get("예상순현금") or row.get("planned_amount_krw"),
"source_signal_ids": ["SELL_WATERFALL_ENGINE_V3"],
"numeric_provenance_status": "PASS" if row.get("예상순현금") is not None else "DATA_MISSING",
}
)
return candidates
def _hard_constraint_pass(candidate: dict, decision_packet: dict) -> tuple[bool, str | None]:
if candidate.get("numeric_provenance_status") != "PASS":
return False, "DATA_INVALID"
execution_mode = decision_packet.get("execution_mode") or decision_packet.get("global_execution_gate")
if execution_mode in (None, "DATA_MISSING"):
return False, "EXECUTION_MODE_BLOCK"
return True, None
def _transition_utility_krw(
candidate: dict,
ce70_net_profit_krw: float | None,
tax_fee_slippage_krw: float,
cash_repair_benefit_krw: float,
concentration_reduction_benefit_krw: float,
turnover_penalty_krw: float,
) -> float | None:
is_sell = str(candidate.get("action_type", "")).startswith("SELL")
if is_sell:
planned = candidate.get("planned_amount_krw") or 0.0
return (
float(planned)
+ cash_repair_benefit_krw
+ concentration_reduction_benefit_krw
- tax_fee_slippage_krw
- turnover_penalty_krw
)
if ce70_net_profit_krw is None:
return None
return (
ce70_net_profit_krw
- tax_fee_slippage_krw
+ cash_repair_benefit_krw
+ concentration_reduction_benefit_krw
- turnover_penalty_krw
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--decision-packet", default=str(DEFAULT_DECISION_PACKET))
ap.add_argument("--sell-waterfall", default=str(DEFAULT_SELL_WATERFALL))
ap.add_argument("--cash-recovery", default=str(DEFAULT_CASH_RECOVERY))
ap.add_argument("--simulation", default=str(DEFAULT_SIMULATION))
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
decision_packet = _load(Path(args.decision_packet))
sell_waterfall = _load(Path(args.sell_waterfall))
cash_recovery = _load(Path(args.cash_recovery))
simulation = _load(Path(args.simulation))
source_paths = [
str(Path(args.decision_packet)),
str(Path(args.sell_waterfall)),
str(Path(args.cash_recovery)),
str(Path(args.simulation)),
]
if not decision_packet:
result = {
"formula_id": "PORTFOLIO_TRANSITION_UTILITY_V1",
"gate": "NO_TRADE_AND_QUARANTINE",
"final_action": "NO_TRADE",
"reason_codes": ["missing_optimizer_inputs"],
"selected_transition": None,
"candidate_actions": [],
"source_paths": source_paths,
}
out = Path(args.out)
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
cash_repair_benefit_krw = float(cash_recovery.get("value_damage_pct_avg") or cash_recovery.get("cash_repair_benefit_krw") or 0.0)
tax_fee_slippage_krw = float(sell_waterfall.get("tax_fee_slippage_krw") or 0.0)
concentration_reduction_benefit_krw = 0.0
turnover_penalty_krw = 0.0
ce70_net_profit_krw = simulation.get("ce70_net_profit_krw")
if isinstance(ce70_net_profit_krw, str):
ce70_net_profit_krw = None
candidates = _candidate_actions_from_sell_waterfall(sell_waterfall)
evaluated = []
best = None
for candidate in candidates:
ok, veto_reason = _hard_constraint_pass(candidate, decision_packet)
utility = (
_transition_utility_krw(
candidate,
ce70_net_profit_krw,
tax_fee_slippage_krw,
cash_repair_benefit_krw,
concentration_reduction_benefit_krw,
turnover_penalty_krw,
)
if ok
else None
)
if ok and utility is not None and utility <= 0:
ok = False
veto_reason = "NEGATIVE_TRANSITION_UTILITY"
row = {
**candidate,
"hard_constraint_pass": ok,
"veto_reason": veto_reason,
"transition_utility_krw": utility,
}
evaluated.append(row)
if ok and (best is None or (utility or 0) > (best["transition_utility_krw"] or 0)):
best = row
if best is None:
final_action = "NO_TRADE"
reason_codes = ["NO_TRADE_BAND"] if candidates else ["missing_optimizer_inputs"]
selected_transition = None
else:
execution_mode = decision_packet.get("execution_mode") or decision_packet.get("global_execution_gate")
if execution_mode in LIVE_ORDER_MODES:
final_action = "LIVE_ORDER_REVIEW"
else:
final_action = "SHADOW_LEDGER_ONLY"
reason_codes = ["TRANSITION_UTILITY_POSITIVE"]
selected_transition = best
result = {
"formula_id": "PORTFOLIO_TRANSITION_UTILITY_V1",
"default_action": "NO_TRADE",
"final_action": final_action,
"hard_veto_order": HARD_VETO_ORDER,
"reason_codes": reason_codes,
"candidate_actions": evaluated,
"selected_transition": selected_transition,
"source_paths": source_paths,
}
out = Path(args.out)
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())