#!/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())