Compare commits
10 Commits
main
..
agent-panel
| Author | SHA1 | Date | |
|---|---|---|---|
| 887aaed6f2 | |||
| 7e8ed754ff | |||
| 9769a0c84d | |||
| 964239e67d | |||
| 5e3758bd53 | |||
| c146fffb5b | |||
| 38c81251e8 | |||
| a7c2bfc7da | |||
| 7be2231eb3 | |||
| 0b604f1ad0 |
@@ -11,8 +11,9 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python test_all.py # QA suite: 359 tests, 22 modules, no network (mocked)
|
python test_all.py # QA suite: 374 tests, 23 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)
|
||||||
python telegram_bot.py # the bot (refuses to boot without credentials)
|
python telegram_bot.py # the bot (refuses to boot without credentials)
|
||||||
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
||||||
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
|
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
|
||||||
@@ -34,7 +35,7 @@ Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy
|
|||||||
|
|
||||||
## Configuration & credentials
|
## Configuration & credentials
|
||||||
|
|
||||||
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap).
|
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap). The `agent_panel` config.json block (all-defaults = feature dark) controls the advisory LLM agent panel: `enabled`, per-surface `telegram`/`dashboard` toggles, `min_quality`, `max_panels_per_day`, `reuse_ttl_seconds`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
|||||||
- **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters.
|
- **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters.
|
||||||
- **`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, 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, 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].
|
||||||
- **`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. Static prompts (prefix caching); spend bounded by an exact per-bar cache, a reuse TTL keyed (symbol, TF, direction) so persisting signals don't re-spend every bar, and a `max_panels_per_day` UTC budget (default 20 ≈ ≤100 requests) on top of the shared 500/day client cap. `<2` valid persona takes skips the synthesizer; any failure degrades to no panel, never a changed signal.
|
||||||
- **`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), 📰 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). 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), 📰 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). Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
|
||||||
- **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown (wall-clock approximation, so it expires over weekends where the backtester would still count bars), trail ratchets at bar close once per *observed* bar (a missed/failed cycle skips those bars entirely — the intra-gap high/low never enters the peak). Accepted divergence: one position total; orders send `positionFill=OPEN_ONLY` so a state/broker divergence cancels the order instead of silently netting. State/broker re-sync runs at boot and after any suspicious fill (`_reconcile_positions`).
|
- **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown (wall-clock approximation, so it expires over weekends where the backtester would still count bars), trail ratchets at bar close once per *observed* bar (a missed/failed cycle skips those bars entirely — the intra-gap high/low never enters the peak). Accepted divergence: one position total; orders send `positionFill=OPEN_ONLY` so a state/broker divergence cancels the order instead of silently netting. State/broker re-sync runs at boot and after any suspicious fill (`_reconcile_positions`).
|
||||||
- **`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`/`oanda_daemon`** — 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`/`oanda_daemon`** — 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`.
|
||||||
|
|||||||
+582
@@ -0,0 +1,582 @@
|
|||||||
|
"""
|
||||||
|
ForexBot2 — LLM agent panel (advisory consensus)
|
||||||
|
=================================================
|
||||||
|
ai-hedge-fund-style persona panel for XAU/USD signals: four DeepSeek
|
||||||
|
personas (macro, technical, risk, contrarian) each review a finished
|
||||||
|
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).
|
||||||
|
|
||||||
|
Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
|
||||||
|
|
||||||
|
- ADVISORY / DISPLAY-ONLY. The verdict is written onto SignalReport
|
||||||
|
fields that no gate and no scoring path ever reads. Any failure —
|
||||||
|
LLM down, cap exhausted, garbage reply — degrades to "no panel",
|
||||||
|
never to a changed or blocked signal.
|
||||||
|
- NEVER runs inside ``analyze_from_dataframes``. The backtester replays
|
||||||
|
that function; consumers (telegram_bot, main --panel,
|
||||||
|
dashboard_publisher) attach the panel POST-HOC via ``attach_panel``,
|
||||||
|
so replayed books can never see a panel field. Backtest parity holds
|
||||||
|
by construction.
|
||||||
|
|
||||||
|
Cost model: every full panel spends 5 requests (4 personas + 1
|
||||||
|
synthesizer) against the shared DeepSeek daily cap (llm_client). Two
|
||||||
|
layers bound the spend on top of that cap:
|
||||||
|
|
||||||
|
- ``max_panels_per_day`` (config, default 20) — a UTC-day budget of
|
||||||
|
panel *attempts*, mirroring the client's request cap.
|
||||||
|
- A reuse TTL (default 3600 s) keyed on (symbol, timeframe, direction):
|
||||||
|
a persisting signal re-serves its cached verdict across bars instead
|
||||||
|
of re-spending. Without this, one sticky M5 dashboard signal would
|
||||||
|
burn 5 calls per 5-minute bar — 1,440/day against the 500 cap.
|
||||||
|
|
||||||
|
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
|
||||||
|
see news_engine.py); all variable content travels in the user payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait as _fwait
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app_config import get_app_config
|
||||||
|
from llm_client import DEFAULT_TIMEOUT, get_llm_client
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # type hints only — keep the coupling one-way
|
||||||
|
from news_engine import FundamentalContext
|
||||||
|
from signal_aggregator import SignalReport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_MIN_QUALITY = "B"
|
||||||
|
DEFAULT_MAX_PANELS_PER_DAY = 20
|
||||||
|
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
||||||
|
|
||||||
|
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
||||||
|
# telegram_bot's ranking is private and dashboard_publisher must not
|
||||||
|
# import telegram_bot (hard independence rule).
|
||||||
|
_QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ── DeepSeek prompts ─────────────────────────────────────────────────
|
||||||
|
# STATIC constants on purpose: DeepSeek prices a repeated byte-identical
|
||||||
|
# prompt prefix at the cache-hit rate (~50x cheaper), so nothing here may
|
||||||
|
# be interpolated or timestamped. All variable content travels in the
|
||||||
|
# user payload. Panel output is display-only — it never gates signals.
|
||||||
|
|
||||||
|
_GOLD_DOCTRINE = """\
|
||||||
|
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
|
||||||
|
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
|
||||||
|
geopolitical stress / weak dollar are gold-bullish.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_TAKE_CONTRACT = """\
|
||||||
|
Your review is advisory research on an already-generated signal; you are
|
||||||
|
one voice on a panel, not the decision maker.
|
||||||
|
Hard rules: use ONLY the data in the JSON you receive — do not invent
|
||||||
|
numbers, events, or price levels. If the evidence your role needs is
|
||||||
|
thin or missing, answer "neutral" with confidence below 30, unless your
|
||||||
|
role's instructions say otherwise.
|
||||||
|
Confidence scale (0-100): 80-100 strong one-sided evidence; 50-79 solid
|
||||||
|
case with some doubt; 30-49 mixed evidence; below 30 little basis to
|
||||||
|
judge.
|
||||||
|
Write the note in plain everyday English for someone who has never
|
||||||
|
traded: short simple sentences, no trading jargon, no acronyms, no
|
||||||
|
abbreviations. Say "profit versus loss", not "RR"; "the 4-hour chart",
|
||||||
|
not "H4"; describe what an indicator MEANS ("the price has climbed
|
||||||
|
unusually fast"), never its name or number.
|
||||||
|
Reply with ONLY JSON:
|
||||||
|
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
|
||||||
|
"note": "<max 25 words>"}
|
||||||
|
"stance" says whether YOUR analysis supports the proposed trade
|
||||||
|
direction, opposes it, or is genuinely mixed; "confidence" is how sure
|
||||||
|
you are of your stance; "note" is your single most important point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MACRO_SYSTEM = """\
|
||||||
|
You are the macro analyst on a gold (XAU/USD) trade-review panel. You
|
||||||
|
receive JSON: a proposed trade brief ("brief": direction, timeframe,
|
||||||
|
quality, price, regime) plus the current fundamental picture — recent
|
||||||
|
"headlines", today's high-impact "events" (released: true means the
|
||||||
|
number is out), a news "sentiment" score for gold, and an optional
|
||||||
|
"news_summary" blurb.
|
||||||
|
Judge ONE thing: does the macro backdrop support the proposed direction?
|
||||||
|
Weigh released surprises over stale headlines; an imminent unreleased
|
||||||
|
high-impact event is a reason for lower confidence, not a direction.
|
||||||
|
Stance rules:
|
||||||
|
- support: the released data and news flow clearly push gold in the
|
||||||
|
proposed direction.
|
||||||
|
- oppose: they clearly push gold the other way.
|
||||||
|
- neutral: the picture is mixed or stale — or there are no meaningful
|
||||||
|
headlines or events to judge (score that low confidence).
|
||||||
|
""" + _GOLD_DOCTRINE + """\
|
||||||
|
Example: brief direction BUY, headlines dominated by hawkish Fed
|
||||||
|
repricing and a hot CPI print -> {"stance": "oppose", "confidence": 70,
|
||||||
|
"note": "Inflation is running hot, so interest rates should stay high — that usually pushes gold down."}
|
||||||
|
Example: brief direction BUY, headlines led by an escalating conflict
|
||||||
|
and a soft jobs report -> {"stance": "support", "confidence": 75,
|
||||||
|
"note": "Worries about war and a cooling economy usually send money into gold."}
|
||||||
|
""" + _TAKE_CONTRACT
|
||||||
|
|
||||||
|
_TECH_SYSTEM = """\
|
||||||
|
You are the technical analyst on a gold (XAU/USD) trade-review panel.
|
||||||
|
You receive JSON: a proposed trade brief ("brief") plus the technical
|
||||||
|
evidence — per-"indicators" signal/confidence, multi-timeframe
|
||||||
|
"mtf" signals and scores, "key_levels" versus the current price, and an
|
||||||
|
"exhaustion" count of consecutive same-direction bars flagged by the
|
||||||
|
engine.
|
||||||
|
Judge ONE thing: is the technical picture internally coherent for the
|
||||||
|
proposed direction? Look for indicators fighting each other, the signal
|
||||||
|
fighting the regime (e.g. a BUY in trending_down), higher timeframes
|
||||||
|
disagreeing, price pressed into a key level, or an exhausted move being
|
||||||
|
chased.
|
||||||
|
Stance rules:
|
||||||
|
- support: the indicators, higher timeframes, and regime mostly agree
|
||||||
|
with the proposed direction and none of the conflicts above appear.
|
||||||
|
- oppose: one or more of those conflicts is present and serious.
|
||||||
|
- neutral: the evidence genuinely cuts both ways, or too much of it is
|
||||||
|
missing to judge (score that low confidence).
|
||||||
|
Example: brief direction SELL in regime trending_up with H1 and H4 both
|
||||||
|
BUY -> {"stance": "oppose", "confidence": 80,
|
||||||
|
"note": "This bets on gold falling while the bigger picture still points up."}
|
||||||
|
Example: brief direction BUY in regime trending_up with M15 and H1 both
|
||||||
|
BUY and exhaustion 0 -> {"stance": "support", "confidence": 70,
|
||||||
|
"note": "Every way of looking at the chart points up, so buying goes with the flow."}
|
||||||
|
""" + _TAKE_CONTRACT
|
||||||
|
|
||||||
|
_RISK_SYSTEM = """\
|
||||||
|
You are the risk manager on a gold (XAU/USD) trade-review panel. You
|
||||||
|
receive JSON: a proposed trade brief ("brief") plus the risk facts —
|
||||||
|
the trade "setup" (entry, stop_loss, take_profit_1/2, risk_reward,
|
||||||
|
risk_dollars), the "volatility" regime, engine "warnings", the calendar
|
||||||
|
"event_risk" level, and the assumed "spread".
|
||||||
|
Judge ONE thing: is this setup's risk acceptable as constructed? Check
|
||||||
|
reward-to-risk adequacy, stop distance versus the volatility regime
|
||||||
|
(a tight stop in extreme vol dies to noise; a huge stop in low vol is
|
||||||
|
oversized), spread as a fraction of the stop, and event landmines about
|
||||||
|
to hit.
|
||||||
|
Stance rules:
|
||||||
|
- support: the likely reward clearly justifies the risk, the stop fits
|
||||||
|
the current volatility, and no event landmine is imminent.
|
||||||
|
- oppose: reward too thin for the risk, a stop mismatched to the
|
||||||
|
volatility, spread eating a big share of the stop, an imminent
|
||||||
|
high-impact event — or no setup at all (a missing setup means the
|
||||||
|
signal graded too low for one; oppose unless the rest is
|
||||||
|
exceptional).
|
||||||
|
- neutral: acceptable but unremarkable — nothing dangerous, nothing
|
||||||
|
attractive.
|
||||||
|
Example: RR 1.4 with volatility extreme and a high-impact event due ->
|
||||||
|
{"stance": "oppose", "confidence": 65,
|
||||||
|
"note": "The possible profit is small for such wild price swings, and big economic news is due soon."}
|
||||||
|
Example: RR 2.4 with normal volatility and no events due ->
|
||||||
|
{"stance": "support", "confidence": 70,
|
||||||
|
"note": "The possible profit is more than double the possible loss, and conditions look calm."}
|
||||||
|
""" + _TAKE_CONTRACT
|
||||||
|
|
||||||
|
_CONTRARIAN_SYSTEM = """\
|
||||||
|
You are the contrarian on a gold (XAU/USD) trade-review panel — the
|
||||||
|
designated skeptic. You receive JSON: a proposed trade brief ("brief"),
|
||||||
|
the engine's own "bullish_reasons" and "bearish_reasons", its
|
||||||
|
"warnings", and an optional "news_summary".
|
||||||
|
Your job: construct the strongest honest case AGAINST the proposed
|
||||||
|
trade, then score how damaging that case is. Use "oppose" only when the
|
||||||
|
counter-case is genuinely strong, "neutral" when it is real but modest,
|
||||||
|
and "support" when even the skeptic finds nothing that bites.
|
||||||
|
""" + _GOLD_DOCTRINE + """\
|
||||||
|
Example: a BUY whose own engine lists three bearish reasons and a
|
||||||
|
counter-trend warning -> {"stance": "oppose", "confidence": 60,
|
||||||
|
"note": "The system's own list of negatives looks stronger than its reason to buy."}
|
||||||
|
""" + _TAKE_CONTRACT
|
||||||
|
|
||||||
|
_SYNTH_SYSTEM = """\
|
||||||
|
You are the chair of a gold (XAU/USD) trade-review panel. You receive
|
||||||
|
JSON: the proposed trade "direction" and the panel's "takes" — one per
|
||||||
|
analyst ("persona", "stance", "confidence", "note").
|
||||||
|
Weigh the takes into ONE consensus on whether to act on the signal:
|
||||||
|
"AGREE" (the panel backs the trade), "OPPOSE" (the panel is against it),
|
||||||
|
or "NEUTRAL" (genuinely split or low conviction). Weigh a high-
|
||||||
|
confidence oppose from any single analyst seriously — one strong veto
|
||||||
|
can outweigh two lukewarm supports. Do not average blindly.
|
||||||
|
Base the verdict and rationale ONLY on the takes given — do not add
|
||||||
|
outside analysis.
|
||||||
|
Confidence scale (0-100): 80-100 the panel clearly leans one way; 50-79
|
||||||
|
a lean with real dissent; below 50 split or low conviction.
|
||||||
|
Write the rationale in plain everyday English for someone who has never
|
||||||
|
traded: short simple sentences, no trading jargon, no acronyms, no
|
||||||
|
abbreviations.
|
||||||
|
Example rationale: "Three of four analysts are against this: the
|
||||||
|
possible profit is too small and the price has already climbed
|
||||||
|
unusually fast. Only the news backdrop supports buying."
|
||||||
|
Reply with ONLY JSON:
|
||||||
|
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
|
||||||
|
"rationale": "<max 40 words>"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Result types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PersonaTake:
|
||||||
|
persona: str # "macro" | "technical" | "risk" | "contrarian"
|
||||||
|
stance: str # "support" | "oppose" | "neutral"
|
||||||
|
confidence: float # 0-100
|
||||||
|
note: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PanelVerdict:
|
||||||
|
verdict: str # "AGREE" | "NEUTRAL" | "OPPOSE"
|
||||||
|
confidence: float # 0-100
|
||||||
|
rationale: str
|
||||||
|
takes: List[PersonaTake] = field(default_factory=list)
|
||||||
|
created_ts: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
||||||
|
|
||||||
|
def _direction(report: "SignalReport") -> Optional[str]:
|
||||||
|
"""BUY/SELL the panel should review; None = nothing to review."""
|
||||||
|
if report.trade_setup is not None:
|
||||||
|
return report.trade_setup.direction
|
||||||
|
if report.final_signal.value > 0:
|
||||||
|
return "BUY"
|
||||||
|
if report.final_signal.value < 0:
|
||||||
|
return "SELL"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _brief(report: "SignalReport") -> Dict[str, Any]:
|
||||||
|
analysis = report.primary_analysis
|
||||||
|
return {
|
||||||
|
"pair": report.display_name,
|
||||||
|
"timeframe": analysis.timeframe if analysis else "",
|
||||||
|
"direction": _direction(report),
|
||||||
|
"quality": report.signal_quality,
|
||||||
|
"score": round(report.final_score, 1),
|
||||||
|
"confidence": round(report.final_confidence),
|
||||||
|
"price": round(report.current_price, 2),
|
||||||
|
"market_regime": report.market_regime,
|
||||||
|
"volatility_regime": report.volatility_regime,
|
||||||
|
"mtf_agreement": report.mtf_agreement,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _macro_payload(report: "SignalReport",
|
||||||
|
context: Optional["FundamentalContext"]) -> Dict[str, Any]:
|
||||||
|
headlines: List[str] = []
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
if context is not None:
|
||||||
|
headlines = [h.headline for h in context.news_headlines[:10]]
|
||||||
|
events = [
|
||||||
|
{"event": e.event, "currency": e.currency,
|
||||||
|
"actual": e.actual, "forecast": e.forecast,
|
||||||
|
"released": not e.is_upcoming}
|
||||||
|
for e in context.events_today if e.is_high_impact
|
||||||
|
][:8]
|
||||||
|
return {
|
||||||
|
"brief": _brief(report),
|
||||||
|
"headlines": headlines,
|
||||||
|
"events": events,
|
||||||
|
"sentiment": round(report.news_sentiment, 3),
|
||||||
|
"news_summary": report.news_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
|
indicators: Dict[str, Dict[str, Any]] = {}
|
||||||
|
key_levels: Dict[str, float] = {}
|
||||||
|
analysis = report.primary_analysis
|
||||||
|
if analysis is not None:
|
||||||
|
# Same visibility filter as main.report_to_dict: private series
|
||||||
|
# ride as _-prefixed pseudo-indicators, weight-0 = context-only.
|
||||||
|
indicators = {
|
||||||
|
name: {"signal": ind.signal.name,
|
||||||
|
"confidence": round(ind.confidence)}
|
||||||
|
for name, ind in analysis.indicators.items()
|
||||||
|
if not name.startswith("_") and ind.weight != 0
|
||||||
|
}
|
||||||
|
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
|
||||||
|
return {
|
||||||
|
"brief": _brief(report),
|
||||||
|
"indicators": indicators,
|
||||||
|
"mtf": {tf: {"signal": sig.name,
|
||||||
|
"score": round(report.mtf_scores.get(tf, 0.0), 1)}
|
||||||
|
for tf, sig in report.mtf_signals.items()},
|
||||||
|
"key_levels": key_levels,
|
||||||
|
"exhaustion": report.exhaustion_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"brief": _brief(report),
|
||||||
|
"volatility": report.volatility_regime,
|
||||||
|
"warnings": list(report.warnings),
|
||||||
|
"event_risk": report.event_risk,
|
||||||
|
"event_description": report.event_description,
|
||||||
|
"spread": round(report.spread_estimate, 2),
|
||||||
|
}
|
||||||
|
ts = report.trade_setup
|
||||||
|
if ts is not None:
|
||||||
|
payload["setup"] = {
|
||||||
|
"direction": ts.direction,
|
||||||
|
"entry": round(ts.entry_price, 2),
|
||||||
|
"stop_loss": round(ts.stop_loss, 2),
|
||||||
|
"take_profit_1": round(ts.take_profit_1, 2),
|
||||||
|
"take_profit_2": round(ts.take_profit_2, 2),
|
||||||
|
"risk_reward": round(ts.risk_reward, 2),
|
||||||
|
"risk_dollars": round(ts.risk_pips * ts.pip_value, 2),
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _contrarian_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"brief": _brief(report),
|
||||||
|
"bullish_reasons": report.bullish_reasons[:3],
|
||||||
|
"bearish_reasons": report.bearish_reasons[:3],
|
||||||
|
"warnings": list(report.warnings),
|
||||||
|
"news_summary": report.news_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Orchestration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
|
||||||
|
# occupying telegram_bot's shared scan executor (which has 4 workers —
|
||||||
|
# borrowing them would stall every other scan for the panel's duration).
|
||||||
|
_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="agent-panel")
|
||||||
|
|
||||||
|
_VALID_STANCES = ("support", "oppose", "neutral")
|
||||||
|
_VALID_VERDICTS = ("AGREE", "NEUTRAL", "OPPOSE")
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_confidence(raw: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
return max(0.0, min(100.0, float(raw)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_take(persona: str, reply: Any) -> Optional[PersonaTake]:
|
||||||
|
if not isinstance(reply, dict):
|
||||||
|
return None
|
||||||
|
stance = reply.get("stance")
|
||||||
|
if stance not in _VALID_STANCES:
|
||||||
|
return None
|
||||||
|
confidence = _clamp_confidence(reply.get("confidence"))
|
||||||
|
if confidence is None:
|
||||||
|
return None
|
||||||
|
note = str(reply.get("note", "")).strip()[:160]
|
||||||
|
return PersonaTake(persona=persona, stance=stance,
|
||||||
|
confidence=confidence, note=note)
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_persona(persona: str, system: str,
|
||||||
|
payload: Dict[str, Any]) -> Optional[PersonaTake]:
|
||||||
|
# Client resolved at call time: tests install stubs via lc._client.
|
||||||
|
reply = get_llm_client().chat_json(system, payload)
|
||||||
|
return _validate_take(persona, reply)
|
||||||
|
|
||||||
|
|
||||||
|
def run_panel(report: "SignalReport",
|
||||||
|
context: Optional["FundamentalContext"] = None,
|
||||||
|
) -> Optional[PanelVerdict]:
|
||||||
|
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
|
||||||
|
|
||||||
|
Requires ≥2 valid persona takes before spending the 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.
|
||||||
|
"""
|
||||||
|
direction = _direction(report)
|
||||||
|
if direction is None:
|
||||||
|
return None
|
||||||
|
jobs = (
|
||||||
|
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
||||||
|
("technical", _TECH_SYSTEM, _technical_payload(report)),
|
||||||
|
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
||||||
|
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
|
||||||
|
)
|
||||||
|
futures = [_pool.submit(_ask_persona, name, system, payload)
|
||||||
|
for name, system, payload in jobs]
|
||||||
|
# The HTTP timeout inside the client bounds each call; the margin
|
||||||
|
# covers pool queueing. Stragglers past the wait are abandoned (their
|
||||||
|
# threads finish harmlessly) and simply don't vote.
|
||||||
|
client_timeout = getattr(get_llm_client(), "_timeout", DEFAULT_TIMEOUT)
|
||||||
|
done, _ = _fwait(futures, timeout=client_timeout + 8.0)
|
||||||
|
takes = [f.result() for f in done if not f.exception()]
|
||||||
|
takes = [t for t in takes if t is not None]
|
||||||
|
if len(takes) < 2:
|
||||||
|
logger.debug("agent panel: %d/4 persona takes — skipping synthesizer",
|
||||||
|
len(takes))
|
||||||
|
return None
|
||||||
|
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
|
||||||
|
takes.sort(key=lambda t: order.get(t.persona, 9))
|
||||||
|
reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
|
||||||
|
"direction": direction,
|
||||||
|
"takes": [{"persona": t.persona, "stance": t.stance,
|
||||||
|
"confidence": t.confidence, "note": t.note}
|
||||||
|
for t in takes],
|
||||||
|
})
|
||||||
|
if not isinstance(reply, dict):
|
||||||
|
return None
|
||||||
|
verdict = reply.get("verdict")
|
||||||
|
confidence = _clamp_confidence(reply.get("confidence"))
|
||||||
|
if verdict not in _VALID_VERDICTS or confidence is None:
|
||||||
|
return None
|
||||||
|
rationale = str(reply.get("rationale", "")).strip()[:240]
|
||||||
|
return PanelVerdict(verdict=verdict, confidence=confidence,
|
||||||
|
rationale=rationale, takes=takes,
|
||||||
|
created_ts=time.time())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cache, budget, attach ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_state_lock = threading.Lock()
|
||||||
|
# Exact result per reviewed bar: repeated attaches on the same signal
|
||||||
|
# (re-scan, multi-consumer) are free.
|
||||||
|
_cache: "OrderedDict[Tuple[str, str, str, str], PanelVerdict]" = OrderedDict()
|
||||||
|
_CACHE_MAX = 32
|
||||||
|
# Reuse layer: a persisting signal (same symbol/tf/direction, newer bar)
|
||||||
|
# re-serves its last verdict for reuse_ttl_seconds instead of re-spending
|
||||||
|
# 5 requests per bar (see module docstring for the M5 dashboard math).
|
||||||
|
_last_by_dir: Dict[Tuple[str, str, str], PanelVerdict] = {}
|
||||||
|
# UTC-day budget of panel attempts (mirrors llm_client._under_cap).
|
||||||
|
_panel_day: Optional[_dt.date] = None
|
||||||
|
_panel_count: int = 0
|
||||||
|
_warned_budget = False
|
||||||
|
|
||||||
|
|
||||||
|
def reset_panel_state() -> None:
|
||||||
|
"""Drop caches and counters (tests / config reloads)."""
|
||||||
|
global _panel_day, _panel_count, _warned_budget
|
||||||
|
with _state_lock:
|
||||||
|
_cache.clear()
|
||||||
|
_last_by_dir.clear()
|
||||||
|
_panel_day = None
|
||||||
|
_panel_count = 0
|
||||||
|
_warned_budget = False
|
||||||
|
|
||||||
|
|
||||||
|
def panel_health() -> Dict[str, Any]:
|
||||||
|
"""Read-only counters for status displays."""
|
||||||
|
settings = get_app_config().agent_panel_settings
|
||||||
|
cap = int(settings.get("max_panels_per_day", DEFAULT_MAX_PANELS_PER_DAY))
|
||||||
|
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||||
|
with _state_lock:
|
||||||
|
used = _panel_count if _panel_day == today else 0
|
||||||
|
return {"used_today": used, "cap": cap}
|
||||||
|
|
||||||
|
|
||||||
|
def _under_panel_budget(cap: int) -> bool:
|
||||||
|
"""Count one panel attempt against the UTC-day budget."""
|
||||||
|
global _panel_day, _panel_count, _warned_budget
|
||||||
|
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||||
|
with _state_lock:
|
||||||
|
if _panel_day != today:
|
||||||
|
_panel_day = today
|
||||||
|
_panel_count = 0
|
||||||
|
_warned_budget = False
|
||||||
|
if _panel_count >= cap:
|
||||||
|
if not _warned_budget:
|
||||||
|
_warned_budget = True
|
||||||
|
logger.warning(
|
||||||
|
"agent panel: daily budget (%d) exhausted — no more "
|
||||||
|
"panels until UTC midnight", cap)
|
||||||
|
return False
|
||||||
|
_panel_count += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_fields(report: "SignalReport", v: PanelVerdict) -> None:
|
||||||
|
report.panel_verdict = v.verdict
|
||||||
|
report.panel_confidence = float(v.confidence)
|
||||||
|
report.panel_rationale = v.rationale
|
||||||
|
report.panel_takes = [
|
||||||
|
{"persona": t.persona, "stance": t.stance,
|
||||||
|
"confidence": t.confidence, "note": t.note} for t in v.takes]
|
||||||
|
|
||||||
|
|
||||||
|
def attach_panel(report: "SignalReport",
|
||||||
|
settings: Optional[Dict[str, Any]] = None,
|
||||||
|
surface: str = "telegram",
|
||||||
|
force: bool = False,
|
||||||
|
context: Optional["FundamentalContext"] = None) -> bool:
|
||||||
|
"""Best-effort panel attach; True iff panel fields were populated.
|
||||||
|
|
||||||
|
Advisory feature contract: NEVER raises — an exploding panel must
|
||||||
|
not cost an alert send or a dashboard publish. ``force`` (CLI
|
||||||
|
--panel) bypasses the enabled/surface/quality toggles but never the
|
||||||
|
daily budget or the shared LLM cap.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if settings is None:
|
||||||
|
settings = get_app_config().agent_panel_settings
|
||||||
|
if not force:
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return False
|
||||||
|
surface_on = {
|
||||||
|
"telegram": settings.get("telegram", True),
|
||||||
|
"dashboard": settings.get("dashboard", False),
|
||||||
|
}.get(surface, True)
|
||||||
|
if not surface_on:
|
||||||
|
return False
|
||||||
|
min_quality = settings.get("min_quality", DEFAULT_MIN_QUALITY)
|
||||||
|
if (_QUALITY_RANK.get(report.signal_quality, -1)
|
||||||
|
< _QUALITY_RANK.get(min_quality, 2)):
|
||||||
|
return False
|
||||||
|
direction = _direction(report)
|
||||||
|
if direction is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
analysis = report.primary_analysis
|
||||||
|
tf = analysis.timeframe if analysis else ""
|
||||||
|
exact_key = (report.symbol, tf, report.timestamp, direction)
|
||||||
|
dir_key = (report.symbol, tf, direction)
|
||||||
|
ttl = float(settings.get("reuse_ttl_seconds",
|
||||||
|
DEFAULT_REUSE_TTL_SECONDS))
|
||||||
|
with _state_lock:
|
||||||
|
hit = _cache.get(exact_key)
|
||||||
|
if hit is None:
|
||||||
|
prior = _last_by_dir.get(dir_key)
|
||||||
|
if prior is not None and time.time() - prior.created_ts < ttl:
|
||||||
|
hit = prior
|
||||||
|
if hit is not None:
|
||||||
|
_write_fields(report, hit)
|
||||||
|
return True
|
||||||
|
|
||||||
|
cap = int(settings.get("max_panels_per_day",
|
||||||
|
DEFAULT_MAX_PANELS_PER_DAY))
|
||||||
|
if not _under_panel_budget(cap):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if context is None:
|
||||||
|
# Best-effort; the news engine's class-level 600 s caches make
|
||||||
|
# this nearly free when warm in both bot and dashboard.
|
||||||
|
try:
|
||||||
|
from config import PlatformConfig
|
||||||
|
from news_engine import NewsEngine
|
||||||
|
context = NewsEngine(
|
||||||
|
PlatformConfig().news).get_fundamental_context()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("agent panel: news context unavailable: %s", e)
|
||||||
|
context = None
|
||||||
|
|
||||||
|
verdict = run_panel(report, context)
|
||||||
|
if verdict is None:
|
||||||
|
return False
|
||||||
|
_write_fields(report, verdict)
|
||||||
|
with _state_lock:
|
||||||
|
_cache[exact_key] = verdict
|
||||||
|
while len(_cache) > _CACHE_MAX:
|
||||||
|
_cache.popitem(last=False)
|
||||||
|
_last_by_dir[dir_key] = verdict
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("agent panel: attach failed — signal unaffected")
|
||||||
|
return False
|
||||||
+31
-1
@@ -86,7 +86,7 @@ _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|||||||
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
|
_SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
|
||||||
|
|
||||||
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
||||||
"oanda", "deepseek"}
|
"oanda", "deepseek", "agent_panel"}
|
||||||
_KNOWN_OANDA = {
|
_KNOWN_OANDA = {
|
||||||
"account_id", "api_token", "base_url", "lot_size",
|
"account_id", "api_token", "base_url", "lot_size",
|
||||||
"quality_gate", "max_hold_bars",
|
"quality_gate", "max_hold_bars",
|
||||||
@@ -94,6 +94,10 @@ _KNOWN_OANDA = {
|
|||||||
_KNOWN_DEEPSEEK = {
|
_KNOWN_DEEPSEEK = {
|
||||||
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
||||||
}
|
}
|
||||||
|
_KNOWN_AGENT_PANEL = {
|
||||||
|
"enabled", "telegram", "dashboard", "min_quality",
|
||||||
|
"max_panels_per_day", "reuse_ttl_seconds",
|
||||||
|
}
|
||||||
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
||||||
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
||||||
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
||||||
@@ -118,6 +122,7 @@ class AppConfig:
|
|||||||
pair_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
pair_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
oanda_settings: Dict[str, Any] = field(default_factory=dict)
|
oanda_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
deepseek_settings: Dict[str, Any] = field(default_factory=dict)
|
deepseek_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
agent_panel_settings: Dict[str, Any] = field(default_factory=dict)
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
@@ -276,6 +281,31 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
|||||||
"config.json: deepseek.model must be a non-empty string")
|
"config.json: deepseek.model must be a non-empty string")
|
||||||
app.deepseek_settings = dict(deepseek)
|
app.deepseek_settings = dict(deepseek)
|
||||||
|
|
||||||
|
panel = raw.get("agent_panel", {})
|
||||||
|
_check_keys(panel, _KNOWN_AGENT_PANEL, '"agent_panel"')
|
||||||
|
for flag in ("enabled", "telegram", "dashboard"):
|
||||||
|
if flag in panel and not isinstance(panel[flag], bool):
|
||||||
|
raise AppConfigError(
|
||||||
|
f"config.json: agent_panel.{flag} must be true or false")
|
||||||
|
if "min_quality" in panel and panel["min_quality"] not in _VALID_QUALITIES:
|
||||||
|
raise AppConfigError(
|
||||||
|
f'config.json: agent_panel.min_quality "{panel["min_quality"]}" '
|
||||||
|
f"not in {_VALID_QUALITIES}")
|
||||||
|
if "max_panels_per_day" in panel:
|
||||||
|
mpd = panel["max_panels_per_day"]
|
||||||
|
if isinstance(mpd, bool) or not isinstance(mpd, int) or mpd < 1:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: agent_panel.max_panels_per_day must be a "
|
||||||
|
"positive integer")
|
||||||
|
if "reuse_ttl_seconds" in panel:
|
||||||
|
ttl = panel["reuse_ttl_seconds"]
|
||||||
|
if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) \
|
||||||
|
or ttl < 0:
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: agent_panel.reuse_ttl_seconds must be a "
|
||||||
|
"non-negative number")
|
||||||
|
app.agent_panel_settings = dict(panel)
|
||||||
|
|
||||||
level = raw.get("log_level", app.log_level)
|
level = raw.get("log_level", app.log_level)
|
||||||
if level not in _VALID_LOG_LEVELS:
|
if level not in _VALID_LOG_LEVELS:
|
||||||
raise AppConfigError(
|
raise AppConfigError(
|
||||||
|
|||||||
@@ -40,5 +40,13 @@
|
|||||||
"timeout_seconds": 12,
|
"timeout_seconds": 12,
|
||||||
"daily_request_cap": 500
|
"daily_request_cap": 500
|
||||||
},
|
},
|
||||||
|
"agent_panel": {
|
||||||
|
"enabled": false,
|
||||||
|
"telegram": true,
|
||||||
|
"dashboard": false,
|
||||||
|
"min_quality": "B",
|
||||||
|
"max_panels_per_day": 20,
|
||||||
|
"reuse_ttl_seconds": 3600
|
||||||
|
},
|
||||||
"log_level": "INFO"
|
"log_level": "INFO"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ class NewsConfig:
|
|||||||
# Must stay below pre_event_buffer_minutes so an event can't cross
|
# Must stay below pre_event_buffer_minutes so an event can't cross
|
||||||
# into the danger window while a stale cache says it's clear.
|
# into the danger window while a stale cache says it's clear.
|
||||||
context_cache_ttl_seconds: int = 600
|
context_cache_ttl_seconds: int = 600
|
||||||
|
# Maximum age of RSS headlines accepted for scoring. Older headlines
|
||||||
|
# are discarded before keyword analysis and LLM enrichment.
|
||||||
|
headline_max_age_seconds: int = 43200 # 12 hours
|
||||||
|
|
||||||
rss_feeds: List[str] = field(default_factory=lambda: [
|
rss_feeds: List[str] = field(default_factory=lambda: [
|
||||||
"https://investinglive.com/feed/",
|
"https://investinglive.com/feed/",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from datetime import datetime, timezone
|
|||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from agent_panel import attach_panel
|
||||||
from config import PlatformConfig, Timeframe, XAUUSD
|
from config import PlatformConfig, Timeframe, XAUUSD
|
||||||
from main import report_to_dict
|
from main import report_to_dict
|
||||||
from news_engine import NewsEngine
|
from news_engine import NewsEngine
|
||||||
@@ -357,6 +358,13 @@ class DashboardPublisher:
|
|||||||
try:
|
try:
|
||||||
report = produce_signal(XAUUSD, tf, self._provider(),
|
report = produce_signal(XAUUSD, tf, self._provider(),
|
||||||
CONFIRMATIONS[tf])
|
CONFIRMATIONS[tf])
|
||||||
|
# Advisory AI panel, post-hoc and display-only. Off unless
|
||||||
|
# config.json agent_panel.dashboard is true. The reuse-TTL
|
||||||
|
# cache + max_panels_per_day budget bound the spend against
|
||||||
|
# this process's own DeepSeek cap (a persisting M5 signal
|
||||||
|
# re-serves its cached verdict instead of re-running every
|
||||||
|
# bar); a fresh panel adds ≤~25 s before this TF publishes.
|
||||||
|
attach_panel(report, surface="dashboard")
|
||||||
self.snapshots[tf.value] = {
|
self.snapshots[tf.value] = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
"scanned_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
================================================================================
|
||||||
|
MULTI-PROVIDER LLM LAYER - PLAN
|
||||||
|
(DeepSeek for news summary, Anthropic Claude for agent panel)
|
||||||
|
Written 2026-08-08. Plan only - no code has been changed.
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
WHY
|
||||||
|
---
|
||||||
|
Today every LLM feature (news enrichment + agent panel) goes through one
|
||||||
|
hardwired client: llm_client.py -> DeepSeekClient, a module singleton reached
|
||||||
|
via get_llm_client(). This plan describes how to route different features to
|
||||||
|
different providers - e.g. keep DeepSeek for cheap high-volume news scoring,
|
||||||
|
put the agent panel on Anthropic Claude for better reasoning.
|
||||||
|
|
||||||
|
The repo already solved this problem once for market data: the providers/
|
||||||
|
package (BaseDataProvider owns shared machinery, subclasses implement only
|
||||||
|
_fetch_raw, a registry + one config knob selects the provider). This plan
|
||||||
|
mirrors that proven pattern at the LLM layer, scaled down to a single module.
|
||||||
|
|
||||||
|
BLAST RADIUS (verified by grep)
|
||||||
|
-------------------------------
|
||||||
|
get_llm_client() call sites:
|
||||||
|
- news_engine.py ....... 4 sites
|
||||||
|
- agent_panel.py ....... 3 sites (+1 timeout getattr)
|
||||||
|
- telegram_bot.py ...... 1 site (/status health; reads private _count,
|
||||||
|
_count_day, _daily_cap fields)
|
||||||
|
- test_all.py .......... 41 locations stub via lc._client = _StubLLM(...)
|
||||||
|
Out of scope: static/news_summary.php (own PHP DeepSeek call + key file,
|
||||||
|
separate deployment).
|
||||||
|
|
||||||
|
DESIGN INVARIANTS (carried over unchanged)
|
||||||
|
------------------------------------------
|
||||||
|
1. Fail-safe contract: every call returns Optional[Dict]; any failure -> None;
|
||||||
|
callers keep their non-LLM fallbacks; LLM output never gates signals.
|
||||||
|
2. Single attempt, no retries (news cache cadence is the retry); per-provider
|
||||||
|
UTC daily cap; warn-once logging; health stamps (last_ok, last_error).
|
||||||
|
3. Static system prompts stay byte-identical constants - provider-neutral,
|
||||||
|
and they map to prompt caching on BOTH providers (DeepSeek prefix cache;
|
||||||
|
Anthropic cache_control ephemeral).
|
||||||
|
4. Test compatibility: all 41 existing lc._client stub sites keep working
|
||||||
|
with zero edits.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
1. llm_client.py REFACTOR (~195 -> ~430 lines, no new files)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
1a. BaseLLMClient - shared machinery (extracted from today's DeepSeekClient)
|
||||||
|
- __init__(api_key, model, timeout, daily_cap, enabled)
|
||||||
|
- public: name ("deepseek"/"anthropic"), timeout property,
|
||||||
|
usage() -> (used_today, cap) # so telegram_bot stops reading privates
|
||||||
|
- owns: available property, _warn_once, _under_cap() (UTC-day counter,
|
||||||
|
lock), health stamps, and the template method:
|
||||||
|
|
||||||
|
def chat_json(self, system_prompt, user_payload):
|
||||||
|
if not self.available or not self._under_cap():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._request(system_prompt, user_payload) # hook
|
||||||
|
except <provider-agnostic error mapping>:
|
||||||
|
# stamps last_error, returns None
|
||||||
|
|
||||||
|
- subclasses implement ONLY _request(system_prompt, payload) -> dict|None.
|
||||||
|
|
||||||
|
1b. DeepSeekClient(BaseLLMClient) - behavior-identical move
|
||||||
|
Current requests-based logic relocates into _request: same URL,
|
||||||
|
thinking:disabled, response_format:json_object, temperature 0,
|
||||||
|
max_tokens 2000, sort_keys=True payload, _parse_response.
|
||||||
|
Review criterion: Module 19 tests pass UNMODIFIED (they patch
|
||||||
|
client._session, which stays).
|
||||||
|
|
||||||
|
1c. AnthropicClient(BaseLLMClient) - new, official SDK
|
||||||
|
- Official `anthropic` Python SDK (add to requirements.txt), NOT raw
|
||||||
|
requests. LAZY IMPORT inside __init__: package missing -> warn-once +
|
||||||
|
client unavailable, so DeepSeek-only deployments never install it.
|
||||||
|
- Constructor: anthropic.Anthropic(api_key=..., timeout=<configured>,
|
||||||
|
max_retries=0) # house rule: single attempt
|
||||||
|
- _request:
|
||||||
|
resp = self._client.messages.create(
|
||||||
|
model=self._model, # default "claude-opus-5"
|
||||||
|
max_tokens=4000, # covers thinking + JSON reply
|
||||||
|
output_config={"effort": "low"}, # cheap structured takes
|
||||||
|
system=[{"type": "text", "text": system_prompt,
|
||||||
|
"cache_control": {"type": "ephemeral"}}],
|
||||||
|
messages=[{"role": "user",
|
||||||
|
"content": json.dumps(user_payload, sort_keys=True)}],
|
||||||
|
)
|
||||||
|
if resp.stop_reason == "refusal": # Opus 5: HTTP 200, check first
|
||||||
|
return None
|
||||||
|
text = first text block in resp.content
|
||||||
|
return self._parse_json(text) # tolerant: strip ```json fences,
|
||||||
|
# json.loads, dict check
|
||||||
|
- DELIBERATELY ABSENT (would 400 on Claude Opus 5): temperature (removed
|
||||||
|
on Opus 5 - do NOT copy from the DeepSeek client), budget_tokens,
|
||||||
|
prefills, response_format.
|
||||||
|
- Error mapping: APIConnectionError -> "network",
|
||||||
|
APIStatusError -> "http_<code>", parse fail -> "parse"
|
||||||
|
(same last_error kinds the /status display already understands).
|
||||||
|
- Defaults: model claude-opus-5 (config-overridable, e.g. claude-haiku-4-5
|
||||||
|
for ~5x cheaper), timeout 30s (Claude slower than V4-Flash), own
|
||||||
|
daily_request_cap 500.
|
||||||
|
|
||||||
|
1d. Registry + per-consumer routing
|
||||||
|
_PROVIDERS = {"deepseek": DeepSeekClient, "anthropic": AnthropicClient}
|
||||||
|
LLM_CONSUMERS = ("news", "agent_panel")
|
||||||
|
_clients = {} # keyed by PROVIDER name -> two consumers on the
|
||||||
|
# same provider share one instance and one cap
|
||||||
|
_client = None # kept: GLOBAL TEST OVERRIDE
|
||||||
|
|
||||||
|
def get_llm_client(consumer="news"):
|
||||||
|
if _client is not None: # tests: lc._client = stub
|
||||||
|
return _client # -> shadows ALL consumers
|
||||||
|
name = get_app_config().llm_routing.get(consumer, "deepseek")
|
||||||
|
create-once per provider, return _clients[name]
|
||||||
|
|
||||||
|
def reset_llm_client(): # clears _client AND _clients
|
||||||
|
|
||||||
|
Keeping _client as a documented test-override global is what makes the
|
||||||
|
41 existing stub sites work untouched.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
2. CONFIG (app_config.py + config.json.example)
|
||||||
|
================================================================================
|
||||||
|
- New top-level "anthropic" block, mirroring "deepseek":
|
||||||
|
{api_key, model, enabled, timeout_seconds, daily_request_cap}
|
||||||
|
plus anthropic_api_key() resolver: ANTHROPIC_API_KEY env -> config.json -> "".
|
||||||
|
- New top-level "llm" ROUTING block:
|
||||||
|
{"news": "deepseek", "agent_panel": "deepseek"}
|
||||||
|
Validation: keys within {news, agent_panel}; values within registered
|
||||||
|
provider names. Absent block -> {} -> everything on DeepSeek (today's
|
||||||
|
behavior; zero-migration default). AppConfig.llm_routing: Dict[str, str].
|
||||||
|
|
||||||
|
- The target state is then PURE CONFIG - no code changes on switch day:
|
||||||
|
|
||||||
|
"llm": { "news": "deepseek", "agent_panel": "anthropic" },
|
||||||
|
"anthropic": { "api_key": "", "model": "claude-opus-5", "enabled": true,
|
||||||
|
"timeout_seconds": 30, "daily_request_cap": 500 }
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
3. CONSUMER EDITS (mechanical, ~10 lines total)
|
||||||
|
================================================================================
|
||||||
|
- news_engine.py: 4 call sites -> get_llm_client("news")
|
||||||
|
- agent_panel.py: 3 call sites -> get_llm_client("agent_panel");
|
||||||
|
getattr(client, "_timeout", ...) -> public client.timeout
|
||||||
|
- telegram_bot.py _news_health_lines: iterate the DISTINCT providers actually
|
||||||
|
routed (news + panel); per provider print "<name>: used/cap calls today"
|
||||||
|
via the new usage() accessor instead of private fields. DeepSeek keeps its
|
||||||
|
"headlines scored" extra; cap/error warnings work off base-class stamps.
|
||||||
|
- NO changes to prompts, payload builders, caches, or the panel's own
|
||||||
|
max_panels_per_day budget - all provider-agnostic already.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
4. TESTS (new "Module 24: LLM provider routing", ~12 tests, ~200 lines)
|
||||||
|
================================================================================
|
||||||
|
Reuse _StubLLM; new _FakeAnthropic (fake messages.create returning a stub
|
||||||
|
response object) injected as client._client.
|
||||||
|
|
||||||
|
1. app_config: anthropic block parses/validates/defaults empty.
|
||||||
|
2. app_config: llm routing block - valid map round-trips; unknown consumer
|
||||||
|
key, unknown provider name, non-dict -> AppConfigError; absent -> {}.
|
||||||
|
3. anthropic_api_key resolves env -> config.json -> empty.
|
||||||
|
4. Base contract on BOTH subclasses (parametrized): keyless/disabled -> no
|
||||||
|
call; daily cap blocks + UTC-roll reset; failure stamps last_error,
|
||||||
|
success stamps last_ok.
|
||||||
|
5. AnthropicClient parse: clean JSON -> dict; ```json-fenced -> dict;
|
||||||
|
non-JSON -> None; stop_reason "refusal" -> None; no text block -> None.
|
||||||
|
6. AnthropicClient request shape: NO temperature key, max_retries=0,
|
||||||
|
cache_control present on system block (assert on captured kwargs).
|
||||||
|
7. AnthropicClient with `anthropic` package missing (simulated) ->
|
||||||
|
available False, chat_json None, no crash at import.
|
||||||
|
8. Routing: no llm block -> both consumers get the SAME DeepSeek singleton
|
||||||
|
(shared cap); routed config -> news gets DeepSeekClient while
|
||||||
|
agent_panel gets AnthropicClient; same-provider consumers share one
|
||||||
|
instance.
|
||||||
|
9. Global lc._client override shadows BOTH consumers (locks the 41 legacy
|
||||||
|
stub sites' semantics).
|
||||||
|
10. reset_llm_client() clears both the override and the per-provider cache.
|
||||||
|
11. End-to-end: routing agent_panel->anthropic, attach_panel reaches the
|
||||||
|
anthropic fake (5 calls) while a news call reaches the deepseek stub.
|
||||||
|
12. telegram _news_health_lines renders per-provider lines from usage()
|
||||||
|
without touching private fields.
|
||||||
|
|
||||||
|
GATE: Modules 19 and 23 must pass UNMODIFIED - the no-regression proof for
|
||||||
|
the refactor.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
5. DOCS
|
||||||
|
================================================================================
|
||||||
|
- CLAUDE.md: llm_client bullet -> provider-abstracted (BaseLLMClient;
|
||||||
|
deepseek + anthropic registered); config.json "llm" block routes consumers;
|
||||||
|
absent = all-DeepSeek. Add ANTHROPIC_API_KEY to env-var list. Test counts.
|
||||||
|
- config.json.example: both new blocks.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
COMMIT SEQUENCE (each commit green)
|
||||||
|
================================================================================
|
||||||
|
1. llm_client: extract BaseLLMClient; DeepSeekClient unchanged behavior
|
||||||
|
(+ base-contract tests) - pure refactor, Modules 19/23 green untouched.
|
||||||
|
2. config: anthropic block + llm consumer-routing block (+ config tests).
|
||||||
|
3. llm_client: AnthropicClient (official SDK, lazy import) + requirements.txt
|
||||||
|
(+ parse/shape tests).
|
||||||
|
4. routing: per-consumer get_llm_client + health lines - consumer call-site
|
||||||
|
edits, telegram usage() lines, routing/e2e tests.
|
||||||
|
5. docs: CLAUDE.md + example config.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
VERIFICATION
|
||||||
|
================================================================================
|
||||||
|
- python test_all.py green at every commit (~374 -> ~386).
|
||||||
|
- Live smoke A (no behavior change): default config, main.py --panel ->
|
||||||
|
still DeepSeek, /status unchanged.
|
||||||
|
- Live smoke B (the goal): llm.agent_panel="anthropic" + real
|
||||||
|
ANTHROPIC_API_KEY, run --panel -> one real Claude panel (5 requests,
|
||||||
|
validates JSON contract + latency + refusal-free path) while a news
|
||||||
|
refresh still hits DeepSeek; /status shows two provider lines.
|
||||||
|
- Backtest untouched by construction (no LLM in replay path); one 30-day
|
||||||
|
run as a formality.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
EFFORT ESTIMATE
|
||||||
|
================================================================================
|
||||||
|
0 new files, 8 modified, ~520 lines, est. 3-5 hours.
|
||||||
|
|
||||||
|
llm_client.py base class + AnthropicClient + routing +235
|
||||||
|
test_all.py Module 24 +200
|
||||||
|
app_config.py anthropic + llm blocks, resolver +55
|
||||||
|
telegram_bot.py per-provider health lines +15
|
||||||
|
news_engine.py consumer tags on call sites +4
|
||||||
|
agent_panel.py consumer tags on call sites +4
|
||||||
|
config.json.example blocks +14
|
||||||
|
requirements.txt anthropic +1
|
||||||
|
CLAUDE.md docs +10
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
KEY DECISIONS EMBEDDED IN THIS PLAN
|
||||||
|
================================================================================
|
||||||
|
- Official Anthropic SDK, not raw requests (current API guidance for Python;
|
||||||
|
lazy import keeps it optional for DeepSeek-only installs).
|
||||||
|
- Default Claude model: claude-opus-5 ($5/$25 per MTok; a full 5-call panel
|
||||||
|
~ 8k in / 500 out ~ $0.05). Config-overridable to claude-haiku-4-5.
|
||||||
|
- Two Claude gotchas handled: temperature is REMOVED on Opus 5 (sending it
|
||||||
|
400s - must not be copied from the DeepSeek request body), and refusals
|
||||||
|
arrive as HTTP 200 with stop_reason "refusal" (checked before parsing,
|
||||||
|
mapped to the normal None/fallback path).
|
||||||
|
- Caps are per-provider, not per-consumer: two consumers on one provider
|
||||||
|
share its cap (today's semantics); splitting across two providers doubles
|
||||||
|
total headroom naturally.
|
||||||
|
- The panel's own max_panels_per_day budget is untouched - it sits above
|
||||||
|
whichever provider serves it.
|
||||||
|
- Not in scope: static/news_summary.php (own PHP client/key), streaming,
|
||||||
|
tool use, per-consumer model overrides (add a "model" key per consumer
|
||||||
|
later if ever needed).
|
||||||
|
================================================================================
|
||||||
@@ -20,6 +20,7 @@ if hasattr(sys.stdout, "reconfigure"):
|
|||||||
if hasattr(sys.stderr, "reconfigure"):
|
if hasattr(sys.stderr, "reconfigure"):
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
from agent_panel import attach_panel
|
||||||
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
||||||
from pair_signals import PAIR_PRODUCERS
|
from pair_signals import PAIR_PRODUCERS
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
@@ -163,6 +164,13 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
|||||||
print(f" {Colors.MAGENTA}📰{Colors.RESET} {report.news_description}")
|
print(f" {Colors.MAGENTA}📰{Colors.RESET} {report.news_description}")
|
||||||
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:
|
||||||
|
print(f" {Colors.CYAN}🤖{Colors.RESET} AI Panel: "
|
||||||
|
f"{Colors.BOLD}{report.panel_verdict}{Colors.RESET} "
|
||||||
|
f"({report.panel_confidence:.0f}%) — {report.panel_rationale}")
|
||||||
|
for t in report.panel_takes:
|
||||||
|
print(f" {Colors.GRAY}{t['persona']:<11} {t['stance']:<8} "
|
||||||
|
f"{t['note']}{Colors.RESET}")
|
||||||
|
|
||||||
# ── 6. DETAIL (only with --detail) ─────────────────────────────
|
# ── 6. DETAIL (only with --detail) ─────────────────────────────
|
||||||
if detail and report.primary_analysis:
|
if detail and report.primary_analysis:
|
||||||
@@ -209,6 +217,10 @@ def report_to_dict(report: SignalReport) -> dict:
|
|||||||
"news_sentiment": round(report.news_sentiment, 3),
|
"news_sentiment": round(report.news_sentiment, 3),
|
||||||
"news_description": report.news_description,
|
"news_description": report.news_description,
|
||||||
"news_summary": report.news_summary,
|
"news_summary": report.news_summary,
|
||||||
|
"panel_verdict": report.panel_verdict,
|
||||||
|
"panel_confidence": round(report.panel_confidence, 1),
|
||||||
|
"panel_rationale": report.panel_rationale,
|
||||||
|
"panel_takes": report.panel_takes,
|
||||||
"bullish_reasons": report.bullish_reasons,
|
"bullish_reasons": report.bullish_reasons,
|
||||||
"bearish_reasons": report.bearish_reasons,
|
"bearish_reasons": report.bearish_reasons,
|
||||||
"warnings": report.warnings,
|
"warnings": report.warnings,
|
||||||
@@ -268,6 +280,9 @@ Examples:
|
|||||||
help="Primary analysis timeframe (default: H1)")
|
help="Primary analysis timeframe (default: H1)")
|
||||||
parser.add_argument("--detail", action="store_true",
|
parser.add_argument("--detail", action="store_true",
|
||||||
help="Show full indicator breakdown")
|
help="Show full indicator breakdown")
|
||||||
|
parser.add_argument("--panel", action="store_true",
|
||||||
|
help="Run the LLM agent panel on the result "
|
||||||
|
"(advisory only; needs a DeepSeek key)")
|
||||||
parser.add_argument("--json", action="store_true",
|
parser.add_argument("--json", action="store_true",
|
||||||
help="Output as JSON")
|
help="Output as JSON")
|
||||||
parser.add_argument("--verbose", "-v", action="store_true",
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
@@ -323,6 +338,11 @@ def run_live(args, config: PlatformConfig):
|
|||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
report = aggregator.analyze_pair(symbol=config.symbol)
|
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||||
|
|
||||||
|
# Explicit user intent: force bypasses the enabled/surface/quality
|
||||||
|
# toggles, but the daily panel budget and LLM cap still apply.
|
||||||
|
if getattr(args, "panel", False):
|
||||||
|
attach_panel(report, surface="cli", force=True)
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(report_to_dict(report), indent=2))
|
print(json.dumps(report_to_dict(report), indent=2))
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-2
@@ -770,8 +770,8 @@ class NewsEngine:
|
|||||||
pub_date_el.text if pub_date_el is not None else None
|
pub_date_el.text if pub_date_el is not None else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only recent headlines (last 24h)
|
# Only recent headlines (configurable: headline_max_age_seconds)
|
||||||
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > 86400:
|
if pub_date and (datetime.now(timezone.utc) - pub_date).total_seconds() > self.config.headline_max_age_seconds:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Dedup within and across feeds so a syndicated story
|
# Dedup within and across feeds so a syndicated story
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ class SignalReport:
|
|||||||
news_description: str = ""
|
news_description: str = ""
|
||||||
news_summary: str = "" # LLM market-context blurb (display-only)
|
news_summary: str = "" # LLM market-context blurb (display-only)
|
||||||
|
|
||||||
|
# LLM agent-panel consensus (advisory/DISPLAY-ONLY). Populated
|
||||||
|
# post-hoc by consumers via agent_panel.attach_panel — NEVER inside
|
||||||
|
# analyze_from_dataframes (backtest parity) and never read by any
|
||||||
|
# gate or scoring path.
|
||||||
|
panel_verdict: str = "" # "AGREE" / "NEUTRAL" / "OPPOSE" / ""
|
||||||
|
panel_confidence: float = 0.0
|
||||||
|
panel_rationale: str = ""
|
||||||
|
panel_takes: List[Dict] = field(default_factory=list)
|
||||||
|
|
||||||
final_signal: SignalStrength = SignalStrength.NEUTRAL
|
final_signal: SignalStrength = SignalStrength.NEUTRAL
|
||||||
final_score: float = 0.0
|
final_score: float = 0.0
|
||||||
final_confidence: float = 0.0
|
final_confidence: float = 0.0
|
||||||
|
|||||||
+85
-25
@@ -630,6 +630,7 @@ const IND_SHORT = {ema_trend:"EMA", rsi:"RSI", macd:"MACD", bollinger:"BB",
|
|||||||
const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
|
const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
|
||||||
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
|
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
|
||||||
const TF_ORDER = ["5min","15min","1h","4h"];
|
const TF_ORDER = ["5min","15min","1h","4h"];
|
||||||
|
const TF_SECONDS = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400};
|
||||||
/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache
|
/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache
|
||||||
answers unchanged files with 304, so most polls transfer no body. */
|
answers unchanged files with 304, so most polls transfer no body. */
|
||||||
const POLL_MS = 5000;
|
const POLL_MS = 5000;
|
||||||
@@ -783,7 +784,8 @@ function renderCards() {
|
|||||||
const foot = `<div class="tf-foot">
|
const foot = `<div class="tf-foot">
|
||||||
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
|
<span>${entry.scanned_at ? "scanned " + fmtAge(entry.scanned_at) : ""}</span>
|
||||||
<span class="cd">closes in
|
<span class="cd">closes in
|
||||||
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span></span></div>`;
|
<span class="num" data-close="${entry.next_close_epoch || ""}"
|
||||||
|
data-interval="${TF_SECONDS[tf]}">--:--</span></span></div>`;
|
||||||
if (!r) {
|
if (!r) {
|
||||||
return `${open}<div class="tf-head"><span class="tf-name">${TF_LABEL[tf]}</span>
|
return `${open}<div class="tf-head"><span class="tf-name">${TF_LABEL[tf]}</span>
|
||||||
<span class="spacer"></span><span class="open-hint">details ›</span></div>
|
<span class="spacer"></span><span class="open-hint">details ›</span></div>
|
||||||
@@ -981,6 +983,22 @@ function detailHTML(tf) {
|
|||||||
${esc(r.news_summary || "")}</div>`);
|
${esc(r.news_summary || "")}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI agent panel (advisory) */
|
||||||
|
if (r.panel_verdict) {
|
||||||
|
const vcls = r.panel_verdict === "AGREE" ? "buy"
|
||||||
|
: r.panel_verdict === "OPPOSE" ? "sell" : "neutral";
|
||||||
|
const takes = (r.panel_takes || []).map(t =>
|
||||||
|
`<tr><td class="ind-k">${esc(t.persona)}</td>
|
||||||
|
<td>${esc(t.stance)}</td>
|
||||||
|
<td class="num">${t.confidence != null ? Math.round(t.confidence) : "—"}</td>
|
||||||
|
<td>${esc(t.note || "")}</td></tr>`).join("");
|
||||||
|
out += sec("AI panel (advisory)", `<div class="d-note" style="margin-bottom:8px">
|
||||||
|
<b class="sig-txt ${vcls}">${esc(r.panel_verdict)}</b> ·
|
||||||
|
conf ${Math.round(r.panel_confidence || 0)}%<br>
|
||||||
|
${esc(r.panel_rationale || "")}</div>
|
||||||
|
${takes ? `<table class="d-table"><tbody>${takes}</tbody></table>` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
/* provenance */
|
/* provenance */
|
||||||
out += sec("Scan detail", `<table class="d-table"><tbody>
|
out += sec("Scan detail", `<table class="d-table"><tbody>
|
||||||
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
<tr><td class="ind-k">Instrument</td><td colspan="5">${esc(r.display_name || r.symbol || "—")}</td></tr>
|
||||||
@@ -1005,7 +1023,8 @@ function renderDetail() {
|
|||||||
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
|
$("detail-title").textContent = TF_LABEL[detailTf] || detailTf;
|
||||||
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
|
$("detail-price").textContent = r?.current_price ? fmt$(r.current_price) : "—";
|
||||||
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
|
$("detail-sub").innerHTML = `${snapshot?.symbol ? esc(snapshot.symbol) + " · " : ""}closes in
|
||||||
<span class="num" data-close="${entry.next_close_epoch || ""}">--:--</span>`;
|
<span class="num" data-close="${entry.next_close_epoch || ""}"
|
||||||
|
data-interval="${TF_SECONDS[detailTf]}">--:--</span>`;
|
||||||
$("detail-body").innerHTML = detailHTML(detailTf);
|
$("detail-body").innerHTML = detailHTML(detailTf);
|
||||||
|
|
||||||
dlg.querySelectorAll("details[data-dkey]").forEach(d => {
|
dlg.querySelectorAll("details[data-dkey]").forEach(d => {
|
||||||
@@ -1144,8 +1163,12 @@ function renderWire() {
|
|||||||
};
|
};
|
||||||
const head = items.slice(0, 12).map(item).join("");
|
const head = items.slice(0, 12).map(item).join("");
|
||||||
const rest = items.slice(12);
|
const rest = items.slice(12);
|
||||||
|
/* preserve the disclosure across poll re-renders — otherwise an expanded
|
||||||
|
list snaps shut within one poll cycle (renderDetail does the same via
|
||||||
|
data-dkey; there is only one of these, so a class lookup suffices) */
|
||||||
|
const moreOpen = $("wire").querySelector("details.more")?.open ?? false;
|
||||||
$("wire").innerHTML = head + (rest.length
|
$("wire").innerHTML = head + (rest.length
|
||||||
? `<details class="more" style="padding-top:6px"><summary>${rest.length} more headlines…</summary>
|
? `<details class="more" style="padding-top:6px"${moreOpen ? " open" : ""}><summary>${rest.length} more headlines…</summary>
|
||||||
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
|
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,43 +1349,62 @@ wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
|
|||||||
async function fetchJSON(name) {
|
async function fetchJSON(name) {
|
||||||
/* no-cache = revalidate with the server every time (If-Modified-Since);
|
/* no-cache = revalidate with the server every time (If-Modified-Since);
|
||||||
unchanged files come back 304 with no body, so aggressive polling
|
unchanged files come back 304 with no body, so aggressive polling
|
||||||
stays nearly free. */
|
stays nearly free. Returns raw TEXT, not parsed JSON: a 304 is
|
||||||
|
transparent to fetch (the browser answers with the cached body and a
|
||||||
|
200), so poll() detects "unchanged" by comparing the payload itself
|
||||||
|
and skips the DOM rebuild entirely. */
|
||||||
const res = await fetch(name, {cache: "no-cache"});
|
const res = await fetch(name, {cache: "no-cache"});
|
||||||
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||||||
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
|
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
|
||||||
is stamped at publish time, minutes stale between candle closes. */
|
is stamped at publish time, minutes stale between candle closes. */
|
||||||
const dateHdr = res.headers.get("Date");
|
const dateHdr = res.headers.get("Date");
|
||||||
const data = await res.json();
|
const text = await res.text();
|
||||||
return {data, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
|
return {text, serverNow: dateHdr ? Date.parse(dateHdr) / 1000 : null};
|
||||||
}
|
}
|
||||||
|
|
||||||
let refetchTimer = null;
|
let refetchTimer = null;
|
||||||
|
let lastSnapText = null, lastNewsText = null; // change detection for the render-skip below
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const [snap, nw] = await Promise.all([
|
const [snap, nw] = await Promise.all([
|
||||||
fetchJSON("snapshot.json"),
|
fetchJSON("snapshot.json"),
|
||||||
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
|
fetchJSON("news.json").catch(() => null), // news outage ≠ page failure
|
||||||
]);
|
]);
|
||||||
snapshot = snap.data;
|
const snapChanged = snap.text !== lastSnapText;
|
||||||
news = (nw && nw.data) || news;
|
const newsChanged = nw != null && nw.text !== lastNewsText;
|
||||||
clockOffset = (snap.serverNow ?? snap.data.server_time_epoch) - Date.now() / 1000;
|
if (snapChanged) { snapshot = JSON.parse(snap.text); lastSnapText = snap.text; }
|
||||||
|
if (newsChanged) { news = JSON.parse(nw.text); lastNewsText = nw.text; }
|
||||||
|
clockOffset = (snap.serverNow ?? snapshot.server_time_epoch) - Date.now() / 1000;
|
||||||
lastPollOk = new Date();
|
lastPollOk = new Date();
|
||||||
$("banner-fail").style.display = "none";
|
$("banner-fail").style.display = "none";
|
||||||
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
|
/* re-render only what new data can actually change — an unchanged
|
||||||
renderDetail(); // keep an open detail view in sync with the new scan
|
payload keeps the existing DOM (selection, disclosure state, scroll)
|
||||||
renderNewsDlg(); // same for an open news/calendar dialog
|
intact; countdowns and the freshness line tick from it regardless.
|
||||||
|
Trade-off: "Xm ago" ages in the wire/calendar only advance when the
|
||||||
|
payload changes. */
|
||||||
|
if (snapChanged) {
|
||||||
|
renderHero(); renderCards();
|
||||||
|
renderDetail(); // keep an open detail view in sync with the new scan
|
||||||
|
}
|
||||||
|
if (newsChanged) {
|
||||||
|
renderCalendar(); renderWire();
|
||||||
|
renderNewsDlg(); // same for an open news/calendar dialog
|
||||||
|
}
|
||||||
|
if (snapChanged || newsChanged) renderContext(); // blurb reads news, risk chip reads snapshot
|
||||||
tick(); // countdowns appear immediately, not after the first 1 s tick
|
tick(); // countdowns appear immediately, not after the first 1 s tick
|
||||||
|
|
||||||
/* one-shot refetch shortly after the next expected publish, so a
|
/* one-shot refetch shortly after the next expected publish, so a
|
||||||
fast scan appears ~2 s after the publisher wakes instead of
|
fast scan appears ~2 s after the publisher wakes instead of
|
||||||
waiting out the poll interval (the regular POLL_MS loop covers
|
waiting out the poll interval (the regular POLL_MS loop covers
|
||||||
slower scans) */
|
slower scans) */
|
||||||
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
if (snapChanged) {
|
||||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||||
if (isFinite(nextScan)) {
|
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||||
clearTimeout(refetchTimer);
|
if (isFinite(nextScan)) {
|
||||||
const delay = (nextScan - nowServer() + 2) * 1000;
|
clearTimeout(refetchTimer);
|
||||||
if (delay > 0) refetchTimer = setTimeout(poll, delay);
|
const delay = (nextScan - nowServer() + 2) * 1000;
|
||||||
|
if (delay > 0) refetchTimer = setTimeout(poll, delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
||||||
@@ -1372,28 +1414,46 @@ async function poll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 1 s countdown ticker (server-clock anchored) */
|
/* 1 s countdown ticker (server-clock anchored). The wrap interval comes
|
||||||
|
from data-interval stamped at render time — reverse-looking the epoch up
|
||||||
|
in snapshot.timeframes mis-resolves shared boundaries (M5/M15/H1 all
|
||||||
|
close on the hour) to the first matching TF in TF_ORDER. */
|
||||||
function tick() {
|
function tick() {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
const now = nowServer();
|
const now = nowServer();
|
||||||
document.querySelectorAll("[data-close]").forEach(el => {
|
document.querySelectorAll("[data-close]").forEach(el => {
|
||||||
let t = parseFloat(el.dataset.close);
|
const t = parseFloat(el.dataset.close);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
const tf = TF_ORDER.find(k => snapshot.timeframes[k]?.next_close_epoch === parseFloat(el.dataset.close));
|
|
||||||
let secs = t - now;
|
let secs = t - now;
|
||||||
if (secs < 0) { // boundary passed while waiting for next publish
|
if (secs < 0) { // boundary passed while waiting for next publish
|
||||||
const interval = {"5min": 300, "15min": 900, "1h": 3600, "4h": 14400}[tf] || 300;
|
const interval = parseFloat(el.dataset.interval);
|
||||||
secs = ((secs % interval) + interval) % interval;
|
/* one-shot countdowns (calendar releases) carry no data-interval:
|
||||||
|
clamp at zero until the next poll re-renders them as "released …" */
|
||||||
|
secs = interval > 0 ? ((secs % interval) + interval) % interval : 0;
|
||||||
}
|
}
|
||||||
el.textContent = fmtCountdown(secs);
|
el.textContent = fmtCountdown(secs);
|
||||||
});
|
});
|
||||||
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
|
const m5 = snapshot.timeframes["5min"]?.next_close_epoch;
|
||||||
if (m5) {
|
if (m5) {
|
||||||
let secs = m5 - now;
|
let secs = m5 - now;
|
||||||
if (secs < 0) secs = ((secs % 300) + 300) % 300;
|
if (secs < 0) secs = ((secs % TF_SECONDS["5min"]) + TF_SECONDS["5min"]) % TF_SECONDS["5min"];
|
||||||
$("hero-cd").textContent = fmtCountdown(secs);
|
$("hero-cd").textContent = fmtCountdown(secs);
|
||||||
}
|
}
|
||||||
if (lastPollOk) $("hero-updated").textContent = `updated ${fmtAge(lastPollOk.toISOString())}`;
|
/* publish-time freshness, not poll health — a dead publisher behind a
|
||||||
|
healthy Apache would otherwise read as "updated just now" forever */
|
||||||
|
const pubEpoch = snapshot.server_time_epoch;
|
||||||
|
if (pubEpoch) {
|
||||||
|
const age = now - pubEpoch;
|
||||||
|
const el = $("hero-updated");
|
||||||
|
/* M5 publishes every 5 min while the market is open, so anything older
|
||||||
|
than 15 min means the pipeline is down, not between scans. The
|
||||||
|
weekend case is already covered by the market-closed banner. */
|
||||||
|
const stale = !snapshot.market_closed && age > 900;
|
||||||
|
el.textContent = (stale ? "STALE — published " : "published ") +
|
||||||
|
fmtAge(new Date(pubEpoch * 1000).toISOString());
|
||||||
|
el.style.color = stale ? "var(--amber)" : "";
|
||||||
|
el.style.fontWeight = stale ? "600" : "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setInterval(tick, 1000);
|
setInterval(tick, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from app_config import (
|
|||||||
telegram_channel_id,
|
telegram_channel_id,
|
||||||
)
|
)
|
||||||
from providers import BaseDataProvider, make_data_provider
|
from providers import BaseDataProvider, make_data_provider
|
||||||
|
from agent_panel import attach_panel, panel_health
|
||||||
from llm_client import get_llm_client
|
from llm_client import get_llm_client
|
||||||
from news_engine import FundamentalContext, NewsEngine
|
from news_engine import FundamentalContext, NewsEngine
|
||||||
from pair_signals import PAIR_PRODUCERS
|
from pair_signals import PAIR_PRODUCERS
|
||||||
@@ -407,6 +408,17 @@ def format_signal_message(report: SignalReport) -> str:
|
|||||||
extra.append(f"⚠️ {_escape_md(w)}")
|
extra.append(f"⚠️ {_escape_md(w)}")
|
||||||
if report.news_summary:
|
if report.news_summary:
|
||||||
extra.append(f"📰 {_escape_md(report.news_summary)}")
|
extra.append(f"📰 {_escape_md(report.news_summary)}")
|
||||||
|
if report.panel_verdict:
|
||||||
|
mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get(
|
||||||
|
report.panel_verdict, "")
|
||||||
|
extra.append(
|
||||||
|
f"🤖 AI Panel {mark} *{_escape_md(report.panel_verdict)}* "
|
||||||
|
f"({report.panel_confidence:.0f}%) — "
|
||||||
|
f"{_escape_md(report.panel_rationale)}")
|
||||||
|
if report.panel_takes:
|
||||||
|
votes = " · ".join(
|
||||||
|
f"{t['persona']}: {t['stance']}" for t in report.panel_takes)
|
||||||
|
extra.append(f" _{_escape_md(votes)}_")
|
||||||
if extra:
|
if extra:
|
||||||
lines += [""] + extra
|
lines += [""] + extra
|
||||||
|
|
||||||
@@ -720,6 +732,13 @@ async def _scan_timeframe(
|
|||||||
async with _alert_lock(key):
|
async with _alert_lock(key):
|
||||||
if not _should_alert(key, report):
|
if not _should_alert(key, report):
|
||||||
continue
|
continue
|
||||||
|
# Panel AFTER the quality gate + dedup decision so no LLM
|
||||||
|
# spend on suppressed alerts. Runs on the scan executor to
|
||||||
|
# keep the event loop free; this key's alert lock is held
|
||||||
|
# for the panel's wall time (≤~25 s), which only delays a
|
||||||
|
# same-key duplicate that _should_alert would drop anyway.
|
||||||
|
await asyncio.get_running_loop().run_in_executor(
|
||||||
|
_executor, attach_panel, report)
|
||||||
detail = format_signal_message(report)
|
detail = format_signal_message(report)
|
||||||
delivered = False
|
delivered = False
|
||||||
for chat_id in _signal_targets():
|
for chat_id in _signal_targets():
|
||||||
@@ -855,6 +874,10 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
|
|||||||
if not _should_alert(key, report):
|
if not _should_alert(key, report):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Same post-gate panel attach as the auto-scan path (see
|
||||||
|
# _scan_timeframe for the spend/lock rationale).
|
||||||
|
await asyncio.get_running_loop().run_in_executor(
|
||||||
|
_executor, attach_panel, report)
|
||||||
header = (
|
header = (
|
||||||
f"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
|
f"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
|
||||||
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
|
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
|
||||||
@@ -1193,6 +1216,10 @@ def _news_health_lines() -> List[str]:
|
|||||||
else:
|
else:
|
||||||
lines.append(f" ✅ DeepSeek: {used}/{cap} calls today,"
|
lines.append(f" ✅ DeepSeek: {used}/{cap} calls today,"
|
||||||
f" {scored} headlines scored")
|
f" {scored} headlines scored")
|
||||||
|
if get_app_config().agent_panel_settings.get("enabled", False):
|
||||||
|
ph = panel_health()
|
||||||
|
lines.append(f" 🤖 AI panel: {ph['used_today']}/{ph['cap']}"
|
||||||
|
" panels today")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+382
@@ -6943,6 +6943,388 @@ def _():
|
|||||||
assert "must be > 0" in str(e)
|
assert "must be > 0" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
print("\n═══ Module 23: LLM agent panel ═══")
|
||||||
|
|
||||||
|
|
||||||
|
@test("app_config: agent_panel block parses; absent block → {}")
|
||||||
|
def _():
|
||||||
|
app = load_app_config(_write_json({"agent_panel": {
|
||||||
|
"enabled": True, "telegram": True, "dashboard": False,
|
||||||
|
"min_quality": "A", "max_panels_per_day": 10,
|
||||||
|
"reuse_ttl_seconds": 600}}))
|
||||||
|
assert app.agent_panel_settings["enabled"] is True
|
||||||
|
assert app.agent_panel_settings["min_quality"] == "A"
|
||||||
|
assert app.agent_panel_settings["max_panels_per_day"] == 10
|
||||||
|
assert load_app_config(_write_json({})).agent_panel_settings == {}
|
||||||
|
assert AppConfig().agent_panel_settings == {}
|
||||||
|
|
||||||
|
|
||||||
|
@test("app_config: agent_panel rejects unknown keys and bad types")
|
||||||
|
def _():
|
||||||
|
for payload in (
|
||||||
|
{"agent_panel": {"enabld": True}}, # unknown key
|
||||||
|
{"agent_panel": {"enabled": "yes"}}, # not a bool
|
||||||
|
{"agent_panel": {"telegram": 1}}, # not a bool
|
||||||
|
{"agent_panel": {"dashboard": "no"}}, # not a bool
|
||||||
|
{"agent_panel": {"min_quality": "Z"}}, # invalid grade
|
||||||
|
{"agent_panel": {"max_panels_per_day": 0}}, # not positive
|
||||||
|
{"agent_panel": {"max_panels_per_day": True}}, # bool is not an int
|
||||||
|
{"agent_panel": {"reuse_ttl_seconds": -5}}, # negative
|
||||||
|
{"agent_panel": "oops"}, # not an object
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
load_app_config(_write_json(payload))
|
||||||
|
assert False, f"should have raised for {payload}"
|
||||||
|
except AppConfigError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
import agent_panel as ap
|
||||||
|
from signal_aggregator import SignalReport, TradeSetup
|
||||||
|
|
||||||
|
|
||||||
|
class _PanelStub:
|
||||||
|
"""llm_client-singleton drop-in that routes replies by system prompt."""
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def __init__(self, replies):
|
||||||
|
self.replies = replies # system prompt -> reply dict | None
|
||||||
|
self.calls = [] # (system_prompt, payload) in call order
|
||||||
|
|
||||||
|
def chat_json(self, system_prompt, user_payload):
|
||||||
|
self.calls.append((system_prompt, user_payload))
|
||||||
|
reply = self.replies.get(system_prompt)
|
||||||
|
return reply(user_payload) if callable(reply) else reply
|
||||||
|
|
||||||
|
|
||||||
|
_TAKE_OK = {"stance": "support", "confidence": 70, "note": "fine"}
|
||||||
|
_SYNTH_OK = {"verdict": "AGREE", "confidence": 75, "rationale": "panel backs it"}
|
||||||
|
_ALL_OK = {ap._MACRO_SYSTEM: _TAKE_OK, ap._TECH_SYSTEM: _TAKE_OK,
|
||||||
|
ap._RISK_SYSTEM: _TAKE_OK, ap._CONTRARIAN_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}
|
||||||
|
|
||||||
|
|
||||||
|
def _panel_report(quality="A", signal=SignalStrength.BUY):
|
||||||
|
"""Real report via the pipeline, then forced directional/quality."""
|
||||||
|
config = PlatformConfig()
|
||||||
|
config.primary_timeframe = Timeframe.H1
|
||||||
|
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||||||
|
agg = SignalAggregator(config)
|
||||||
|
dfs = {Timeframe.H1: make_ohlcv(300, seed=42),
|
||||||
|
Timeframe.M15: make_ohlcv(300, seed=43),
|
||||||
|
Timeframe.H4: make_ohlcv(300, seed=44)}
|
||||||
|
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||||||
|
report.final_signal = signal
|
||||||
|
report.signal_quality = quality
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: payload builders compact, filtered, JSON-safe")
|
||||||
|
def _():
|
||||||
|
report = _panel_report()
|
||||||
|
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
|
||||||
|
ctx = FundamentalContext(
|
||||||
|
news_headlines=[NewsSentiment(
|
||||||
|
headline=f"h{i}", source="rss", timestamp=now,
|
||||||
|
currencies_mentioned=["USD"], sentiment_score=0.1, keywords=[])
|
||||||
|
for i in range(15)],
|
||||||
|
events_today=[EconomicEvent(
|
||||||
|
time=now, currency="USD", impact="high", event=f"e{i}",
|
||||||
|
forecast="1.0", actual="2.0") for i in range(12)],
|
||||||
|
)
|
||||||
|
macro = ap._macro_payload(report, ctx)
|
||||||
|
assert len(macro["headlines"]) == 10, "headline cap"
|
||||||
|
assert len(macro["events"]) == 8, "event cap"
|
||||||
|
assert macro["events"][0]["released"] is True
|
||||||
|
assert ap._macro_payload(report, None)["headlines"] == []
|
||||||
|
|
||||||
|
tech = ap._technical_payload(report)
|
||||||
|
assert tech["indicators"], "real analysis must yield indicators"
|
||||||
|
hidden = [n for n in tech["indicators"]
|
||||||
|
if n.startswith("_")
|
||||||
|
or report.primary_analysis.indicators[n].weight == 0]
|
||||||
|
assert not hidden, f"private/weight-0 leaked: {hidden}"
|
||||||
|
assert tech["brief"]["direction"] == "BUY"
|
||||||
|
assert tech["brief"]["timeframe"] == report.primary_analysis.timeframe
|
||||||
|
|
||||||
|
assert "setup" not in ap._risk_payload(report), "no setup when None"
|
||||||
|
report.trade_setup = TradeSetup(
|
||||||
|
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
|
||||||
|
entry_price=2000.0, stop_loss=2010.0, take_profit_1=1985.0,
|
||||||
|
take_profit_2=1970.0, risk_reward=1.5, risk_pips=100.0,
|
||||||
|
reward_pips=150.0, pip_value=0.1)
|
||||||
|
risk = ap._risk_payload(report)
|
||||||
|
assert risk["setup"]["risk_reward"] == 1.5
|
||||||
|
assert risk["brief"]["direction"] == "SELL", "setup direction wins"
|
||||||
|
|
||||||
|
contra = ap._contrarian_payload(report)
|
||||||
|
assert len(contra["bullish_reasons"]) <= 3
|
||||||
|
|
||||||
|
for payload in (macro, tech, risk, contra):
|
||||||
|
json.dumps(payload, sort_keys=True, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: persona reply validation clamps and rejects")
|
||||||
|
def _():
|
||||||
|
v = ap._validate_take
|
||||||
|
assert v("macro", {"stance": "moon", "confidence": 50, "note": "x"}) is None
|
||||||
|
assert v("macro", {"stance": "support", "confidence": "hot"}) is None
|
||||||
|
assert v("macro", {"stance": "support"}) is None, "missing confidence"
|
||||||
|
assert v("macro", None) is None
|
||||||
|
assert v("macro", "support") is None
|
||||||
|
take = v("risk", {"stance": "oppose", "confidence": 250, "note": "n" * 300})
|
||||||
|
assert take.confidence == 100.0 and len(take.note) == 160
|
||||||
|
assert v("risk", {"stance": "neutral", "confidence": -3, "note": ""}).confidence == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: <2 valid takes → None without spending synthesizer")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None
|
||||||
|
lc._client = stub
|
||||||
|
assert ap.run_panel(_panel_report()) is None
|
||||||
|
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
|
||||||
|
"synthesizer request must not be spent on a failed panel"
|
||||||
|
assert len(stub.calls) == 4
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: synthesizer failure or garbage verdict → None")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
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}"
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: full run yields verdict + 4 ordered takes")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
stub.replies[ap._SYNTH_SYSTEM] = {
|
||||||
|
"verdict": "OPPOSE", "confidence": 72.4,
|
||||||
|
"rationale": "r" * 300}
|
||||||
|
lc._client = stub
|
||||||
|
v = ap.run_panel(_panel_report())
|
||||||
|
assert v.verdict == "OPPOSE" and v.confidence == 72.4
|
||||||
|
assert len(v.rationale) == 240, "rationale truncated"
|
||||||
|
assert [t.persona for t in v.takes] == \
|
||||||
|
["macro", "technical", "risk", "contrarian"]
|
||||||
|
synth_payload = dict(stub.calls)[ap._SYNTH_SYSTEM]
|
||||||
|
assert synth_payload["direction"] == "BUY"
|
||||||
|
assert len(synth_payload["takes"]) == 4
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: NEUTRAL signal → no panel even forced")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
lc._client = stub
|
||||||
|
report = _panel_report(signal=SignalStrength.NEUTRAL)
|
||||||
|
assert ap.run_panel(report) is None
|
||||||
|
assert ap.attach_panel(report, _PANEL_ON, force=True,
|
||||||
|
context=FundamentalContext()) is False
|
||||||
|
assert stub.calls == [], "NEUTRAL must spend nothing"
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: exact + reuse-TTL cache re-attach with zero spend")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
lc._client = stub
|
||||||
|
report = _panel_report()
|
||||||
|
assert ap.attach_panel(report, _PANEL_ON,
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
assert report.panel_verdict == "AGREE"
|
||||||
|
assert len(stub.calls) == 5
|
||||||
|
|
||||||
|
# same bar again → exact-key hit
|
||||||
|
report2 = _panel_report()
|
||||||
|
assert ap.attach_panel(report2, _PANEL_ON,
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
assert report2.panel_verdict == "AGREE" and len(stub.calls) == 5
|
||||||
|
|
||||||
|
# newer bar, same symbol/tf/direction → reuse-TTL hit
|
||||||
|
report3 = _panel_report()
|
||||||
|
report3.timestamp = "2099-01-01 01:00:00+00:00"
|
||||||
|
assert ap.attach_panel(report3, _PANEL_ON,
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
assert len(stub.calls) == 5, "persisting signal must not re-spend"
|
||||||
|
|
||||||
|
# TTL 0 → the reuse layer is off; a new bar re-runs the panel
|
||||||
|
report4 = _panel_report()
|
||||||
|
report4.timestamp = "2099-01-01 02:00:00+00:00"
|
||||||
|
assert ap.attach_panel(report4, {**_PANEL_ON, "reuse_ttl_seconds": 0},
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
assert len(stub.calls) == 10
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: daily budget exhausts and re-arms on UTC roll")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
lc._client = stub
|
||||||
|
settings = {**_PANEL_ON, "max_panels_per_day": 1,
|
||||||
|
"reuse_ttl_seconds": 0}
|
||||||
|
assert ap.attach_panel(_panel_report(), settings,
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
assert len(stub.calls) == 5
|
||||||
|
report = _panel_report()
|
||||||
|
report.timestamp = "2099-01-01 01:00:00+00:00"
|
||||||
|
assert ap.attach_panel(report, settings,
|
||||||
|
context=FundamentalContext()) is False
|
||||||
|
assert len(stub.calls) == 5, "budget-blocked attempt must spend nothing"
|
||||||
|
ap._panel_day = (_dt_m19.datetime.now(_dt_m19.timezone.utc).date()
|
||||||
|
- _dt_m19.timedelta(days=1))
|
||||||
|
assert ap.attach_panel(report, settings,
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
assert len(stub.calls) == 10
|
||||||
|
assert ap.panel_health()["used_today"] == 1
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: enabled/surface/min_quality gates; force bypasses")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
lc._client = stub
|
||||||
|
report = _panel_report()
|
||||||
|
ctx = FundamentalContext()
|
||||||
|
assert ap.attach_panel(report, {}, context=ctx) is False, \
|
||||||
|
"absent config block must keep the feature dark"
|
||||||
|
assert ap.attach_panel(report, {**_PANEL_ON, "enabled": False},
|
||||||
|
context=ctx) is False
|
||||||
|
assert ap.attach_panel(report, {**_PANEL_ON, "telegram": False},
|
||||||
|
context=ctx) is False
|
||||||
|
assert ap.attach_panel(report, _PANEL_ON, surface="dashboard",
|
||||||
|
context=ctx) is False, "dashboard defaults off"
|
||||||
|
assert ap.attach_panel(_panel_report(quality="C"), _PANEL_ON,
|
||||||
|
context=ctx) is False, "C < min_quality B"
|
||||||
|
assert ap.attach_panel(_panel_report(quality="F"), _PANEL_ON,
|
||||||
|
context=ctx) is False
|
||||||
|
assert stub.calls == [], "every gate must block before any spend"
|
||||||
|
assert ap.attach_panel(report, {**_PANEL_ON, "dashboard": True},
|
||||||
|
surface="dashboard", context=ctx) is True
|
||||||
|
ap.reset_panel_state()
|
||||||
|
assert ap.attach_panel(_panel_report(quality="C"),
|
||||||
|
{**_PANEL_ON, "enabled": False},
|
||||||
|
force=True, context=ctx) is True, \
|
||||||
|
"force bypasses enabled/surface/quality"
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("panel verdict flows to Telegram message; absent verdict silent")
|
||||||
|
def _():
|
||||||
|
import telegram_bot as tb
|
||||||
|
report = _panel_report()
|
||||||
|
assert "AI Panel" not in tb.format_signal_message(report)
|
||||||
|
report.panel_verdict = "OPPOSE"
|
||||||
|
report.panel_confidence = 64.0
|
||||||
|
report.panel_rationale = "counter-trend against aligned H4"
|
||||||
|
report.panel_takes = [
|
||||||
|
{"persona": "macro", "stance": "support", "confidence": 60,
|
||||||
|
"note": "n"},
|
||||||
|
{"persona": "technical", "stance": "oppose", "confidence": 80,
|
||||||
|
"note": "n"},
|
||||||
|
]
|
||||||
|
msg = tb.format_signal_message(report)
|
||||||
|
assert "AI Panel" in msg and "OPPOSE" in msg and "(64%)" in msg
|
||||||
|
assert "counter-trend against aligned H4" in msg
|
||||||
|
assert "macro: support" in msg and "technical: oppose" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@test("backtest parity: analyze_from_dataframes never populates panel")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
lc._client = stub
|
||||||
|
config = PlatformConfig()
|
||||||
|
config.primary_timeframe = Timeframe.H1
|
||||||
|
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
|
||||||
|
agg = SignalAggregator(config)
|
||||||
|
dfs = {Timeframe.H1: make_ohlcv(300, seed=42),
|
||||||
|
Timeframe.M15: make_ohlcv(300, seed=43),
|
||||||
|
Timeframe.H4: make_ohlcv(300, seed=44)}
|
||||||
|
for use_news in (True, False):
|
||||||
|
report = agg.analyze_from_dataframes(
|
||||||
|
"XAU/USD", dfs, context=FundamentalContext(),
|
||||||
|
use_news=use_news)
|
||||||
|
assert report.panel_verdict == ""
|
||||||
|
assert report.panel_confidence == 0.0
|
||||||
|
assert report.panel_rationale == ""
|
||||||
|
assert report.panel_takes == []
|
||||||
|
assert stub.calls == [], \
|
||||||
|
"the replayable core must never invoke the panel"
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("report_to_dict carries panel keys → dashboard snapshot propagation")
|
||||||
|
def _():
|
||||||
|
from main import report_to_dict as _rtd
|
||||||
|
from dashboard_publisher import report_to_webapp_dict as _rtw
|
||||||
|
report = _panel_report()
|
||||||
|
d = _rtd(report)
|
||||||
|
assert d["panel_verdict"] == "" and d["panel_takes"] == []
|
||||||
|
report.panel_verdict = "AGREE"
|
||||||
|
report.panel_confidence = 77.5
|
||||||
|
report.panel_rationale = "solid confluence"
|
||||||
|
report.panel_takes = [{"persona": "risk", "stance": "support",
|
||||||
|
"confidence": 66.0, "note": "rr fine"}]
|
||||||
|
wd = _rtw(report)
|
||||||
|
assert wd["panel_verdict"] == "AGREE"
|
||||||
|
assert wd["panel_confidence"] == 77.5
|
||||||
|
assert wd["panel_takes"][0]["persona"] == "risk"
|
||||||
|
json.dumps(wd, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: attach never raises (LLM explosion, garbage settings)")
|
||||||
|
def _():
|
||||||
|
class _Bomb:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def chat_json(self, system_prompt, user_payload):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
try:
|
||||||
|
lc._client = _Bomb()
|
||||||
|
report = _panel_report()
|
||||||
|
assert ap.attach_panel(report, _PANEL_ON,
|
||||||
|
context=FundamentalContext()) is False
|
||||||
|
assert report.panel_verdict == "" and report.panel_takes == []
|
||||||
|
logging.disable(logging.CRITICAL) # silence the expected traceback
|
||||||
|
try:
|
||||||
|
assert ap.attach_panel(report, 42,
|
||||||
|
context=FundamentalContext()) is False
|
||||||
|
finally:
|
||||||
|
logging.disable(logging.NOTSET)
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
# SUMMARY
|
# SUMMARY
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user