agent_panel: ATR-normalized candle + indicator-value enrichment, hardened prompts, verdict logging

This commit is contained in:
Harwinder Sekhon
2026-08-15 15:24:28 -04:00
parent 4861cd9771
commit 7b60676a7a
9 changed files with 865 additions and 29 deletions
+163 -20
View File
@@ -32,10 +32,26 @@ layers bound the spend on top of that cap:
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
see news_engine.py); all variable content travels in the user payload.
Payload enrichment (2026-08-15, owner-approved, advisory-only): the
technical persona also receives raw indicator VALUES (with a units
legend in the prompt) and ATR-NORMALIZED recent candles per timeframe
(code does the arithmetic — raw prices are the model's weakest input;
see backtest/research/llm_news_eval_2026-08-06.md). Sized by config
``candle_bars_primary`` (default 20) / ``candle_bars_confirm``
(default 10); ``candles_enabled: false`` reverts to the pre-enrichment
payload. Prompts forbid outside/training-data knowledge and cap notes
at 15 words / rationales at 30 (output tokens are the pricey side).
Verdict logging (2026-08-15): after a successful attach, the verdict is
written back onto the scan's signals.db row via signal_log's rowid
hand-off — building the "does OPPOSE predict losers?" dataset without
changing any live behavior.
"""
import datetime as _dt
import logging
import math
import threading
import time
from collections import OrderedDict
@@ -44,6 +60,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from app_config import get_app_config
from config import Timeframe
from llm_client import DEFAULT_TIMEOUT, get_llm_client
if TYPE_CHECKING: # type hints only — keep the coupling one-way
@@ -55,6 +72,8 @@ logger = logging.getLogger(__name__)
DEFAULT_MIN_QUALITY = "B"
DEFAULT_MAX_PANELS_PER_DAY = 20
DEFAULT_REUSE_TTL_SECONDS = 3600.0
DEFAULT_CANDLE_BARS_PRIMARY = 20
DEFAULT_CANDLE_BARS_CONFIRM = 10
# Quality ranks for the min_quality gate. Deliberately a local copy —
# telegram_bot's ranking is private and dashboard_publisher must not
@@ -78,9 +97,11 @@ _TAKE_CONTRACT = """\
Your review is advisory research on an already-generated signal; you are
one voice on a panel, not the decision maker.
Hard rules: use ONLY the data in the JSON you receive — do not invent
numbers, events, or price levels. If the evidence your role needs is
thin or missing, answer "neutral" with confidence below 30, unless your
role's instructions say otherwise.
numbers, events, or price levels. Your training data about gold, the
markets, and current events is stale and OFF-LIMITS: never use outside
knowledge; if a fact is not in the JSON, treat it as unknown. If the
evidence your role needs is thin or missing, answer "neutral" with
confidence below 30, unless your role's instructions say otherwise.
Confidence scale (0-100): 80-100 strong one-sided evidence; 50-79 solid
case with some doubt; 30-49 mixed evidence; below 30 little basis to
judge.
@@ -91,7 +112,7 @@ not "H4"; describe what an indicator MEANS ("the price has climbed
unusually fast"), never its name or number.
Reply with ONLY JSON:
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
"note": "<max 25 words>"}
"note": "<max 15 words>"}
"stance" says whether YOUR analysis supports the proposed trade
direction, opposes it, or is genuinely mixed; "confidence" is how sure
you are of your stance; "note" is your single most important point.
@@ -125,15 +146,26 @@ and a soft jobs report -> {"stance": "support", "confidence": 75,
_TECH_SYSTEM = """\
You are the technical analyst on a gold (XAU/USD) trade-review panel.
You receive JSON: a proposed trade brief ("brief") plus the technical
evidence — per-"indicators" signal/confidence, multi-timeframe
"mtf" signals and scores, "key_levels" versus the current price, and an
evidence — per-"indicators" signal/confidence/value, multi-timeframe
"mtf" signals and scores, "key_levels" versus the current price, an
"exhaustion" count of consecutive same-direction bars flagged by the
engine.
engine, and optionally "candles".
Indicator "value" units: rsi 0-100 (above 70 overbought, below 30
oversold); adx 0-100 (above 25 = strong trend); macd = histogram in
dollars (momentum); roc = percent; bollinger = position 0-1 (0 = lower
band, 1 = upper band); keltner = dollar distance from the mid-line;
ema_trend = 0-1 alignment score; divergence and candlestick = pattern
counts.
"candles" is per timeframe: {"ref_close", "atr", "bars"} — bars are
oldest-first, each [open, high, low, close] expressed in ATR units
relative to ref_close: 0.0 = the latest close, +1.0 = one ATR above it,
-2.0 = two ATR below it. Read the SHAPE (drift, swings, wicks); never
quote these as prices.
Judge ONE thing: is the technical picture internally coherent for the
proposed direction? Look for indicators fighting each other, the signal
fighting the regime (e.g. a BUY in trending_down), higher timeframes
disagreeing, price pressed into a key level, or an exhausted move being
chased.
disagreeing, price pressed into a key level, an exhausted move being
chased, or the candle path contradicting the indicator readings.
Stance rules:
- support: the indicators, higher timeframes, and regime mostly agree
with the proposed direction and none of the conflicts above appear.
@@ -213,7 +245,7 @@ possible profit is too small and the price has already climbed
unusually fast. Only the news backdrop supports buying."
Reply with ONLY JSON:
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
"rationale": "<max 40 words>"}
"rationale": "<max 30 words>"}
"""
@@ -238,6 +270,16 @@ class PanelVerdict:
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
def _finite(x: Any) -> Optional[float]:
"""float(x) or None for non-numeric / non-finite (NaN/inf breaks the
strict-JSON wire format the client sends)."""
try:
f = float(x)
except (TypeError, ValueError):
return None
return f if math.isfinite(f) else None
def _direction(report: "SignalReport") -> Optional[str]:
"""BUY/SELL the panel should review; None = nothing to review."""
if report.trade_setup is not None:
@@ -286,21 +328,26 @@ def _macro_payload(report: "SignalReport",
}
def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
def _technical_payload(report: "SignalReport",
candles: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
indicators: Dict[str, Dict[str, Any]] = {}
key_levels: Dict[str, float] = {}
analysis = report.primary_analysis
if analysis is not None:
# Same visibility filter as main.report_to_dict: private series
# ride as _-prefixed pseudo-indicators, weight-0 = context-only.
indicators = {
name: {"signal": ind.signal.name,
"confidence": round(ind.confidence)}
for name, ind in analysis.indicators.items()
if not name.startswith("_") and ind.weight != 0
}
for name, ind in analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
entry: Dict[str, Any] = {"signal": ind.signal.name,
"confidence": round(ind.confidence)}
value = _finite(getattr(ind, "value", None))
if value is not None:
entry["value"] = round(value, 2)
indicators[name] = entry
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
return {
payload: Dict[str, Any] = {
"brief": _brief(report),
"indicators": indicators,
"mtf": {tf: {"signal": sig.name,
@@ -309,6 +356,9 @@ def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
"key_levels": key_levels,
"exhaustion": report.exhaustion_count,
}
if candles:
payload["candles"] = candles
return payload
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
@@ -344,6 +394,81 @@ def _contrarian_payload(report: "SignalReport") -> Dict[str, Any]:
}
# ── Candle context (ATR-normalized; technical persona only) ─────────
#
# Raw prices are the LLM's weakest input modality (measured in-repo:
# numeric-comparison misreads, backtest/research/llm_news_eval_2026-08-06.md),
# so code does the arithmetic and the model reads a normalized SHAPE:
# each bar's [o,h,l,c] as offsets from the latest close in ATR units.
# A panel-private provider singleton keeps the TTL cache warm across
# attaches; any failure degrades to "no candles", never a failed panel.
_TF_BY_VALUE = {tf.value: tf for tf in Timeframe}
# Patchable for tests (mirrors the llm_client._client stub pattern).
_dp: Any = None
def _panel_provider() -> Any:
global _dp
if _dp is None:
from config import PlatformConfig
from providers import make_data_provider
_dp = make_data_provider(PlatformConfig())
return _dp
def _normalize_candles(df: Any, atr: float, bars: int) -> Dict[str, Any]:
"""Last `bars` closed candles as ATR-unit offsets from the final
close: 0.0 = latest close, +1.0 = one ATR above it. Oldest-first."""
tail = df.tail(bars)
ref = float(tail["Close"].iloc[-1])
out = []
for _, row in tail.iterrows():
out.append([round((float(row["Open"]) - ref) / atr, 1),
round((float(row["High"]) - ref) / atr, 1),
round((float(row["Low"]) - ref) / atr, 1),
round((float(row["Close"]) - ref) / atr, 1)])
return {"ref_close": round(ref, 2), "atr": round(float(atr), 2),
"bars": out}
def _candle_context(report: "SignalReport",
settings: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Best-effort {"<tf>": _normalize_candles(...)} per analysis TF;
None = omit the key from the payload. NEVER raises."""
try:
if not settings.get("candles_enabled", True):
return None
confirmations = list(report.confirmation_analyses or [])
analyses = [report.primary_analysis] + confirmations
counts = [int(settings.get("candle_bars_primary",
DEFAULT_CANDLE_BARS_PRIMARY))] + [
int(settings.get("candle_bars_confirm",
DEFAULT_CANDLE_BARS_CONFIRM))
] * len(confirmations)
dp = _panel_provider()
out: Dict[str, Any] = {}
for analysis, n in zip(analyses, counts):
if analysis is None:
continue
atr = _finite(getattr(
(analysis.indicators or {}).get("atr"), "value", None))
if not atr or atr <= 0:
continue
tf = _TF_BY_VALUE.get(analysis.timeframe)
if tf is None:
continue
df = dp.fetch_ohlcv(report.symbol, tf)
if df is None or len(df) < 2:
continue
out[analysis.timeframe] = _normalize_candles(df, atr, n)
return out or None
except Exception as e:
logger.debug("agent panel: candle context unavailable: %s", e)
return None
# ── Orchestration ────────────────────────────────────────────────────
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
@@ -385,6 +510,7 @@ def _ask_persona(persona: str, system: str,
def run_panel(report: "SignalReport",
context: Optional["FundamentalContext"] = None,
candles: Optional[Dict[str, Any]] = None,
) -> Optional[PanelVerdict]:
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
@@ -398,7 +524,7 @@ def run_panel(report: "SignalReport",
return None
jobs = (
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
("technical", _TECH_SYSTEM, _technical_payload(report)),
("technical", _TECH_SYSTEM, _technical_payload(report, candles)),
("risk", _RISK_SYSTEM, _risk_payload(report)),
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
)
@@ -502,6 +628,20 @@ def _write_fields(report: "SignalReport", v: PanelVerdict) -> None:
"confidence": t.confidence, "note": t.note} for t in v.takes]
def _log_panel_verdict(report: "SignalReport") -> None:
"""Persist the verdict onto the scan's signals.db row (fail-safe).
The row is inserted inside analyze_pair — BEFORE the panel attaches
post-hoc — so panel columns arrive via this update. A report that was
never logged (backtest replay, logging disabled) is a silent no-op.
"""
try:
from signal_log import log_panel_update
log_panel_update(report)
except Exception:
logger.debug("agent panel: verdict logging failed", exc_info=True)
def attach_panel(report: "SignalReport",
settings: Optional[Dict[str, Any]] = None,
surface: str = "telegram",
@@ -548,6 +688,7 @@ def attach_panel(report: "SignalReport",
hit = prior
if hit is not None:
_write_fields(report, hit)
_log_panel_verdict(report)
return True
cap = int(settings.get("max_panels_per_day",
@@ -567,7 +708,8 @@ def attach_panel(report: "SignalReport",
logger.debug("agent panel: news context unavailable: %s", e)
context = None
verdict = run_panel(report, context)
candles = _candle_context(report, settings)
verdict = run_panel(report, context, candles=candles)
if verdict is None:
return False
_write_fields(report, verdict)
@@ -576,6 +718,7 @@ def attach_panel(report: "SignalReport",
while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False)
_last_by_dir[dir_key] = verdict
_log_panel_verdict(report)
return True
except Exception:
logger.exception("agent panel: attach failed — signal unaffected")