agent_panel: harden evidence and track outcomes

This commit is contained in:
root
2026-08-23 12:57:05 -04:00
parent 6c5e58f1c3
commit 4dd0ca0b46
7 changed files with 1313 additions and 159 deletions
+365 -121
View File
@@ -25,17 +25,17 @@ layers bound the spend on top of that cap:
- ``max_panels_per_day`` (config, default 20) — a UTC-day budget of
panel *attempts*, mirroring the client's request cap.
- A reuse TTL (default 3600 s) keyed on (symbol, timeframe, direction):
a persisting signal re-serves its cached verdict across bars instead
of re-spending. Without this, one sticky M5 dashboard signal would
burn 5 calls per 5-minute bar — 1,440/day against the 500 cap.
- A reuse TTL (default 3600 s) keyed on the complete reviewed evidence
payload, ignoring only the bar timestamp. Changed price, setup, news,
warnings, indicators, or candles always trigger a fresh review (subject
to the daily budget); genuinely identical evidence can be re-served.
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
Payload enrichment (2026-08-15/23, owner-approved, advisory-only): the
technical persona receives strategy/pattern context, raw indicator VALUES
(with a units legend in the prompt), and ATR-NORMALIZED 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``
@@ -43,13 +43,19 @@ see backtest/research/llm_news_eval_2026-08-06.md). Sized by config
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.
Macro and contrarian receive only code-scored directional news; contrarian
also receives the timestamped closed-market snapshot, complete setup, and
visible indicators. Risk receives the stamped trailing policy and strategy
cost assumption, never the candle range mislabeled as a spread.
Verdict logging writes the verdict, creation time, cache provenance, payload
hash, and a pending deterministic advisory outcome onto the scan's signals.db
row via signal_log's rowid hand-off. No field is read by live behavior.
"""
import datetime as _dt
import hashlib
import json
import logging
import math
import threading
@@ -122,9 +128,11 @@ _MACRO_SYSTEM = """\
You are the macro analyst on a gold (XAU/USD) trade-review panel. You
receive JSON: a proposed trade brief ("brief": direction, timeframe,
quality, price, regime) plus the current fundamental picture — recent
"headlines", today's high-impact "events" (released: true means the
number is out), a news "sentiment" score for gold, and an optional
"news_summary" blurb.
directional "headlines", today's high-impact "events" (released: true
means the number is out), and optional aggregate "sentiment" for gold.
Neutral and unrelated headlines were removed by code. Each remaining
headline has a code-computed "gold_influence" and score; trust that
direction and do not reinterpret it from the wording alone.
Judge ONE thing: does the macro backdrop support the proposed direction?
Weigh released surprises over stale headlines; an imminent unreleased
high-impact event is a reason for lower confidence, not a direction.
@@ -146,16 +154,18 @@ 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/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, and optionally "candles".
evidence — the setup "strategy" and optional "setup_pattern",
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, 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.
band, 1 = upper band, values may extend outside the bands); keltner =
dollar distance from the mid-line; ema_trend = -1 to +1 alignment score;
divergence and candlestick = pattern counts. RSI is interpreted in the
supplied market regime, not as an automatic reversal at 70/30.
"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,
@@ -166,6 +176,10 @@ 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, an exhausted move being
chased, or the candle path contradicting the indicator readings.
For XAU_CP, the named completed reversal/continuation pattern is the
setup trigger and may lead lagging indicators; indicator lag alone is
not sufficient reason to oppose it. Judge whether the pattern and the
remaining evidence are coherent together.
Stance rules:
- support: the indicators, higher timeframes, and regime mostly agree
with the proposed direction and none of the conflicts above appear.
@@ -183,19 +197,22 @@ BUY and exhaustion 0 -> {"stance": "support", "confidence": 70,
_RISK_SYSTEM = """\
You are the risk manager on a gold (XAU/USD) trade-review panel. You
receive JSON: a proposed trade brief ("brief") plus the risk facts —
the trade "setup" (entry, stop_loss, take_profit_1/2, risk_reward,
risk_dollars), the "volatility" regime, engine "warnings", the calendar
"event_risk" level, and the assumed "spread".
the trade "setup" (strategy, optional setup pattern, entry, stop,
advisory targets, risk, strategy cost assumption, and trailing policy),
the "volatility" regime, engine "warnings", and calendar "event_risk".
When trailing.enabled is true, TP1/TP2 are reference levels rather than
hard exits: profit can run beyond them, and the stop starts ratcheting
after arm_r using distance_r. Never evaluate that setup as a static TP1
trade. "round_trip_cost" is a strategy assumption, not a broker or data-
provider quote.
Judge ONE thing: is this setup's risk acceptable as constructed? Check
reward-to-risk adequacy, stop distance versus the volatility regime
(a tight stop in extreme vol dies to noise; a huge stop in low vol is
oversized), spread as a fraction of the stop, and event landmines about
to hit.
stop distance versus volatility, cost as a fraction of risk, trailing
geometry, and event landmines about to hit.
Stance rules:
- support: the likely reward clearly justifies the risk, the stop fits
- support: the trailing opportunity justifies the risk, the stop fits
the current volatility, and no event landmine is imminent.
- oppose: reward too thin for the risk, a stop mismatched to the
volatility, spread eating a big share of the stop, an imminent
- oppose: a stop/trailing policy mismatched to volatility, strategy cost
eating a big share of the stop, an imminent
high-impact event — or no setup at all (a missing setup means the
signal graded too low for one; oppose unless the rest is
exceptional).
@@ -203,17 +220,21 @@ Stance rules:
attractive.
Example: RR 1.4 with volatility extreme and a high-impact event due ->
{"stance": "oppose", "confidence": 65,
"note": "The possible profit is small for such wild price swings, and big economic news is due soon."}
Example: RR 2.4 with normal volatility and no events due ->
"note": "The stop and trailing plan do not suit wild price swings before major news."}
Example: trailing enabled with normal volatility, low cost versus risk,
and no events due ->
{"stance": "support", "confidence": 70,
"note": "The possible profit is more than double the possible loss, and conditions look calm."}
"note": "The stop and trailing plan fit calm conditions, with low costs and no major news."}
""" + _TAKE_CONTRACT
_CONTRARIAN_SYSTEM = """\
You are the contrarian on a gold (XAU/USD) trade-review panel — the
designated skeptic. You receive JSON: a proposed trade brief ("brief"),
the engine's own "bullish_reasons" and "bearish_reasons", its
"warnings", and an optional "news_summary".
the timestamped latest-closed-bar "market_snapshot", the complete trade
"setup" and strategy context, visible primary-timeframe "indicators",
filtered directional "news", the engine's bullish/bearish reasons, and
its warnings. Neutral and unrelated news was removed by code; trust each
headline's code-computed gold_influence instead of reinterpreting it.
Your job: construct the strongest honest case AGAINST the proposed
trade, then score how damaging that case is. Use "oppose" only when the
counter-case is genuinely strong, "neutral" when it is real but modest,
@@ -230,9 +251,11 @@ JSON: the proposed trade "direction" and the panel's "takes" — one per
analyst ("persona", "stance", "confidence", "note").
Weigh the takes into ONE consensus on whether to act on the signal:
"AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it),
or "NEUTRAL" (genuinely split or low conviction). Weigh a high-
confidence oppose from any single analyst seriously — one strong veto
can outweigh two lukewarm supports. Do not average blindly.
or "NEUTRAL" (genuinely split or low conviction). The risk manager and
contrarian have intentionally defensive roles; their objections are not
automatic vetoes. Do not count repeated versions of the same warning as
independent evidence. Weigh role relevance, evidence quality, and
confidence rather than averaging blindly.
Base the verdict and rationale ONLY on the takes given — do not add
outside analysis.
Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79
@@ -266,6 +289,7 @@ class PanelVerdict:
rationale: str
takes: List[PersonaTake] = field(default_factory=list)
created_ts: float = 0.0
payload_hash: str = ""
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
@@ -307,49 +331,181 @@ def _brief(report: "SignalReport") -> Dict[str, Any]:
}
def _primary_indicator_payload(report: "SignalReport") -> Dict[str, Dict[str, Any]]:
indicators: Dict[str, Dict[str, Any]] = {}
analysis = report.primary_analysis
if analysis is None:
return indicators
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
return indicators
def _strategy_context(report: "SignalReport") -> Dict[str, Any]:
setup = report.trade_setup
code = getattr(setup, "strategy", None) if setup is not None else None
meanings = {
"XAU_ATR": "gold ATR setup produced by the scored technical signal",
"XAU_PB": "trend-continuation pullback to EMA21 or Keltner mid-line",
"XAU_CP": "completed classical chart-pattern entry",
"GENERIC": "swing and ATR setup produced by the scored technical signal",
}
out: Dict[str, Any] = {
"code": code or "NO_SETUP",
"meaning": meanings.get(code, "no executable trade setup was produced"),
}
pattern = getattr(setup, "setup_pattern", None) if setup is not None else None
if pattern:
out["setup_pattern"] = str(pattern)
return out
def _trade_setup_payload(report: "SignalReport") -> Optional[Dict[str, Any]]:
setup = report.trade_setup
if setup is None:
return None
entry = _finite(getattr(setup, "entry_price", None))
stop = _finite(getattr(setup, "stop_loss", None))
risk = abs(entry - stop) if entry is not None and stop is not None else None
cost = _finite(getattr(setup, "round_trip_cost", None))
arm = _finite(getattr(setup, "trail_arm_r", None))
distance = _finite(getattr(setup, "trail_dist_r", None))
out: Dict[str, Any] = {
"direction": getattr(setup, "direction", None),
"strategy": _strategy_context(report),
"entry": round(entry, 2) if entry is not None else None,
"stop_loss": round(stop, 2) if stop is not None else None,
"take_profit_1": round(float(setup.take_profit_1), 2),
"take_profit_2": round(float(setup.take_profit_2), 2),
"risk_reward_to_advisory_tp1": round(float(setup.risk_reward), 2),
"risk_price_distance": round(risk, 2) if risk is not None else None,
"round_trip_cost": round(cost, 2) if cost is not None else None,
"trailing": {
"enabled": bool(arm is not None and arm > 0),
"arm_r": round(arm, 2) if arm is not None else None,
"distance_r": round(distance, 2) if distance is not None else None,
"tp1_tp2_are_advisory": bool(arm is not None and arm > 0),
},
}
analysis = report.primary_analysis
atr = _finite((analysis.key_levels or {}).get("atr")) \
if analysis is not None else None
if atr is not None and atr > 0 and risk is not None:
out["atr"] = round(atr, 2)
out["stop_distance_atr"] = round(risk / atr, 2)
if risk and cost is not None:
out["cost_to_risk_pct"] = round(100.0 * cost / risk, 2)
return out
def _market_snapshot(report: "SignalReport") -> Dict[str, Any]:
return {
"price": round(report.current_price, 2),
"bar_timestamp": report.timestamp,
"basis": "latest_closed_candle",
}
def _headline_gold_score(headline: Any) -> Optional[float]:
"""Deterministic XAU/USD influence from an already-scored headline."""
by_currency = getattr(headline, "sentiment_by_currency", None) or {}
xau = _finite(by_currency.get("XAU"))
usd = _finite(by_currency.get("USD"))
if xau is not None or usd is not None:
score = (xau or 0.0) - (usd or 0.0)
else:
mentioned = set(getattr(headline, "currencies_mentioned", None) or [])
raw = _finite(getattr(headline, "sentiment_score", None))
if raw is None:
return None
if "XAU" in mentioned and "USD" in mentioned:
return None # pair direction is ambiguous without per-currency scores
if "XAU" in mentioned:
score = raw
elif "USD" in mentioned:
score = -raw
else:
return None
return max(-1.0, min(1.0, score)) if math.isfinite(score) else None
def _directional_news_payload(
context: Optional["FundamentalContext"], limit: int = 10
) -> List[Dict[str, Any]]:
if context is None:
return []
out: List[Dict[str, Any]] = []
for headline in context.news_headlines:
score = _headline_gold_score(headline)
if score is None or abs(score) < 0.1:
continue
timestamp = getattr(headline, "timestamp", None)
out.append({
"headline": str(getattr(headline, "headline", "")),
"published_at": (timestamp.isoformat()
if hasattr(timestamp, "isoformat") else str(timestamp or "")),
"gold_influence": "bullish" if score > 0 else "bearish",
"score": round(score, 3),
})
if len(out) >= limit:
break
return out
def _aggregate_sentiment_payload(report: "SignalReport") -> Optional[Dict[str, Any]]:
score = _finite(report.news_sentiment)
if score is None or abs(score) < 0.1:
return None
return {
"gold_influence": "bullish" if score > 0 else "bearish",
"score": round(score, 3),
}
def _macro_payload(report: "SignalReport",
context: Optional["FundamentalContext"]) -> Dict[str, Any]:
headlines: List[str] = []
events: List[Dict[str, Any]] = []
if context is not None:
headlines = [h.headline for h in context.news_headlines[:10]]
events = [
{"event": e.event, "currency": e.currency,
"actual": e.actual, "forecast": e.forecast,
"time": e.time.isoformat(), "actual": e.actual,
"forecast": e.forecast, "previous": e.previous,
"surprise": e.surprise,
"minutes_until": round(e.minutes_until, 1),
"released": not e.is_upcoming}
for e in context.events_today if e.is_high_impact
][:8]
return {
payload: Dict[str, Any] = {
"brief": _brief(report),
"headlines": headlines,
"headlines": _directional_news_payload(context),
"events": events,
"sentiment": round(report.news_sentiment, 3),
"news_summary": report.news_summary,
}
sentiment = _aggregate_sentiment_payload(report)
if sentiment is not None:
payload["sentiment"] = sentiment
return payload
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.
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()}
payload: Dict[str, Any] = {
"brief": _brief(report),
"indicators": indicators,
"strategy": _strategy_context(report),
"indicators": _primary_indicator_payload(report),
"mtf": {tf: {"signal": sig.name,
"score": round(report.mtf_scores.get(tf, 0.0), 1)}
for tf, sig in report.mtf_signals.items()},
@@ -368,29 +524,25 @@ def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
"warnings": list(report.warnings),
"event_risk": report.event_risk,
"event_description": report.event_description,
"spread": round(report.spread_estimate, 2),
}
ts = report.trade_setup
if ts is not None:
payload["setup"] = {
"direction": ts.direction,
"entry": round(ts.entry_price, 2),
"stop_loss": round(ts.stop_loss, 2),
"take_profit_1": round(ts.take_profit_1, 2),
"take_profit_2": round(ts.take_profit_2, 2),
"risk_reward": round(ts.risk_reward, 2),
"risk_dollars": round(ts.risk_pips * ts.pip_value, 2),
}
setup = _trade_setup_payload(report)
if setup is not None:
payload["setup"] = setup
return payload
def _contrarian_payload(report: "SignalReport") -> Dict[str, Any]:
def _contrarian_payload(
report: "SignalReport", context: Optional["FundamentalContext"] = None
) -> Dict[str, Any]:
return {
"brief": _brief(report),
"market_snapshot": _market_snapshot(report),
"setup": _trade_setup_payload(report),
"indicators": _primary_indicator_payload(report),
"news": _directional_news_payload(context),
"bullish_reasons": report.bullish_reasons[:3],
"bearish_reasons": report.bearish_reasons[:3],
"warnings": list(report.warnings),
"news_summary": report.news_summary,
}
@@ -502,15 +654,58 @@ def _validate_take(persona: str, reply: Any) -> Optional[PersonaTake]:
def _ask_persona(persona: str, system: str,
payload: Dict[str, Any]) -> Optional[PersonaTake]:
payload: Dict[str, Any]) -> Optional[PersonaTake]:
# Client resolved at call time: tests install stubs via lc._client.
reply = get_llm_client().chat_json(system, payload)
return _validate_take(persona, reply)
def _panel_jobs(
report: "SignalReport",
context: Optional["FundamentalContext"],
candles: Optional[Dict[str, Any]],
) -> Tuple[Tuple[str, str, Dict[str, Any]], ...]:
return (
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
("technical", _TECH_SYSTEM, _technical_payload(report, candles)),
("risk", _RISK_SYSTEM, _risk_payload(report)),
("contrarian", _CONTRARIAN_SYSTEM,
_contrarian_payload(report, context)),
)
def _panel_payload_hash(
jobs: Tuple[Tuple[str, str, Dict[str, Any]], ...],
ignore_bar_timestamp: bool = False,
) -> str:
client = get_llm_client()
personas = []
for persona, system, payload in jobs:
payload_copy = json.loads(json.dumps(payload, allow_nan=False))
if ignore_bar_timestamp and persona == "contrarian":
snapshot = payload_copy.get("market_snapshot")
if isinstance(snapshot, dict):
snapshot.pop("bar_timestamp", None)
personas.append({
"persona": persona, "system": system, "payload": payload_copy,
})
envelope = {
"contract": "agent-panel-input-v2",
"model": getattr(client, "_model", client.__class__.__name__),
"personas": personas,
"synthesizer_system": _SYNTH_SYSTEM,
}
raw = json.dumps(
envelope, sort_keys=True, separators=(",", ":"),
ensure_ascii=True, allow_nan=False,
).encode("utf-8")
return "sha256:" + hashlib.sha256(raw).hexdigest()
def run_panel(report: "SignalReport",
context: Optional["FundamentalContext"] = None,
candles: Optional[Dict[str, Any]] = None,
jobs: Optional[Tuple[Tuple[str, str, Dict[str, Any]], ...]] = None,
) -> Optional[PanelVerdict]:
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
@@ -522,12 +717,8 @@ def run_panel(report: "SignalReport",
direction = _direction(report)
if direction is None:
return None
jobs = (
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
("technical", _TECH_SYSTEM, _technical_payload(report, candles)),
("risk", _RISK_SYSTEM, _risk_payload(report)),
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
)
jobs = jobs or _panel_jobs(report, context, candles)
payload_hash = _panel_payload_hash(jobs)
futures = [_pool.submit(_ask_persona, name, system, payload)
for name, system, payload in jobs]
# The HTTP timeout inside the client bounds each call; the margin
@@ -558,7 +749,7 @@ def run_panel(report: "SignalReport",
rationale = str(reply.get("rationale", "")).strip()[:240]
return PanelVerdict(verdict=verdict, confidence=confidence,
rationale=rationale, takes=takes,
created_ts=time.time())
created_ts=time.time(), payload_hash=payload_hash)
# ── Cache, budget, attach ────────────────────────────────────────────
@@ -566,13 +757,14 @@ def run_panel(report: "SignalReport",
_state_lock = threading.Lock()
# Exact result per reviewed bar: repeated attaches on the same signal
# (re-scan, multi-consumer) are free.
_cache: "OrderedDict[Tuple[str, str, str, str], PanelVerdict]" = OrderedDict()
_cache: "OrderedDict[Tuple[str, str, str, str, str], PanelVerdict]" = OrderedDict()
_CACHE_MAX = 32
# Reuse layer: a persisting signal (same symbol/tf/direction, newer bar)
# re-serves its last verdict for reuse_ttl_seconds instead of re-spending
# 5 requests per bar (see module docstring for the M5 dashboard math).
_last_by_dir: Dict[Tuple[str, str, str], PanelVerdict] = {}
# UTC-day budget of panel attempts (mirrors llm_client._under_cap).
# Reuse only when the full prompt + evidence payload is byte-equivalent.
# Direction-only reuse served stale warnings/setups to materially changed
# signals; the daily panel budget remains the hard spend bound.
_last_by_payload: Dict[str, PanelVerdict] = {}
_inflight: Dict[str, threading.Event] = {}
# Per-process UTC-day budget of panel attempts (mirrors llm_client._under_cap).
_panel_day: Optional[_dt.date] = None
_panel_count: int = 0
_warned_budget = False
@@ -583,7 +775,10 @@ def reset_panel_state() -> None:
global _panel_day, _panel_count, _warned_budget
with _state_lock:
_cache.clear()
_last_by_dir.clear()
_last_by_payload.clear()
for event in _inflight.values():
event.set()
_inflight.clear()
_panel_day = None
_panel_count = 0
_warned_budget = False
@@ -619,13 +814,19 @@ def _under_panel_budget(cap: int) -> bool:
return True
def _write_fields(report: "SignalReport", v: PanelVerdict) -> None:
def _write_fields(
report: "SignalReport", v: PanelVerdict, cache_status: str
) -> None:
report.panel_verdict = v.verdict
report.panel_confidence = float(v.confidence)
report.panel_rationale = v.rationale
report.panel_takes = [
{"persona": t.persona, "stance": t.stance,
"confidence": t.confidence, "note": t.note} for t in v.takes]
report.panel_created_at = _dt.datetime.fromtimestamp(
v.created_ts, tz=_dt.timezone.utc).isoformat()
report.panel_cache_status = cache_status
report.panel_payload_hash = v.payload_hash
def _log_panel_verdict(report: "SignalReport") -> None:
@@ -674,28 +875,6 @@ def attach_panel(report: "SignalReport",
if direction is None:
return False
analysis = report.primary_analysis
tf = analysis.timeframe if analysis else ""
exact_key = (report.symbol, tf, report.timestamp, direction)
dir_key = (report.symbol, tf, direction)
ttl = float(settings.get("reuse_ttl_seconds",
DEFAULT_REUSE_TTL_SECONDS))
with _state_lock:
hit = _cache.get(exact_key)
if hit is None:
prior = _last_by_dir.get(dir_key)
if prior is not None and time.time() - prior.created_ts < ttl:
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",
DEFAULT_MAX_PANELS_PER_DAY))
if not _under_panel_budget(cap):
return False
if context is None:
# Best-effort; the news engine's class-level 600 s caches make
# this nearly free when warm in both bot and dashboard.
@@ -709,17 +888,82 @@ def attach_panel(report: "SignalReport",
context = None
candles = _candle_context(report, settings)
verdict = run_panel(report, context, candles=candles)
if verdict is None:
return False
_write_fields(report, verdict)
jobs = _panel_jobs(report, context, candles)
payload_hash = _panel_payload_hash(jobs)
reuse_hash = _panel_payload_hash(jobs, ignore_bar_timestamp=True)
analysis = report.primary_analysis
tf = analysis.timeframe if analysis else ""
exact_key = (report.symbol, tf, report.timestamp, direction,
payload_hash)
ttl = float(settings.get("reuse_ttl_seconds",
DEFAULT_REUSE_TTL_SECONDS))
inflight_key = reuse_hash if ttl > 0 else payload_hash
hit = None
cache_status = ""
leader = False
event: Optional[threading.Event] = None
with _state_lock:
_cache[exact_key] = verdict
while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False)
_last_by_dir[dir_key] = verdict
_log_panel_verdict(report)
return True
hit = _cache.get(exact_key)
if hit is not None:
cache_status = "EXACT_HIT"
elif ttl > 0:
prior = _last_by_payload.get(reuse_hash)
if prior is not None and time.time() - prior.created_ts < ttl:
hit = prior
cache_status = "TTL_REUSE"
if hit is None:
event = _inflight.get(inflight_key)
if event is None:
event = threading.Event()
_inflight[inflight_key] = event
leader = True
if hit is not None:
_write_fields(report, hit, cache_status)
_log_panel_verdict(report)
return True
if not leader:
# A successful leader can spend one client timeout on personas
# and another on synthesis; followers must cover both phases.
client_timeout = getattr(
get_llm_client(), "_timeout", DEFAULT_TIMEOUT)
timeout = 2.0 * client_timeout + 12.0
if event is None or not event.wait(timeout=timeout):
return False
with _state_lock:
hit = _cache.get(exact_key)
cache_status = "EXACT_HIT"
if hit is None and ttl > 0:
hit = _last_by_payload.get(reuse_hash)
cache_status = "TTL_REUSE"
if hit is None:
return False
_write_fields(report, hit, cache_status)
_log_panel_verdict(report)
return True
try:
cap = int(settings.get("max_panels_per_day",
DEFAULT_MAX_PANELS_PER_DAY))
if not _under_panel_budget(cap):
return False
verdict = run_panel(report, context, candles=candles, jobs=jobs)
if verdict is None:
return False
_write_fields(report, verdict, "MISS")
with _state_lock:
_cache[exact_key] = verdict
while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False)
_last_by_payload[reuse_hash] = verdict
_log_panel_verdict(report)
return True
finally:
with _state_lock:
current = _inflight.pop(inflight_key, None)
if current is not None:
current.set()
except Exception:
logger.exception("agent panel: attach failed — signal unaffected")
return False