From b8f903ad0ebc80af8b38c2cb30decbb32b052879 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 3 Sep 2026 21:16:45 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL --- CLAUDE.md | 4 +- agent_panel.py | 60 ++++++++++++++++++----- app_config.py | 6 +++ config.json.example | 3 +- main.py | 4 +- signal_aggregator.py | 1 + signal_log.py | 8 +++- telegram_bot.py | 4 +- test_all.py | 111 ++++++++++++++++++++++++++++++++++++++++--- 9 files changed, 175 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e22bac3..a55da2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest ## Commands ```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, # --panel to run the advisory LLM agent panel on-demand) 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]. - **`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. -- **`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. - **`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`. diff --git a/agent_panel.py b/agent_panel.py index 85539b2..a74f5cf 100644 --- a/agent_panel.py +++ b/agent_panel.py @@ -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: diff --git a/app_config.py b/app_config.py index 43f4296..ae6f502 100644 --- a/app_config.py +++ b/app_config.py @@ -98,6 +98,7 @@ _KNOWN_AGENT_PANEL = { "enabled", "telegram", "dashboard", "min_quality", "max_panels_per_day", "reuse_ttl_seconds", "candles_enabled", "candle_bars_primary", "candle_bars_confirm", + "verdict_source", } _KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"} _KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"} @@ -315,6 +316,11 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig: raise AppConfigError( f"config.json: agent_panel.{key} must be an integer " "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) outlook = raw.get("market_outlook", {}) diff --git a/config.json.example b/config.json.example index 43761c2..b87079e 100644 --- a/config.json.example +++ b/config.json.example @@ -46,7 +46,8 @@ "reuse_ttl_seconds": 3600, "candles_enabled": true, "candle_bars_primary": 20, - "candle_bars_confirm": 10 + "candle_bars_confirm": 10, + "verdict_source": "macro" }, "market_outlook": { "enabled": false, diff --git a/main.py b/main.py index 28752cf..91b2bb1 100755 --- a/main.py +++ b/main.py @@ -167,7 +167,9 @@ def print_signal_report(report: SignalReport, detail: bool = False): if report.news_summary: print(f" {Colors.MAGENTA}🧠{Colors.RESET} {report.news_summary}") 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"({report.panel_confidence:.0f}%) — {report.panel_rationale}") for t in report.panel_takes: diff --git a/signal_aggregator.py b/signal_aggregator.py index 3181291..baad38c 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -106,6 +106,7 @@ class SignalReport: panel_created_at: str = "" panel_cache_status: str = "" panel_payload_hash: str = "" + panel_verdict_source: str = "" # "macro" / "chair" / "" final_signal: SignalStrength = SignalStrength.NEUTRAL final_score: float = 0.0 diff --git a/signal_log.py b/signal_log.py index 9b00913..350e6bf 100644 --- a/signal_log.py +++ b/signal_log.py @@ -169,6 +169,7 @@ _ADDITIVE_SIGNAL_COLS = ( ("bar_range_median", "REAL"), ("scan_kind", "TEXT"), ("config_fingerprint", "TEXT"), + ("panel_verdict_source", "TEXT"), ) # Read-side compatibility view `signals_with_news`: legacy rows keep @@ -779,6 +780,7 @@ class SignalLogger: created_at: Optional[str] = None, cache_status: Optional[str] = None, payload_hash: Optional[str] = None, + verdict_source: Optional[str] = None, ) -> None: """Fill panel provenance; seeds an outcome only for rows that 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_cache_status=COALESCE(?, panel_cache_status)," " panel_payload_hash=COALESCE(?, panel_payload_hash)," + " panel_verdict_source=COALESCE(?, panel_verdict_source)," " advisory_outcome_status=COALESCE(advisory_outcome_status, ?)," " advisory_outcome_json=COALESCE(advisory_outcome_json, ?)" " WHERE id=?", (verdict, confidence, rationale, takes_json, - created_at, cache_status, payload_hash, + created_at, cache_status, payload_hash, verdict_source, advisory_status, advisory_json, rowid)) self._conn.commit() @@ -1205,7 +1208,8 @@ def log_panel_update(report: Any) -> None: _dumps(getattr(report, "panel_takes", None) or []), created_at=getattr(report, "panel_created_at", "") 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: logger.warning("signal_log: failed to persist panel verdict", exc_info=True) diff --git a/telegram_bot.py b/telegram_bot.py index 4a618f8..f6ca59c 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -415,8 +415,10 @@ def format_signal_message(report: SignalReport) -> str: if report.panel_verdict: mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get( report.panel_verdict, "") + panel_label = ("AI macro view" + if report.panel_verdict_source == "macro" else "AI Panel") 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"{_escape_md(report.panel_rationale)}") if report.panel_takes: diff --git a/test_all.py b/test_all.py index 5eb2299..c12fafe 100644 --- a/test_all.py +++ b/test_all.py @@ -6319,6 +6319,22 @@ def _(): 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 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} _PANEL_ON = {"enabled": True, "telegram": True, "dashboard": False, "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): @@ -6490,7 +6506,8 @@ def _(): for synth in (None, {"verdict": "MAYBE", "confidence": 50}, {"verdict": "AGREE", "confidence": "hot"}): 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: lc._client = None ap.reset_panel_state() @@ -6508,8 +6525,9 @@ def _(): stub.replies[ap._MACRO_SYSTEM] = { **stub.replies[ap._MACRO_SYSTEM], "stance": "oppose"} 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.source == "chair" assert v.payload_hash.startswith("sha256:") assert len(v.rationale) == 240, "rationale truncated" assert [t.persona for t in v.takes] == \ @@ -6522,6 +6540,57 @@ def _(): 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") def _(): try: @@ -6590,6 +6659,32 @@ def _(): 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") def _(): try: @@ -6972,7 +7067,8 @@ def _(): lc._client = stub try: 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.verdict == "NEUTRAL", verdict assert verdict.confidence <= 49.0 @@ -7190,13 +7286,15 @@ def _(): slog._dumps(report.panel_takes), created_at=report.panel_created_at, cache_status=report.panel_cache_status, - payload_hash=report.panel_payload_hash) + payload_hash=report.panel_payload_hash, + verdict_source="macro") lg.close() conn = sqlite3.connect(path) row = conn.execute( "SELECT panel_verdict, panel_confidence, panel_rationale," " 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=?", (rowid,)).fetchone() conn.close() @@ -7206,6 +7304,7 @@ def _(): assert row[4] == report.panel_created_at and row[5] == "MISS" assert row[6] == report.panel_payload_hash assert row[7] == "PENDING" + assert row[8] == "macro" finally: slog.reset_loggers_for_tests() os.remove(path)