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>
This commit is contained in:
2026-06-13 13:20:14 +09:00
commit ee3e799de1
1474 changed files with 176087 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
"""validate_execution_simulator_v1.py — spec/55: H004_EXECUTION_SIMULATOR
Simulates tick normalization, minimum order quantity, cash floor, and
slippage checks for every order in the final_decision_packet.
formula_id: VALIDATE_EXECUTION_SIMULATOR_V1
contract: spec/55_execution_simulator_contract.yaml
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PACKET = ROOT / "Temp" / "final_decision_packet_active.json"
OUTPUT_PATH = ROOT / "Temp" / "execution_simulator_v1.json"
# Simulation parameters from spec/55
SLIPPAGE_BPS = 5
MINIMUM_RESERVE_KRW = 10_000_000
GOAL_TARGET_KRW = 500_000_000
D_PLUS_2_COUNTS = True
# KRX tick table (spec/00 HS008 / spec/13_formula_registry TICK_NORMALIZER_V1)
TICK_TABLE = [
( 2_000, 1),
( 5_000, 5),
( 20_000, 10),
( 50_000, 50),
( 200_000, 100),
( 500_000, 500),
(2_000_000, 1_000),
]
TICK_ABOVE_2M = 1_000
def _load_json(path: Path) -> dict:
if not path.exists():
return {"_missing": True, "_path": str(path)}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
return {"_error": str(e), "_path": str(path)}
def _tick_size(price: float) -> int:
for threshold, tick in TICK_TABLE:
if price < threshold:
return tick
return TICK_ABOVE_2M
def _normalize_tick(price: float) -> float:
tick = _tick_size(price)
return math.floor(price / tick) * tick
def _apply_slippage(price: float, side: str) -> float:
factor = SLIPPAGE_BPS / 10_000
if side == "BUY":
return price * (1 + factor)
return price * (1 - factor)
def _extract_orders(packet: dict) -> list[dict]:
orders = []
blueprint = packet.get("order_blueprint_json") or []
if isinstance(blueprint, list):
for order in blueprint:
if isinstance(order, dict):
orders.append(order)
per_ticker = packet.get("per_ticker") or {}
if isinstance(per_ticker, dict) and not orders:
for ticker, data in per_ticker.items():
if not isinstance(data, dict):
continue
action = str(data.get("final_action") or "").upper()
if any(x in action for x in ("BUY", "SELL", "TRIM", "EXIT")):
orders.append({
"ticker": ticker,
"action": action,
"price": data.get("entry_price") or data.get("close_price") or 0,
"quantity": data.get("final_qty") or data.get("sell_qty") or 0,
"validation_status": data.get("validation_status") or "UNKNOWN",
})
return orders
def _simulate_order(order: dict) -> dict:
errors = []
price = float(order.get("price") or 0)
qty = int(order.get("quantity") or order.get("qty") or 0)
action = str(order.get("action") or order.get("order_type") or "").upper()
side = "BUY" if "BUY" in action else "SELL"
# Tick normalization check
if price > 0:
normalized = _normalize_tick(price)
if abs(normalized - price) > 0.01:
errors.append(f"TICK_INVALID: price={price} → normalized={normalized}")
else:
errors.append("PRICE_MISSING")
# Minimum quantity check
if qty < 1:
errors.append(f"QTY_BELOW_MIN: qty={qty} < 1")
# Slippage-adjusted price
slippage_price = _apply_slippage(price, side) if price > 0 else 0
order_amount = slippage_price * qty
return {
"ticker": order.get("ticker"),
"action": action,
"original_price": price,
"normalized_price": _normalize_tick(price) if price > 0 else 0,
"slippage_adjusted_price": round(slippage_price, 0),
"quantity": qty,
"order_amount_krw": round(order_amount),
"errors": errors,
"valid": len(errors) == 0,
}
def run(packet_path: Path) -> dict:
packet = _load_json(packet_path)
if packet.get("_missing"):
result = {
"gate": "SKIP",
"reason": f"packet missing: {packet_path}",
"invalid_order_count": 0,
"cash_floor_after_orders_krw": 0,
"slippage_adjusted_orders": [],
"contract": "spec/55_execution_simulator_contract.yaml",
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=2))
return result
orders = _extract_orders(packet)
simulated = [_simulate_order(o) for o in orders]
invalid_count = sum(1 for s in simulated if not s["valid"])
# Cash floor check
cash_data = packet.get("cash_recovery_plan_json") or {}
available_cash = float(
(cash_data.get("available_cash_krw") if isinstance(cash_data, dict) else None)
or packet.get("available_cash_krw")
or 0
)
buy_total = sum(
s["order_amount_krw"] for s in simulated
if "BUY" in str(s.get("action") or "").upper()
)
cash_after = available_cash - buy_total
cash_floor_ok = (cash_after >= MINIMUM_RESERVE_KRW) if available_cash > 0 else True
gate = "PASS"
if invalid_order_count := invalid_count:
gate = "FAIL"
elif not cash_floor_ok:
gate = "FAIL"
result = {
"gate": gate,
"invalid_order_count": invalid_order_count,
"cash_floor_after_orders_krw": round(cash_after),
"cash_floor_ok": cash_floor_ok,
"minimum_reserve_krw": MINIMUM_RESERVE_KRW,
"available_cash_krw": available_cash,
"buy_total_krw": buy_total,
"slippage_adjusted_orders": simulated,
"order_count": len(simulated),
"contract": "spec/55_execution_simulator_contract.yaml",
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=2))
return result
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="H004 Execution Simulator")
parser.add_argument("--packet", default=str(DEFAULT_PACKET))
args = parser.parse_args()
result = run(Path(args.packet))
gate = result.get("gate", "FAIL")
print(f"[H004_EXECUTION_SIMULATOR] gate={gate} "
f"orders={result.get('order_count', 0)} "
f"invalid={result.get('invalid_order_count', 0)} "
f"cash_floor_ok={result.get('cash_floor_ok', True)}")
if gate == "FAIL":
print(" Invalid orders:", [o for o in result.get("slippage_adjusted_orders", []) if not o.get("valid")])
sys.exit(1)
if __name__ == "__main__":
main()