ee3e799de1
주요 변경: - 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>
340 lines
12 KiB
Python
340 lines
12 KiB
Python
#!/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())
|