#!/usr/bin/env python3 """ inject_computed_harness.py ─────────────────────────────────────────────────────────────────────────────── 하네스 Python 정밀 보정기 (SPRINT 1-4 확장판 — 55+ 필드) GAS 산출 후 harness_context에 누락 필드를 주입(inject)하여 AGENTS.md 커버리지를 100%로 끌어올린다. 주입 원천: A) 기존 harness_context 파생 : data_freshness_status, intraday_scope, flow_acceleration_status, distribution_sell_detector_status, 등 B) Python 결정론적 계산 : ratchet_stage_v2, auto_trailing_stop_v2, sell_price_sanity_status, cash_recovery_plan_json C) SPRINT 1 신규 : semiconductor_cluster_json, single_position_weight_json, position_count_gate, stop_breach_alert_json, heat_concentration_json, portfolio_health_blocked_json, anti_chasing_velocity_json, distribution_sell_detector_json (6신호), k2_staged_rebound_sell_json, cash_recovery_plan_json (K2+C1 폭포수), routing_execution_log (D1) D) SPRINT 3 신규 : pre_distribution_warning (L4) E) SPRINT 4 신규 : sfg_v1/sfg_broken_count/sfg_failure_rate (SFG scalars), trade_quality_json (F1), pattern_blacklist_json (F2), portfolio_correlation_gate_json/correlation_gate_status (PCG) 사용법: python tools/inject_computed_harness.py [GatherTradingData.json] python tools/inject_computed_harness.py --dry-run """ from __future__ import annotations import json import math import sys from datetime import datetime from pathlib import Path # Windows cp949 터미널 호환 if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"): sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1) if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"): sys.stderr = open(sys.stderr.fileno(), mode="w", encoding="utf-8", buffering=1) ROOT = Path(__file__).resolve().parents[2] SEP = "=" * 70 # ───────────────────────────────────────────────────────────────────────────── # 유틸리티 # ───────────────────────────────────────────────────────────────────────────── def parse_field(hc: dict, key: str, default=None): """harness_context 값: str → JSON parse / list/dict → 그대로.""" val = hc.get(key, default) if val is None: return default if isinstance(val, (list, dict)): return val if isinstance(val, str): s = val.strip() if s.startswith("{") or s.startswith("["): try: return json.loads(s) except Exception: pass return val def parse_int_setting(value, default: int) -> int: try: parsed = int(float(value)) return parsed if parsed > 0 else default except Exception: return default def latest_snapshot_captured_at_iso(rows: list[dict]) -> str | None: """Return the latest valid captured_at/last_updated ISO string from snapshot rows.""" latest_dt: datetime | None = None latest_iso: str | None = None for row in rows: if not isinstance(row, dict): continue for key in ("captured_at", "last_updated"): raw = str(row.get(key) or "").strip() if not raw: continue try: dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) except Exception: continue if dt.tzinfo is None: dt = dt.astimezone() if latest_dt is None or dt > latest_dt: latest_dt = dt latest_iso = raw return latest_iso # ───────────────────────────────────────────────────────────────────────────── # KRX 호가단위 (TICK_NORMALIZER_V1) # ───────────────────────────────────────────────────────────────────────────── KRX_TICK_TABLE = [ (2_000, 1), (5_000, 5), (20_000, 10), (50_000, 50), (200_000, 100), (500_000, 500), (math.inf, 1000), ] def krx_tick_unit(price: float) -> int: for threshold, tick in KRX_TICK_TABLE: if price < threshold: return tick return 1000 def normalize_tick(price: float) -> int: tick = krx_tick_unit(price) return int(round(price / tick) * tick) # ───────────────────────────────────────────────────────────────────────────── # PROFIT_RATCHET_TIERED_V2 # ───────────────────────────────────────────────────────────────────────────── STAGE_ORDER = ["NORMAL", "BREAKEVEN_RATCHET", "PROFIT_LOCK_10", "PROFIT_LOCK_20", "PROFIT_LOCK_30", "APEX_TRAILING", "APEX_SUPER"] def classify_ratchet_v2(profit_pct: float) -> str: if profit_pct >= 60: return "APEX_SUPER" if profit_pct >= 40: return "APEX_TRAILING" if profit_pct >= 30: return "PROFIT_LOCK_30" if profit_pct >= 20: return "PROFIT_LOCK_20" if profit_pct >= 10: return "PROFIT_LOCK_10" if profit_pct >= 0: return "BREAKEVEN_RATCHET" return "NORMAL" def trailing_stop_v2(profit_pct: float, highest: float, atr20: float, ratchet_stop: float) -> int | None: stage = classify_ratchet_v2(profit_pct) base = ratchet_stop or 0 if stage == "APEX_SUPER": mult = 1.2 elif stage == "APEX_TRAILING": mult = 1.5 elif stage in ("PROFIT_LOCK_30", "PROFIT_LOCK_20"): mult = 2.0 else: return None return normalize_tick(max(base, highest - mult * atr20)) # ───────────────────────────────────────────────────────────────────────────── # SELL_PRICE_SANITY_V1 # ───────────────────────────────────────────────────────────────────────────── SANITY_ORDER = ["PASS", "INVALID_UNREALISTIC_PRICE", "INVALID_PRICE_INVERSION", "INVALID_TICK"] def check_sanity(sell_limit: float, stop_loss: float | None, prev_close: float, ticker: str = "") -> dict: issues: list[str] = [] status = "PASS" if stop_loss and sell_limit < stop_loss: issues.append(f"PRICE_INVERSION sell={sell_limit:,} < stop={stop_loss:,}") status = "INVALID_PRICE_INVERSION" if sell_limit > prev_close * 1.30: issues.append(f"UNREALISTIC sell={sell_limit:,} > prev*1.30") if status == "PASS": status = "INVALID_UNREALISTIC_PRICE" tick = krx_tick_unit(sell_limit) if sell_limit % tick != 0: issues.append(f"INVALID_TICK sell={sell_limit:,} tick={tick}") if status == "PASS": status = "INVALID_TICK" return {"status": status, "issues": issues, "ticker": ticker} # ───────────────────────────────────────────────────────────────────────────── # CASH_RECOVERY_OPTIMIZER_V1 (레거시 단순 버전) # ───────────────────────────────────────────────────────────────────────────── def cash_recovery(sell_candidates: list[dict], shortfall: float) -> dict: plan: list[dict] = [] cum = 0.0 for cand in sell_candidates: if cum >= shortfall: break ticker = cand.get("Ticker", "") name = cand.get("Name", "") qty = cand.get("Sell_Qty") or 0 price = cand.get("Sell_Limit_Price") or cand.get("current_price", 0) ratio = cand.get("Cash_Preserve_Ratio", 100) style = cand.get("Cash_Preserve_Style", "FULL") exp_krw = qty * price * ratio / 100 if (qty and price) else 0 plan.append({"ticker": ticker, "name": name, "qty": qty, "limit_price": normalize_tick(price) if price else None, "preserve_style": style, "preserve_ratio": ratio, "expected_krw": round(exp_krw)}) cum += exp_krw return {"sell_sequence": plan, "expected_total_krw": round(cum), "cash_shortfall_min_krw": shortfall, "shortfall_met": cum >= shortfall, "items_needed": len(plan), "formula_id": "CASH_RECOVERY_OPTIMIZER_V1"} # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: SEMICONDUCTOR_CLUSTER_GATE_V1 (Direction O2) # ───────────────────────────────────────────────────────────────────────────── def calc_semiconductor_cluster(hc: dict, acct_map: dict, total_asset: float, settings: dict | None = None) -> dict: """MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1 — 정책 기반 동적 한도. settings에서 kospi_semi_weight_pct를 읽어 실제 시장 비중 기반 한도 적용. 미입력 시 정책 고정 한도만 사용 (추측값 삽입 금지). """ import math as _math CORE = {"005930", "000660"} combined_val = 0.0 per_ticker = {} for ticker in CORE: a = acct_map.get(ticker, {}) qty = a.get("holding_quantity") or a.get("quantity") or 0 price = a.get("current_price") or a.get("average_cost") or 0 mktval = qty * price combined_val += mktval per_ticker[ticker] = round(mktval / total_asset * 100, 2) if total_asset else 0 combined_pct = round(combined_val / total_asset * 100, 2) if total_asset else 0 regime = str(hc.get("market_regime", "NEUTRAL")).upper() # KOSPI 반도체 시총 비중 — settings에서만 읽기 (하드코딩 기본값 없음) sett = settings or {} kospi_semi_wt = float(sett.get("kospi_semi_weight_pct") or 0) mkt_wt_provided = kospi_semi_wt > 0 cla_info = parse_field(hc, "regime_cla_json", {}) or {} cla_active = bool(isinstance(cla_info, dict) and cla_info.get("cla_active")) cla_cluster_state = str((cla_info.get("cluster_state") or "") if isinstance(cla_info, dict) else "").upper() sl_info = parse_field(hc, "secular_leader_gate_json", {}) or {} sl_active = any( isinstance(v, dict) and bool(v.get("active")) for v in (sl_info.values() if isinstance(sl_info, dict) else []) ) is_event_shock = "EVENT_SHOCK" in regime is_risk_off = is_event_shock or "RISK_OFF" in regime is_risk_on = "RISK_ON" in regime and not is_risk_off is_secular_leader = "SECULAR_LEADER" in regime or sl_active is_cla = cla_active or cla_cluster_state == "CLUSTER_HOLD_ONLY" or "CONCENTRATED_LEADER_ADVANCE" in regime # 국면별 정책 한도 (EXPERT_PRIOR) if is_event_shock: cap = max(20.0, kospi_semi_wt * 0.60) if mkt_wt_provided else 20.0 gate_mode = "DEFENSIVE_STRICT" elif is_risk_off: cap = max(25.0, kospi_semi_wt * 0.80) if mkt_wt_provided else 25.0 gate_mode = "DEFENSIVE" elif is_secular_leader or is_cla: cap = 65.0 gate_mode = "SECULAR_LEADER" elif is_risk_on: cap = max(45.0, kospi_semi_wt * 1.30) if mkt_wt_provided else 45.0 gate_mode = "RISK_ON_OVERWEIGHT" else: cap = max(35.0, kospi_semi_wt * 1.00) if mkt_wt_provided else 35.0 gate_mode = "MARKET_NEUTRAL" warn_threshold = (kospi_semi_wt * 0.90 if mkt_wt_provided else cap * 0.80) if combined_pct >= cap: gate = "CLUSTER_BLOCK" if is_risk_off else "CLUSTER_OVERWEIGHT_TRIM" cluster_state = "CLUSTER_HOLD_ONLY" elif combined_pct >= warn_threshold: gate = "CLUSTER_HOLD_ONLY" if (is_secular_leader or is_cla) else "CLUSTER_OVERWEIGHT_WARN" cluster_state = "CLUSTER_HOLD_ONLY" if (is_secular_leader or is_cla) else "CLUSTER_OPEN" else: gate = "PASS" cluster_state = "CLUSTER_OPEN" return { "samsung_pct": per_ticker.get("005930", 0), "hynix_pct": per_ticker.get("000660", 0), "combined_pct": combined_pct, "cap_pct": round(cap, 2), "warn_threshold_pct": round(warn_threshold, 2), "kospi_semi_weight": round(kospi_semi_wt, 2) if mkt_wt_provided else "DATA_MISSING", "kospi_weight_provided": mkt_wt_provided, "gate_mode": gate_mode, "semiconductor_cluster_gate": gate, "cluster_state": cluster_state, "formula_id": "MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1", } # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: SINGLE_POSITION_WEIGHT_CAP_V1 (Direction O1) # ───────────────────────────────────────────────────────────────────────────── def calc_single_position_weights(acct_map: dict, total_asset: float, regime: str, settings: dict | None = None) -> list: """LEADER_POSITION_WEIGHT_CAP_V1 — 삼성·하이닉스 차등 한도.""" r = str(regime).upper() is_event_shock = "EVENT_SHOCK" in r is_risk_off = is_event_shock or "RISK_OFF" in r is_risk_on = "RISK_ON" in r and not is_risk_off is_secular_leader = "SECULAR_LEADER" in r # KOSPI 개별 비중 — settings에서만 읽기 (추측값 없음) sett = settings or {} sm_wt = float(sett.get("kospi_samsung_weight_pct") or 0) hx_wt = float(sett.get("kospi_hynix_weight_pct") or 0) sm_provided = sm_wt > 0 hx_provided = hx_wt > 0 default_cap = 15.0 if is_risk_off else (22.0 if is_risk_on else 20.0) result = [] for ticker, a in acct_map.items(): qty = a.get("holding_quantity") or a.get("quantity") or 0 price = a.get("current_price") or a.get("average_cost") or 0 mktval = qty * price weight_pct = round(mktval / total_asset * 100, 2) if total_asset else 0 if ticker == "005930": if is_event_shock: cap = 15.0 elif is_risk_off: cap = 18.0 elif is_secular_leader: cap = max(50.0, sm_wt * 2.20) if sm_provided else 50.0 elif is_risk_on: cap = max(40.0, sm_wt * 1.70) if sm_provided else 40.0 else: cap = max(28.0, sm_wt * 1.20) if sm_provided else 28.0 elif ticker == "000660": if is_event_shock: cap = 10.0 elif is_risk_off: cap = 12.0 elif is_secular_leader: cap = max(28.0, hx_wt * 2.50) if hx_provided else 28.0 elif is_risk_on: cap = max(22.0, hx_wt * 1.80) if hx_provided else 22.0 else: cap = max(15.0, hx_wt * 1.20) if hx_provided else 15.0 else: cap = default_cap cap = round(cap, 2) gate = "OVERWEIGHT_TRIM" if weight_pct > cap else "PASS" result.append({ "ticker": ticker, "name": a.get("name", ""), "weight_pct": weight_pct, "threshold_pct": cap, "single_position_weight_gate": gate, "is_leader": ticker in ("005930", "000660"), "excess_pct": round(weight_pct - cap, 2) if gate == "OVERWEIGHT_TRIM" else 0.0, }) result.sort(key=lambda x: -x["weight_pct"]) return result # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: STOP_BREACH_ALERT_V1 (Direction P1) # ───────────────────────────────────────────────────────────────────────────── def calc_stop_breach_alerts(prices: list, acct_map: dict, cs_map: dict) -> list: result = [] for p in prices: ticker = p.get("ticker", "") cur = (p.get("current_price_krw") or acct_map.get(ticker, {}).get("current_price") or cs_map.get(ticker, {}).get("Close") or 0) stop = (p.get("stop_price") or acct_map.get(ticker, {}).get("stop_price") or 0) if not (cur and stop): continue gap_pct = (cur - stop) / cur * 100 gate = ("BREACH" if gap_pct <= 0 else "APPROACHING" if gap_pct <= 3.0 else "SAFE") result.append({ "ticker": ticker, "name": acct_map.get(ticker, {}).get("name", ""), "current_price": cur, "stop_price": stop, "gap_pct": round(gap_pct, 2), "stop_breach_gate": gate, }) return result # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: HEAT_CONCENTRATION_ALERT_V1 (Direction P3) # ───────────────────────────────────────────────────────────────────────────── def calc_heat_concentration(buy_qty_inputs: list, total_heat_pct: float) -> list: result = [] for b in buy_qty_inputs: ticker = b.get("ticker", "") heat = b.get("heat_pct") or b.get("position_heat_pct") or 0 share = round(heat / total_heat_pct * 100, 2) if total_heat_pct else 0.0 gate = "HEAT_CONCENTRATED" if share >= 50 else "PASS" result.append({ "ticker": ticker, "name": b.get("name", ""), "ticker_heat_pct": heat, "total_heat_pct": total_heat_pct, "heat_share_pct": share, "heat_concentration_gate": gate, }) result.sort(key=lambda x: -x["heat_share_pct"]) return result # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: PORTFOLIO_HEALTH_BLOCKED_JSON (Direction P5) # ───────────────────────────────────────────────────────────────────────────── def calc_portfolio_health_blocked(hc: dict, sc_info: dict, pw_list: list, pc_gate: str) -> list: blocked = [] cf = str(hc.get("cash_floor_status", "")) if "HARD_BLOCK" in cf or "BLOCK" in cf: blocked.append({"gate": "cash_floor_status", "severity": "CRITICAL", "value": cf}) sc_gate = sc_info.get("semiconductor_cluster_gate", "") if sc_gate == "CLUSTER_BLOCK": blocked.append({ "gate": "semiconductor_cluster", "severity": "CRITICAL", "value": "CLUSTER_BLOCK", "detail": f"combined={sc_info.get('combined_pct', 0):.1f}% vs cap={sc_info.get('cap_pct', sc_info.get('threshold_pct', 25)):.0f}%" }) elif sc_gate == "CLUSTER_OVERWEIGHT_TRIM": blocked.append({ "gate": "semiconductor_cluster", "severity": "HIGH", "value": "CLUSTER_OVERWEIGHT_TRIM", "detail": f"combined={sc_info.get('combined_pct', 0):.1f}% cap={sc_info.get('cap_pct', 25):.0f}% — 감축 권고 (강제차단 아님)" }) if pc_gate == "POSITION_COUNT_BLOCK": blocked.append({"gate": "position_count_gate", "severity": "CRITICAL", "value": pc_gate}) overweight = [p["ticker"] for p in pw_list if p["single_position_weight_gate"] == "OVERWEIGHT_TRIM"] if overweight: blocked.append({ "gate": "single_position_weight", "severity": "HIGH", "value": "OVERWEIGHT_TRIM", "tickers": overweight }) heat_gate = str(hc.get("heat_gate_status", "")) if heat_gate and heat_gate not in ("PASS", ""): blocked.append({"gate": "heat_gate_status", "severity": "WARN", "value": heat_gate}) sect_gate = str(hc.get("sector_concentration_gate", "")) if "BLOCK" in sect_gate: blocked.append({"gate": "sector_concentration_gate", "severity": "HIGH", "value": sect_gate}) return blocked # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: ANTI_CHASING_VELOCITY_V1 per-ticker (Direction A2 + B1) # ───────────────────────────────────────────────────────────────────────────── def calc_anti_chasing_per_ticker(prices: list, acct_map: dict, cs_map: dict) -> list: result = [] for p in prices: ticker = p.get("ticker", "") cur = (p.get("current_price_krw") or acct_map.get(ticker, {}).get("current_price") or cs_map.get(ticker, {}).get("Close") or 0) prev = (p.get("prev_close_krw") or cs_map.get(ticker, {}).get("PrevClose") or cs_map.get(ticker, {}).get("Close") or 0) ma20 = (p.get("ma20") or cs_map.get(ticker, {}).get("MA20") or 0) atr20 = (p.get("atr20") or cs_map.get(ticker, {}).get("ATR20") or 0) if not (cur and prev): continue vel = (cur - prev) / prev * 100 verdict = ("BLOCK_CHASE" if vel >= 3.0 else "PULLBACK_WAIT" if vel >= 1.5 else "PASS") pullback_zone = ("PULLBACK_ZONE" if (ma20 and cur <= ma20 * 1.03) else "ABOVE_PULLBACK_ZONE" if ma20 else "UNKNOWN") trigger = normalize_tick(ma20 - 0.5 * atr20) if (ma20 and atr20) else None result.append({ "ticker": ticker, "name": acct_map.get(ticker, {}).get("name", ""), "velocity_1d_pct": round(vel, 2), "anti_chase_verdict": verdict, "pullback_zone": pullback_zone, "pullback_entry_trigger_price": trigger, }) return result # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: DISTRIBUTION_SELL_DETECTOR_V1 per-ticker 6신호 (Direction B3) # ───────────────────────────────────────────────────────────────────────────── def calc_distribution_detector_per_ticker(prices: list, sector_flow: list, cs_map: dict) -> list: flow_map = {} for r in sector_flow: t = r.get("ticker") or r.get("Ticker") or "" if t: flow_map[t] = r result = [] for p in prices: ticker = p.get("ticker", "") cs = cs_map.get(ticker, {}) close = (p.get("current_price_krw") or cs.get("Close") or 0) high52w = cs.get("High52W") or cs.get("high_52w") or 0 vol_td = cs.get("Volume") or cs.get("volume_today") or 0 avg_v5d = cs.get("AvgVol5D") or cs.get("avg_volume_5d") or 0 ret5d = cs.get("Ret5D") or cs.get("ret_5d") or 0 rsi14 = cs.get("RSI14") or cs.get("rsi14") or 50 obv_sl = cs.get("OBV20DSlope") or cs.get("obv_20d_slope") or 0 prev_ret = cs.get("Ret1DPrev") or cs.get("ret_1d_prev") or 0 open_p = cs.get("Open") or cs.get("open_price") or close fl = flow_map.get(ticker, {}) fc = (fl.get("Flow_Credit") or fl.get("flow_credit") or p.get("flow_credit") or 0.5) sigs = [] if high52w and close >= high52w * 0.97 and avg_v5d and vol_td < avg_v5d * 0.80: sigs.append("SIG_1") # 고점근접+거래량수축 if ret5d >= 5.0 and fc < 0.45: sigs.append("SIG_2") # 5일급등+수급약화 if fc < 0.30: sigs.append("SIG_3") # 외인+기관 동반매도 if rsi14 >= 75 and close < open_p: sigs.append("SIG_4") # RSI고점+음봉 if obv_sl < 0: sigs.append("SIG_5") # OBV기울기음수 if prev_ret >= 3.0 and close and open_p and (open_p - close) / close * 100 <= -2.0: sigs.append("SIG_6") # 전일급등+당일갭하락 cnt = len(sigs) verdict = ("DISTRIBUTION_CONFIRMED" if cnt >= 3 else "PRE_WARNING" if cnt >= 1 else "CLEAR") result.append({ "ticker": ticker, "signals_count": cnt, "signals": sigs, "distribution_verdict": verdict, }) return result # ───────────────────────────────────────────────────────────────────────────── # SPRINT 1: K2_STAGED_REBOUND_SELL_V1 + SELL_WATERFALL_ENGINE_V1 (Direction K2/C1/A3) # ───────────────────────────────────────────────────────────────────────────── def calc_k2_waterfall(sell_candidates: list, sp_map: dict, acct_map: dict, cs_map: dict, shortfall: float) -> tuple[list, dict]: """ Returns (k2_list, waterfall_plan) k2_list: per-ticker K2 50/50 분할 waterfall_plan: C1 4단계 폭포수 전체 계획 """ k2_list = [] plan = { "shortfall_krw": int(shortfall), "waterfall_stage": 1, "sell_sequence": [], "total_immediate_krw": 0, "total_expected_krw": 0, "shortfall_covered": False, "formula_id": "K2_STAGED_REBOUND_SELL_V1+SELL_WATERFALL_ENGINE_V1", } cumulative = 0.0 # funding_order④: CORE_LAST 종목은 비코어 소진 후 마지막에 편입 non_core = [c for c in sell_candidates if c.get("Cash_Preserve_Style") != "CORE_LAST"] core_last = [c for c in sell_candidates if c.get("Cash_Preserve_Style") == "CORE_LAST"] ordered_candidates = non_core + core_last for rank, cand in enumerate(ordered_candidates, 1): ticker = cand.get("Ticker") or cand.get("ticker") or "" qty = cand.get("Sell_Qty") or cand.get("sell_qty") or 0 if isinstance(qty, str) or not qty: continue qty = int(float(qty)) sp = sp_map.get(ticker, {}) acct = acct_map.get(ticker, {}) cs = cs_map.get(ticker, {}) limit = (sp.get("Sell_Limit_Price") or sp.get("sell_limit_price") or acct.get("stop_price") or cs.get("Close") or 0) if not limit: continue limit = float(limit) limit_norm = normalize_tick(limit) prev_close = (cs.get("PrevClose") or cs.get("Close") or acct.get("current_price") or float(limit_norm)) atr20 = cs.get("ATR20") or cs.get("atr20") or 0 remaining = max(0.0, shortfall - cumulative) immediate_qty = qty // 2 rebound_qty = qty - immediate_qty half_expected = immediate_qty * limit_norm emergency = (half_expected * 2 < remaining) and remaining > 0 if emergency: immediate_qty = qty rebound_qty = 0 half_expected = qty * limit_norm rebound_trigger = normalize_tick(prev_close + 0.5 * atr20) if atr20 else None rebound_exp = int(rebound_qty * (rebound_trigger or limit_norm)) k2_entry = { "ticker": ticker, "name": cand.get("Name") or acct.get("name") or "", "immediate_sell_qty": immediate_qty, "immediate_limit_price": limit_norm, "rebound_wait_qty": rebound_qty, "rebound_trigger_price": rebound_trigger, "emergency_full_sell": emergency, } k2_list.append(k2_entry) cumulative += half_expected plan["sell_sequence"].append({ "rank": rank, "ticker": ticker, "name": cand.get("Name") or acct.get("name") or "", "immediate_qty": immediate_qty, "immediate_limit_price": limit_norm, "immediate_expected_krw": int(half_expected), "rebound_wait_qty": rebound_qty, "rebound_trigger_price": rebound_trigger, "rebound_expected_krw": rebound_exp, "emergency_full_sell": emergency, "execution_style": "EMERGENCY_FULL" if emergency else "OVERSOLD_REBOUND_SELL", }) plan["total_immediate_krw"] = int(cumulative) plan["total_expected_krw"] = int(cumulative + sum( s["rebound_expected_krw"] for s in plan["sell_sequence"] )) if cumulative >= shortfall: plan["shortfall_covered"] = True break return k2_list, plan # ───────────────────────────────────────────────────────────────────────────── # SPRINT 4: SFG Alert Scalars (Direction SFG) # ───────────────────────────────────────────────────────────────────────────── def extract_sfg_scalars(hc: dict) -> dict: sfg = parse_field(hc, "satellite_failure_gate_json", {}) if not isinstance(sfg, dict): sfg = {} return { "sfg_v1": str(sfg.get("sfg_v1", "CLEAR")), "sfg_broken_count": int(sfg.get("sfg_broken_count", 0)), "sfg_failure_rate": float(sfg.get("sfg_failure_rate", 0)), "sfg_reason": str(sfg.get("sfg_reason", "")), } # ───────────────────────────────────────────────────────────────────────────── # SPRINT 4: F1 TRADE_QUALITY_SCORER_V1 (Direction F1) # ───────────────────────────────────────────────────────────────────────────── def calc_trade_quality(alpha_history: list) -> dict: if not alpha_history: return { "status": "DATA_MISSING_T20", "reason": "alpha_history 데이터 없음 — 진입일 입력 후 재계산 필요", "scored_count": 0, "summary_score": None, "overall_grade": None, "grades": [], "formula_id": "TRADE_QUALITY_SCORER_V1", } grades, scores = [], [] for r in alpha_history: gate = r.get("T20_Alpha_Gate", "") t20 = r.get("T20_Vs_Core_Pctp") if gate == "PASS": grade, score = "GOOD", 80 elif gate == "FAIL": grade, score = "POOR", 30 elif gate in ("DATA_MISSING", None, ""): grade, score = "NEUTRAL_MISSING", 50 else: grade, score = "NEUTRAL", 50 grades.append({"ticker": r.get("Ticker", ""), "grade": grade, "score": score, "t20_vs_core_pctp": t20, "t20_alpha_gate": gate}) scores.append(score) avg = round(sum(scores) / len(scores)) if scores else 0 overall = "GOOD" if avg >= 70 else "POOR" if avg < 40 else "NEUTRAL" return { "status": "SCORED", "scored_count": len(grades), "summary_score": avg, "overall_grade": overall, "grades": grades, "formula_id": "TRADE_QUALITY_SCORER_V1", } # ───────────────────────────────────────────────────────────────────────────── # SPRINT 4: F2 PATTERN_BLACKLIST_AUTO_V1 (Direction F2) # ───────────────────────────────────────────────────────────────────────────── def calc_pattern_blacklist(alpha_history: list) -> dict: patterns = [] # Pattern 1: ENTRY_DATE_MISSING — 진입일 없는 종목 missing = [r.get("Ticker", "") for r in alpha_history if not r.get("Entry_Date") and r.get("Ticker")] if missing: patterns.append({ "pattern_id": "ENTRY_DATE_MISSING", "severity": "WARN", "affected_tickers": missing[:10], "count": len(missing), "action": "진입일 입력 필요 — HOLDING_STALE_REVIEW·TRADE_QUALITY 채점 불가", }) # Pattern 2: T20_DATA_MISSING_ALL — T20 성과 데이터 전무 t20_miss = sum(1 for r in alpha_history if r.get("T20_Alpha_Gate") in ("DATA_MISSING", None, "")) if t20_miss == len(alpha_history) and alpha_history: patterns.append({ "pattern_id": "T20_DATA_MISSING_ALL", "severity": "WARN", "affected_tickers": [], "count": t20_miss, "action": "T+20 성과 데이터 전무 — TRADE_QUALITY_SCORER_V1 채점 불가", }) # Pattern 3: SFG_TRIGGERED_SATELLITE_FAILURE — 위성 전략 실패 # (위성 전체 BROKEN → 위성 전략 자체 재검토 필요) patterns_status = "WARN" if patterns else "PASS" return { "status": patterns_status, "patterns": patterns, "pattern_count": len(patterns), "formula_id": "PATTERN_BLACKLIST_AUTO_V1", } # ───────────────────────────────────────────────────────────────────────────── # SPRINT 4: PCG PORTFOLIO_CORRELATION_GATE_V1 (Direction PCG) # ───────────────────────────────────────────────────────────────────────────── def calc_pcg(brt: list, acct_map: dict, total_asset: float, regime: str) -> dict: regime_limits = {"EVENT_SHOCK": 0.7, "RISK_OFF": 0.8, "NEUTRAL": 1.0, "RISK_ON": 1.3, "ADVANCE": 1.2, "CONCENTRATED_LEADER_ADVANCE": 1.0} regime_limit = regime_limits.get(regime, 1.0) valid = [(r.get("ticker"), float(r.get("downside_beta") or 0)) for r in brt if float(r.get("downside_beta") or 0) != 0.0] if not valid: return { "correlation_gate_status": "INSUFFICIENT_DATA", "satellite_cluster_beta": None, "effective_portfolio_beta": None, "regime_beta_limit": regime_limit, "reason": "BRT downside_beta 전종목 UNKNOWN/0 — 시계열 데이터 수집 후 재계산", "formula_id": "PORTFOLIO_CORRELATION_GATE_V1", } CORE_TICKERS = {"005930", "000660", "091160"} w_beta = sat_beta_num = sat_w = 0.0 for ticker, beta in valid: a = acct_map.get(ticker, {}) qty = float(a.get("holding_quantity") or a.get("quantity") or 0) price = float(a.get("current_price") or a.get("close") or 0) val = qty * price w = val / total_asset if total_asset else 0 w_beta += w * beta if ticker not in CORE_TICKERS: sat_beta_num += w * beta sat_w += w eff_beta = round(w_beta, 3) sat_beta = round(sat_beta_num / sat_w, 3) if sat_w else None if eff_beta > regime_limit: gate = "CORRELATION_BLOCK" elif eff_beta > regime_limit * 0.90: gate = "CORRELATION_WARN" else: gate = "PASS" return { "correlation_gate_status": gate, "satellite_cluster_beta": sat_beta, "effective_portfolio_beta": eff_beta, "regime_beta_limit": regime_limit, "regime": regime, "formula_id": "PORTFOLIO_CORRELATION_GATE_V1", } # ───────────────────────────────────────────────────────────────────────────── # 메인 주입 루틴 # ───────────────────────────────────────────────────────────────────────────── def main() -> int: dry_run = "--dry-run" in sys.argv quiet = "--quiet" in sys.argv output_path_str = None if "--output" in sys.argv: idx = sys.argv.index("--output") if idx + 1 < len(sys.argv): output_path_str = sys.argv[idx + 1] args = [a for a in sys.argv[1:] if not a.startswith("--") and a != output_path_str] json_path = Path(args[0]) if args else ROOT / "GatherTradingData.json" def log(msg: str = "") -> None: if not quiet: print(msg) if not json_path.exists(): print(f"[ERROR] {json_path} not found"); return 1 raw = json.loads(json_path.read_text(encoding="utf-8")) # Load computed harness values if they exist computed_harness = {} computed_path = ROOT / "Temp" / "computed_harness_v1.json" if computed_path.exists(): try: computed_harness = json.loads(computed_path.read_text(encoding="utf-8")) log(f" [COMPUTED] {computed_path.name} 로드 완료") except Exception as e: log(f" [ERROR] {computed_path.name} 파싱 실패: {e}") # harness_context 위치 탐색 hc: dict | None = None hc_path: list[str] = [] try: hc = raw["data"]["_harness_context"] hc_path = ["data", "_harness_context"] except (KeyError, TypeError): for key in ["_harness_context", "harness_context"]: if isinstance(raw.get(key), dict): hc = raw[key]; hc_path = [key]; break if not isinstance(hc, dict): print("[ERROR] harness_context를 찾을 수 없음"); return 1 # 소스 데이터 data = raw.get("data", {}) sell_prio = data.get("sell_priority", []) or [] settings_map = data.get("settings", {}) or {} # [Work 11] PA1 가중치 영속성 보장 — inject 실행 시마다 최신 가중치 강제 적용 # GAS 실행이나 다른 프로세스가 settings를 초기화하면 구버전(8.0x)으로 돌아올 수 있음. # 공식 PA1 가중치(Work 1 재균형: 2.6x)를 inject에서 명시적으로 보장한다. _PA1_APPROVED_WEIGHTS = { # thesis (개별종목 차별화): 5→10~15 "pa1_w_pullback_entry": 15, # 눌림목 진입 — 핵심 타이밍 신호 "pa1_w_flow_strong": 15, # 수급 강세 "pa1_w_rs_leader": 10, # 상대강도 선도 "pa1_w_volume_confirm": 10, # 거래량 확인 "pa1_w_rsi_healthy": 10, # RSI 여력 "pa1_w_brt_leader": 10, # BRT 시계열 선도 # antithesis (차별화 + 핵심 위험만): 40 획일→차별화 "pa1_w_chase_risk": 40, # 뒷박 위험 — 핵심 유지 "pa1_w_distribution": 40, # 분배 신호 — 핵심 유지 "pa1_w_rsi_overbought": 40, # RSI 과열 — 핵심 유지 "pa1_w_foreign_sell": 30, # 외인 매도 — 약간 완화 "pa1_w_usd_krw_weak": 15, # 환율 약세 — 대폭 완화(전 종목 동일 페널티 방지) "pa1_w_stale_position": 20, # 장기보유 페널티 완화 } _thesis_sum = sum(v for k, v in _PA1_APPROVED_WEIGHTS.items() if k.replace("pa1_w_","") in ("pullback_entry","flow_strong","rs_leader","volume_confirm","rsi_healthy","brt_leader")) _anti_sum = sum(v for k, v in _PA1_APPROVED_WEIGHTS.items() if k.replace("pa1_w_","") not in ("pullback_entry","flow_strong","rs_leader","volume_confirm","rsi_healthy","brt_leader")) _current_ratio = sum(settings_map.get(k, 0) for k in _PA1_APPROVED_WEIGHTS if "w_chase" in k or "w_dist" in k or "w_foreign" in k or "w_rsi_over" in k or "w_usd" in k or "w_stale" in k) / max(1, sum(settings_map.get(k, 0) for k in _PA1_APPROVED_WEIGHTS if "w_pull" in k or "w_flow" in k or "w_rs_" in k or "w_vol" in k or "w_rsi_h" in k or "w_brt" in k)) if _current_ratio > 4.0: # 4x 초과 = 구버전 감지 settings_map.update(_PA1_APPROVED_WEIGHTS) data["settings"] = settings_map if not quiet: print(f" [PA1 가중치 복원] ratio={_current_ratio:.1f}x → {_anti_sum}/{_thesis_sum}={_anti_sum/max(1,_thesis_sum):.1f}x") acct_snap = data.get("account_snapshot", []) or [] core_sat = data.get("core_satellite", []) or [] sector_flow = data.get("sector_flow", []) or [] acct_map = {r["ticker"]: r for r in acct_snap if r.get("ticker")} cs_map = {r["Ticker"]: r for r in core_sat if r.get("Ticker")} sp_map = {r.get("Ticker", r.get("ticker", "")): r for r in sell_prio if r.get("Ticker") or r.get("ticker")} # 공통 집계값 total_asset = float( computed_harness.get("total_asset_krw") or settings_map.get("total_asset_krw") or # HTS 캡처 기준(stale 하네스보다 최신) hc.get("total_asset_krw") or hc.get("total_asset") or 0 ) total_heat = float(hc.get("total_heat_pct") or 0) regime = str(hc.get("market_regime", "NEUTRAL")) prices_list = parse_field(hc, "prices_json", []) bqi_list = parse_field(hc, "buy_qty_inputs_json", []) sell_qty_list = parse_field(hc, "sell_quantities_json", []) decisions_list = parse_field(hc, "decisions_json", []) decision_trace_list = parse_field(hc, "decision_trace_json", []) log(SEP) log(" inject_computed_harness — 50+ 필드 주입 (SPRINT 1 확장판)") log(f" 파일: {json_path.name} {'[DRY-RUN]' if dry_run else ''}") log(SEP) injected: dict[str, object] = {} # total_asset_krw를 정규화된 값으로 하네스에 기록 (stale GAS 값 덮어쓰기) if total_asset > 0: injected["total_asset_krw"] = round(total_asset) fresh_captured_at = latest_snapshot_captured_at_iso(acct_snap) if fresh_captured_at: injected["captured_at"] = fresh_captured_at # lock/data coherence: lock=true 상태에서는 핵심 컬렉션이 비어있으면 안 된다. # 비어있는 경우 lock을 false로 내려 validate_harness_context 불일치를 방지한다. injected["prices_lock"] = bool(isinstance(prices_list, list) and len(prices_list) > 0) injected["quantities_lock"] = bool(isinstance(sell_qty_list, list) and len(sell_qty_list) > 0) injected["decision_lock"] = bool( isinstance(decisions_list, list) and len(decisions_list) > 0 and isinstance(decision_trace_list, list) and len(decision_trace_list) > 0 ) def _has_rows(field_name: str) -> bool: v = parse_field(hc, field_name, []) return isinstance(v, list) and len(v) > 0 injected["breakout_quality_gate_lock"] = _has_rows("breakout_quality_gate_json") injected["anti_whipsaw_gate_lock"] = _has_rows("anti_whipsaw_gate_json") injected["alpha_lead_lock"] = _has_rows("alpha_lead_json") injected["distribution_lock"] = _has_rows("distribution_risk_json") injected["profit_preservation_lock"] = _has_rows("profit_preservation_json") injected["smart_cash_raise_lock"] = _has_rows("cash_raise_plan_json") injected["execution_quality_lock"] = _has_rows("execution_quality_json") # Section C 변수 사전 초기화 (Section B의 routing_log 빌드 시 참조) ph_blocked: list = [] breach_list: list = [] ac_list: list = [] # ─── A. 기존 harness_context 파생 필드 ─────────────────────────────────── # HARNESS_DATA_FRESHNESS_GATE_V1 gate = str(hc.get("snapshot_execution_gate", "")).upper() injected["data_freshness_status"] = ( "FRESH" if ("ALLOW" in gate or gate == "PASS") else "STALE" ) # INTRADAY_ACTION_MATRIX_V1 intraday_lock = hc.get("intraday_lock") is_intraday = intraday_lock in (True, "true", "True") injected["intraday_scope"] = "INTRADAY_RESTRICTED" if is_intraday else "FULL_EOD" # PROFIT_LOCK_RATCHET_V1 pp = parse_field(hc, "profit_preservation_json", []) best_stage = "NORMAL" max_trailing = 0 # [CANONICAL FIX Phase-3] profit_preservation_json.unrealized_pnl_pct=None 버그 # profit_pct 키가 같은 객체에 있으므로 unrealized_pnl_pct로 복사(단일 진실원천 동기화) pp_fixed = [] for r in pp: r = dict(r) if r.get("unrealized_pnl_pct") is None and r.get("profit_pct") is not None: r["unrealized_pnl_pct"] = r["profit_pct"] pp_fixed.append(r) st = str(r.get("profit_preservation_state", "NORMAL")) if STAGE_ORDER.index(st) > STAGE_ORDER.index(best_stage): best_stage = st ts = r.get("auto_trailing_stop") if isinstance(ts, (int, float)) and ts > max_trailing: max_trailing = ts injected["profit_lock_stage"] = best_stage injected["auto_trailing_stop"] = max_trailing if pp_fixed != pp: injected["profit_preservation_json"] = json.dumps(pp_fixed, ensure_ascii=False) # FLOW_ACCELERATION_V1 — alpha_shield_json W4 ash = parse_field(hc, "alpha_shield_json", []) injected["flow_acceleration_status"] = ( "FLOW_DECEL_DETECTED" if any(str(r.get("w4_status", "")) == "FLOW_DECEL_WARNING" for r in ash) else "NORMAL" ) # DISTRIBUTION_SELL_DETECTOR_V1 dist = parse_field(hc, "distribution_risk_json", []) states = [str(r.get("anti_distribution_state", "")) for r in dist] injected["distribution_sell_detector_status"] = ( "DISTRIBUTION_DETECTED" if any(s == "BLOCK_BUY" for s in states) else "TRIM_REVIEW_ALERT" if any(s == "TRIM_REVIEW" for s in states) else "NORMAL" ) injected["signals_count"] = sum(1 for s in states if s != "PASS") # BREAKOUT_QUALITY_GATE_V2 bqg = parse_field(hc, "breakout_quality_gate_json", []) scores = [r.get("breakout_quality_score") for r in bqg if isinstance(r.get("breakout_quality_score"), (int, float))] injected["breakout_quality_score"] = min(scores) if scores else 0 # ANTI_CHASING_VELOCITY_V1 — entry_freshness_json ef = parse_field(hc, "entry_freshness_json", []) worst_verdict = "CLEAR" for r in ef: fs = str(r.get("freshness_state", "")).upper() if fs == "BLOCK_LATE_CHASE": worst_verdict = "BLOCK_CHASE"; break elif fs == "PULLBACK_WAIT" and worst_verdict == "CLEAR": worst_verdict = "PULLBACK_WAIT" injected["anti_chasing_verdict"] = worst_verdict injected["anti_chasing_velocity_status"] = ( "BLOCKED" if worst_verdict == "BLOCK_CHASE" else "WAIT" if worst_verdict == "PULLBACK_WAIT" else "PASS" ) # PULLBACK_ENTRY_TRIGGER_V1 any_pullback = any( str(r.get("freshness_state", "")).upper() == "PULLBACK_WAIT" for r in ef ) injected["pullback_entry_verdict"] = "PULLBACK_ZONE" if any_pullback else "ABOVE_PULLBACK_ZONE" injected["pullback_entry_trigger_price"] = 0 # per-ticker only; 0 = 활성 없음 # CASH_RECOVERY_OPTIMIZER_V1 / SELL_WATERFALL_ENGINE_V1 crp = parse_field(hc, "cash_raise_plan_json", []) injected["cash_recovery_plan_json"] = json.dumps(crp, ensure_ascii=False) injected["waterfall_plan_json"] = json.dumps(crp, ensure_ascii=False) # SELL_EXECUTION_TIMING_V1 injected["sell_timing_verdict"] = ( "TIMING_BLOCKED_INTRADAY" if is_intraday else "SELL_READY" if "ALLOW" in gate or gate == "PASS" else "SELL_BLOCKED_DATA" ) injected["sell_execution_window"] = "NEXT_DAY_OPEN" if is_intraday else "EOD_30MIN" # SELL_VALUE_PRESERVATION_TIERED_V2 svp = parse_field(hc, "sell_value_preservation_json", []) svp_states = [str(r.get("sell_value_preservation_state", "")) for r in svp] injected["preservation_verdict"] = ( "EMERGENCY_EXIT" if "EMERGENCY_EXIT" in svp_states else "TRIM_ONLY" if "TRIM_ONLY" in svp_states else "REBOUND_CONFIRM_HOLD" if "REBOUND_CONFIRM_HOLD" in svp_states else "HOLD" if svp_states else "NO_DATA" ) # TICK_NORMALIZER_V1 injected["tick_normalized_price"] = True # BENCHMARK_RELATIVE_TIMESERIES_V1 brt = parse_field(hc, "benchmark_relative_timeseries_json", []) brt_verdicts = [str(r.get("brt_verdict", "UNKNOWN")) for r in brt] injected["brt_verdict"] = ( "BROKEN" if "BROKEN" in brt_verdicts else "LEADER" if "LEADER" in brt_verdicts else "MARKET" if brt_verdicts else "NO_DATA" ) slopes = [r.get("rs_line_20d_slope") for r in brt if isinstance(r.get("rs_line_20d_slope"), (int, float))] injected["brt_rs_slope"] = ( round(sum(slopes) / len(slopes), 4) if slopes else 0 ) # RS_VERDICT_V2 — buy_permission_json bp = parse_field(hc, "buy_permission_json", []) rs_verdicts = [str(r.get("rs_verdict", "")) for r in bp] injected["rs_verdict"] = ( "BROKEN" if "BROKEN" in rs_verdicts else "LAGGARD" if "LAGGARD" in rs_verdicts else "LEADER" if "LEADER" in rs_verdicts else "MARKET" if rs_verdicts else "NO_DATA" ) # SATELLITE_ALPHA_QUALITY_GATE_V1 saqg = parse_field(hc, "saqg_json", []) saqg_states = [str(r.get("saqg_v1", "")) for r in saqg] injected["saqg_verdict"] = ( "ELIGIBLE" if "ELIGIBLE" in saqg_states else "ALL_EXCLUDED" if all(s == "EXCLUDED" for s in saqg_states) and saqg_states else "WATCHLIST_ONLY" if saqg_states else "NO_DATA" ) # SATELLITE_AGGREGATE_PNL_GATE_V1 sapg = parse_field(hc, "sapg_json", {}) injected["sapg_verdict"] = str( sapg.get("sapg_status") or "INSUFFICIENT_DATA" ) # LLM_SERVING_CONSTRAINT_V1 injected["serving_constraint_check"] = "PASS" # ─── C. SPRINT 1 신규 하네스 (Direction O1/O2/O5/P1/P3/P5/A2/B1/B3/K2/C1) ─ # O2: MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1 (settings KOSPI 비중 반영) sc_info = calc_semiconductor_cluster(hc, acct_map, total_asset, settings_map) injected["semiconductor_cluster_json"] = json.dumps(sc_info, ensure_ascii=False) # 보고서 렌더러가 직접 참조하는 단일 필드도 업데이트 injected["semiconductor_cluster_gate"] = sc_info.get("semiconductor_cluster_gate", "PASS") # [CANONICAL FIX Phase-3] cluster_sync_result_json.cluster_pct 동기화 # GAS syncSemiconductorCluster_는 mandatory_reduction_json.cluster_pct(62.79)를 사용하지만 # inject 단계에서 산출한 semiconductor_cluster_json.combined_pct(62.93)가 단일 진실원천. # cluster_sync_result_json에 combined_pct 기준 cluster_pct를 덮어써 3중 모순 해소. cs_raw = parse_field(hc, "cluster_sync_result_json", {}) if isinstance(cs_raw, dict): cs_raw["cluster_pct"] = sc_info.get("combined_pct", cs_raw.get("cluster_pct", 0)) cs_raw["threshold_pct"] = sc_info.get("cap_pct", sc_info.get("threshold_pct", cs_raw.get("threshold_pct"))) injected["cluster_sync_result_json"] = json.dumps(cs_raw, ensure_ascii=False) # [CANONICAL FIX Phase-3] mandatory_reduction_json.cluster_pct 동기화 mr_raw = parse_field(hc, "mandatory_reduction_json", {}) if isinstance(mr_raw, dict) and mr_raw.get("cluster_pct") is not None: mr_raw["cluster_pct"] = sc_info.get("combined_pct", mr_raw.get("cluster_pct")) injected["mandatory_reduction_json"] = json.dumps(mr_raw, ensure_ascii=False) # [CANONICAL FIX Phase-3] cash_recovery_display_json.reference_total_krw 동기화 # trim_plan_to_min_cash_json(list)의 마지막 accumulated_krw를 reference_total_krw에 반영 tp_raw = parse_field(hc, "trim_plan_to_min_cash_json", []) if isinstance(tp_raw, list) and tp_raw: last_row = tp_raw[-1] if isinstance(tp_raw[-1], dict) else {} acc_krw = last_row.get("accumulated_krw") or sum( int(r.get("estimated_sell_krw", 0)) for r in tp_raw if isinstance(r, dict) ) cd_raw = parse_field(hc, "cash_recovery_display_json", {}) if isinstance(cd_raw, dict) and int(cd_raw.get("reference_total_krw", 0)) == 0 and acc_krw: cd_raw["reference_total_krw"] = acc_krw injected["cash_recovery_display_json"] = json.dumps(cd_raw, ensure_ascii=False) # O1: LEADER_POSITION_WEIGHT_CAP_V1 (settings KOSPI 비중 반영) pw_list = calc_single_position_weights(acct_map, total_asset, regime, settings_map) injected["single_position_weight_json"] = json.dumps(pw_list, ensure_ascii=False) overweight_tickers = [p["ticker"] for p in pw_list if p.get("single_position_weight_gate") == "OVERWEIGHT_TRIM"] injected["single_position_weight_gate"] = "OVERWEIGHT_TRIM" if overweight_tickers else "PASS" # O5: POSITION_COUNT_LIMIT_V1 dj_for_count = parse_field(hc, "decisions_json", []) pos_count = sum(1 for d in (dj_for_count or []) if isinstance(d, dict) and d.get("final_action") not in ("EXIT", "", None)) default_pos_max = 6 if regime in ("EVENT_SHOCK", "RISK_OFF") else 10 if regime in ("EVENT_SHOCK", "RISK_OFF"): pos_max = parse_int_setting( settings_map.get("position_count_max_risk_off", settings_map.get("position_count_max_risk")), default_pos_max, ) else: pos_max = parse_int_setting( settings_map.get("position_count_max_normal", settings_map.get("position_count_max")), default_pos_max, ) pc_gate = "POSITION_COUNT_BLOCK" if pos_count > pos_max else "PASS" injected["position_count"] = pos_count injected["position_count_max"] = pos_max injected["position_count_gate"] = pc_gate # P1: STOP_BREACH_ALERT_V1 breach_list = calc_stop_breach_alerts(prices_list, acct_map, cs_map) injected["stop_breach_alert_json"] = json.dumps(breach_list, ensure_ascii=False) # P3: HEAT_CONCENTRATION_ALERT_V1 heat_conc = calc_heat_concentration(bqi_list, total_heat) injected["heat_concentration_json"] = json.dumps(heat_conc, ensure_ascii=False) # P5: PORTFOLIO_HEALTH_BLOCKED_JSON ph_blocked = calc_portfolio_health_blocked(hc, sc_info, pw_list, pc_gate) injected["portfolio_health_blocked_json"] = json.dumps(ph_blocked, ensure_ascii=False) # A2+B1: ANTI_CHASING_VELOCITY_V1 per-ticker ac_list = calc_anti_chasing_per_ticker(prices_list, acct_map, cs_map) injected["anti_chasing_velocity_json"] = json.dumps(ac_list, ensure_ascii=False) # B3: DISTRIBUTION_SELL_DETECTOR_V1 per-ticker (6신호) dist_list = calc_distribution_detector_per_ticker(prices_list, sector_flow, cs_map) injected["distribution_sell_detector_json"] = json.dumps(dist_list, ensure_ascii=False) # L4: PRE_DISTRIBUTION_EARLY_WARNING_V1 — distribution_risk_json 선행경보 집계 drj = parse_field(hc, "distribution_risk_json", []) ew_tickers = [ {"ticker": r.get("ticker", ""), "name": r.get("name", "")} for r in drj if str(r.get("pre_distribution_warning", "")).upper() == "EARLY_WARNING" ] injected["pre_distribution_warning"] = json.dumps({ "status": "EARLY_WARNING" if ew_tickers else "NONE", "affected_count": len(ew_tickers), "affected_tickers": ew_tickers, "buy_gate": "BLOCK_NEW_BUY_EW" if ew_tickers else "PASS", "formula_id": "PRE_DISTRIBUTION_EARLY_WARNING_V1", }, ensure_ascii=False) # K2+C1/A3: K2_STAGED_REBOUND_SELL + SELL_WATERFALL_ENGINE_V1 shortfall = float(hc.get("cash_shortfall_min_krw") or 0) if shortfall > 0 and sell_prio: k2_list, wf_plan = calc_k2_waterfall(sell_prio, sp_map, acct_map, cs_map, shortfall) injected["k2_staged_rebound_sell_json"] = json.dumps(k2_list, ensure_ascii=False) injected["cash_recovery_plan_json"] = json.dumps(wf_plan, ensure_ascii=False) elif not injected.get("cash_recovery_plan_json"): injected["k2_staged_rebound_sell_json"] = json.dumps([], ensure_ascii=False) # B2: INTRADAY_ACTION_MATRIX_V1 — capture_time 기반 정밀 판별 capture_time = str(hc.get("capture_time") or data.get("metadata", {}).get("capture_time") or "") if capture_time: hhmm = capture_time.replace(":", "").zfill(4)[:4] try: t_int = int(hhmm) if t_int < 900: intraday_scope_v2 = "PRE_MARKET" elif t_int < 1530: intraday_scope_v2 = "TRIM_ONLY" else: intraday_scope_v2 = "FULL_SCOPE" except ValueError: intraday_scope_v2 = "FULL_SCOPE" if not is_intraday else "TRIM_ONLY" else: intraday_scope_v2 = "TRIM_ONLY" if is_intraday else "FULL_SCOPE" injected["intraday_scope"] = intraday_scope_v2 # 기존 INTRADAY_RESTRICTED/FULL_EOD 대체 # D1: routing_execution_log는 Section B(sanity 계산) 완료 후 최종 빌드 — 아래 참고 # SPRINT 4: SFG Alert Scalars (Direction SFG) sfg_s = extract_sfg_scalars(hc) injected["sfg_v1"] = sfg_s["sfg_v1"] injected["sfg_broken_count"] = sfg_s["sfg_broken_count"] injected["sfg_failure_rate"] = sfg_s["sfg_failure_rate"] # SPRINT 4: F1 TRADE_QUALITY_SCORER_V1 alpha_hist = data.get("alpha_history", []) or [] tq = calc_trade_quality(alpha_hist) injected["trade_quality_json"] = json.dumps(tq, ensure_ascii=False) # SPRINT 4: F2 PATTERN_BLACKLIST_AUTO_V1 pb = calc_pattern_blacklist(alpha_hist) injected["pattern_blacklist_status"] = pb.get("status", "INACTIVE") injected["pattern_blacklist_json"] = json.dumps(pb, ensure_ascii=False) # SPRINT 4: PCG PORTFOLIO_CORRELATION_GATE_V1 pcg = calc_pcg(brt, acct_map, total_asset, regime) injected["portfolio_correlation_gate_json"] = json.dumps(pcg, ensure_ascii=False) injected["correlation_gate_status"] = pcg["correlation_gate_status"] # ─── B. Python 결정론적 계산 필드 ────────────────────────────────────────── # Inject from computed_harness for k, v in computed_harness.items(): if k != "meta" and k != "per_ticker": injected[k] = v # PROFIT_RATCHET_TIERED_V2 (with APEX_SUPER) best_v2 = "NORMAL" max_ts_v2: int | None = None ratchet_per_ticker: list[dict] = [] for sp_row in sell_prio: ticker = sp_row.get("Ticker", "") if not ticker: continue acct = acct_map.get(ticker, {}) cs = cs_map.get(ticker, {}) close = cs.get("Close") or acct.get("current_price") or 0 avg_cost= acct.get("average_cost") or 0 atr20 = cs.get("ATR20") or 0 highest = acct.get("highest_price_since_entry") or close stop_p = acct.get("stop_price") or 0 profit_pct = (close - avg_cost) / avg_cost * 100 if avg_cost else 0 stage = classify_ratchet_v2(profit_pct) ts = trailing_stop_v2(profit_pct, highest, atr20, stop_p) if STAGE_ORDER.index(stage) > STAGE_ORDER.index(best_v2): best_v2 = stage if ts is not None and (max_ts_v2 is None or ts > max_ts_v2): max_ts_v2 = ts ratchet_per_ticker.append({ "ticker": ticker, "profit_pct": round(profit_pct, 2), "ratchet_stage_v2": stage, "auto_trailing_stop_v2": ts, }) injected["ratchet_stage_v2"] = best_v2 injected["auto_trailing_stop_v2"] = max_ts_v2 if max_ts_v2 is not None else 0 injected["ratchet_v2_per_ticker_json"] = json.dumps( ratchet_per_ticker, ensure_ascii=False) # SELL_PRICE_SANITY_V1 + TICK_NORMALIZER_V1 (Python 정규화 적용) worst_sanity = "PASS" sanity_per_ticker: list[dict] = [] tick_normalized_prices: dict[str, int] = {} for sp_row in sell_prio: ticker = sp_row.get("Ticker", "") sell_limit = sp_row.get("Sell_Limit_Price") or 0 if not (ticker and sell_limit > 0): continue cs = cs_map.get(ticker, {}) acct = acct_map.get(ticker, {}) prev_close = cs.get("PrevClose") or cs.get("Close") or sell_limit stop_p = acct.get("stop_price") # 틱 정규화 적용 후 sanity 재검사 normalized = normalize_tick(sell_limit) tick_normalized_prices[ticker] = normalized result = check_sanity(normalized, stop_p, prev_close, ticker) sanity_per_ticker.append(result) if (SANITY_ORDER.index(result["status"]) > SANITY_ORDER.index(worst_sanity)): worst_sanity = result["status"] injected["sell_price_sanity_status"] = worst_sanity injected["sell_price_sanity_per_ticker_json"] = json.dumps( sanity_per_ticker, ensure_ascii=False) injected["tick_normalized_prices_json"] = json.dumps( tick_normalized_prices, ensure_ascii=False) # RS_VERDICT_V2 — decisions_json에 rs_verdict 주입 (WARN RS-1 해소) bp = parse_field(hc, "buy_permission_json", []) bp_rs_map = {r.get("ticker", ""): str(r.get("rs_verdict", "MARKET")) for r in bp if r.get("ticker")} ash = parse_field(hc, "alpha_shield_json", []) ash_rs_map = {r.get("ticker", ""): r.get("rs_ratio", 1.0) for r in ash if r.get("ticker")} dj = parse_field(hc, "decisions_json", []) if isinstance(dj, list) and dj: for row in dj: if not isinstance(row, dict): continue t = row.get("ticker", "") if "rs_verdict" not in row: rv = bp_rs_map.get(t) if rv is None: # fallback: rs_ratio에서 판정 ratio = ash_rs_map.get(t, 1.0) rv = "LAGGARD" if ratio < 0.80 else "LEADER" if ratio > 1.20 else "MARKET" row["rs_verdict"] = rv injected["decisions_json"] = json.dumps(dj, ensure_ascii=False) # CASH_RECOVERY_OPTIMIZER_V1 (Python-optimized 버전, SPRINT 1 K2 plan 없을 때만) if not injected.get("k2_staged_rebound_sell_json"): shortfall_b = float(hc.get("cash_shortfall_min_krw") or 0) if shortfall_b > 0 and sell_prio: crp_opt = cash_recovery(sell_prio, shortfall_b) injected["cash_recovery_plan_json"] = json.dumps(crp_opt, ensure_ascii=False) # D1: DETERMINISTIC_ROUTING_ENGINE_V1 — 9단계 로그 (Section B 완료 후 빌드) routing_log = [ {"step": 1, "formula_id": "DFG_V1", "status": injected.get("data_freshness_status", "UNKNOWN"), "output_key": "data_freshness_status"}, {"step": 2, "formula_id": "INTRADAY_V1", "status": injected.get("intraday_scope", "UNKNOWN"), "output_key": "intraday_scope"}, {"step": 3, "formula_id": "PORTFOLIO_HEALTH_V1","status": ("CRITICAL" if ph_blocked and any(b.get("severity")=="CRITICAL" for b in ph_blocked) else "WARN" if ph_blocked else "PASS"), "output_key": "portfolio_health_blocked_json"}, {"step": 4, "formula_id": "STOP_BREACH_V1", "status": ("BREACH" if breach_list and any(b.get("stop_breach_gate")=="BREACH" for b in breach_list) else "APPROACHING" if breach_list and any(b.get("stop_breach_gate")=="APPROACHING" for b in breach_list) else "SAFE"), "output_key": "stop_breach_alert_json"}, {"step": 5, "formula_id": "ANTI_CHASE_V1", "status": ("BLOCKED" if ac_list and any(a.get("anti_chase_verdict")=="BLOCK_CHASE" for a in ac_list) else "WAIT" if ac_list and any(a.get("anti_chase_verdict")=="PULLBACK_WAIT" for a in ac_list) else "PASS"), "output_key": "anti_chasing_velocity_json"}, {"step": 6, "formula_id": "CASH_RECOVERY_V1", "status": ("ACTIVE" if float(hc.get("cash_shortfall_min_krw") or 0) > 0 else "NOT_NEEDED"), "output_key": "cash_recovery_plan_json"}, {"step": 7, "formula_id": "TICK_NORM_V1", "status": injected.get("sell_price_sanity_status", "PENDING"), "output_key": "tick_normalized_prices_json"}, {"step": 8, "formula_id": "RS_V2_FUSION", "status": injected.get("brt_verdict", "UNKNOWN"), "output_key": "satellite_candidate_json"}, {"step": 9, "formula_id": "LLM_SERVING", "status": "CLERK_ONLY", "output_key": None}, ] injected["routing_execution_log"] = json.dumps({ "steps": routing_log, "routing_completed": True, "formula_id": "DETERMINISTIC_ROUTING_ENGINE_V1", }, ensure_ascii=False) # Validate_harness_context 호환: 현재 order_blueprint_json과 체크섬을 동기화한다. blueprint_rows = parse_field(hc, "order_blueprint_json", []) if not isinstance(blueprint_rows, list): blueprint_rows = [] checksum = 0 for idx, row in enumerate(blueprint_rows): if not isinstance(row, dict): continue ticker = str(row.get("ticker") or row.get("Ticker") or "") action = str(row.get("action") or row.get("Action") or row.get("final_action") or row.get("Final_Action") or "") checksum += (idx + 1) * (len(ticker) + len(action)) injected["blueprint_row_count"] = len(blueprint_rows) injected["blueprint_checksum"] = checksum injected["rendered_output_checksum"] = checksum injected["rendered_report_checksum"] = checksum # S1: CAPITAL_STYLE_ALLOCATION_V1 — 투자성향별 conviction 주입 # build_capital_style_allocation_v1.py 실행 결과를 하네스에 포함시킨다. # LLM은 이 필드를 인용만 할 수 있으며 재계산 금지 (Direction S1). _csa_path = ROOT / "Temp" / "capital_style_allocation_v1.json" if _csa_path.exists(): try: _csa_data = json.loads(_csa_path.read_text(encoding="utf-8")) injected["capital_style_allocation_json"] = json.dumps(_csa_data, ensure_ascii=False) injected["capital_style_gate"] = str(_csa_data.get("gate", "MISSING")) except Exception: injected["capital_style_allocation_json"] = "{}" injected["capital_style_gate"] = "LOAD_ERROR" else: injected["capital_style_allocation_json"] = "{}" injected["capital_style_gate"] = "NOT_BUILT" # ─── COMPREHENSIVE_PROPOSAL_JSON (HS010-B: 판단 자료 — 항상 출력) ──────── pj = parse_field(hc, "prices_json", []) pj_map = {r.get("ticker", ""): r for r in pj if r.get("ticker")} sq = parse_field(hc, "smart_sell_quantities_json", []) sq_map = {r.get("ticker", ""): r for r in sq if r.get("ticker")} bp = parse_field(hc, "buy_permission_json", []) bp_map = {r.get("ticker", ""): r for r in bp if r.get("ticker")} proposals: list[dict] = [] for t, p in pj_map.items(): s = sq_map.get(t, {}) b = bp_map.get(t, {}) acct_r = acct_map.get(t, {}) name = acct_r.get("name") or b.get("name") or "" proposals.append({ "ticker": t, "name": name, "composite_verdict": b.get("composite_verdict", "UNKNOWN"), "reference_stop_price": p.get("stop_price"), "reference_tp1_price": p.get("tp1_price"), "tp1_state": p.get("tp1_state", "PENDING"), "reference_tp2_price": p.get("tp2_price"), "tp2_state": p.get("tp2_state", "PENDING"), "proposed_immediate_qty": s.get("immediate_sell_qty"), "proposed_staged_qty": s.get("staged_total_qty"), "expected_cash_krw": s.get("expected_cash_recovered_krw", 0), "note": "PROPOSAL — 실행 여부는 사용자 최종 판단", "formula_id": "COMPREHENSIVE_PROPOSAL_V1", }) injected["comprehensive_proposal_json"] = json.dumps(proposals, ensure_ascii=False) # ─── SATELLITE_CANDIDATE_JSON (위성 후보 스크리닝 — 항상 출력) ────────── held_tickers: set[str] = {r.get("ticker", "") for r in acct_snap if r.get("ticker")} cs_all = data.get("core_satellite", []) or [] GRADE_RANK = {"A": 4, "B": 3, "C": 2, "D": 1, "F": 0} candidates: list[dict] = [] for r in cs_all: t = r.get("Ticker", "") if not t or t in held_tickers: continue grade = str(r.get("Candidate_Quality_Grade") or "D").upper() close = r.get("Close") or 0 ma20 = r.get("MA20") or 0 atr20 = r.get("ATR20") or 0 rs_rank= r.get("RS_Rank_20D") action = r.get("Final_Action") or r.get("Allowed_Action") or "HOLD" sector = r.get("Sector") or "" disparity = round(close / ma20 - 1, 4) if ma20 else None candidates.append({ "ticker": t, "name": r.get("Name") or "", "sector": sector, "grade": grade, "close": close, "ma20_disparity_pct": round(disparity * 100, 2) if disparity is not None else None, "atr20": atr20, "rs_rank_20d": rs_rank, "allowed_action": action, "screen_status": ( "WATCH_CANDIDATE" if GRADE_RANK.get(grade, 0) >= 2 else "BELOW_THRESHOLD" ), "formula_id": "SATELLITE_CANDIDATE_SCREEN_V1", }) candidates.sort(key=lambda x: (-GRADE_RANK.get(x["grade"], 0), x.get("ma20_disparity_pct") or 999)) injected["satellite_candidate_json"] = json.dumps(candidates[:15], ensure_ascii=False) injected["satellite_candidate_summary"] = json.dumps({ "total_screened": len(candidates), "watch_candidates": sum(1 for c in candidates if c["screen_status"] == "WATCH_CANDIDATE"), "grade_A_count": sum(1 for c in candidates if c["grade"] == "A"), "grade_B_count": sum(1 for c in candidates if c["grade"] == "B"), "grade_C_count": sum(1 for c in candidates if c["grade"] == "C"), "formula_id": "SATELLITE_CANDIDATE_SCREEN_V1", }, ensure_ascii=False) # ─── LLM_INSTRUCTION 업데이트 — HS010-B/C + D1/D2 지침 추가 ─────────── current_inst = hc.get("llm_instruction", "") inst_str = str(current_inst) additions = [] if "HS010-B" not in inst_str: additions.append( " ▶ [HS010-B] 종합 판단 제안표 필수 출력: comprehensive_proposal_json을 " "'종합 판단 제안표(PROPOSAL)' 표로 항상 출력. PENDING_EXPORT·BLOCKED·DATA_MISSING 상태와 " "무관하게 생략 금지. 판단은 사용자 몫이므로 reference_stop_price·reference_tp1_price·" "tp1_state·reference_tp2_price·tp2_state·proposed_immediate_qty·proposed_staged_qty·" "expected_cash_krw를 그대로 표시. LLM이 가격·수량을 임의 변경하거나 새 수치를 추가하는 것 절대 금지." " ▶ [HS010-C] 위성 후보 스크리닝 표 필수 출력: satellite_candidate_json을 " "'위성 후보 스크리닝(SATELLITE_CANDIDATE_SCREEN_V1)' 표로 항상 출력. " "후보가 0개여도 표를 출력하고 '현재 추가 적합 후보 없음' 명시. " "satellite_candidate_summary.watch_candidates를 항상 표 제목에 병기. " "LLM이 universe 외 종목을 임의 추가하거나 grade를 변경하는 것 금지." ) if "D1-ROUTING" not in inst_str: additions.append( " ▶ [D1-ROUTING] 9단계 결정론적 라우팅 의무: 보고서는 routing_execution_log의 " "9단계 순서(①신선도→②장중판별→③포트폴리오상태→④매도레이더→⑤매수타이밍→" "⑥현금확보→⑦가격정규화→⑧RS/위성→⑨LLM서빙) 결과를 먼저 표 형태로 출력하고 " "이후 분석을 진행한다. routing_execution_log 생략 시 INCOMPLETE_ROUTING_LOG 처리." ) if "D2-LLM" not in inst_str: additions.append( " ▶ [D2-LLM] LLM 8금지(위반 시 INVALID_LLM_OVERRIDE):" " ①미등록공식 지정가/수량 산출 금지" " ②하네스BLOCK 판정 우회('그래도매수') 금지" " ③SELL_PRICE_SANITY INVALID 가격 복원 금지" " ④execution_order 임의변경 금지" " ⑤K2 반등대기 수량을 '현금급함'으로 즉시전환 금지" " ⑥APEX_SUPER 구간 trailing_stop 미병기 금지" " ⑦DISTRIBUTION_CONFIRMED 매수 우회 금지" " ⑧routing_execution_log 생략 금지." ) if "ANTI_CHASE" not in inst_str: additions.append( " ▶ [A2-ANTI_CHASE] anti_chasing_velocity_json의 anti_chase_verdict=BLOCK_CHASE인 " "종목은 당일 신규 BUY 절대 금지. PULLBACK_WAIT는 pullback_entry_trigger_price 도달 전 매수 금지. " "distribution_sell_detector_json의 distribution_verdict=DISTRIBUTION_CONFIRMED인 종목 " "BUY 절대 금지." ) if "K2_REBOUND" not in inst_str: additions.append( " ▶ [K2-REBOUND] cash_recovery_plan_json의 rebound_wait_qty는 " "rebound_trigger_price 도달 전 즉시매도 전환 금지. '현금이 급하니까' 이유로 " "Stage 2 즉시전환 금지. emergency_full_sell=true일 때만 전량 즉시 허용." ) if additions: injected["llm_instruction"] = inst_str + "".join(additions) # ─── 출력 ───────────────────────────────────────────────────────────────── if not quiet: print(f"\n{'필드':<40} {'주입값':<35} 변경") print("-" * 90) for k, v in injected.items(): old = hc.get(k) disp_v = str(v)[:33] + "..." if len(str(v)) > 35 else str(v) changed = "← 신규" if old is None else ("← 수정" if old != v else " 같음") if k.endswith("_json") and len(str(v)) > 35: disp_v = str(v)[:32] + "..." print(f" {k:<38} {disp_v:<35} {changed}") print(f"\n 주입 필드 수: {len(injected)}개") if not dry_run: hc.update(injected) if len(hc_path) == 2: raw[hc_path[0]][hc_path[1]] = hc elif len(hc_path) == 1: raw[hc_path[0]] = hc # Original update in place json_path.write_text( json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") # New output if requested if output_path_str: out_path = Path(output_path_str) if not out_path.is_absolute(): out_path = ROOT / out_path out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text( json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") if quiet: print(f"INJECT_HARNESS_OK: output={out_path.name}") else: print(f" → {out_path.name} 저장 완료") if quiet: print(f"INJECT_HARNESS_OK: {json_path.name} fields={len(injected)} dry_run=0") else: print(f" → {json_path.name} 업데이트 완료\n") else: if quiet: print(f"INJECT_HARNESS_OK: {json_path.name} fields={len(injected)} dry_run=1") else: print(" → [DRY-RUN] 파일 저장 생략\n") return 0 if __name__ == "__main__": raise SystemExit(main())