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
@@ -0,0 +1,339 @@
#!/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("--manifest", default="runtime/active_artifact_manifest.yaml")
ap.add_argument("--packet", default="Temp/final_decision_packet_active.json")
ap.add_argument("--out", default="Temp/final_context_for_llm_v5.yaml")
args = ap.parse_args()
manifest_path = ROOT / args.manifest
packet_path = ROOT / args.packet
out_path = ROOT / args.out
if not manifest_path.exists():
print(f"Manifest not found: {manifest_path}")
return 1
if not packet_path.exists():
print(f"Packet not found: {packet_path}")
return 1
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
packet = json.loads(packet_path.read_text(encoding="utf-8"))
# Also load GatherTradingData.json to get harness context fields!
trading_data_path = ROOT / "GatherTradingData.json"
trading_data = {}
if trading_data_path.exists():
try:
trading_data = json.loads(trading_data_path.read_text(encoding="utf-8"))
except Exception:
pass
# Extract _harness_context from GatherTradingData.json
harness_context = trading_data.get("data", {}).get("_harness_context", {})
# Extract info from manifest
manifest_info = {
"generated_at": manifest.get("generated_at"),
"active_count_per_formula": manifest.get("active_count_per_formula"),
"report_active_artifact_match_pct": manifest.get("report_active_artifact_match_pct"),
}
# Extract canonical metrics
metrics = packet.get("canonical_metrics", {})
total_asset = metrics.get("total_asset_krw", {}).get("value")
cash_shortfall = metrics.get("cash_shortfall_min_krw", {}).get("value")
cash_recovered = metrics.get("cash_recovered_krw", {}).get("value") or metrics.get("cash_recovered", {}).get("value")
gate = metrics.get("final_execution_gate", {}).get("value") or packet.get("routing_serving", {}).get("global_execution_gate", {}).get("value")
hts_order_count = metrics.get("hts_order_count", {}).get("value")
# Build blockers
blockers = []
if gate != "PASS":
blockers.append({
"gate": gate,
"reason": "final_execution_gate is not PASS (AUDIT_ONLY / BLOCK_EXECUTION)"
})
action_table = []
# Add per ticker decisions
for item in packet.get("per_ticker_final_judgment", []):
action_table.append({
"ticker": item.get("ticker"),
"verdict": item.get("verdict"),
"effective_confidence": item.get("effective_confidence"),
"horizon": item.get("horizon")
})
order_blueprints = []
if "order_blueprint" in packet and isinstance(packet["order_blueprint"], dict):
order_blueprints = packet["order_blueprint"].get("rows", [])
shadow_ledger_info = {
"cash_raise_plan": packet.get("cash_raise_plan", {}),
"total_asset_krw": total_asset,
"cash_shortfall_min_krw": cash_shortfall,
"cash_recovered_krw": cash_recovered,
"hts_order_count": hts_order_count,
"order_blueprints": order_blueprints
}
data_missing_info = []
data_quality = packet.get("data_quality", {})
if data_quality.get("missing_critical_field_count", {}).get("value", 0) > 0:
data_missing_info.append(f"Missing {data_quality.get('missing_critical_field_count', {}).get('value')} critical fields")
dq_gate_raw = harness_context.get("data_quality_gate_v2_json")
if dq_gate_raw:
if isinstance(dq_gate_raw, str):
try:
dq_gate_raw = json.loads(dq_gate_raw)
except Exception:
pass
if isinstance(dq_gate_raw, dict):
for warn in dq_gate_raw.get("special_warnings", []):
data_missing_info.append(warn)
education_notes = [
"LLM must copy and render already calculated values only.",
"Do NOT perform any mathematical calculations.",
"Strictly forbidden to modify target execution actions or sizing.",
]
# Map the 11 contract-required sections:
# 01_metadata_and_manifest_alias
metadata_kst = manifest.get("generated_at", "")
if metadata_kst.endswith("Z"):
metadata_kst = metadata_kst.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(metadata_kst)
kst = timezone(timedelta(hours=9))
dt_kst = dt.astimezone(kst)
generated_at_kst = dt_kst.isoformat()
except Exception:
generated_at_kst = metadata_kst
active_aliases = manifest.get("active_aliases", {})
active_alias = list(active_aliases.keys())[0] if active_aliases else "final_decision_packet_active"
sec01 = {
"document_id": "FINAL_CONTEXT_FOR_LLM_V5",
"generated_at_kst": generated_at_kst,
"active_artifact_alias": active_alias
}
# 02_portfolio_health
cash_ratio = harness_context.get("cash_current_pct_d2") or harness_context.get("settlement_cash_pct") or 0.0
if not cash_ratio and total_asset:
buy_power = harness_context.get("buy_power_krw") or harness_context.get("settlement_cash_d2_krw") or 0.0
cash_ratio = round(buy_power / total_asset * 100, 2)
achievement_pct = harness_context.get("goal_achievement_pct") or 0.0
if not achievement_pct and total_asset:
achievement_pct = round(total_asset / 500_000_000 * 100, 2)
sec02 = {
"total_asset_krw": total_asset or 0,
"cash_ratio_pct": float(cash_ratio),
"goal_achievement_pct": float(achievement_pct)
}
# 03_hard_blockers
blocked_tickers = []
blocker_reasons = []
for blocker in blockers:
blocker_reasons.append(blocker.get("reason"))
for item in packet.get("per_ticker_final_judgment", []):
if item.get("verdict") in ("BLOCK", "BLOCK_BUY"):
blocked_tickers.append(item.get("ticker"))
blocker_reasons.append(f"{item.get('ticker')} verdict is {item.get('verdict')}")
sec03 = {
"blocked_tickers": blocked_tickers,
"blocker_reasons": blocker_reasons
}
# 04_sell_priority_table
sell_priority_rows = []
raw_sell_priority = trading_data.get("data", {}).get("sell_priority", []) or []
for row in raw_sell_priority:
sell_priority_rows.append({
"rank": row.get("Rank"),
"ticker": row.get("Ticker"),
"sell_action": row.get("Sell_Action"),
"sell_reason_code": row.get("Action_Reason") or "STOP_OR_TIME_EXIT"
})
sec04 = sell_priority_rows
# 05_buy_hold_sell_action_table
buy_hold_sell_rows = []
decisions_raw = harness_context.get("decisions_json") or []
if isinstance(decisions_raw, str):
try:
decisions_raw = json.loads(decisions_raw)
except Exception:
decisions_raw = []
prices_raw = harness_context.get("prices_json") or []
if isinstance(prices_raw, str):
try:
prices_raw = json.loads(prices_raw)
except Exception:
prices_raw = []
prices_by_ticker = {p.get("ticker"): p for p in prices_raw if p.get("ticker")}
sell_qtys_raw = harness_context.get("sell_quantities_json") or []
if isinstance(sell_qtys_raw, str):
try:
sell_qtys_raw = json.loads(sell_qtys_raw)
except Exception:
sell_qtys_raw = []
sell_qtys_by_ticker = {q.get("ticker"): q.get("sell_qty") for q in sell_qtys_raw if q.get("ticker")}
account_snap = trading_data.get("data", {}).get("account_snapshot", []) or []
acct_by_ticker = {a.get("ticker"): a for a in account_snap if a.get("ticker")}
for d in decisions_raw:
ticker = d.get("ticker")
action = d.get("final_action")
p_info = prices_by_ticker.get(ticker, {})
a_info = acct_by_ticker.get(ticker, {})
entry_price = p_info.get("avg_cost") or a_info.get("average_cost") or 0
stop_price = p_info.get("stop_price") or a_info.get("stop_price") or 0
qty = sell_qtys_by_ticker.get(ticker) or a_info.get("holding_quantity") or 0
buy_hold_sell_rows.append({
"ticker": ticker,
"final_action": action,
"entry_price": entry_price,
"stop_price": stop_price,
"quantity": qty
})
sec05 = buy_hold_sell_rows
# 06_cash_and_risk_budget
available_cash = harness_context.get("buy_power_krw") or harness_context.get("settlement_cash_d2_krw") or 0
d2_cash = harness_context.get("settlement_cash_d2_krw") or available_cash
sec06 = {
"available_cash_krw": available_cash,
"d2_cash_krw": d2_cash,
"max_allowed_mdd_pct": 20.0
}
# 07_shadow_ledger_visible_items
sec07 = []
shadow_ledger_path = ROOT / "Temp" / "shadow_ledger_v2.json"
if shadow_ledger_path.exists():
try:
sl_data = json.loads(shadow_ledger_path.read_text(encoding="utf-8"))
for f in sl_data.get("shadow_formulas", []):
sec07.append({
"formula_id": f.get("formula_id"),
"lifecycle_state": f.get("lifecycle_state"),
"sample_n": f.get("sample_n"),
"promotion_allowed": f.get("promotion_allowed")
})
except Exception:
pass
# 08_data_missing_items
sec08 = []
if data_missing_info:
for idx, warn in enumerate(data_missing_info):
sec08.append({
"missing_field": f"warning_{idx}",
"reason": warn
})
else:
sec08.append({
"missing_field": "None",
"reason": "No missing critical fields detected."
})
# 09_market_regime_summary_precomputed
regime_label = harness_context.get("market_regime_state") or "NEUTRAL"
regime_score = harness_context.get("mrs_score") or 0
pos_scale = harness_context.get("regime_size_scale") or 1.0
sec09 = {
"regime_label": regime_label,
"regime_score": regime_score,
"position_scale_factor": pos_scale
}
# 10_education_notes_preapproved
sec10 = education_notes
# 11_forbidden_phrases_and_no_math_rules
sec11 = {
"forbidden_phrases": [
"최종 수량 결정",
"손절가 구하기",
"익절가 구하기",
"LLM에 의한 임의 수치 생성"
],
"no_math_rule": "LLM must copy-only pre-calculated values. Arithmetic calculations are strictly prohibited."
}
context = {
"formula_id": "FINAL_CONTEXT_FOR_LLM_V5",
"executive": {
"display_value": "FINAL_CONTEXT_FOR_LLM_V5",
"source_key": "meta.builder_version",
"manifest": manifest_info
},
"blockers": blockers,
"action_table": action_table,
"shadow_ledger": shadow_ledger_info,
"data_missing": data_missing_info,
"education_notes": education_notes,
"llm_forbidden_numeric_fields": [
"price",
"quantity",
"stop_price",
"take_profit_price",
"score",
"gate"
],
"instruction_source": "This file is the single read path source of truth for the LLM. Do not read other directories.",
# 11 contract-required sections
"01_metadata_and_manifest_alias": sec01,
"02_portfolio_health": sec02,
"03_hard_blockers": sec03,
"04_sell_priority_table": sec04,
"05_buy_hold_sell_action_table": sec05,
"06_cash_and_risk_budget": sec06,
"07_shadow_ledger_visible_items": sec07,
"08_data_missing_items": sec08,
"09_market_regime_summary_precomputed": sec09,
"10_education_notes_preapproved": sec10,
"11_forbidden_phrases_and_no_math_rules": sec11
}
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(yaml.safe_dump(context, sort_keys=False, allow_unicode=True), encoding="utf-8")
print(json.dumps({
"formula_id": context["formula_id"],
"section_count": len(context),
"gate": "PASS"
}, ensure_ascii=True))
return 0
if __name__ == "__main__":
import sys
sys.exit(main())