agent_panel: ATR-normalized candle + indicator-value enrichment, hardened prompts, verdict logging
This commit is contained in:
+163
-20
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user