agent_panel: alerts use the macro persona's verdict; all 4 takes still logged

agent_panel.verdict_source defaults to "macro": the macro take's stance is the
verdict, the synthesizer call is skipped (4 LLM calls per panel instead of 5);
"chair" restores the four-voice consensus + OPPOSE guard. Verdict source is
cached per mode and logged (panel_verdict_source). 406 tests.

Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
Harwinder Sekhon
2026-09-03 21:16:45 -04:00
parent 354778683e
commit 593ddfe81f
9 changed files with 175 additions and 26 deletions
+47 -13
View File
@@ -7,6 +7,13 @@ SignalReport from their own angle, then a synthesizer chair weighs the
takes and issues a consensus verdict (AGREE / NEUTRAL / OPPOSE with
confidence and a short rationale).
Verdict source (2026-09-03, owner decision): all four personas always
run and are logged, but by default (``agent_panel.verdict_source =
"macro"``) the verdict shown to consumers is the macro persona's own
stance, and the synthesizer call is skipped (4 LLM calls per panel
instead of 5); ``"chair"`` restores the synthesizer + OPPOSE-guard path
below.
Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
- ADVISORY / DISPLAY-ONLY. The verdict is written onto SignalReport
@@ -80,6 +87,11 @@ DEFAULT_MAX_PANELS_PER_DAY = 20
DEFAULT_REUSE_TTL_SECONDS = 3600.0
DEFAULT_CANDLE_BARS_PRIMARY = 20
DEFAULT_CANDLE_BARS_CONFIRM = 10
DEFAULT_VERDICT_SOURCE = "macro"
# Maps a persona's stance straight onto the verdict vocabulary when the
# macro take stands in for the chair.
_STANCE_TO_VERDICT = {"support": "AGREE", "neutral": "NEUTRAL", "oppose": "OPPOSE"}
# Quality ranks for the min_quality gate. Deliberately a local copy —
# telegram_bot's ranking is private and dashboard_publisher must not
@@ -294,6 +306,7 @@ class PanelVerdict:
takes: List[PersonaTake] = field(default_factory=list)
created_ts: float = 0.0
payload_hash: str = ""
source: str = "chair" # "macro" | "chair" — which path produced this
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
@@ -719,10 +732,14 @@ 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,
verdict_source: str = DEFAULT_VERDICT_SOURCE,
) -> Optional[PanelVerdict]:
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
"""Run 4 personas, then either take the macro persona's stance as the
verdict (``verdict_source="macro"``, default — the synthesizer is not
called) or weigh all four takes through the synthesizer chair
(``"chair"``); None = no verdict (fail-safe).
Requires ≥2 valid persona takes before spending the synthesizer
Requires ≥2 valid persona takes before spending a synthesizer
request; the synthesizer's reply is validated strictly and there is
deliberately NO programmatic fallback verdict — a panel that could
not really convene reports nothing rather than something invented.
@@ -747,6 +764,15 @@ def run_panel(report: "SignalReport",
return None
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
takes.sort(key=lambda t: order.get(t.persona, 9))
if verdict_source == "macro":
macro = next((t for t in takes if t.persona == "macro"), None)
if macro is not None:
return PanelVerdict(
verdict=_STANCE_TO_VERDICT[macro.stance],
confidence=macro.confidence, rationale=macro.note[:240],
takes=takes, created_ts=time.time(),
payload_hash=payload_hash, source="macro")
# Macro take missing (timed out / invalid) — fall back to the chair.
reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
"direction": direction,
"takes": [{"persona": t.persona, "stance": t.stance,
@@ -764,7 +790,8 @@ def run_panel(report: "SignalReport",
verdict, confidence, rationale, takes)
return PanelVerdict(verdict=verdict, confidence=confidence,
rationale=rationale, takes=takes,
created_ts=time.time(), payload_hash=payload_hash)
created_ts=time.time(), payload_hash=payload_hash,
source="chair")
# Deterministic backstop for the chair (ADVISORY-ONLY, no gate reads it).
@@ -807,13 +834,15 @@ def _apply_oppose_guard(verdict: str, confidence: float, rationale: str,
_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, str], PanelVerdict]" = OrderedDict()
_cache: "OrderedDict[Tuple[str, str, str, str, str, str], PanelVerdict]" = OrderedDict()
_CACHE_MAX = 32
# 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] = {}
# signals; the daily panel budget remains the hard spend bound. Keyed by
# (reuse_hash, verdict_source) so a macro-mode verdict is never served to
# a chair-mode attach (and vice versa).
_last_by_payload: Dict[Tuple[str, str], PanelVerdict] = {}
_inflight: Dict[Any, 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
@@ -877,6 +906,7 @@ def _write_fields(
v.created_ts, tz=_dt.timezone.utc).isoformat()
report.panel_cache_status = cache_status
report.panel_payload_hash = v.payload_hash
report.panel_verdict_source = v.source
def _log_panel_verdict(report: "SignalReport") -> None:
@@ -921,6 +951,8 @@ def attach_panel(report: "SignalReport",
if (_QUALITY_RANK.get(report.signal_quality, -1)
< _QUALITY_RANK.get(min_quality, 2)):
return False
verdict_source = str(settings.get("verdict_source",
DEFAULT_VERDICT_SOURCE))
direction = _direction(report)
if direction is None:
return False
@@ -944,10 +976,11 @@ def attach_panel(report: "SignalReport",
analysis = report.primary_analysis
tf = analysis.timeframe if analysis else ""
exact_key = (report.symbol, tf, report.timestamp, direction,
payload_hash)
payload_hash, verdict_source)
reuse_key = (reuse_hash, verdict_source)
ttl = float(settings.get("reuse_ttl_seconds",
DEFAULT_REUSE_TTL_SECONDS))
inflight_key = reuse_hash if ttl > 0 else payload_hash
inflight_key = reuse_key if ttl > 0 else payload_hash
hit = None
cache_status = ""
leader = False
@@ -957,7 +990,7 @@ def attach_panel(report: "SignalReport",
if hit is not None:
cache_status = "EXACT_HIT"
elif ttl > 0:
prior = _last_by_payload.get(reuse_hash)
prior = _last_by_payload.get(reuse_key)
if prior is not None and time.time() - prior.created_ts < ttl:
hit = prior
cache_status = "TTL_REUSE"
@@ -984,7 +1017,7 @@ def attach_panel(report: "SignalReport",
hit = _cache.get(exact_key)
cache_status = "EXACT_HIT"
if hit is None and ttl > 0:
hit = _last_by_payload.get(reuse_hash)
hit = _last_by_payload.get(reuse_key)
cache_status = "TTL_REUSE"
if hit is None:
return False
@@ -998,7 +1031,8 @@ def attach_panel(report: "SignalReport",
if not _under_panel_budget(cap):
return False
verdict = run_panel(report, context, candles=candles, jobs=jobs)
verdict = run_panel(report, context, candles=candles, jobs=jobs,
verdict_source=verdict_source)
if verdict is None:
return False
_write_fields(report, verdict, "MISS")
@@ -1006,7 +1040,7 @@ def attach_panel(report: "SignalReport",
_cache[exact_key] = verdict
while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False)
_last_by_payload[reuse_hash] = verdict
_last_by_payload[reuse_key] = verdict
_log_panel_verdict(report)
return True
finally: