aedabdd37b
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>
155 lines
4.8 KiB
Python
155 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""SELL_LOT_PARETO_SELECTOR_V1 — spec/formulas/domains/cash.yaml.
|
|
|
|
Extends tools/build_sell_waterfall_engine_v3.py output with lot-level scoring
|
|
(tax_loss_benefit, missed_upside_penalty, reentry_cost) and a Pareto dominance
|
|
ranking within each hard_precedence stage, per
|
|
governance/todo/v8_9_p0_adoption_plan.yaml P0-2.2.
|
|
|
|
Backward compatible: every row from v3 is preserved unchanged; only new fields
|
|
are appended.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_V3 = ROOT / "Temp" / "sell_waterfall_engine_v3.json"
|
|
DEFAULT_OUT = ROOT / "Temp" / "sell_waterfall_engine_v4.json"
|
|
|
|
MAXIMIZE_FIELDS = [
|
|
"avoided_tail_loss_krw",
|
|
"cash_repair_benefit_krw",
|
|
"concentration_reduction_benefit_krw",
|
|
"tax_loss_benefit_krw",
|
|
]
|
|
MINIMIZE_FIELDS = [
|
|
"tax_fee_slippage_krw",
|
|
"reentry_cost_krw",
|
|
"missed_upside_penalty_krw",
|
|
]
|
|
|
|
|
|
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 _numeric_or_missing(row: dict, field: str) -> tuple[float, bool]:
|
|
value = row.get(field)
|
|
if value is None:
|
|
return 0.0, True
|
|
try:
|
|
return float(value), False
|
|
except (TypeError, ValueError):
|
|
return 0.0, True
|
|
|
|
|
|
def _lot_sell_score(row: dict) -> tuple[float, list[str]]:
|
|
missing_fields = []
|
|
values = {}
|
|
for field in MAXIMIZE_FIELDS + MINIMIZE_FIELDS:
|
|
value, missing = _numeric_or_missing(row, field)
|
|
values[field] = value
|
|
if missing:
|
|
missing_fields.append(field)
|
|
score = (
|
|
values["avoided_tail_loss_krw"]
|
|
+ values["cash_repair_benefit_krw"]
|
|
+ values["concentration_reduction_benefit_krw"]
|
|
+ values["tax_loss_benefit_krw"]
|
|
- values["tax_fee_slippage_krw"]
|
|
- values["reentry_cost_krw"]
|
|
- values["missed_upside_penalty_krw"]
|
|
)
|
|
return score, missing_fields
|
|
|
|
|
|
def _dominates(a: dict, b: dict) -> bool:
|
|
at_least_as_good = all(a.get(f, 0.0) >= b.get(f, 0.0) for f in MAXIMIZE_FIELDS) and all(
|
|
a.get(f, 0.0) <= b.get(f, 0.0) for f in MINIMIZE_FIELDS
|
|
)
|
|
strictly_better = any(a.get(f, 0.0) > b.get(f, 0.0) for f in MAXIMIZE_FIELDS) or any(
|
|
a.get(f, 0.0) < b.get(f, 0.0) for f in MINIMIZE_FIELDS
|
|
)
|
|
return at_least_as_good and strictly_better
|
|
|
|
|
|
def _rank_pareto_group(rows: list[dict]) -> list[dict]:
|
|
annotated = []
|
|
for row in rows:
|
|
dominated_by = [
|
|
other["candidate_id"]
|
|
for other in rows
|
|
if other is not row and _dominates(other, row)
|
|
]
|
|
annotated.append({**row, "pareto_dominated": bool(dominated_by), "dominated_by": dominated_by})
|
|
annotated.sort(
|
|
key=lambda r: (
|
|
r["pareto_dominated"],
|
|
-r["lot_sell_score_krw"],
|
|
r.get("tax_fee_slippage_krw", 0.0),
|
|
r.get("reentry_cost_krw", 0.0),
|
|
)
|
|
)
|
|
for idx, row in enumerate(annotated, start=1):
|
|
row["pareto_rank"] = idx
|
|
return annotated
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base", default=str(DEFAULT_V3))
|
|
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
|
args = ap.parse_args()
|
|
|
|
base = _load(Path(args.base))
|
|
rows = base.get("rows") if isinstance(base.get("rows"), list) else []
|
|
|
|
scored_rows = []
|
|
for idx, row in enumerate(rows):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
candidate_id = row.get("candidate_id") or row.get("종목명") or f"LOT_{idx}"
|
|
score, missing_fields = _lot_sell_score(row)
|
|
scored_rows.append(
|
|
{
|
|
**row,
|
|
"candidate_id": candidate_id,
|
|
**{f: _numeric_or_missing(row, f)[0] for f in MAXIMIZE_FIELDS + MINIMIZE_FIELDS},
|
|
"lot_sell_score_krw": score,
|
|
"lot_sell_score_missing_fields": missing_fields,
|
|
"hard_precedence_stage": row.get("hard_precedence_stage") or row.get("우선순위단계"),
|
|
}
|
|
)
|
|
|
|
groups: dict[object, list[dict]] = {}
|
|
for row in scored_rows:
|
|
groups.setdefault(row.get("hard_precedence_stage"), []).append(row)
|
|
|
|
ranked_rows: list[dict] = []
|
|
for _stage, group_rows in groups.items():
|
|
ranked_rows.extend(_rank_pareto_group(group_rows))
|
|
|
|
result = {
|
|
"formula_id": "SELL_LOT_PARETO_SELECTOR_V1",
|
|
"gate": base.get("gate") or "PASS",
|
|
"rows": ranked_rows,
|
|
"source_paths": [str(Path(args.base))],
|
|
}
|
|
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())
|