Files
QuantEngineByItz/tools/validate_cash_ledger_v2.py
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- 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>
2026-06-13 13:20:14 +09:00

145 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import yaml
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--snapshot", default="GatherTradingData.json")
ap.add_argument("--contract", default="spec/15_account_snapshot_contract.yaml")
args = ap.parse_args()
snapshot_path = ROOT / args.snapshot
contract_path = ROOT / args.contract
if not snapshot_path.exists():
print(f"Snapshot file not found: {snapshot_path}")
return 1
data_payload = json.loads(snapshot_path.read_text(encoding="utf-8"))
data = data_payload.get("data", {})
settings = data.get("settings", {})
# 1. Total Asset & Prev Market Regime
total_asset = settings.get("total_asset_krw", 0)
prev_regime = settings.get("prev_market_regime", "NORMAL")
# Define cash floor ratio based on regime
# normal: 10%, overheated_or_event_week: 15%, risk_off: 25% (or min_cash_ratio 7%/10%/15%)
# Let's assume standard target is 10% for normal, 15% for overheated, 25% for risk_off
# But settings could provide weekly_target_cash_pct
target_ratio = settings.get("weekly_target_cash_pct", 10.0) / 100.0
if not settings.get("weekly_target_cash_pct"):
if "risk_off" in prev_regime.lower():
target_ratio = 0.15 # min 15%
elif "overheated" in prev_regime.lower() or "event" in prev_regime.lower():
target_ratio = 0.10 # min 10%
else:
target_ratio = 0.07 # min 7%
target_cash_floor = total_asset * target_ratio
# 2. Gather Cash from snapshot
snap = data.get("account_snapshot", [])
immediate_cash_map = {}
settlement_cash_d2_map = {}
# Track accounts by type
general_accounts = set()
restricted_accounts = set()
for item in snap:
acc = item.get("account")
acc_type = item.get("account_type", "")
# Handle broken characters for general account type
is_general = False
if acc_type:
acc_type_str = str(acc_type)
if "일반" in acc_type_str or "Ϲ" in acc_type_str:
is_general = True
elif "isa" in acc_type_str.lower() or "연금" in acc_type_str or "pension" in acc_type_str.lower():
is_general = False
else:
is_general = True # Default to general if unknown
if is_general:
if acc:
general_accounts.add(acc)
else:
if acc:
restricted_accounts.add(acc)
# Retrieve cash values if present
imm_cash = item.get("immediate_cash")
if imm_cash is not None:
immediate_cash_map[acc] = max(immediate_cash_map.get(acc, 0.0), float(imm_cash))
d2_cash = item.get("settlement_cash_d2")
if d2_cash is not None:
settlement_cash_d2_map[acc] = max(settlement_cash_d2_map.get(acc, 0.0), float(d2_cash))
# Backup from settings for general account D+2 cash if empty
settings_d2 = settings.get("settlement_cash_d2_krw")
if not settlement_cash_d2_map and settings_d2 is not None:
# Assign to a stub general account if no accounts exist
acc_stub = list(general_accounts)[0] if general_accounts else "general_stub"
general_accounts.add(acc_stub)
settlement_cash_d2_map[acc_stub] = float(settings_d2)
# 3. Sum up cash by account priority rules
general_immediate = sum(immediate_cash_map.get(acc, 0.0) for acc in general_accounts)
general_d2 = sum(settlement_cash_d2_map.get(acc, 0.0) for acc in general_accounts)
restricted_cash = sum(immediate_cash_map.get(acc, 0.0) + settlement_cash_d2_map.get(acc, 0.0) for acc in restricted_accounts)
# Cross account cash leak detection
# If restricted cash is added to general cash or general accounts have restricted tags
cross_account_cash_leak_count = 0
overlap = general_accounts.intersection(restricted_accounts)
if overlap:
cross_account_cash_leak_count += len(overlap)
# D+2 Cash Defense Rule Applied
d2_cash_defense_rule_applied = True
eligible_cash = general_immediate + general_d2
cash_shortfall = max(0.0, target_cash_floor - eligible_cash)
gate = "PASS" if cross_account_cash_leak_count == 0 else "FAIL"
result = {
"formula_id": "CASH_LEDGER_V2",
"total_asset_krw": total_asset,
"target_cash_floor_krw": target_cash_floor,
"immediate_cash": general_immediate,
"settlement_cash_d2": general_d2,
"restricted_cash": restricted_cash,
"cross_account_cash_leak_count": cross_account_cash_leak_count,
"d2_cash_defense_rule_applied": d2_cash_defense_rule_applied,
"cash_shortfall": cash_shortfall,
"gate": gate
}
out_path = ROOT / "Temp" / "cash_ledger_v2.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(result, ensure_ascii=True, indent=2))
# Log information separately
print(f"Eligible Cash (General Immediate + D+2): {eligible_cash:,.0f} KRW")
print(f"Restricted Cash (ISA + Pension): {restricted_cash:,.0f} KRW")
print(f"Cash Shortfall against Target Floor ({target_cash_floor:,.0f} KRW): {cash_shortfall:,.0f} KRW")
return 0 if gate == "PASS" else 1
if __name__ == "__main__":
import sys
sys.exit(main())