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:
@@ -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()
|
||||
Reference in New Issue
Block a user