"""
LLM Decision Engine — the brain of V2.

Philosophy: LLM reads ALL orderflow data and decides.
Deterministic modules prepare structured context (CVD, Volume Profile,
Absorption, Signal Alignment Matrix). Risk engine only validates basics.

This is smarter than V1 because:
1. Context is 10x richer (CVD, VP, stacked imbalances, absorption scores)
2. Signal Alignment Matrix pre-computed — LLM sees PASS/FAIL checklist
3. Symmetry enforced in prompt — no more 80/20 long bias
4. Regime classification included — LLM knows market structure
5. Learning from past trades injected into prompt
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from http.client import RemoteDisconnected
from dataclasses import dataclass, field
from typing import Optional

import requests

from .models import OrderflowSnapshot, MarketRegime, Candle
from .policy import TradingPolicy, alignment_summary, format_alignment, true_side_triggers

log = logging.getLogger(__name__)

# === LLM Config ===
DEFAULT_TIMEOUT = 45
DEFAULT_TEMPERATURE = 0.1  # slight creativity for pattern recognition


SYSTEM_PROMPT = """You are HYPERLIQUID ORDERFLOW EXECUTION BRAIN.

You trade Hyperliquid perpetual futures from structured live market data. Your task is to choose the SINGLE BEST trade across all scanned coins, or WAIT. You are not a signal cheerleader. You are paid to avoid bad entries, fix weak plans before submitting, and only trade when structure, orderflow, and execution quality agree.

## Core Objective
Return one JSON decision:
- `enter` only when a confirmed playbook trigger is already active, the entry is not chasing, the stop survives current OHLC noise, and the target is a real executable destination with at least {min_rr}R after fees.
- `wait` when the setup is early, vague, contradictory, stale, too noisy, too tight, or only good because of one short-term tape burst.

The bot will execute your entry, original stop, and one final target. Break-even and trailing are disabled unless explicitly shown in data/config. Therefore `target_price` must be the final executable target for the chosen `target_mode`, not a random baby scalp and not an unreachable moon target.
- Use `target_mode="reaction"` when direction is right but 1h/4h are mixed, price is in range/chop, or the setup is a sweep/absorption/reversion that should pay quickly.
- Use `target_mode="runner"` only when the trade direction has 15m/1h/4h alignment, fresh tape confirms, and the target is a real HTF/value/swing destination.
- A reaction target is valid when it clears {min_rr}R after fees using the noise-valid stop and is justified by nearby value/structure or a measured OHLC/orderflow extension.
- A runner target is valid only when the larger move is realistic now.

## Data You Receive
For each coin you may see:
- Orderbook and tape: book imbalance, wall skew, stacked bids/asks, tape delta, CVD, large prints, absorption, spread, depth.
- Websocket orderflow: 10s/30s/1m/2m/5m tape windows, book velocity, freshness, OI/funding/premium deltas.
- Technical context: VWAP, VPOC, VAH, VAL, RSI, MACD, ATR, recent 1m/5m/15m candles, 15m/1h/4h regime.
- Computed fields: `levels`, `trigger_state`, `trigger_meta`, `crowding_state`, `noise_state`, `session`.
- CoinGlass liquidation heatmap when available: estimated liquidation clusters above/below price, nearest and largest levels.
- CoinGlass Hobbyist-compatible context when available: exchange 24h liquidations by coin, cross-exchange funding snapshot, and global long/short crowding.
- Recent OHLC lessons from our own trades and missed candidates.

Use only fields that are present. Do not invent exact liquidations, iceberg orders, hidden liquidity, spot-perp premium, news, or off-exchange flow. If CoinGlass says unavailable/unparsed, treat liquidation data as missing. If public liquidation prints or spot-perp premium are not provided, list them under `missing_data` and do not use them as evidence.

## Decision Order
Generate your JSON analysis fields first, then `action` near the end. This matters because the model has thinking disabled and must reason through the output structure.

## Agentic Structured Pipeline
You must simulate three specialist agents in one structured JSON output:

1. `analyst_report`
   - Summarize the best long case, best short case, data quality, and no-trade risks.
   - This agent does NOT choose an order.

2. `trader_proposal`
   - Convert the analyst view into one concrete trade proposal or WAIT.
   - It must name the executable coin/side/entry/stop/target and target mode.
   - It must also say what a realistic 1.5R reaction target would be if the runner is too far.

3. `risk_portfolio_decision`
   - Final judge. It can only return `approve_enter` or `reject_wait`.
   - It must compare the proposal against allowed actions, side arbitration, stop noise, target reachability, HTF conflict, and recent lessons.
   - It must choose `target_mode`: `reaction`, `runner`, or `none`.
   - If the runner target is too ambitious but a realistic {min_rr}R reaction target exists, approve the reaction plan instead of rejecting the whole trade.
   - If direction is weak, opposite side is better, or no noise-valid {min_rr}R reaction target exists, reject.

The final top-level `action` must follow `risk_portfolio_decision.final_action`:
- `approve_enter` -> `action: "enter"`
- `reject_wait` -> `action: "wait"`
The final top-level `target_price` must follow `risk_portfolio_decision.approved_target_price` when that field is present.

### 0. Cross-Coin Ranking Rule
Do not default to BTC/ETH. Rank all coins by live edge quality:
1. confirmed side-specific trigger_state,
2. side alignment magnitude and fresh 30s/1m websocket tape in the same direction,
3. 15m/1h/4h regime alignment,
4. structural target room after a noise-valid stop,
5. spread/depth quality.

A weak BTC/ETH sweep in chop must lose to an altcoin with trend-aligned trigger, stronger tape magnitude, and cleaner target room. If one coin has side trigger + full 15m/1h/4h trend alignment + tape ratio magnitude > 0.50, it is the default candidate unless stop/target/spread/staleness invalidates it.
Use the PRIMARY CANDIDATE RANKING section as your first pass. If the top ranked candidate is not chosen, your WAIT/ENTER reason must name the exact blocker. Do not ignore a trend-aligned altcoin trigger just because BTC/ETH are familiar.
If the top candidate has no concrete stop/target, evaluate the next ranked valid candidate instead of outputting malformed ENTER or generic WAIT.

### 1. Market Regime Gate
Classify each real candidate before choosing:
- Trend continuation: prefer pullback/retest/acceptance in the direction of 15m/1h/4h trend.
- Range: prefer failed pushes, absorption reversals, VWAP/VPOC reversion, and liquidity sweeps at range edges.
- Breakout: require acceptance or retest after the break. A first candle through a level is not enough.
- Chop/stale data: WAIT.

Counter-trend trades must be harder than trend trades. A counter-trend entry needs a completed sweep/reclaim, strong absorption, VWAP/VPOC reclaim, or clear failed-push rejection. Aggressive tape alone is not enough.

### 2. Mandatory Playbook Gate
An `enter` must match exactly one of these playbooks:

1. `trend_pullback_continuation`
   - Trend aligned on at least the traded timeframe plus one higher timeframe.
   - Price pulled back to VWAP/VPOC/value edge/breakout level.
   - Level held or retested.
   - Tape/book flip back with price response.

2. `liquidity_sweep_reclaim`
   - Prior high/low, value edge, or obvious swing is swept.
   - Price reclaims or rejects the level.
   - Follow-through appears after the reclaim/rejection.
   - Do not enter on the sweep candle alone. Recent OHLC losses came from entering ETH/LIT shorts on sell tape before true retest/acceptance.

3. `absorption_reversal`
   - Aggressive buyers/sellers hit a passive wall.
   - Price fails to progress in the aggressive side.
   - Price turns away from the absorbed side.
   - Long requires bid absorption/support response. Short requires ask absorption/resistance response.

4. `vwap_poc_reversion`
   - Market is range/balanced or extended from value.
   - RSI/tape/CVD shows exhaustion or divergence.
   - Price starts rotating back to VWAP/VPOC.
   - Do not fade a strong trend without reclaim/absorption.

5. `breakout_acceptance`
   - Range/high/low/value edge breaks.
   - Price accepts beyond it or retests and holds.
   - Orderflow confirms continuation.
   - If target is too close, search the next structural target. If none exists, WAIT.

If no playbook is confirmed, output WAIT even if alignment is 7/7.

### 3. Signal Alignment And Magnitude
For any entry, require at least {min_alignment}/7 side alignment and judge strength, not just count. A 5/7 with tape ratio -0.79 in a trend-aligned ZEC short can be stronger than a 6/7 BTC long in chop with weak/neutral tape.

LONG alignment:
1. Book bid-heavy: imbalance > {book_threshold}
2. Tape buy aggression: delta > {tape_threshold}
3. Price near/above VWAP or reclaiming VWAP/value
4. MACD bullish
5. RSI {rsi_min}-{rsi_max}, not exhausted
6. Ask absorption <= {absorption_hard_veto}
7. Funding not crowded: < {funding_threshold}

SHORT alignment:
1. Book ask-heavy: imbalance < {book_threshold_neg}
2. Tape sell aggression: delta < {tape_threshold_neg}
3. Price near/below VWAP or losing VWAP/value
4. MACD bearish
5. RSI {rsi_min}-{rsi_max}, not exhausted
6. Bid absorption <= {absorption_hard_veto}
7. Funding not crowded: > {funding_threshold_neg}

A strong 5/7 with clean trigger, clear price response, and good stop/target beats a weak 6/7 where VWAP, absorption, or HTF trend contradicts. A high score with no trigger is still WAIT.

### 3b. Mandatory Side Arbitration
Before any ENTER, compare the chosen side against the opposite side on the same coin.
This is not optional. Recent losses came from selecting the best-looking short/long while ignoring critical opposite-side evidence.

For the chosen side, explicitly list critical weaknesses:
- 15m chop or HTF trend conflict.
- fresh tape only weak/borderline or contradicting 1m/5m tape.
- MACD/CVD against the side.
- price extended into support/resistance/value instead of entering after retest.
- runner target requiring a full move while 4h trend is mixed/opposite.

Rules:
- If chosen side has critical weaknesses and opposite side has a confirmed playbook, choose the opposite side.
- If chosen side has critical weaknesses and opposite side has no confirmed playbook, WAIT.
- Do not enter just because one side has a higher binary alignment score. Direction needs a trigger, price response, and fresh flow.
- For breakdown/breakout acceptance, require actual acceptance/retest/hold. A price merely below/above a level with weak tape is not enough.

### 4. Orderflow Interpretation Rules
- Tape must move price. Buy tape that cannot lift price is possible ask absorption, not a long. Sell tape that cannot push price lower is possible bid absorption, not a short.
- Absorption warning: bid/ask absorption > {absorption_warning}. Hard veto against the absorbed side: > {absorption_hard_veto}.
- Never long into high ask absorption unless price has reclaimed value and ask absorption has failed.
- Never short into high bid absorption unless price has lost value and bid absorption has failed.
- CVD divergence matters only with a level event. Price up + CVD down at resistance is bearish. Price down + CVD up at support is bullish.
- OI/funding/crowding are context, not standalone entries.
- REST tape is weaker than fresh websocket tape. If websocket book/tape are stale or missing, WAIT.
- CoinGlass liquidation clusters are magnets and risk zones, not automatic entries. Use a cluster only with a confirmed playbook trigger, price response, and orderflow confirmation.
- Do not place a stop directly inside a nearby liquidation cluster. For targets, prefer the next structural/liquidation magnet only if R/R and OHLC noise are valid.
- CoinGlass 24h liquidation totals, funding snapshots, and long/short ratios are crowding/regime context only. They can veto weak trades or raise/lower confidence; they cannot replace a trigger.

### 5. Entry Quality
An entry is valid only if `level_reasoning.entry` can name:
- exact trigger level/zone,
- current orderflow confirmation,
- why it is not a current-mid chase.

Bad entry phrases that should cause WAIT:
- "fresh tape only"
- "book ask-heavy/bid-heavy only"
- "momentum"
- "best candidate" without a level event
- "sweep happened" without reclaim/rejection/acceptance

### 6. Stop Quality
The stop must be structural AND noise-valid:
- Use ATR and recent 1m/5m ranges shown in data.
- Minimum distance: ATR(14) * {stop_atr_multiplier} when ATR is available.
- Minimum OHLC noise check: stop distance >= {min_stop_noise_multiple}x `dynamicStopFloor`.
- A stop merely equal to the noise floor is a wick-tag zone. Widen it or WAIT.
- Stop must be beyond invalidation: failed high/low, reclaimed/lost value level, swing high/low, VWAP/VPOC/VAH/VAL, or absorption flip.
- Do not place stops inside normal altcoin noise just to get prettier R:R.

### 7. Target Quality And Missed Opportunity Rule
Targets must be structural:
- VWAP, VPOC, VAH, VAL
- prior high/low
- range edge
- swing high/low
- liquidity pocket
- imbalance fill
- next support/resistance from candles/levels

Minimum: rr_after_noise_stop >= {min_rr}R after {fee_round_trip_bps} bps round-trip fees.
Preferred: {runner_target_rr}R or more when the next structural level supports it, capped around {max_target_rr}R unless a very clear HTF level is closer.
Target mode:
- `reaction`: choose the first realistic destination that clears {min_rr}R after fees. Use this for chop/range, mixed HTF, sweep reclaim, absorption reversal, VWAP/VPOC reversion, or strong short-term flow without full HTF alignment.
- `runner`: prefer at least {min_target_pct} of entry price or about {min_target_atr_multiple}x ATR when a real 1h/4h/session/value target supports it. Use this only with 15m/1h/4h trend alignment and fresh tape confirmation.
- Do not choose the nearest minor round number when a real value/swing/liquidity target or measured {min_rr}R reaction extension is more executable.
- If 4h is mixed/opposite or fresh tape is only borderline, do not force runner. Pick a reaction target that clears {min_rr}R, or WAIT if even that is not available.
- Because execution has a single TP, `target_price` must equal the approved target for the chosen mode: reaction target for `reaction`, full-move target for `runner`.
- This is an LLM planning preference, not permission to invent levels. Deterministic hard enforcement is `enforce_min_target_distance={enforce_min_target_distance}`.

Recent missed-candidate OHLC lesson:
If a candidate has a confirmed trigger but the nearest obvious target makes R/R too small, do NOT simply output a tiny target and do NOT blindly WAIT. First search for a valid {min_rr}R reaction target, then a runner target only if HTF trend supports it. If neither exists, WAIT.

### 8. Same-Coin Failure Memory
Use the injected OHLC lessons:
- If recent similar coin:side shows `wrong_quickly_no_mfe`, lower confidence or WAIT until the trigger quality is clearly better.
- If recent similar rejects later hit target, repair target/stop selection rather than loosening every gate.
- If recent rejects later hit stop, keep the rejection strict.
- Manual/legacy closes are not clean bot edge. Do not overfit to them.

### 9. Position And Risk Context
- Account equity: {equity}
- Fixed margin per trade: {margin}
- Max leverage: {leverage}x
- Notional = margin * leverage
- Spread must be <= {spread_max_bps} bps.
- One best trade only. If two are similar, choose the one with cleaner trigger, stronger magnitude, better spread/depth, and more structural target room.

## Hard WAIT Conditions
Return WAIT if any condition applies:
- No confirmed playbook trigger.
- All side-relevant `trigger_state` keys are false.
- Entry would chase current mid after a move.
- Book/tape contradict and price response is unclear.
- Absorption contradicts the side.
- Websocket data stale or missing for a live orderflow entry.
- Spread > {spread_max_bps} bps.
- RSI exhausted against the side: long RSI > 70, short RSI < 30, unless clear absorption/reclaim proves reversal.
- Market regime is choppy and no edge-level event exists.
- Stop cannot be made noise-valid.
- Target cannot reach 1.5R after a noise-valid stop.
- You cannot explain entry, stop, and target as concrete levels.
- Your own analysis says R/R, target room, or the structural target is insufficient, borderline, too small, or not provided. In that case final `action` must be `wait`.
- Target is only a nearby round number / psychological level without provided structure, value, swing, liquidity, or imbalance support.

## Output JSON Schema
Return raw JSON only. No markdown. No comments. No text before or after JSON.
Keep every string concise: one sentence, max 30 words. Long prose risks truncation and becomes invalid JSON.

For ENTER, output this shape:
{{
  "analysis_steps": {{
    "regime": "15m/1h/4h regime and why this playbook fits",
    "orderflow": "book/tape/absorption/price response summary",
    "cvd_and_oi": "CVD, OI, funding, crowding context, with missing data listed separately",
    "alignment_score": "5/7 short alignment with magnitude comment",
    "missing_data": ["public_liquidation_prints", "spot_perp_premium"]
  }},
  "alignment_magnitude": {{
    "side": "long or short",
    "score": "5/7",
    "strength": 0.00,
    "strongest_evidence": ["specific numeric evidence"],
    "weakest_evidence": ["specific weak/failed condition"]
  }},
  "analyst_report": {{
    "best_long": "coin, trigger, strength, blocker if any",
    "best_short": "coin, trigger, strength, blocker if any",
    "data_quality": "websocket freshness/spread/liquidation data availability",
    "no_trade_risks": ["critical risks visible before entry"]
  }},
  "trader_proposal": {{
    "proposed_action": "enter or wait",
    "coin": "BTC",
    "side": "short",
    "entry_logic": "exact executable entry logic",
    "stop_logic": "structural invalidation and noise check",
    "target_logic": "target mode and destination",
    "target_mode": "reaction or runner",
    "reaction_target_if_runner_fails": "nearest realistic {min_rr}R/value reaction target",
    "proposal_weaknesses": ["known weaknesses or empty"]
  }},
  "risk_portfolio_decision": {{
    "final_action": "approve_enter",
    "target_mode": "reaction or runner",
    "approved_target_price": 76000.0,
    "allowed_action_check": "chosen action is in allowed signal actions and exposure is acceptable",
    "side_verdict": "chosen side remains better after opposite-side comparison",
    "stop_target_verdict": "stop is noise-valid and target is reachable; runner not too far for regime",
    "memory_verdict": "recent lessons do not match a losing pattern",
    "decision_reason": "why final judge approves or rejects"
  }},
  "side_arbitration": {{
    "chosen_side": "short",
    "opposite_side": "long",
    "chosen_critical_weaknesses": ["specific weakness or empty"],
    "opposite_case": "whether opposite has a confirmed playbook and why",
    "direction_verdict": "chosen_valid or opposite_better or wait_no_clean_side",
    "why_not_opposite": "specific reason the opposite side is not better"
  }},
  "playbook_match": {{
    "playbook": "trend_pullback_continuation",
    "trigger_present": true,
    "trigger_level": "exact level/zone and true trigger_state key",
    "price_response": "how price responded after the level event",
    "anti_chase_check": "why this is not current-mid chasing",
    "self_reject_if": "specific condition that would make this WAIT"
  }},
  "trade_plan": {{
    "thesis": "why this orderflow setup should work",
    "entry_trigger": "exact confirmation already present now",
    "invalidation": "price/flow condition that proves thesis wrong",
    "management": "No automatic break-even/trailing/profit-lock. Hold original TP or original SL unless manually closed.",
    "no_reentry_until": "new condition required after stop/failure"
  }},
  "level_reasoning": {{
    "entry": "exact trigger level + current orderflow confirmation + not chase",
    "stop": "structural invalidation + ATR/noise validation",
    "target": "structural target + why it can be reached now"
  }},
  "exit_plan": {{
    "stop_invalidation_level": "exact stop/invalidation level and structure",
    "stop_noise_check": "ATR/noise calculation in words using provided data",
    "target_level": "exact target price + structure",
    "target_reason": "why target attracts/stops price now",
    "target1_level": "optional nearest first reaction level; informational only, bot will not scale out",
    "runner_target_level": "full-move target if runner mode is valid; otherwise informational only",
    "full_move_plan": "why chosen target mode is reaction or runner and why TP is reachable",
    "rr_after_noise_stop": "R multiple after noise-valid stop and fees",
    "if_wrong_fast": "immediate adverse flow/price response to watch"
  }},
  "action": "enter",
  "coin": "BTC",
  "side": "short",
  "confidence": 0.75,
  "entry_price": 77000.0,
  "stop_price": 77500.0,
  "target_price": 76000.0,
  "setup_type": "trend_pullback_continuation",
  "evidence": ["specific numeric evidence"],
  "reason": "one concise reason",
  "regime_note": "short regime note",
  "risk_assessment": "R/R, stop/noise, spread, fees",
  "best_candidate": {{}}
}}

For WAIT, output this shape:
{{
  "analysis_steps": {{
    "regime": "why no regime/playbook edge is clean",
    "orderflow": "best candidate and blocker",
    "cvd_and_oi": "CVD/OI/crowding context",
    "alignment_score": "best candidate score",
    "missing_data": ["public_liquidation_prints", "spot_perp_premium"]
  }},
  "alignment_magnitude": {{
    "side": "none",
    "score": "best candidate score",
    "strength": 0.00,
    "strongest_evidence": [],
    "weakest_evidence": ["main blocker"]
  }},
  "analyst_report": {{
    "best_long": "best long case and blocker",
    "best_short": "best short case and blocker",
    "data_quality": "websocket freshness/spread/liquidation data availability",
    "no_trade_risks": ["why no trade is clean"]
  }},
  "trader_proposal": {{
    "proposed_action": "wait",
    "coin": "",
    "side": "",
    "entry_logic": "why no executable entry exists",
    "stop_logic": "",
    "target_logic": "",
    "target_mode": "none",
    "reaction_target_if_runner_fails": "",
    "proposal_weaknesses": ["main blocker"]
  }},
  "risk_portfolio_decision": {{
    "final_action": "reject_wait",
    "target_mode": "none",
    "approved_target_price": 0.0,
    "allowed_action_check": "no allowed action clears all gates",
    "side_verdict": "no clean side",
    "stop_target_verdict": "target/stop or trigger invalid",
    "memory_verdict": "recent lessons or current data reject",
    "decision_reason": "specific reason for waiting"
  }},
  "side_arbitration": {{
    "chosen_side": "none",
    "opposite_side": "none",
    "chosen_critical_weaknesses": ["why the best side is not clean"],
    "opposite_case": "why the opposite side also lacks a confirmed playbook",
    "direction_verdict": "wait_no_clean_side",
    "why_not_opposite": "opposite side blocker"
  }},
  "playbook_match": {{
    "playbook": "None",
    "trigger_present": false,
    "trigger_level": "",
    "price_response": "",
    "anti_chase_check": "why entering would chase or be early",
    "self_reject_if": "what confirmation is missing"
  }},
  "trade_plan": {{}},
  "level_reasoning": {{}},
  "exit_plan": {{}},
  "action": "wait",
  "reason": "specific reason for waiting",
  "best_candidate": {{"coin": "ETH", "side": "short", "alignment": "6/7", "blocker": "no retest/acceptance or target too small"}}
}}

Do not output `enter` and rely on downstream gates to reject it. If the plan would be rejected, output WAIT yourself and explain the blocker in JSON."""


@dataclass
class LLMTradeDecision:
    """LLM's complete trade decision."""
    action: str              # "enter" or "wait"
    coin: str = ""
    side: str = ""
    confidence: float = 0.0
    entry_price: float = 0.0
    stop_price: float = 0.0
    target_price: float = 0.0
    setup_type: str = ""
    evidence: list[str] = field(default_factory=list)
    reason: str = ""
    regime_note: str = ""
    risk_assessment: str = ""
    analysis_steps: dict = field(default_factory=dict)
    playbook_match: dict = field(default_factory=dict)
    level_reasoning: dict = field(default_factory=dict)
    exit_plan: dict = field(default_factory=dict)
    trade_plan: dict = field(default_factory=dict)
    best_candidate: dict = field(default_factory=dict)
    alignment_magnitude: dict = field(default_factory=dict)
    analyst_report: dict = field(default_factory=dict)
    trader_proposal: dict = field(default_factory=dict)
    risk_portfolio_decision: dict = field(default_factory=dict)
    side_arbitration: dict = field(default_factory=dict)
    raw_response: str = ""


def _fmt_window(window: object) -> str:
    if not isinstance(window, dict):
        return "n/a"
    return (
        f"r={float(window.get('delta_ratio') or 0):+.2f} "
        f"d=${float(window.get('delta_usd') or 0):,.0f} "
        f"px={float(window.get('price_change_pct') or 0):+.2%} "
        f"tps={float(window.get('trades_per_sec') or 0):.1f}"
    )


def _multi_timeframe_ohlc_context(cd: dict) -> str:
    """Compact recent OHLC rows so the LLM can judge entries, stops, and targets."""
    rows = []
    specs = (
        ("1m", cd.get("candles_1m") or [], 8),
        ("5m", cd.get("candles_5m") or [], 8),
        ("15m", cd.get("candles_15m") or [], 8),
        ("1h", cd.get("candles_1h") or [], 6),
        ("4h", cd.get("candles_4h") or [], 4),
    )
    for label, candles, limit in specs:
        if not candles:
            continue
        rows.append(f"  {label}: {_compact_ohlc(candles[-limit:])}")
    return "\n".join(rows)


def _primary_candidate_ranking_context(coin_data: list[dict], policy: TradingPolicy) -> str:
    rows = []
    for cd in coin_data:
        coin = cd.get("coin", "")
        of = cd.get("of")
        regime = cd.get("regime")
        if not coin or of is None:
            continue
        trigger_state = cd.get("trigger_state") or {}
        ws = cd.get("ws_summary") or {}
        windows = ws.get("tape_windows") if isinstance(ws.get("tape_windows"), dict) else {}
        w30 = windows.get("30s") if isinstance(windows.get("30s"), dict) else {}
        w1m = windows.get("1m") if isinstance(windows.get("1m"), dict) else {}
        d30 = float(w30.get("delta_ratio") or ws.get("delta_ratio_30s") or 0.0)
        d1m = float(w1m.get("delta_ratio") or 0.0)
        spread = float(getattr(of, "spread_bps", 999.0) or 999.0)
        for side in ("long", "short"):
            triggers = true_side_triggers(side, trigger_state)
            if not triggers:
                continue
            alignment = alignment_summary(of, side, policy)
            passing = int(alignment.get("passing") or 0)
            strength = float(alignment.get("strength") or 0.0)
            trend_15m = str(getattr(regime, "trend_15m", "") or "")
            trend_1h = str(getattr(regime, "trend_1h", "") or "")
            if side == "long":
                full_trend = trend_15m == "up" and trend_1h == "up" and str(getattr(regime, "trend_4h", "") or "") == "up"
                trend_ok = trend_15m == "up" or trend_1h == "up"
                tape_ok = d30 >= policy.trigger_response_delta_threshold or d1m >= policy.trigger_response_delta_threshold
                strong_tape = max(d30, d1m)
            else:
                full_trend = trend_15m == "down" and trend_1h == "down" and str(getattr(regime, "trend_4h", "") or "") == "down"
                trend_ok = trend_15m == "down" or trend_1h == "down"
                tape_ok = d30 <= -policy.trigger_response_delta_threshold or d1m <= -policy.trigger_response_delta_threshold
                strong_tape = max(abs(min(d30, 0.0)), abs(min(d1m, 0.0)))
            score = 4.0 + passing + strength
            if full_trend:
                score += 4.0
            if trend_ok:
                score += 2.0
            if tape_ok:
                score += 2.0
            if strong_tape >= 0.50:
                score += 1.5
            if spread <= policy.spread_max_bps:
                score += 1.0
            rows.append(
                {
                    "score": score,
                    "coin": coin,
                    "side": side,
                    "triggers": triggers,
                    "alignment": alignment.get("score"),
                    "strength": strength,
                    "trend": f"15m={trend_15m} 1h={trend_1h} 4h={getattr(regime, 'trend_4h', '')}",
                    "d30": d30,
                    "d1m": d1m,
                    "spread": spread,
                    "must_eval": trend_ok and tape_ok and passing >= policy.min_alignment_score,
                    "full_trend": full_trend,
                }
            )
    if not rows:
        return ""
    rows.sort(key=lambda row: row["score"], reverse=True)
    lines = []
    for idx, row in enumerate(rows[:8], 1):
        lines.append(
            f"  #{idx} {row['coin']} {row['side']} score={row['score']:.2f} "
            f"trigger={row['triggers']} align={row['alignment']} strength={row['strength']:.2f} "
            f"tape30={row['d30']:+.2f} tape1m={row['d1m']:+.2f} spread={row['spread']:.2f}bps "
            f"{row['trend']} full_trend={row['full_trend']} must_eval={row['must_eval']}"
        )
    return "\n".join(lines)


def _allowed_actions_context(coin_data: list[dict], policy: TradingPolicy) -> str:
    """Compact prevalidated action hints for the risk/portfolio judge."""
    rows = []
    for cd in coin_data:
        coin = cd.get("coin", "")
        of = cd.get("of")
        if of is None:
            continue
        ws = cd.get("ws_summary") or {}
        trigger_state = cd.get("trigger_state") or {}
        stale = max(float(ws.get("book_stale_s") or 9999), float(ws.get("tape_stale_s") or 9999))
        spread_ok = float(getattr(of, "spread_bps", 999.0) or 999.0) <= float(policy.spread_max_bps)
        fresh_ok = stale <= 20.0
        actions = []
        blockers = []
        for side in ("long", "short"):
            summary = alignment_summary(of, side, policy)
            triggers = true_side_triggers(side, trigger_state)
            allowed = bool(triggers) and int(summary["passing"]) >= int(policy.min_alignment_score) and spread_ok and fresh_ok
            label = (
                f"{side}:{'allowed' if allowed else 'blocked'} "
                f"align={summary['score']} strength={summary['strength']:.2f} "
                f"triggers={triggers or 'none'}"
            )
            actions.append(label)
            if not allowed:
                side_blockers = []
                if not triggers:
                    side_blockers.append("no_side_trigger")
                if int(summary["passing"]) < int(policy.min_alignment_score):
                    side_blockers.append("weak_alignment")
                if not spread_ok:
                    side_blockers.append("wide_spread")
                if not fresh_ok:
                    side_blockers.append("stale_ws")
                blockers.append(f"{side}={'+'.join(side_blockers) or 'blocked'}")
        rows.append(f"  {coin}: " + " | ".join(actions) + " | blockers: " + "; ".join(blockers))
    return "\n".join(rows)


def _compact_ohlc(candles: list[Candle]) -> str:
    parts = []
    for candle in candles:
        direction = "up" if candle.close > candle.open else "down" if candle.close < candle.open else "flat"
        body_pct = abs(candle.close - candle.open) / candle.open * 100 if candle.open else 0.0
        range_pct = candle.range / candle.open * 100 if candle.open else 0.0
        parts.append(
            f"{direction} o={candle.open:.6g} h={candle.high:.6g} "
            f"l={candle.low:.6g} c={candle.close:.6g} b={body_pct:.2f}% r={range_pct:.2f}%"
        )
    return " | ".join(parts)


def _level_candidate_context(cd: dict, policy: TradingPolicy) -> str:
    """Show usable structural destinations as distances/R multiples, not just names."""
    of = cd.get("of")
    if of is None or not getattr(of, "mid", 0):
        return ""
    entry = float(of.mid)
    levels = _candidate_levels(cd)
    if not levels:
        return ""
    noise = cd.get("noise_state") or {}
    floor = float(noise.get("dynamic_stop_floor") or 0.0)
    stop_floor = floor * float(policy.min_stop_noise_multiple or 0.0) if floor > 0 else 0.0
    fee_floor = entry * float(policy.fee_round_trip_bps or 0.0) / 10000.0
    reaction_floor = stop_floor * float(policy.min_rr or 0.0) + fee_floor
    target_floor = max(
        entry * float(policy.min_target_pct or 0.0),
        float(noise.get("atr14") or getattr(of, "atr_14", 0.0) or 0.0) * float(policy.min_target_atr_multiple or 0.0),
    )
    runner_floor = max(reaction_floor, target_floor, stop_floor * float(policy.runner_target_rr or 0.0))

    lines = [
        f"  planning_mid={entry:.8g} stopNoiseMin={stop_floor:.8g} "
        f"reactionGuide={reaction_floor:.8g} targetGuide={target_floor:.8g} runnerGuide={runner_floor:.8g} "
        f"runnerRR={policy.runner_target_rr:.2f} maxRR={policy.max_target_rr:.2f} "
        f"enforceTargetGuide={policy.enforce_min_target_distance}"
    ]
    lines.append("  longTargets: " + _format_level_candidates(entry, levels, "long_target", stop_floor, target_floor, runner_floor))
    lines.append("  shortTargets: " + _format_level_candidates(entry, levels, "short_target", stop_floor, target_floor, runner_floor))
    lines.append("  longReactionTargets: " + _format_level_candidates(entry, levels, "long_reaction_target", stop_floor, reaction_floor, runner_floor))
    lines.append("  shortReactionTargets: " + _format_level_candidates(entry, levels, "short_reaction_target", stop_floor, reaction_floor, runner_floor))
    lines.append("  longRunnerTargets: " + _format_level_candidates(entry, levels, "long_runner_target", stop_floor, target_floor, runner_floor))
    lines.append("  shortRunnerTargets: " + _format_level_candidates(entry, levels, "short_runner_target", stop_floor, target_floor, runner_floor))
    lines.append("  longStops: " + _format_level_candidates(entry, levels, "long_stop", stop_floor, target_floor, runner_floor))
    lines.append("  shortStops: " + _format_level_candidates(entry, levels, "short_stop", stop_floor, target_floor, runner_floor))
    return "\n".join(lines)


def _liquidation_proxy_context(cd: dict) -> str:
    """Estimate likely stop/liquidation pressure without pretending exact heatmap data."""
    of = cd.get("of")
    if of is None or not getattr(of, "mid", 0):
        return ""
    entry = float(of.mid)
    ws = cd.get("ws_summary") or {}
    levels = _candidate_levels(cd)
    tape_windows = ws.get("tape_windows") if isinstance(ws.get("tape_windows"), dict) else {}
    w5 = tape_windows.get("5m") if isinstance(tape_windows.get("5m"), dict) else {}
    w2 = tape_windows.get("2m") if isinstance(tape_windows.get("2m"), dict) else {}
    price_change_5m = float(w5.get("price_change_pct") or w2.get("price_change_pct") or 0.0)
    oi_delta_5m = float(ws.get("oi_delta_5m") or 0.0)
    oi_delta_15m = float(ws.get("oi_delta_15m") or 0.0)
    funding = float(ws.get("funding_ws") or getattr(of, "funding_rate", 0.0) or 0.0)
    premium_delta = float(ws.get("premium_delta_5m") or 0.0)
    large_buy = int(ws.get("large_buy_prints_2m") or 0)
    large_sell = int(ws.get("large_sell_prints_2m") or 0)

    pressure = _oi_price_pressure(price_change_5m, oi_delta_5m)
    long_pools = _pool_candidates(entry, levels, above=False)
    short_pools = _pool_candidates(entry, levels, above=True)
    bands = _leverage_sensitivity_bands(entry)

    return "\n".join(
        [
            "  Exact heatmap unavailable; use as proxy only.",
            (
                "  OI/Price: "
                f"price5m={price_change_5m:+.2%} oi5m={oi_delta_5m:+.2%} "
                f"oi15m={oi_delta_15m:+.2%} pressure={pressure}"
            ),
            (
                "  Crowding: "
                f"funding={funding:+.8f} premium5m={premium_delta:+.8f} "
                f"largeB/S={large_buy}/{large_sell}"
            ),
            f"  Long stop/liquidation proxy below: {long_pools}",
            f"  Short stop/liquidation proxy above: {short_pools}",
            f"  New-position leverage sensitivity bands: {bands}",
        ]
    )


def _oi_price_pressure(price_change: float, oi_delta: float) -> str:
    if price_change > 0.001 and oi_delta > 0.001:
        return "new_longs_or_short_defense; downside long-liquidation pool may build below"
    if price_change > 0.001 and oi_delta < -0.001:
        return "short_covering_or_short_liquidation; squeeze may be mature if tape fades"
    if price_change < -0.001 and oi_delta > 0.001:
        return "new_shorts_or_long_defense; upside short-liquidation pool may build above"
    if price_change < -0.001 and oi_delta < -0.001:
        return "long_deleveraging_or_long_liquidation; downside move may be exhaustion if selling stalls"
    return "neutral_or_unclear"


def _pool_candidates(entry: float, levels: list[tuple[float, str]], *, above: bool) -> str:
    if above:
        keys = ("high", "swing", "vah", "vwap", "prior")
        candidates = [(px, name) for px, name in levels if px > entry and any(key in name.lower() for key in keys)]
        candidates.sort(key=lambda item: item[0] - entry)
    else:
        keys = ("low", "swing", "val", "vwap", "prior")
        candidates = [(px, name) for px, name in levels if 0 < px < entry and any(key in name.lower() for key in keys)]
        candidates.sort(key=lambda item: entry - item[0])
    if not candidates:
        return "none"
    labels = []
    for px, name in candidates[:5]:
        dist = abs(px - entry)
        pct = dist / entry if entry else 0.0
        proximity = "near" if pct <= 0.005 else "mid" if pct <= 0.02 else "far"
        labels.append(f"{name}@{px:.8g} dist={dist:.8g} pct={pct:.2%} {proximity}")
    return "; ".join(labels)


def _leverage_sensitivity_bands(entry: float) -> str:
    # Approximate adverse move bands for positions opened near current price.
    # Real liquidation depends on entry, margin mode, maintenance margin, and account equity.
    bands = {
        "50x": 0.018,
        "20x": 0.045,
        "10x": 0.090,
    }
    labels = []
    for lev, move in bands.items():
        labels.append(f"{lev}: long~{entry * (1 - move):.8g} short~{entry * (1 + move):.8g}")
    return "; ".join(labels)


def _candidate_levels(cd: dict) -> list[tuple[float, str]]:
    levels = dict(cd.get("levels") or {})
    of = cd.get("of")
    if of is not None:
        levels.setdefault("vwap", getattr(of, "vwap", 0.0))
        levels.setdefault("vpoc", getattr(of, "vpoc", 0.0))
        levels.setdefault("vah", getattr(of, "vah", 0.0))
        levels.setdefault("val", getattr(of, "val", 0.0))
    for key, label, limit in (
        ("candles_15m", "15m", 24),
        ("candles_1h", "1h", 24),
        ("candles_4h", "4h", 18),
    ):
        candles = list(cd.get(key) or [])[-limit:]
        if not candles:
            continue
        highs = [float(getattr(candle, "high", 0.0) or 0.0) for candle in candles]
        lows = [float(getattr(candle, "low", 0.0) or 0.0) for candle in candles]
        highs = [px for px in highs if px > 0]
        lows = [px for px in lows if px > 0]
        if highs:
            levels.setdefault(f"{label}_recent_high", max(highs))
        if lows:
            levels.setdefault(f"{label}_recent_low", min(lows))
        prior = candles[:-1]
        if prior:
            prior_highs = [float(getattr(candle, "high", 0.0) or 0.0) for candle in prior]
            prior_lows = [float(getattr(candle, "low", 0.0) or 0.0) for candle in prior]
            prior_highs = [px for px in prior_highs if px > 0]
            prior_lows = [px for px in prior_lows if px > 0]
            if prior_highs:
                levels.setdefault(f"{label}_prior_high", max(prior_highs))
            if prior_lows:
                levels.setdefault(f"{label}_prior_low", min(prior_lows))
    out: list[tuple[float, str]] = []
    for name, value in levels.items():
        try:
            px = float(value)
        except (TypeError, ValueError):
            continue
        if px > 0:
            out.append((px, str(name)))
    unique: dict[tuple[float, str], tuple[float, str]] = {}
    for px, name in out:
        unique[(round(px, 8), name)] = (px, name)
    return list(unique.values())


def _format_level_candidates(
    entry: float,
    levels: list[tuple[float, str]],
    kind: str,
    stop_floor: float,
    target_floor: float = 0.0,
    runner_floor: float = 0.0,
) -> str:
    if kind == "long_target":
        candidates = [(px, name) for px, name in levels if px > entry]
        candidates.sort(key=lambda item: item[0] - entry)
    elif kind == "short_target":
        candidates = [(px, name) for px, name in levels if 0 < px < entry]
        candidates.sort(key=lambda item: entry - item[0])
    elif kind == "long_reaction_target":
        min_dist = float(target_floor or 0.0)
        candidates = [(px, name) for px, name in levels if px >= entry + min_dist]
        candidates.sort(key=lambda item: item[0] - entry)
    elif kind == "short_reaction_target":
        min_dist = float(target_floor or 0.0)
        candidates = [(px, name) for px, name in levels if 0 < px <= entry - min_dist]
        candidates.sort(key=lambda item: entry - item[0])
    elif kind == "long_runner_target":
        min_dist = max(float(target_floor or 0.0), float(runner_floor or 0.0))
        candidates = [(px, name) for px, name in levels if px >= entry + min_dist]
        candidates.sort(key=lambda item: item[0] - entry)
    elif kind == "short_runner_target":
        min_dist = max(float(target_floor or 0.0), float(runner_floor or 0.0))
        candidates = [(px, name) for px, name in levels if 0 < px <= entry - min_dist]
        candidates.sort(key=lambda item: entry - item[0])
    elif kind == "long_stop":
        candidates = [(px, name) for px, name in levels if 0 < px < entry]
        candidates.sort(key=lambda item: entry - item[0])
    else:
        candidates = [(px, name) for px, name in levels if px > entry]
        candidates.sort(key=lambda item: item[0] - entry)
    if not candidates:
        return "none"
    return "; ".join(_candidate_label(entry, px, name, stop_floor) for px, name in candidates[:8])


def _candidate_label(entry: float, px: float, name: str, stop_floor: float) -> str:
    dist = abs(px - entry)
    pct = dist / entry if entry else 0.0
    r_noise = dist / stop_floor if stop_floor > 0 else 0.0
    return f"{name}@{px:.8g} dist={dist:.8g} pct={pct:.2%} noiseR={r_noise:.2f}"


class LLMBrain:
    """
    LLM as the primary decision maker.
    Receives structured orderflow context for all coins, returns one decision.
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = "https://api.deepseek.com",
        model: str = "deepseek-v4-flash",
        temperature: float = DEFAULT_TEMPERATURE,
        timeout: float = DEFAULT_TIMEOUT,
        thinking: str = "disabled",
        equity: float = 100.0,
        margin: float = 30.0,
        leverage: int = 20,
        policy: TradingPolicy | None = None,
        ohlc_lessons_path: str | None = None,
    ):
        self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY", "")
        self.base_url = base_url
        self.model = model
        self.temperature = temperature
        self.timeout = timeout
        self.thinking = thinking
        self.equity = equity
        self.margin = margin
        self.leverage = leverage
        self.policy = policy or TradingPolicy()
        self.ohlc_lessons_path = ohlc_lessons_path
        self.session = requests.Session()
        self._last_call = 0

    @property
    def available(self) -> bool:
        return bool(self.api_key)

    def decide(
        self,
        coin_data: list[dict],
    ) -> LLMTradeDecision:
        """
        Feed all coin orderflow data to LLM, get one decision.

        coin_data: list of dicts with keys:
            coin, of (OrderflowSnapshot), regime (MarketRegime),
            candles_recent (list of candle summaries)
        """
        if not self.available:
            log.warning("LLM not available (no API key)")
            return LLMTradeDecision(action="wait", reason="LLM unavailable")

        prompt = self._build_prompt(coin_data)

        # Rate limit: max 1 call per 30 seconds
        elapsed = time.time() - self._last_call
        if elapsed < 30:
            wait = 30 - elapsed
            log.info(f"LLM rate limit: waiting {wait:.0f}s")
            time.sleep(wait)

        try:
            raw = self._call(prompt)
            self._last_call = time.time()
            return self._parse(raw)
        except Exception as e:
            log.error(f"LLM call failed: {e}")
            return LLMTradeDecision(action="wait", reason=f"LLM error: {e}")

    def _build_prompt(self, coin_data: list[dict]) -> str:
        parts = [
            f"MARKET DATA FOR {len(coin_data)} COINS\n",
            "DATA AVAILABILITY:",
            "- Public liquidation prints: NOT PROVIDED. Do not claim exact liquidation events.",
            "- Exact public liquidation heatmap/position liquidation levels: NOT PROVIDED. Use only the liquidation/stop-pool proxy if present.",
            "- Hyperliquid spot-vs-perp premium: NOT PROVIDED. Do not use spot/perp arbitrage as evidence.",
            "- Available proxy context: OI delta, funding/premium, large prints, CVD, book velocity, tape, candles, VWAP/volume profile.",
            "",
            "SHARED STRATEGY POLICY:",
            json.dumps(self.policy.as_dict(), sort_keys=True),
            "",
        ]
        lessons = self._load_ohlc_lessons()
        if lessons:
            parts.extend([
                "RECENT OHLC LESSONS FROM OUR OWN BOT OUTCOMES:",
                json.dumps(lessons, ensure_ascii=False, sort_keys=True)[:3500],
                "Apply these lessons before selecting a trade. If a candidate matches a recent losing pattern, WAIT unless current data shows a concrete reason the pattern is different.",
                "For missed candidates where first_hit=target, do not blindly loosen gates. Instead, repair the plan: use a noise-valid stop and a reachable reaction or runner target. If that still cannot reach valid R:R, WAIT.",
                "",
            ])
        ranking = _primary_candidate_ranking_context(coin_data, self.policy)
        if ranking:
            parts.extend([
                "PRIMARY CANDIDATE RANKING (computed from trigger_state, alignment, regime, tape, spread):",
                ranking,
                "You must evaluate these ranked candidates first. Prefer the top valid candidate unless a concrete stop/target/spread/staleness blocker exists.",
                "",
            ])
        allowed_actions = _allowed_actions_context(coin_data, self.policy)
        if allowed_actions:
            parts.extend([
                "ALLOWED SIGNAL ACTIONS FOR RISK/PORTFOLIO JUDGE:",
                allowed_actions,
                "Risk/Portfolio may only approve an ENTER if the matching coin side is marked allowed here and stop/target/recent-lessons also pass.",
                "",
            ])

        for cd in coin_data:
            coin = cd["coin"]
            of: OrderflowSnapshot = cd["of"]
            regime: MarketRegime = cd["regime"]
            candles_summary = cd.get("candles_summary", "")
            ws = cd.get("ws_summary") or {}
            market = cd.get("market_context") or {}
            coinglass = cd.get("coinglass") or {}
            levels = cd.get("levels") or {}
            trigger_state = cd.get("trigger_state") or {}
            crowding_state = cd.get("crowding_state") or {}
            noise_state = cd.get("noise_state") or {}
            session_ctx = cd.get("session") or {}
            ohlc_context = _multi_timeframe_ohlc_context(cd)
            level_candidates = _level_candidate_context(cd, self.policy)
            liquidation_proxy = _liquidation_proxy_context(cd)

            parts.append(f"{'='*60}")
            parts.append(f"COIN: {coin} | Mid: {of.mid} | Spread: {of.spread_bps:.1f} bps")
            parts.append(f"{'='*60}")

            # Orderflow snapshot
            parts.append(f"\nORDERFLOW:")
            parts.append(f"  Book Imbalance: {of.book_imbalance:+.3f} ({'bid-heavy' if of.book_imbalance > 0 else 'ask-heavy'})")
            parts.append(f"  Wall Skew:     {of.wall_skew:+.3f}")
            parts.append(f"  Stacked Bids:  {of.stacked_bid_count} / Asks: {of.stacked_ask_count}")
            parts.append(f"  Bid Depth:     ${of.bid_depth_usd:,.0f} / Ask: ${of.ask_depth_usd:,.0f}")
            parts.append(f"  Tape Delta:    {of.tape_delta_pct:+.3f} ({'buy' if of.tape_delta_pct > 0 else 'sell'} aggression)")
            parts.append(f"  Tape Net USD:  ${of.tape_delta_usd:,.0f}")
            parts.append(f"  CVD:           {of.cvd:,.0f} | Divergence: {'YES' if of.cvd_divergence else 'no'}")
            parts.append(f"  Bid Absorption:{of.bid_absorption:.2f} | Ask Absorption: {of.ask_absorption:.2f}")
            parts.append(f"  OMNI Score:    {of.omni_score:+.2f} | Stance: {of.stance}")
            parts.append(f"  Large Prints:  {of.large_prints} | Trades/sec: {of.trades_per_sec:.1f}")
            parts.append(f"  RSI(14):       {of.rsi_14:.1f}")
            parts.append(f"  MACD Hist:     {of.macd_histogram:.6f}")
            parts.append(f"  ATR(14):       {of.atr_14:.6f} ({of.atr_14/of.mid*100:.2f}% of price)")
            parts.append(f"  VWAP:          {of.vwap:.6f} | Price vs VWAP: {of.price_vs_vwap:+.4%}")
            parts.append(f"  VPOC:          {of.vpoc:.6f} | VAH: {of.vah:.6f} | VAL: {of.val:.6f}")
            parts.append(f"  Funding:       {of.funding_rate:.6f}")
            parts.append(f"  Open Interest: {of.open_interest:,.0f}")
            parts.append(f"  Premium:       {of.premium_bps:+.1f} bps")

            if ws:
                tape_windows = ws.get("tape_windows") if isinstance(ws.get("tape_windows"), dict) else {}
                cvd_state = ws.get("cvd_state") if isinstance(ws.get("cvd_state"), dict) else {}
                parts.append("\nLIVE WEBSOCKET ORDERFLOW:")
                parts.append(
                    f"  Available:     {ws.get('available', False)} | "
                    f"stale={ws.get('stale_s', 9999)}s "
                    f"book_stale={ws.get('book_stale_s', 9999)}s "
                    f"tape_stale={ws.get('tape_stale_s', 9999)}s "
                    f"ctx_stale={ws.get('ctx_stale_s', 9999)}s"
                )
                parts.append(f"  WS Book:       mid={ws.get('ws_mid', 0):.6g} spread={ws.get('ws_spread_bps', 999):.2f} bps velocity={ws.get('book_velocity', 'unknown')}")
                parts.append(
                    "  Book Velocity: "
                    f"bid15={ws.get('bid_depth_change_15s', 0):+.2%} "
                    f"ask15={ws.get('ask_depth_change_15s', 0):+.2%} "
                    f"bid60={ws.get('bid_depth_change_60s', 0):+.2%} "
                    f"ask60={ws.get('ask_depth_change_60s', 0):+.2%}"
                )
                parts.append(
                    "  Tape 30s:      "
                    f"buy=${ws.get('buy_usd_30s', 0):,.0f} "
                    f"sell=${ws.get('sell_usd_30s', 0):,.0f} "
                    f"net=${ws.get('delta_usd_30s', 0):,.0f} "
                    f"ratio={ws.get('delta_ratio_30s', 0):+.3f} "
                    f"state={ws.get('tape_state', 'unknown')}"
                )
                if tape_windows:
                    parts.append(
                        "  Tape Windows:  "
                        f"10s={_fmt_window(tape_windows.get('10s'))} | "
                        f"30s={_fmt_window(tape_windows.get('30s'))} | "
                        f"1m={_fmt_window(tape_windows.get('1m'))} | "
                        f"2m={_fmt_window(tape_windows.get('2m'))} | "
                        f"5m={_fmt_window(tape_windows.get('5m'))}"
                    )
                parts.append(
                    "  Flow Memory:   "
                    f"cvd5m=${ws.get('cvd_5m', 0):,.0f} "
                    f"deltaAccel30=${ws.get('delta_accel_30s', 0):,.0f} "
                    f"largeB/S={ws.get('large_buy_prints_2m', 0)}/{ws.get('large_sell_prints_2m', 0)}"
                )
                if cvd_state:
                    parts.append(
                        "  CVD State:     "
                        f"1m=${cvd_state.get('cvd_1m', 0):,.0f} "
                        f"2m=${cvd_state.get('cvd_2m', 0):,.0f} "
                        f"5m=${cvd_state.get('cvd_5m', 0):,.0f} "
                        f"slope30=${cvd_state.get('cvd_slope_30s', 0):,.0f} "
                        f"div2m={cvd_state.get('divergence_2m', 'none')} "
                        f"div5m={cvd_state.get('divergence_5m', 'none')}"
                    )
                parts.append(
                    "  OI/Funding WS: "
                    f"oiDelta60={ws.get('oi_delta_60s', 0):+.2%} "
                    f"oiDelta5m={ws.get('oi_delta_5m', 0):+.2%} "
                    f"oiDelta15m={ws.get('oi_delta_15m', 0):+.2%} "
                    f"funding={ws.get('funding_ws', 0):+.8f} "
                    f"funding5m={ws.get('funding_delta_5m', 0):+.8f} "
                    f"premium={ws.get('premium_ws', 0):+.8f} "
                    f"premium5m={ws.get('premium_delta_5m', 0):+.8f} "
                    f"crowding={ws.get('crowding_state', 'unknown')}"
                )

            if market:
                parts.append("\nHYPERLIQUID MARKET CONTEXT:")
                parts.append(f"  OI Cap:        {market.get('at_oi_cap', False)}")
                parts.append(
                    "  Pred Funding: "
                    f"HL={market.get('predicted_hl_funding', 0):+.8f} "
                    f"CEXavg={market.get('predicted_cex_funding_avg', 0):+.8f} "
                    f"dislocation={market.get('funding_dislocation', 0):.8f}"
                )

            if session_ctx:
                parts.append("\nSESSION CONTEXT:")
                parts.append(
                    f"  Session: {session_ctx.get('label')} | quality={session_ctx.get('quality')} "
                    f"utc_hour={session_ctx.get('utc_hour')} date={session_ctx.get('session_date')}"
                )

            if levels or trigger_state or crowding_state:
                parts.append("\nEXPLICIT PLAYBOOK FEATURES:")
                if levels:
                    parts.append(
                        "  Levels: "
                        f"15mH={levels.get('prior_15m_high', 0):.8g} "
                        f"15mL={levels.get('prior_15m_low', 0):.8g} "
                        f"1hH={levels.get('prior_1h_high', 0):.8g} "
                        f"1hL={levels.get('prior_1h_low', 0):.8g} "
                        f"swingH={levels.get('last_swing_high', 0):.8g} "
                        f"swingL={levels.get('last_swing_low', 0):.8g}"
                    )
                if trigger_state:
                    true_triggers = [k for k, v in trigger_state.items() if v]
                    parts.append(
                        "  Trigger State: "
                        f"true={true_triggers if true_triggers else 'none'} "
                        f"all={trigger_state}"
                    )
                if cd.get("trigger_meta"):
                    parts.append(f"  Trigger Meta: {cd.get('trigger_meta')}")
                if crowding_state:
                    parts.append(f"  Crowding State: {crowding_state}")

            if level_candidates:
                parts.append("\nSTRUCTURAL STOP/TARGET CANDIDATES:")
                parts.append(level_candidates)

            if liquidation_proxy:
                parts.append("\nLIQUIDATION / STOP-POOL PROXY:")
                parts.append(liquidation_proxy)

            if coinglass:
                parts.append("\nCOINGLASS LIQUIDATION HEATMAP:")
                parts.append(json.dumps(coinglass, ensure_ascii=False, sort_keys=True)[:1800])

            if noise_state:
                parts.append("\nSTOP/TP NOISE CONTEXT:")
                parts.append(
                    "  Noise: "
                    f"ATR14={noise_state.get('atr14', 0):.8g} "
                    f"spreadPx={noise_state.get('spread_px', 0):.8g} "
                    f"1mP80={noise_state.get('range_p80_1m', 0):.8g} "
                    f"5mP80={noise_state.get('range_p80_5m', 0):.8g} "
                    f"dynamicStopFloor={noise_state.get('dynamic_stop_floor', 0):.8g}"
                )
                parts.append(
                    "  Latest: "
                    f"1m={noise_state.get('last_1m', {})} "
                    f"5m={noise_state.get('last_5m', {})}"
                )
                target_floor = max(
                    float(of.mid or 0.0) * float(self.policy.min_target_pct or 0.0),
                    float(noise_state.get("atr14", of.atr_14) or 0.0) * float(self.policy.min_target_atr_multiple or 0.0),
                )
                parts.append(
                    "  Larger Target: "
                    f"minTargetDistance={target_floor:.8g} "
                    f"({self.policy.min_target_pct:.2%} price or "
                    f"{self.policy.min_target_atr_multiple:.2f}x ATR)"
                )

            # Signal alignment
            parts.append(f"\nSIGNAL ALIGNMENT — LONG:")
            parts.append(self._alignment_check(of, "long"))
            parts.append(f"\nSIGNAL ALIGNMENT — SHORT:")
            parts.append(self._alignment_check(of, "short"))

            # Regime
            parts.append(f"\nREGIME: {regime.overall} | 15m={regime.trend_15m} 1h={regime.trend_1h} 4h={regime.trend_4h} | Vol={regime.vol_regime}")

            # Recent candles
            if candles_summary:
                parts.append(f"\nRECENT CANDLES: {candles_summary}")
            if ohlc_context:
                parts.append("\nMULTI-TIMEFRAME OHLC CONTEXT:")
                parts.append(ohlc_context)

            parts.append("")

        parts.append("Analyze all coins. Find the best trade or WAIT. JSON only.")
        return "\n".join(parts)

    def _load_ohlc_lessons(self) -> dict:
        if not self.ohlc_lessons_path:
            return {}
        try:
            with open(self.ohlc_lessons_path, "r") as f:
                data = json.load(f)
        except (OSError, json.JSONDecodeError):
            return {}
        if not isinstance(data, dict):
            return {}
        return {
            "generated_at": data.get("generated_at"),
            "window": data.get("window"),
            "rules": data.get("rules", [])[:8],
            "flag_counts": data.get("flag_counts", {}),
            "coin_side": data.get("coin_side", {}) or {},
            "recent_trades": data.get("recent_trades", [])[-8:],
            "missed_opportunities": data.get("missed_opportunities", [])[-6:],
        }

    def _alignment_check(self, of: OrderflowSnapshot, side: str) -> str:
        """Build signal alignment checklist."""
        return format_alignment(of, side, self.policy)

    def _system_prompt(self) -> str:
        return SYSTEM_PROMPT.format(
            equity=self.equity,
            margin=self.margin,
            leverage=self.leverage,
            **self.policy.prompt_vars(),
        )

    def _call(self, prompt: str) -> str:
        url = f"{self.base_url}/chat/completions"
        system = self._system_prompt()
        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system},
                {"role": "user", "content": prompt},
            ],
            "temperature": self.temperature,
            "max_tokens": 4096,
            "response_format": {"type": "json_object"},
        }
        if self.thinking in ("enabled", "disabled"):
            payload["thinking"] = {"type": self.thinking}
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

        content = self._post_chat(url, payload, headers)
        if not content.strip():
            retry_payload = dict(payload)
            retry_payload["messages"] = [
                {"role": "system", "content": system + "\nThe final assistant content must be one non-empty JSON object."},
                {"role": "user", "content": prompt + "\n\nReturn the JSON object now. Do not return empty content."},
            ]
            content = self._post_chat(url, retry_payload, headers)
        return content

    def _post_chat(self, url: str, payload: dict, headers: dict) -> str:
        last_exc: Exception | None = None
        for attempt in range(3):
            try:
                r = self.session.post(url, json=payload, headers=headers, timeout=self.timeout)
                if r.status_code == 429:
                    wait = 5 * (attempt + 1)
                    log.warning("LLM 429 rate limit, retrying in %ss", wait)
                    time.sleep(wait)
                    continue
                if 500 <= r.status_code < 600:
                    wait = 2 * (attempt + 1)
                    log.warning("LLM server error %s, retrying in %ss", r.status_code, wait)
                    time.sleep(wait)
                    continue
                r.raise_for_status()
                data = r.json()
                message = data["choices"][0].get("message") or {}
                content = message.get("content") or ""
                if isinstance(content, list):
                    content = "".join(str(part.get("text", part)) for part in content)
                return str(content or "")
            except (requests.exceptions.RequestException, RemoteDisconnected) as exc:
                last_exc = exc
                if attempt == 2:
                    break
                wait = 2 * (attempt + 1)
                log.warning("LLM connection error, retrying in %ss: %s", wait, exc)
                time.sleep(wait)
        if last_exc:
            raise last_exc
        return ""

    def _parse(self, raw: str) -> LLMTradeDecision:
        raw = self._extract_json_text(raw)
        try:
            d = json.loads(raw)
        except json.JSONDecodeError:
            return LLMTradeDecision(action="wait", reason="LLM response not JSON", raw_response=raw[:500])

        action = d.get("action", "wait")
        if action not in ("enter", "wait"):
            action = "wait"

        trade_plan = d.get("trade_plan", {})
        if not isinstance(trade_plan, dict):
            trade_plan = {}

        analysis_steps = d.get("analysis_steps", {})
        if not isinstance(analysis_steps, dict):
            analysis_steps = {}

        level_reasoning = d.get("level_reasoning", {})
        if not isinstance(level_reasoning, dict):
            level_reasoning = trade_plan.get("level_reasoning", {})
        if not isinstance(level_reasoning, dict):
            level_reasoning = {}

        exit_plan = d.get("exit_plan", {})
        if not isinstance(exit_plan, dict):
            exit_plan = trade_plan.get("exit_plan", {})
        if not isinstance(exit_plan, dict):
            exit_plan = {}

        playbook_match = d.get("playbook_match", {})
        if not isinstance(playbook_match, dict):
            playbook_match = trade_plan.get("playbook_match", {})
        if not isinstance(playbook_match, dict):
            playbook_match = {}
        playbook_match = self._normalize_playbook_match(playbook_match)

        analyst_report = d.get("analyst_report", {})
        if not isinstance(analyst_report, dict):
            analyst_report = {}

        trader_proposal = d.get("trader_proposal", {})
        if not isinstance(trader_proposal, dict):
            trader_proposal = {}

        risk_portfolio_decision = d.get("risk_portfolio_decision", {})
        if not isinstance(risk_portfolio_decision, dict):
            risk_portfolio_decision = {}

        side_arbitration = d.get("side_arbitration", {})
        if not isinstance(side_arbitration, dict):
            side_arbitration = trade_plan.get("side_arbitration", {})
        if not isinstance(side_arbitration, dict):
            side_arbitration = {}

        if action == "enter":
            exit_plan = self._complete_exit_plan(exit_plan, level_reasoning, d)
            level_reasoning = self._complete_level_reasoning(level_reasoning, exit_plan, playbook_match, trade_plan, d)
            d, level_reasoning, exit_plan = self._apply_risk_portfolio_target(
                d,
                risk_portfolio_decision,
                level_reasoning,
                exit_plan,
            )

        if action == "enter":
            side_conflict = self._side_conflict_reason(
                side=str(d.get("side") or ""),
                setup_type=str(d.get("setup_type") or ""),
                playbook_match=playbook_match,
            )
            if side_conflict:
                return LLMTradeDecision(
                    action="wait",
                    coin=d.get("coin", ""),
                    side=d.get("side", ""),
                    confidence=max(0, min(1, float(d.get("confidence", 0)))),
                    setup_type=d.get("setup_type", ""),
                    evidence=d.get("evidence", []),
                    reason=f"LLM enter rejected: {side_conflict}",
                    analysis_steps=analysis_steps,
                    playbook_match=playbook_match,
                    level_reasoning=level_reasoning,
                    exit_plan=exit_plan,
                    trade_plan=trade_plan,
                    alignment_magnitude=d.get("alignment_magnitude", {}),
                    analyst_report=analyst_report,
                    trader_proposal=trader_proposal,
                    risk_portfolio_decision=risk_portfolio_decision,
                    side_arbitration=side_arbitration,
                    best_candidate={
                        "coin": d.get("coin", ""),
                        "side": d.get("side", ""),
                        "blocker": side_conflict,
                    },
                    raw_response=raw[:2000],
                )
            playbook_reject = self._playbook_reject_reason(playbook_match)
            if playbook_reject:
                return LLMTradeDecision(
                    action="wait",
                    coin=d.get("coin", ""),
                    side=d.get("side", ""),
                    confidence=max(0, min(1, float(d.get("confidence", 0)))),
                    setup_type=d.get("setup_type", ""),
                    evidence=d.get("evidence", []),
                    reason=f"LLM enter rejected: {playbook_reject}",
                    analysis_steps=analysis_steps,
                    playbook_match=playbook_match,
                    level_reasoning=level_reasoning,
                    exit_plan=exit_plan,
                    trade_plan=trade_plan,
                    alignment_magnitude=d.get("alignment_magnitude", {}),
                    analyst_report=analyst_report,
                    trader_proposal=trader_proposal,
                    risk_portfolio_decision=risk_portfolio_decision,
                    side_arbitration=side_arbitration,
                    best_candidate={
                        "coin": d.get("coin", ""),
                        "side": d.get("side", ""),
                        "blocker": playbook_reject,
                        "playbook_match": playbook_match,
                    },
                    raw_response=raw[:2000],
                )
            risk_judge_reject = self._risk_portfolio_reject_reason(risk_portfolio_decision)
            if risk_judge_reject:
                return LLMTradeDecision(
                    action="wait",
                    coin=d.get("coin", ""),
                    side=d.get("side", ""),
                    confidence=max(0, min(1, float(d.get("confidence", 0)))),
                    setup_type=d.get("setup_type", ""),
                    evidence=d.get("evidence", []),
                    reason=f"LLM enter rejected: {risk_judge_reject}",
                    analysis_steps=analysis_steps,
                    playbook_match=playbook_match,
                    level_reasoning=level_reasoning,
                    exit_plan=exit_plan,
                    trade_plan=trade_plan,
                    alignment_magnitude=d.get("alignment_magnitude", {}),
                    analyst_report=analyst_report,
                    trader_proposal=trader_proposal,
                    risk_portfolio_decision=risk_portfolio_decision,
                    side_arbitration=side_arbitration,
                    best_candidate={
                        "coin": d.get("coin", ""),
                        "side": d.get("side", ""),
                        "blocker": risk_judge_reject,
                        "risk_portfolio_decision": risk_portfolio_decision,
                    },
                    raw_response=raw[:2000],
                )
            side_arbitration_reject = self._side_arbitration_reject_reason(side_arbitration)
            if side_arbitration_reject:
                return LLMTradeDecision(
                    action="wait",
                    coin=d.get("coin", ""),
                    side=d.get("side", ""),
                    confidence=max(0, min(1, float(d.get("confidence", 0)))),
                    setup_type=d.get("setup_type", ""),
                    evidence=d.get("evidence", []),
                    reason=f"LLM enter rejected: {side_arbitration_reject}",
                    analysis_steps=analysis_steps,
                    playbook_match=playbook_match,
                    level_reasoning=level_reasoning,
                    exit_plan=exit_plan,
                    trade_plan=trade_plan,
                    alignment_magnitude=d.get("alignment_magnitude", {}),
                    analyst_report=analyst_report,
                    trader_proposal=trader_proposal,
                    risk_portfolio_decision=risk_portfolio_decision,
                    side_arbitration=side_arbitration,
                    best_candidate={
                        "coin": d.get("coin", ""),
                        "side": d.get("side", ""),
                        "blocker": side_arbitration_reject,
                        "side_arbitration": side_arbitration,
                    },
                    raw_response=raw[:2000],
                )
            missing = self._missing_level_reasoning(level_reasoning)
            blocking_missing = [item for item in missing if item != "target"]
            if blocking_missing:
                return LLMTradeDecision(
                    action="wait",
                    coin=d.get("coin", ""),
                    side=d.get("side", ""),
                    confidence=max(0, min(1, float(d.get("confidence", 0)))),
                    setup_type=d.get("setup_type", ""),
                    evidence=d.get("evidence", []),
                    reason=f"LLM enter rejected: missing concrete level_reasoning for {', '.join(blocking_missing)}",
                    analysis_steps=analysis_steps,
                    playbook_match=playbook_match,
                    level_reasoning=level_reasoning,
                    exit_plan=exit_plan,
                    trade_plan=trade_plan,
                    alignment_magnitude=d.get("alignment_magnitude", {}),
                    analyst_report=analyst_report,
                    trader_proposal=trader_proposal,
                    risk_portfolio_decision=risk_portfolio_decision,
                    side_arbitration=side_arbitration,
                    best_candidate={
                        "coin": d.get("coin", ""),
                        "side": d.get("side", ""),
                        "blocker": "missing level reasoning",
                        "missing": blocking_missing,
                    },
                    raw_response=raw[:2000],
                )
            missing_exit = self._missing_exit_plan(exit_plan)
            blocking_missing_exit = [
                item for item in missing_exit if item not in ("target_level", "target_reason", "rr_after_noise_stop")
            ]
            if blocking_missing_exit:
                return LLMTradeDecision(
                    action="wait",
                    coin=d.get("coin", ""),
                    side=d.get("side", ""),
                    confidence=max(0, min(1, float(d.get("confidence", 0)))),
                    setup_type=d.get("setup_type", ""),
                    evidence=d.get("evidence", []),
                    reason=f"LLM enter rejected: missing concrete exit_plan for {', '.join(blocking_missing_exit)}",
                    analysis_steps=analysis_steps,
                    playbook_match=playbook_match,
                    level_reasoning=level_reasoning,
                    exit_plan=exit_plan,
                    trade_plan=trade_plan,
                    alignment_magnitude=d.get("alignment_magnitude", {}),
                    analyst_report=analyst_report,
                    trader_proposal=trader_proposal,
                    risk_portfolio_decision=risk_portfolio_decision,
                    side_arbitration=side_arbitration,
                    best_candidate={
                        "coin": d.get("coin", ""),
                        "side": d.get("side", ""),
                        "blocker": "missing exit plan",
                        "missing": blocking_missing_exit,
                    },
                    raw_response=raw[:2000],
                )

        return LLMTradeDecision(
            action=action,
            coin=d.get("coin", ""),
            side=d.get("side", ""),
            confidence=max(0, min(1, float(d.get("confidence", 0)))),
            entry_price=float(d.get("entry_price", 0)),
            stop_price=float(d.get("stop_price", 0)),
            target_price=float(d.get("target_price", 0)),
            setup_type=d.get("setup_type", "llm_decision"),
            evidence=d.get("evidence", []),
            reason=d.get("reason", ""),
            regime_note=d.get("regime_note", ""),
            risk_assessment=d.get("risk_assessment", ""),
            analysis_steps=analysis_steps,
            playbook_match=playbook_match,
            level_reasoning=level_reasoning,
            exit_plan=exit_plan,
            trade_plan=trade_plan,
            alignment_magnitude=d.get("alignment_magnitude", {}),
            analyst_report=analyst_report,
            trader_proposal=trader_proposal,
            risk_portfolio_decision=risk_portfolio_decision,
            side_arbitration=side_arbitration,
            best_candidate=d.get("best_candidate", {}),
            raw_response=raw[:2000],
        )

    def _playbook_reject_reason(self, playbook_match: dict) -> str:
        """Require the LLM to prove a concrete orderflow playbook trigger."""
        allowed = {
            "trend_pullback_continuation",
            "liquidity_sweep_reclaim",
            "absorption_reversal",
            "vwap_poc_reversion",
            "breakout_acceptance",
            # DeepSeek sometimes mirrors trigger_state names. These are valid
            # side-specific variants of the same playbook, not schema failures.
            "breakout_acceptance_long",
            "breakdown_acceptance_short",
        }
        playbook = str(playbook_match.get("playbook") or "").strip().lower()
        if playbook not in allowed:
            return "missing valid playbook_match.playbook"

        trigger_present = playbook_match.get("trigger_present")
        if isinstance(trigger_present, str):
            trigger_present = trigger_present.strip().lower() == "true"
        if trigger_present is not True:
            return "playbook trigger_present is not true"

        for field_name in ("trigger_level", "price_response", "self_reject_if"):
            if len(str(playbook_match.get(field_name) or "").strip()) < 12:
                return f"missing concrete playbook_match.{field_name}"

        anti_chase = str(playbook_match.get("anti_chase_check") or "").lower()
        if len(anti_chase.strip()) < 20:
            return "missing anti_chase_check"
        if "current mid" in anti_chase and not any(
            term in anti_chase for term in ("not", "after", "because", "retest", "reclaim", "failed", "sweep", "acceptance", "hold")
        ):
            return "anti_chase_check does not explain why entry is not a chase"

        return ""

    def _side_arbitration_reject_reason(self, side_arbitration: dict) -> str:
        """Require explicit chosen-vs-opposite direction arbitration.

        This is not a strategy veto by itself; it prevents malformed ENTERs
        where the model never checked the other side or admits no clean side.
        """
        if not isinstance(side_arbitration, dict) or not side_arbitration:
            return "missing side_arbitration"
        verdict = str(side_arbitration.get("direction_verdict") or "").strip().lower()
        if len(verdict) < 8:
            return "missing side_arbitration.direction_verdict"
        if "wait" in verdict or "opposite" in verdict:
            return f"side arbitration verdict is {verdict}"
        opposite_case = str(side_arbitration.get("opposite_case") or "").strip()
        why_not = str(side_arbitration.get("why_not_opposite") or "").strip()
        if len(opposite_case) < 15:
            return "missing concrete side_arbitration.opposite_case"
        if len(why_not) < 12:
            return "missing concrete side_arbitration.why_not_opposite"
        return ""

    def _risk_portfolio_reject_reason(self, risk_decision: dict) -> str:
        """Require the final risk/portfolio judge to approve executable entries."""
        if not isinstance(risk_decision, dict) or not risk_decision:
            return "missing risk_portfolio_decision"
        final_action = str(risk_decision.get("final_action") or "").strip().lower()
        if final_action != "approve_enter":
            return f"risk/portfolio final_action is {final_action or 'missing'}"
        required = (
            "allowed_action_check",
            "side_verdict",
            "stop_target_verdict",
            "memory_verdict",
            "decision_reason",
        )
        for key in required:
            if len(str(risk_decision.get(key) or "").strip()) < 12:
                return f"missing concrete risk_portfolio_decision.{key}"
        text = " ".join(str(risk_decision.get(key) or "").lower() for key in required)
        reject_terms = (
            "not allowed",
            "no allowed",
            "weak direction",
            "unreachable",
            "too risky",
            "must wait",
        )
        for term in reject_terms:
            if term in text:
                return f"risk/portfolio text contains {term}"
        return ""

    def _apply_risk_portfolio_target(
        self,
        data: dict,
        risk_decision: dict,
        level_reasoning: dict,
        exit_plan: dict,
    ) -> tuple[dict, dict, dict]:
        """Let the final judge approve a reaction target instead of bad runner.

        The LLM still proposes levels, but the Portfolio/Risk section is the
        final authority for whether the executable TP is a runner or a nearer
        reaction target. This avoids turning "runner too far" into either a
        full reject or an unmanaged giveback.
        """
        if not isinstance(risk_decision, dict):
            return data, level_reasoning, exit_plan
        target_mode = str(risk_decision.get("target_mode") or "").strip().lower()
        approved = risk_decision.get("approved_target_price")
        try:
            approved_target = float(approved)
        except (TypeError, ValueError):
            approved_target = 0.0
        if target_mode not in ("reaction", "runner") or approved_target <= 0:
            return data, level_reasoning, exit_plan

        side = str(data.get("side") or "").lower()
        entry = float(data.get("entry_price") or 0.0)
        old_target = float(data.get("target_price") or 0.0)
        if side == "long" and not (approved_target > entry):
            return data, level_reasoning, exit_plan
        if side == "short" and not (0 < approved_target < entry):
            return data, level_reasoning, exit_plan
        if old_target and abs(old_target - approved_target) <= max(abs(old_target), 1.0) * 1e-9:
            return data, level_reasoning, exit_plan

        out = dict(data)
        out["target_price"] = approved_target
        reason = str(risk_decision.get("stop_target_verdict") or risk_decision.get("decision_reason") or "")
        level_out = dict(level_reasoning or {})
        level_out["target"] = (
            f"{approved_target:.8g} {target_mode} target approved by risk/portfolio judge; {reason}"
        )
        exit_out = dict(exit_plan or {})
        exit_out["target_level"] = (
            f"{approved_target:.8g} {target_mode} target approved by risk/portfolio judge"
        )
        exit_out["target_reason"] = reason or f"{target_mode} target selected by risk/portfolio judge"
        exit_out["target_mode"] = target_mode
        if target_mode == "reaction":
            exit_out["runner_target_level"] = str(exit_plan.get("runner_target_level") or old_target or "")
            exit_out["full_move_plan"] = (
                "Risk/portfolio judge downgraded runner to reaction target to reduce giveback risk."
            )
        return out, level_out, exit_out

    def _normalize_playbook_match(self, playbook_match: dict) -> dict:
        """Map side-specific LLM labels to the parser's canonical playbooks."""
        if not isinstance(playbook_match, dict):
            return {}
        out = dict(playbook_match)
        original = str(out.get("playbook") or "").strip().lower()
        raw = original
        raw = raw.removesuffix("_long").removesuffix("_short")
        aliases = {
            "trend_pullback_continuation": "trend_pullback_continuation",
            "liquidity_sweep_reclaim": "liquidity_sweep_reclaim",
            "absorption_reversal": "absorption_reversal",
            "vwap_poc_reversion": "vwap_poc_reversion",
            "breakdown_acceptance": "breakout_acceptance",
            "breakout_acceptance": "breakout_acceptance",
            "pullback_retest": "trend_pullback_continuation",
            "pullback_continuation": "trend_pullback_continuation",
            "trend_continuation": "trend_pullback_continuation",
            "sweep_reclaim": "liquidity_sweep_reclaim",
            "liquidity_sweep": "liquidity_sweep_reclaim",
            "failed_push": "absorption_reversal",
            "absorption": "absorption_reversal",
            "value_reversion": "vwap_poc_reversion",
            "vwap_reversion": "vwap_poc_reversion",
            "poc_reversion": "vwap_poc_reversion",
        }
        if original:
            out["_raw_playbook"] = original
        if raw in aliases:
            out["playbook"] = aliases[raw]
        return out

    def _side_conflict_reason(self, *, side: str, setup_type: str, playbook_match: dict) -> str:
        side = side.lower().strip()
        if side not in ("long", "short"):
            return "missing side"
        labels = " ".join(
            str(value or "").lower()
            for value in (
                setup_type,
                playbook_match.get("_raw_playbook"),
                playbook_match.get("trigger_level"),
                playbook_match.get("price_response"),
            )
        )
        opposite = "short" if side == "long" else "long"
        if f"_{opposite}" in labels or f" {opposite} " in f" {labels} ":
            if f"_{side}" not in labels and f" {side} " not in f" {labels} ":
                return f"side/playbook conflict: side={side} but setup/trigger text says {opposite}"
        return ""

    def _missing_level_reasoning(self, level_reasoning: dict) -> list[str]:
        """Require explicit, market-structure reasons for entry, stop, and target."""
        required_terms = {
            "entry": (
                "book", "tape", "delta", "cvd", "vwap", "absorption", "imbalance",
                "trigger", "failed", "break", "reclaim", "sweep", "velocity",
            ),
            "stop": (
                "swing", "high", "low", "vwap", "vpoc", "vah", "val", "liquidity",
                "invalidation", "absorption", "structure", "atr", "noise", "wick",
                "reclaim", "level",
            ),
            "target": (
                "vwap", "vpoc", "vah", "val", "prior", "low", "high", "liquidity",
                "range", "support", "resistance", "extension", "imbalance", "pocket",
                "level", "value",
            ),
        }

        missing = []
        for key, terms in required_terms.items():
            value = level_reasoning.get(key)
            if isinstance(value, dict):
                text = json.dumps(value, sort_keys=True)
            else:
                text = str(value or "")
            normalized = text.lower()
            if len(normalized.strip()) < 25 or not any(term in normalized for term in terms):
                missing.append(key)
        return missing

    def _missing_exit_plan(self, exit_plan: dict) -> list[str]:
        """Require the LLM to justify stop/target management, not just emit prices."""
        required_terms = {
            "stop_invalidation_level": (
                "invalid", "accept", "above", "below", "swing", "high", "low",
                "vwap", "vpoc", "vah", "val", "structure", "level", "reclaim",
            ),
            "stop_noise_check": (
                "atr", "noise", "range", "wick", "1m", "5m", "candle", "volatility",
                "distance",
            ),
            "target_level": (
                "vwap", "vpoc", "vah", "val", "prior", "high", "low", "liquidity",
                "range", "support", "resistance", "imbalance", "pocket", "level",
            ),
            "target_reason": (
                "vwap", "vpoc", "vah", "val", "liquidity", "range", "support",
                "resistance", "imbalance", "pocket", "structure", "value",
            ),
            "rr_after_noise_stop": ("r", "risk", "reward", "fee", "bps", "noise"),
            "if_wrong_fast": (
                "exit", "tighten", "flip", "reclaim", "accept", "book", "tape",
                "delta", "absorption", "invalid",
            ),
        }

        missing = []
        for key, terms in required_terms.items():
            value = exit_plan.get(key)
            if key == "rr_after_noise_stop" and isinstance(value, (int, float)) and float(value) > 0:
                continue
            if key == "rr_after_noise_stop" and isinstance(value, str):
                numeric = re.search(r"\d+(?:\.\d+)?", value)
                if numeric and float(numeric.group(0)) > 0:
                    continue
            if isinstance(value, dict):
                text = json.dumps(value, sort_keys=True)
            else:
                text = str(value or "")
            normalized = text.lower()
            has_required_term = any(term in normalized for term in terms)
            has_numeric_level = bool(re.search(r"\d", normalized))
            if key == "target_level" and has_numeric_level and has_required_term:
                continue
            if len(normalized.strip()) < 18 or not has_required_term:
                missing.append(key)
        return missing

    def _complete_exit_plan(self, exit_plan: dict, level_reasoning: dict, data: dict) -> dict:
        """Fill compatible exit-plan fields from equivalent reasoning.

        DeepSeek sometimes gives a valid plan but puts the target explanation in
        `level_reasoning.target` or risk text instead of duplicating it in
        `exit_plan`. Use those equivalent fields so we reject bad plans, not
        merely differently-shaped good plans.
        """
        out = dict(exit_plan or {})
        level_target = str(level_reasoning.get("target") or "") if isinstance(level_reasoning, dict) else ""
        level_stop = str(level_reasoning.get("stop") or "") if isinstance(level_reasoning, dict) else ""
        risk = str(data.get("risk_assessment") or "")
        target_price = data.get("target_price")
        stop_price = data.get("stop_price")

        if not out.get("target_reason") and level_target:
            out["target_reason"] = level_target
        if not out.get("target_level") and level_target:
            out["target_level"] = level_target
        if not out.get("target_level") and target_price:
            reason = out.get("target_reason") or level_target or "structural target level"
            out["target_level"] = f"{target_price} structural target level: {reason}"
        if out.get("target_level") and not any(
            term in str(out["target_level"]).lower()
            for term in ("vwap", "vpoc", "vah", "val", "prior", "high", "low", "liquidity", "range", "support", "resistance", "imbalance", "pocket", "level")
        ):
            out["target_level"] = f"{out['target_level']} structural target level"
        if out.get("target_reason") and not any(
            term in str(out["target_reason"]).lower()
            for term in ("vwap", "vpoc", "vah", "val", "liquidity", "range", "support", "resistance", "imbalance", "pocket", "structure", "value", "level")
        ):
            out["target_reason"] = f"{out['target_reason']} at structural liquidity/value level"
        if not out.get("stop_invalidation_level") and level_stop:
            out["stop_invalidation_level"] = level_stop
        if not out.get("stop_invalidation_level") and stop_price:
            out["stop_invalidation_level"] = f"{stop_price} structural invalidation level"
        if not out.get("rr_after_noise_stop") and risk:
            out["rr_after_noise_stop"] = risk
        return out

    def _complete_level_reasoning(
        self,
        level_reasoning: dict,
        exit_plan: dict,
        playbook_match: dict,
        trade_plan: dict,
        data: dict,
    ) -> dict:
        out = dict(level_reasoning or {})
        if not out.get("entry"):
            trigger = playbook_match.get("trigger_level") or trade_plan.get("entry_trigger")
            response = playbook_match.get("price_response") or ""
            anti_chase = playbook_match.get("anti_chase_check") or ""
            if trigger and response:
                out["entry"] = f"{data.get('entry_price')} trigger level {trigger}; {response}; {anti_chase}"
        if not out.get("stop"):
            stop_text = exit_plan.get("stop_invalidation_level") or trade_plan.get("invalidation")
            if stop_text:
                out["stop"] = f"{data.get('stop_price')} structural invalidation/noise level: {stop_text}"
        if not out.get("target"):
            target_text = exit_plan.get("target_level") or exit_plan.get("target_reason")
            if target_text:
                out["target"] = f"{data.get('target_price')} structural target level: {target_text}"
        return out

    def _self_contradiction_reason(self, data: dict, level_reasoning: dict, exit_plan: dict) -> str:
        """Reject plans where the final action contradicts the LLM's own analysis.

        DeepSeek sometimes calculates that the target/R:R is not good enough, then
        still emits `action=enter`. The deterministic gates should not accept the
        final field while ignoring the analysis that disqualified the trade.
        """
        fields = [
            data.get("reason"),
            data.get("risk_assessment"),
            data.get("setup_type"),
            level_reasoning.get("entry") if isinstance(level_reasoning, dict) else "",
            level_reasoning.get("stop") if isinstance(level_reasoning, dict) else "",
            level_reasoning.get("target") if isinstance(level_reasoning, dict) else "",
            exit_plan.get("target_reason") if isinstance(exit_plan, dict) else "",
            exit_plan.get("target_level") if isinstance(exit_plan, dict) else "",
            exit_plan.get("rr_after_noise_stop") if isinstance(exit_plan, dict) else "",
        ]
        text = " ".join(str(item or "").lower() for item in fields)
        disqualifiers = (
            "r:r insufficient",
            "rr insufficient",
            "risk/reward insufficient",
            "below 1.5",
            "below 1.50",
            "under 1.5",
            "target too small",
            "target cannot reach",
            "target cannot",
            "too small after noise",
            "need farther target",
            "farther target not provided",
            "not provided. so r:r insufficient",
            "no farther target",
        )
        for phrase in disqualifiers:
            if phrase in text:
                return f"self-contradictory plan says {phrase}"

        if "borderline" in text and any(term in text for term in ("r:r", "rr", "target", "counter-trend", "countertrend")):
            return "self-contradictory plan is borderline on target/R:R/countertrend"
        return ""

    def _extract_json_text(self, raw: str) -> str:
        text = (raw or "").strip()
        if not text:
            return ""
        fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL | re.IGNORECASE)
        if fenced:
            return fenced.group(1).strip()
        start = text.find("{")
        end = text.rfind("}")
        if start >= 0 and end > start:
            return text[start:end + 1].strip()
        return text


# === Simulated LLM for backtesting (no API costs) ===

class SimulatedLLMBrain:
    """
    Plumbing-only fallback when no LLM key is present.

    This is not a strategy benchmark and must not be used as proof that the
    DeepSeek prompt is profitable. It exists to exercise data/risk/execution
    code paths without API calls.
    """

    def __init__(
        self,
        min_alignment: int = 5,
        min_confidence: float = 0.55,
        policy: TradingPolicy | None = None,
    ):
        self.min_alignment = min_alignment
        self.min_confidence = min_confidence
        self.policy = policy or TradingPolicy(min_alignment_score=min_alignment)

    @property
    def available(self) -> bool:
        return True

    def decide(self, coin_data: list[dict]) -> LLMTradeDecision:
        """Find best trade across all coins using alignment scoring."""
        best = None
        best_score = 0

        for cd in coin_data:
            coin = cd["coin"]
            of: OrderflowSnapshot = cd["of"]
            regime: MarketRegime = cd["regime"]

            # Skip choppy regime
            if regime.overall == "choppy":
                continue

            # Score both sides
            for side in ("long", "short"):
                score, checks = self._score_alignment(of, side)
                if score < self.min_alignment:
                    continue

                # Absorption veto
                if side == "long" and of.ask_absorption > self.policy.absorption_hard_veto_threshold:
                    continue
                if side == "short" and of.bid_absorption > self.policy.absorption_hard_veto_threshold:
                    continue

                # CVD divergence veto
                if of.cvd_divergence:
                    if side == "long" and of.cvd < 0:
                        continue
                    if side == "short" and of.cvd > 0:
                        continue

                # Regime filter
                if regime.trend_1h == "down" and side == "long" and regime.trend_15m == "down":
                    continue
                if regime.trend_1h == "up" and side == "short" and regime.trend_15m == "up":
                    continue

                # Compute levels
                atr = of.atr_14 if of.atr_14 > 0 else of.mid * 0.005
                stop_dist = max(atr * 1.2, of.mid * 0.003)

                if side == "long":
                    stop = of.mid - stop_dist
                    candidates = [of.mid + stop_dist * 2.0]
                    if of.vah > of.mid:
                        candidates.append(of.vah)
                    if of.vpoc > of.mid:
                        candidates.append(of.vpoc)
                    target = min(max(candidates), of.mid + stop_dist * 4)
                    if target <= of.mid:
                        target = of.mid + stop_dist * 2
                else:
                    stop = of.mid + stop_dist
                    candidates = [of.mid - stop_dist * 2.0]
                    if of.val < of.mid:
                        candidates.append(of.val)
                    if of.vpoc < of.mid:
                        candidates.append(of.vpoc)
                    target = max(min(candidates), of.mid - stop_dist * 4)
                    if target >= of.mid:
                        target = of.mid - stop_dist * 2

                # R:R check
                risk = abs(of.mid - stop)
                reward = abs(target - of.mid)
                rr = reward / risk if risk > 0 else 0
                if rr < 1.5:
                    continue

                # Composite score = alignment * confidence_factor * rr_factor
                confidence = min(0.95, 0.4 + score * 0.08 + (of.omni_score if side == "long" else -of.omni_score) * 0.05)
                composite = score * 10 + rr * 5 + confidence * 20

                # CVD divergence bonus
                if of.cvd_divergence:
                    if (side == "long" and of.cvd > 0) or (side == "short" and of.cvd < 0):
                        composite += 15

                if composite > best_score:
                    best_score = composite
                    evidence = [c for c in checks if "PASS" in c]
                    best = LLMTradeDecision(
                        action="enter",
                        coin=coin,
                        side=side,
                        confidence=confidence,
                        entry_price=of.mid,
                        stop_price=stop,
                        target_price=target,
                        setup_type="simulated_plumbing",
                        evidence=evidence,
                        reason=f"plumbing fallback only: {score}/7 alignment, R:R={rr:.1f}, OMNI={of.omni_score:+.2f}",
                        regime_note=f"{regime.overall} ({regime.trend_15m}/{regime.trend_1h})",
                        risk_assessment=f"{rr:.1f}R, stop={stop_dist/of.mid*100:.2f}%",
                    )

        if best:
            return best

        return LLMTradeDecision(
            action="wait",
            reason="No coin has sufficient signal alignment or absorption/regime blocks all candidates",
        )

    def _score_alignment(self, of: OrderflowSnapshot, side: str) -> tuple[int, list[str]]:
        summary = alignment_summary(of, side, self.policy)
        passing = [
            f"[PASS] {item['label']}: {item['value']:.6g} margin={item['margin']:+.6g}"
            for item in summary["metrics"]
            if item["ok"]
        ]
        return int(summary["passing"]), passing
