feat: fix rebalance plan for MID cap 75% violation and implement validate_factor_lifecycle_completeness_v1.py

This commit is contained in:
2026-06-14 12:45:37 +09:00
parent 6fc58cfa45
commit 7d42a51318
3 changed files with 267 additions and 80 deletions
+121 -78
View File
@@ -19,7 +19,7 @@ DEFAULT_JSON = ROOT / "GatherTradingData.json"
DEFAULT_OUT = TEMP / "horizon_rebalance_plan_v1.json"
FORMULA_ID = "HORIZON_REBALANCE_PLAN_V1"
SHORT_CAP_PCT = 40.0
HORIZON_CAPS = {"SHORT": 40.0, "MID": 50.0, "LONG": 80.0}
def _load(path: Path) -> Any:
@@ -69,17 +69,10 @@ def main() -> int:
routing = _load(TEMP / "strategy_routing_audit_v1.json")
alloc = hz.get("allocation_pct") or {}
short_pct = _f(alloc.get("SHORT", 0))
excess_pct = max(0.0, short_pct - SHORT_CAP_PCT)
# SHORT 종목 목록 (horizon_classification)
hz_rows = hz.get("rows") or []
short_tickers = [r for r in hz_rows if isinstance(r, dict) and r.get("horizon") == "SHORT"]
# final_judgment_gate의 verdict와 confidence 병합
fj_map = {r.get("ticker"): r for r in (fj.get("rows") or []) if isinstance(r, dict)}
# 총 포트폴리오 자산
# 총 포트폴리오 자산 및 주식 자산 산출
total_asset = _f(harness.get("total_asset_krw", 0))
portfolio_equity = total_asset - _f(harness.get("settlement_cash_d2_krw", 0))
@@ -93,78 +86,128 @@ def main() -> int:
if isinstance(item, dict):
weight_map[str(item.get("ticker", ""))] = _f(item.get("weight_pct", 0))
# SHORT 종목별 리밸런싱 우선순위 산출
# 우선순위: SELL verdict > 낮은 confidence > 높은 weight
candidates = []
for r in short_tickers:
ticker = r.get("ticker", "")
fj_row = fj_map.get(ticker, {})
verdict = str(fj_row.get("action_verdict", "UNKNOWN"))
conf = _f(fj_row.get("effective_confidence", 50))
weight_pct = weight_map.get(ticker, 0)
market_value = portfolio_equity * weight_pct / 100 if portfolio_equity > 0 else 0
disparity = _f(r.get("disparity_pct", 0))
rsi14 = _f(r.get("rsi14", 50))
# 우선순위 점수 (높을수록 먼저 줄임)
priority = 0
if verdict in ("SELL",): priority += 40
elif verdict in ("TRIM",): priority += 20
priority += max(0, 60 - conf) # confidence 낮을수록 +
priority += max(0, disparity - 5) * 2 # 이격도 높을수록 +
priority += max(0, rsi14 - 60) * 0.5 # RSI 과매수일수록 +
candidates.append({
"ticker": ticker,
"name": r.get("name", ""),
"horizon": "SHORT",
"verdict": verdict,
"effective_confidence": conf,
"weight_pct": weight_pct,
"market_value_krw": round(market_value),
"disparity_pct": disparity,
"rsi14": rsi14,
"priority_score": round(priority, 1),
})
candidates.sort(key=lambda x: x["priority_score"], reverse=True)
# 목표: SHORT 비중을 40%로 줄이기 위한 최소 감축량
target_short_pct = SHORT_CAP_PCT
# 단순 비례: 현재 71.4% → 40% = 31.4%p 감축 필요
# 각 종목의 비중을 합산해 필요 감축 시뮬레이션
required_reduction_pct = excess_pct # 31.4%p (SHORT 내 비중)
# 절대 금액 환산 (portfolio_equity 기준)
required_reduction_krw = portfolio_equity * required_reduction_pct / 100 if portfolio_equity > 0 else 0
# 누적 시뮬레이션
cum_reduction = 0.0
# 호라이즌별 산출 데이터 저장소
horizon_results = {}
plan_rows = []
for c in candidates:
if cum_reduction >= required_reduction_pct:
break
# 해당 종목 전량 매도 시 감축 pct (portfolio_equity 기준)
trim_pct = c["weight_pct"] # 포트폴리오 비중 = 감축 효과
action = "FULL_TRIM" if verdict == "SELL" else "PARTIAL_TRIM"
plan_rows.append({
**c,
"recommended_action": action,
"trim_weight_pct": round(trim_pct, 2),
"cum_short_reduction_pct": round(cum_reduction + trim_pct, 2),
})
cum_reduction += trim_pct
for H, cap_pct in HORIZON_CAPS.items():
current_pct = _f(alloc.get(H, 0))
excess_pct = max(0.0, current_pct - cap_pct)
required_reduction_pct = excess_pct
required_reduction_krw = portfolio_equity * required_reduction_pct / 100 if portfolio_equity > 0 else 0
# 해당 호라이즌 종목 목록 추출
h_tickers = [r for r in hz_rows if isinstance(r, dict) and r.get("horizon") == H]
candidates = []
for r in h_tickers:
ticker = r.get("ticker", "")
fj_row = fj_map.get(ticker, {})
verdict = str(fj_row.get("action_verdict", "UNKNOWN"))
conf = _f(fj_row.get("effective_confidence", 50))
weight_pct = weight_map.get(ticker, 0)
market_value = portfolio_equity * weight_pct / 100 if portfolio_equity > 0 else 0
disparity = _f(r.get("disparity_pct", 0))
rsi14 = _f(r.get("rsi14", 50))
# 우선순위 점수 산출 (기존 로직 유지)
priority = 0
if verdict in ("SELL",): priority += 40
elif verdict in ("TRIM",): priority += 20
priority += max(0, 60 - conf)
priority += max(0, disparity - 5) * 2
priority += max(0, rsi14 - 60) * 0.5
candidates.append({
"ticker": ticker,
"name": r.get("name", ""),
"horizon": H,
"verdict": verdict,
"effective_confidence": conf,
"weight_pct": weight_pct,
"market_value_krw": round(market_value),
"disparity_pct": disparity,
"rsi14": rsi14,
"priority_score": round(priority, 1),
})
candidates.sort(key=lambda x: x["priority_score"], reverse=True)
# 누적 감축 계획 시뮬레이션
cum_reduction = 0.0
h_plan_rows = []
if excess_pct > 0:
for c in candidates:
if cum_reduction >= required_reduction_pct:
break
trim_pct = c["weight_pct"]
action = "FULL_TRIM" if c["verdict"] == "SELL" else "PARTIAL_TRIM"
plan_row = {
**c,
"recommended_action": action,
"trim_weight_pct": round(trim_pct, 2),
}
if H == "SHORT":
plan_row["cum_short_reduction_pct"] = round(cum_reduction + trim_pct, 2)
elif H == "MID":
plan_row["cum_mid_reduction_pct"] = round(cum_reduction + trim_pct, 2)
elif H == "LONG":
plan_row["cum_long_reduction_pct"] = round(cum_reduction + trim_pct, 2)
h_plan_rows.append(plan_row)
cum_reduction += trim_pct
estimated_after_plan = max(0.0, current_pct - cum_reduction)
gate_status = "PASS" if estimated_after_plan <= cap_pct else "FAIL"
horizon_results[H] = {
"current_pct": current_pct,
"cap_pct": cap_pct,
"excess_pct": round(excess_pct, 1),
"required_reduction_pct": round(required_reduction_pct, 1),
"required_reduction_krw": round(required_reduction_krw),
"estimated_after_plan": round(estimated_after_plan, 1),
"gate_status": gate_status,
"candidates": candidates,
"plan_rows": h_plan_rows,
}
plan_rows.extend(h_plan_rows)
# 전체 게이트 판정
all_gate_status = "PASS" if all(res["gate_status"] == "PASS" for res in horizon_results.values()) else "FAIL"
result = {
"formula_id": FORMULA_ID,
"current_short_pct": short_pct,
"short_cap_pct": SHORT_CAP_PCT,
"excess_pct": round(excess_pct, 1),
"required_reduction_pct": round(required_reduction_pct, 1),
"required_reduction_krw": round(required_reduction_krw),
"estimated_short_after_plan": round(max(0, short_pct - cum_reduction), 1),
"gate_after_plan": "PASS" if max(0, short_pct - cum_reduction) <= SHORT_CAP_PCT else "FAIL",
# 하위 호환성 필드 (SHORT 기준)
"current_short_pct": horizon_results["SHORT"]["current_pct"],
"short_cap_pct": horizon_results["SHORT"]["cap_pct"],
"excess_pct": horizon_results["SHORT"]["excess_pct"],
"required_reduction_pct": horizon_results["SHORT"]["required_reduction_pct"],
"required_reduction_krw": horizon_results["SHORT"]["required_reduction_krw"],
"estimated_short_after_plan": horizon_results["SHORT"]["estimated_after_plan"],
"gate_after_plan": all_gate_status,
# 신규 확장 필드 (MID 기준)
"current_mid_pct": horizon_results["MID"]["current_pct"],
"mid_cap_pct": horizon_results["MID"]["cap_pct"],
"mid_excess_pct": horizon_results["MID"]["excess_pct"],
"required_mid_reduction_pct": horizon_results["MID"]["required_reduction_pct"],
"required_mid_reduction_krw": horizon_results["MID"]["required_reduction_krw"],
"estimated_mid_after_plan": horizon_results["MID"]["estimated_after_plan"],
# 신규 확장 필드 (LONG 기준)
"current_long_pct": horizon_results["LONG"]["current_pct"],
"long_cap_pct": horizon_results["LONG"]["cap_pct"],
"long_excess_pct": horizon_results["LONG"]["excess_pct"],
"required_long_reduction_pct": horizon_results["LONG"]["required_reduction_pct"],
"required_long_reduction_krw": horizon_results["LONG"]["required_reduction_krw"],
"estimated_long_after_plan": horizon_results["LONG"]["estimated_after_plan"],
"plan_rows": plan_rows,
"all_short_candidates": candidates,
"all_short_candidates": horizon_results["SHORT"]["candidates"],
"all_mid_candidates": horizon_results["MID"]["candidates"],
"all_long_candidates": horizon_results["LONG"]["candidates"],
"note": (
"포트폴리오 total_asset 기준 시뮬레이션. "
"실제 weight_pct는 prices_json 기준이며 "
@@ -174,9 +217,9 @@ def main() -> int:
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
f"[{FORMULA_ID}] SHORT={short_pct}% excess={excess_pct}%p "
f"[{FORMULA_ID}] SHORT={result['current_short_pct']}%(excess={result['excess_pct']}%p) "
f"MID={result['current_mid_pct']}%(excess={result['mid_excess_pct']}%p) "
f"plan_tickers={[r['ticker'] for r in plan_rows]} "
f"after_plan={result['estimated_short_after_plan']}% "
f"gate={result['gate_after_plan']} -> {out_path}"
)
return 0