agent_panel: harden evidence and track outcomes
This commit is contained in:
+365
-121
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user