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.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
Harwinder Sekhon
2026-09-03 21:16:45 -04:00
parent d681027b20
commit b8f903ad0e
9 changed files with 175 additions and 26 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
## Commands ## Commands
```bash ```bash
python test_all.py # QA suite: 402 tests, 27 modules, no network (mocked) python test_all.py # QA suite: 406 tests, 27 modules, no network (mocked)
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump, python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump,
# --panel to run the advisory LLM agent panel on-demand) # --panel to run the advisory LLM agent panel on-demand)
python telegram_bot.py # the bot (refuses to boot without credentials) python telegram_bot.py # the bot (refuses to boot without credentials)
@@ -60,7 +60,7 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
- **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, M5 SELL-in-transitioning block, H1 all-shorts block (2026-09-03), etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. - **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, M5 SELL-in-transitioning block, H1 all-shorts block (2026-09-03), etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1].
- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped). - **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped).
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that. - **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
- **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again. - **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again. **Verdict source (2026-09-03, owner decision):** all four personas still run and are logged (`panel_takes_json`), but `agent_panel.verdict_source` defaults to `"macro"` — the alert verdict is the macro persona's stance (support/neutral/oppose → AGREE/NEUTRAL/OPPOSE) and the synthesizer call is skipped (4 LLM calls per panel instead of 5); `"chair"` restores the four-voice synthesizer + OPPOSE guard. Re-score personas monthly with `analyze_signal_log.py --stored-candles`; weights are not to be tuned before ~300 graded outcomes with a held-out half.
- **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook. - **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook.
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups. - **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`. - **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`.
+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 takes and issues a consensus verdict (AGREE / NEUTRAL / OPPOSE with
confidence and a short rationale). 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): Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
- ADVISORY / DISPLAY-ONLY. The verdict is written onto SignalReport - 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_REUSE_TTL_SECONDS = 3600.0
DEFAULT_CANDLE_BARS_PRIMARY = 20 DEFAULT_CANDLE_BARS_PRIMARY = 20
DEFAULT_CANDLE_BARS_CONFIRM = 10 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 — # Quality ranks for the min_quality gate. Deliberately a local copy —
# telegram_bot's ranking is private and dashboard_publisher must not # telegram_bot's ranking is private and dashboard_publisher must not
@@ -294,6 +306,7 @@ class PanelVerdict:
takes: List[PersonaTake] = field(default_factory=list) takes: List[PersonaTake] = field(default_factory=list)
created_ts: float = 0.0 created_ts: float = 0.0
payload_hash: str = "" payload_hash: str = ""
source: str = "chair" # "macro" | "chair" — which path produced this
# ── Payload builders (pure functions; llm_client json-dumps sorted) ── # ── Payload builders (pure functions; llm_client json-dumps sorted) ──
@@ -719,10 +732,14 @@ def run_panel(report: "SignalReport",
context: Optional["FundamentalContext"] = None, context: Optional["FundamentalContext"] = None,
candles: Optional[Dict[str, Any]] = None, candles: Optional[Dict[str, Any]] = None,
jobs: Optional[Tuple[Tuple[str, str, Dict[str, Any]], ...]] = None, jobs: Optional[Tuple[Tuple[str, str, Dict[str, Any]], ...]] = None,
verdict_source: str = DEFAULT_VERDICT_SOURCE,
) -> Optional[PanelVerdict]: ) -> 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 request; the synthesizer's reply is validated strictly and there is
deliberately NO programmatic fallback verdict — a panel that could deliberately NO programmatic fallback verdict — a panel that could
not really convene reports nothing rather than something invented. not really convene reports nothing rather than something invented.
@@ -747,6 +764,15 @@ def run_panel(report: "SignalReport",
return None return None
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3} order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
takes.sort(key=lambda t: order.get(t.persona, 9)) 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, { reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
"direction": direction, "direction": direction,
"takes": [{"persona": t.persona, "stance": t.stance, "takes": [{"persona": t.persona, "stance": t.stance,
@@ -764,7 +790,8 @@ def run_panel(report: "SignalReport",
verdict, confidence, rationale, takes) verdict, confidence, rationale, takes)
return PanelVerdict(verdict=verdict, confidence=confidence, return PanelVerdict(verdict=verdict, confidence=confidence,
rationale=rationale, takes=takes, 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). # 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() _state_lock = threading.Lock()
# Exact result per reviewed bar: repeated attaches on the same signal # Exact result per reviewed bar: repeated attaches on the same signal
# (re-scan, multi-consumer) are free. # (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 _CACHE_MAX = 32
# Reuse only when the full prompt + evidence payload is byte-equivalent. # Reuse only when the full prompt + evidence payload is byte-equivalent.
# Direction-only reuse served stale warnings/setups to materially changed # Direction-only reuse served stale warnings/setups to materially changed
# signals; the daily panel budget remains the hard spend bound. # signals; the daily panel budget remains the hard spend bound. Keyed by
_last_by_payload: Dict[str, PanelVerdict] = {} # (reuse_hash, verdict_source) so a macro-mode verdict is never served to
_inflight: Dict[str, threading.Event] = {} # 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). # Per-process UTC-day budget of panel attempts (mirrors llm_client._under_cap).
_panel_day: Optional[_dt.date] = None _panel_day: Optional[_dt.date] = None
_panel_count: int = 0 _panel_count: int = 0
@@ -877,6 +906,7 @@ def _write_fields(
v.created_ts, tz=_dt.timezone.utc).isoformat() v.created_ts, tz=_dt.timezone.utc).isoformat()
report.panel_cache_status = cache_status report.panel_cache_status = cache_status
report.panel_payload_hash = v.payload_hash report.panel_payload_hash = v.payload_hash
report.panel_verdict_source = v.source
def _log_panel_verdict(report: "SignalReport") -> None: def _log_panel_verdict(report: "SignalReport") -> None:
@@ -921,6 +951,8 @@ def attach_panel(report: "SignalReport",
if (_QUALITY_RANK.get(report.signal_quality, -1) if (_QUALITY_RANK.get(report.signal_quality, -1)
< _QUALITY_RANK.get(min_quality, 2)): < _QUALITY_RANK.get(min_quality, 2)):
return False return False
verdict_source = str(settings.get("verdict_source",
DEFAULT_VERDICT_SOURCE))
direction = _direction(report) direction = _direction(report)
if direction is None: if direction is None:
return False return False
@@ -944,10 +976,11 @@ def attach_panel(report: "SignalReport",
analysis = report.primary_analysis analysis = report.primary_analysis
tf = analysis.timeframe if analysis else "" tf = analysis.timeframe if analysis else ""
exact_key = (report.symbol, tf, report.timestamp, direction, 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", ttl = float(settings.get("reuse_ttl_seconds",
DEFAULT_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 hit = None
cache_status = "" cache_status = ""
leader = False leader = False
@@ -957,7 +990,7 @@ def attach_panel(report: "SignalReport",
if hit is not None: if hit is not None:
cache_status = "EXACT_HIT" cache_status = "EXACT_HIT"
elif ttl > 0: 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: if prior is not None and time.time() - prior.created_ts < ttl:
hit = prior hit = prior
cache_status = "TTL_REUSE" cache_status = "TTL_REUSE"
@@ -984,7 +1017,7 @@ def attach_panel(report: "SignalReport",
hit = _cache.get(exact_key) hit = _cache.get(exact_key)
cache_status = "EXACT_HIT" cache_status = "EXACT_HIT"
if hit is None and ttl > 0: 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" cache_status = "TTL_REUSE"
if hit is None: if hit is None:
return False return False
@@ -998,7 +1031,8 @@ def attach_panel(report: "SignalReport",
if not _under_panel_budget(cap): if not _under_panel_budget(cap):
return False 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: if verdict is None:
return False return False
_write_fields(report, verdict, "MISS") _write_fields(report, verdict, "MISS")
@@ -1006,7 +1040,7 @@ def attach_panel(report: "SignalReport",
_cache[exact_key] = verdict _cache[exact_key] = verdict
while len(_cache) > _CACHE_MAX: while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False) _cache.popitem(last=False)
_last_by_payload[reuse_hash] = verdict _last_by_payload[reuse_key] = verdict
_log_panel_verdict(report) _log_panel_verdict(report)
return True return True
finally: finally:
+6
View File
@@ -98,6 +98,7 @@ _KNOWN_AGENT_PANEL = {
"enabled", "telegram", "dashboard", "min_quality", "enabled", "telegram", "dashboard", "min_quality",
"max_panels_per_day", "reuse_ttl_seconds", "max_panels_per_day", "reuse_ttl_seconds",
"candles_enabled", "candle_bars_primary", "candle_bars_confirm", "candles_enabled", "candle_bars_primary", "candle_bars_confirm",
"verdict_source",
} }
_KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"} _KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"}
_KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"} _KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"}
@@ -315,6 +316,11 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
raise AppConfigError( raise AppConfigError(
f"config.json: agent_panel.{key} must be an integer " f"config.json: agent_panel.{key} must be an integer "
"between 1 and 100") "between 1 and 100")
if "verdict_source" in panel and panel["verdict_source"] not in \
("macro", "chair"):
raise AppConfigError(
'config.json: agent_panel.verdict_source must be "macro" or '
'"chair"')
app.agent_panel_settings = dict(panel) app.agent_panel_settings = dict(panel)
outlook = raw.get("market_outlook", {}) outlook = raw.get("market_outlook", {})
+2 -1
View File
@@ -46,7 +46,8 @@
"reuse_ttl_seconds": 3600, "reuse_ttl_seconds": 3600,
"candles_enabled": true, "candles_enabled": true,
"candle_bars_primary": 20, "candle_bars_primary": 20,
"candle_bars_confirm": 10 "candle_bars_confirm": 10,
"verdict_source": "macro"
}, },
"market_outlook": { "market_outlook": {
"enabled": false, "enabled": false,
+3 -1
View File
@@ -167,7 +167,9 @@ def print_signal_report(report: SignalReport, detail: bool = False):
if report.news_summary: if report.news_summary:
print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}") print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}")
if report.panel_verdict: if report.panel_verdict:
print(f" {Colors.CYAN}🤖{Colors.RESET} AI Panel: " panel_label = ("AI macro view"
if report.panel_verdict_source == "macro" else "AI Panel")
print(f" {Colors.CYAN}🤖{Colors.RESET} {panel_label}: "
f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} " f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} "
f"({report.panel_confidence:.0f}%) — {report.panel_rationale}") f"({report.panel_confidence:.0f}%) — {report.panel_rationale}")
for t in report.panel_takes: for t in report.panel_takes:
+1
View File
@@ -106,6 +106,7 @@ class SignalReport:
panel_created_at: str = "" panel_created_at: str = ""
panel_cache_status: str = "" panel_cache_status: str = ""
panel_payload_hash: str = "" panel_payload_hash: str = ""
panel_verdict_source: str = "" # "macro" / "chair" / ""
final_signal: SignalStrength = SignalStrength.NEUTRAL final_signal: SignalStrength = SignalStrength.NEUTRAL
final_score: float = 0.0 final_score: float = 0.0
+6 -2
View File
@@ -169,6 +169,7 @@ _ADDITIVE_SIGNAL_COLS = (
("bar_range_median", "REAL"), ("bar_range_median", "REAL"),
("scan_kind", "TEXT"), ("scan_kind", "TEXT"),
("config_fingerprint", "TEXT"), ("config_fingerprint", "TEXT"),
("panel_verdict_source", "TEXT"),
) )
# Read-side compatibility view `signals_with_news`: legacy rows keep # Read-side compatibility view `signals_with_news`: legacy rows keep
@@ -779,6 +780,7 @@ class SignalLogger:
created_at: Optional[str] = None, created_at: Optional[str] = None,
cache_status: Optional[str] = None, cache_status: Optional[str] = None,
payload_hash: Optional[str] = None, payload_hash: Optional[str] = None,
verdict_source: Optional[str] = None,
) -> None: ) -> None:
"""Fill panel provenance; seeds an outcome only for rows that """Fill panel provenance; seeds an outcome only for rows that
log() did not already seed (rows written by an older process).""" log() did not already seed (rows written by an older process)."""
@@ -804,11 +806,12 @@ class SignalLogger:
" panel_created_at=COALESCE(?, panel_created_at)," " panel_created_at=COALESCE(?, panel_created_at),"
" panel_cache_status=COALESCE(?, panel_cache_status)," " panel_cache_status=COALESCE(?, panel_cache_status),"
" panel_payload_hash=COALESCE(?, panel_payload_hash)," " panel_payload_hash=COALESCE(?, panel_payload_hash),"
" panel_verdict_source=COALESCE(?, panel_verdict_source),"
" advisory_outcome_status=COALESCE(advisory_outcome_status, ?)," " advisory_outcome_status=COALESCE(advisory_outcome_status, ?),"
" advisory_outcome_json=COALESCE(advisory_outcome_json, ?)" " advisory_outcome_json=COALESCE(advisory_outcome_json, ?)"
" WHERE id=?", " WHERE id=?",
(verdict, confidence, rationale, takes_json, (verdict, confidence, rationale, takes_json,
created_at, cache_status, payload_hash, created_at, cache_status, payload_hash, verdict_source,
advisory_status, advisory_json, rowid)) advisory_status, advisory_json, rowid))
self._conn.commit() self._conn.commit()
@@ -1205,7 +1208,8 @@ def log_panel_update(report: Any) -> None:
_dumps(getattr(report, "panel_takes", None) or []), _dumps(getattr(report, "panel_takes", None) or []),
created_at=getattr(report, "panel_created_at", "") or None, created_at=getattr(report, "panel_created_at", "") or None,
cache_status=getattr(report, "panel_cache_status", "") or None, cache_status=getattr(report, "panel_cache_status", "") or None,
payload_hash=getattr(report, "panel_payload_hash", "") or None) payload_hash=getattr(report, "panel_payload_hash", "") or None,
verdict_source=getattr(report, "panel_verdict_source", "") or None)
except Exception: except Exception:
logger.warning("signal_log: failed to persist panel verdict", logger.warning("signal_log: failed to persist panel verdict",
exc_info=True) exc_info=True)
+3 -1
View File
@@ -415,8 +415,10 @@ def format_signal_message(report: SignalReport) -> str:
if report.panel_verdict: if report.panel_verdict:
mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get( mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get(
report.panel_verdict, "") report.panel_verdict, "")
panel_label = ("AI macro view"
if report.panel_verdict_source == "macro" else "AI Panel")
extra.append( extra.append(
f"🤖 AI Panel {mark} *{_escape_md(report.panel_verdict)}* " f"🤖 {panel_label} {mark} *{_escape_md(report.panel_verdict)}* "
f"({report.panel_confidence:.0f}%) — " f"({report.panel_confidence:.0f}%) — "
f"{_escape_md(report.panel_rationale)}") f"{_escape_md(report.panel_rationale)}")
if report.panel_takes: if report.panel_takes:
+105 -6
View File
@@ -6319,6 +6319,22 @@ def _():
pass pass
@test("app_config: agent_panel.verdict_source validated")
def _():
assert load_app_config(_write_json(
{"agent_panel": {"verdict_source": "macro"}}
)).agent_panel_settings["verdict_source"] == "macro"
assert load_app_config(_write_json(
{"agent_panel": {"verdict_source": "chair"}}
)).agent_panel_settings["verdict_source"] == "chair"
try:
load_app_config(_write_json(
{"agent_panel": {"verdict_source": "technical"}}))
assert False, "should have raised"
except AppConfigError:
pass
import agent_panel as ap import agent_panel as ap
from signal_aggregator import SignalReport, TradeSetup from signal_aggregator import SignalReport, TradeSetup
@@ -6361,7 +6377,7 @@ _ALL_OK = {ap._MACRO_SYSTEM: _TAKE_OK, ap._TECH_SYSTEM: _TAKE_OK,
ap._SYNTH_SYSTEM: _SYNTH_OK} ap._SYNTH_SYSTEM: _SYNTH_OK}
_PANEL_ON = {"enabled": True, "telegram": True, "dashboard": False, _PANEL_ON = {"enabled": True, "telegram": True, "dashboard": False,
"min_quality": "B", "max_panels_per_day": 20, "min_quality": "B", "max_panels_per_day": 20,
"reuse_ttl_seconds": 3600} "reuse_ttl_seconds": 3600, "verdict_source": "chair"}
def _panel_report(quality="A", signal=SignalStrength.BUY): def _panel_report(quality="A", signal=SignalStrength.BUY):
@@ -6490,7 +6506,8 @@ def _():
for synth in (None, {"verdict": "MAYBE", "confidence": 50}, for synth in (None, {"verdict": "MAYBE", "confidence": 50},
{"verdict": "AGREE", "confidence": "hot"}): {"verdict": "AGREE", "confidence": "hot"}):
lc._client = _PanelStub({**_ALL_OK, ap._SYNTH_SYSTEM: synth}) lc._client = _PanelStub({**_ALL_OK, ap._SYNTH_SYSTEM: synth})
assert ap.run_panel(_panel_report()) is None, f"synth={synth}" assert ap.run_panel(_panel_report(), verdict_source="chair") is None, \
f"synth={synth}"
finally: finally:
lc._client = None lc._client = None
ap.reset_panel_state() ap.reset_panel_state()
@@ -6508,8 +6525,9 @@ def _():
stub.replies[ap._MACRO_SYSTEM] = { stub.replies[ap._MACRO_SYSTEM] = {
**stub.replies[ap._MACRO_SYSTEM], "stance": "oppose"} **stub.replies[ap._MACRO_SYSTEM], "stance": "oppose"}
lc._client = stub lc._client = stub
v = ap.run_panel(_panel_report()) v = ap.run_panel(_panel_report(), verdict_source="chair")
assert v.verdict == "OPPOSE" and v.confidence == 72.4 assert v.verdict == "OPPOSE" and v.confidence == 72.4
assert v.source == "chair"
assert v.payload_hash.startswith("sha256:") assert v.payload_hash.startswith("sha256:")
assert len(v.rationale) == 240, "rationale truncated" assert len(v.rationale) == 240, "rationale truncated"
assert [t.persona for t in v.takes] == \ assert [t.persona for t in v.takes] == \
@@ -6522,6 +6540,57 @@ def _():
ap.reset_panel_state() ap.reset_panel_state()
@test("agent panel: macro-only mode uses the macro take as the verdict and skips the chair")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
v = ap.run_panel(_panel_report())
assert v is not None
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
"macro-only mode must not spend the synthesizer"
assert len(stub.calls) == 4
assert v.verdict == "AGREE", "macro take's stance is support"
assert v.confidence == _TAKE_OK["confidence"]
assert v.rationale == _TAKE_OK["note"]
assert v.source == "macro"
assert [t.persona for t in v.takes] == \
["macro", "technical", "risk", "contrarian"]
finally:
lc._client = None
ap.reset_panel_state()
try:
stub2 = _PanelStub(dict(_ALL_OK))
stub2.replies[ap._MACRO_SYSTEM] = {**_TAKE_OK, "stance": "oppose"}
lc._client = stub2
v2 = ap.run_panel(_panel_report())
assert v2.verdict == "OPPOSE", \
"macro-only verdict follows macro even when others support"
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub2.calls]
finally:
lc._client = None
ap.reset_panel_state()
@test("agent panel: macro take missing falls back to the chair")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
stub.replies[ap._MACRO_SYSTEM] = None # _validate_take rejects it
lc._client = stub
v = ap.run_panel(_panel_report())
assert v is not None
assert ap._SYNTH_SYSTEM in [s for s, _ in stub.calls], \
"a missing macro take must fall back to the synthesizer"
assert v.source == "chair"
assert v.verdict == _SYNTH_OK["verdict"]
assert [t.persona for t in v.takes] == ["technical", "risk", "contrarian"]
finally:
lc._client = None
ap.reset_panel_state()
@test("agent_panel: NEUTRAL signal → no panel even forced") @test("agent_panel: NEUTRAL signal → no panel even forced")
def _(): def _():
try: try:
@@ -6590,6 +6659,32 @@ def _():
ap.reset_panel_state() ap.reset_panel_state()
@test("agent panel: attach_panel honours verdict_source and caches per source")
def _():
try:
stub = _PanelStub(dict(_ALL_OK))
lc._client = stub
ap._dp = _NullDP()
report = _panel_report()
assert ap.attach_panel(report, {**_PANEL_ON, "verdict_source": "macro"},
context=FundamentalContext()) is True
assert report.panel_verdict_source == "macro"
assert report.panel_cache_status == "MISS"
assert len(stub.calls) == 4
report2 = _panel_report()
assert ap.attach_panel(report2, {**_PANEL_ON, "verdict_source": "chair"},
context=FundamentalContext()) is True
assert report2.panel_verdict_source == "chair"
assert report2.panel_cache_status == "MISS", \
"a chair-mode attach must not reuse a macro-mode cached verdict"
assert len(stub.calls) == 9
finally:
lc._client = None
ap._dp = None
ap.reset_panel_state()
@test("agent_panel: daily budget exhausts and re-arms on UTC roll") @test("agent_panel: daily budget exhausts and re-arms on UTC roll")
def _(): def _():
try: try:
@@ -6972,7 +7067,8 @@ def _():
lc._client = stub lc._client = stub
try: try:
ap.reset_panel_state_for_tests() if hasattr(ap, "reset_panel_state_for_tests") else None ap.reset_panel_state_for_tests() if hasattr(ap, "reset_panel_state_for_tests") else None
verdict = ap.run_panel(_panel_report(), FundamentalContext()) verdict = ap.run_panel(_panel_report(), FundamentalContext(),
verdict_source="chair")
assert verdict is not None assert verdict is not None
assert verdict.verdict == "NEUTRAL", verdict assert verdict.verdict == "NEUTRAL", verdict
assert verdict.confidence <= 49.0 assert verdict.confidence <= 49.0
@@ -7190,13 +7286,15 @@ def _():
slog._dumps(report.panel_takes), slog._dumps(report.panel_takes),
created_at=report.panel_created_at, created_at=report.panel_created_at,
cache_status=report.panel_cache_status, cache_status=report.panel_cache_status,
payload_hash=report.panel_payload_hash) payload_hash=report.panel_payload_hash,
verdict_source="macro")
lg.close() lg.close()
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
row = conn.execute( row = conn.execute(
"SELECT panel_verdict, panel_confidence, panel_rationale," "SELECT panel_verdict, panel_confidence, panel_rationale,"
" panel_takes_json, panel_created_at, panel_cache_status," " panel_takes_json, panel_created_at, panel_cache_status,"
" panel_payload_hash, advisory_outcome_status" " panel_payload_hash, advisory_outcome_status,"
" panel_verdict_source"
" FROM signals WHERE id=?", " FROM signals WHERE id=?",
(rowid,)).fetchone() (rowid,)).fetchone()
conn.close() conn.close()
@@ -7206,6 +7304,7 @@ def _():
assert row[4] == report.panel_created_at and row[5] == "MISS" assert row[4] == report.panel_created_at and row[5] == "MISS"
assert row[6] == report.panel_payload_hash assert row[6] == report.panel_payload_hash
assert row[7] == "PENDING" assert row[7] == "PENDING"
assert row[8] == "macro"
finally: finally:
slog.reset_loggers_for_tests() slog.reset_loggers_for_tests()
os.remove(path) os.remove(path)