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:
@@ -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`.
|
||||
|
||||
+47
-13
@@ -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:
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
+3
-1
@@ -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:
|
||||
|
||||
+105
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user