agent_panel: ATR-normalized candle + indicator-value enrichment, hardened prompts, verdict logging
This commit is contained in:
@@ -35,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). 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`.
|
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`, plus the technical-payload enrichment knobs `candles_enabled`/`candle_bars_primary`/`candle_bars_confirm`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
|||||||
- **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0.
|
- **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0.
|
||||||
- **`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].
|
||||||
- **`signal_log.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, Oanda daemon `oanda_daemon`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, and the news at scan time (event risk + calendar events + scored headlines JSON, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields are NOT logged (attach_panel runs post-hoc, after analyze_pair returns).
|
- **`signal_log.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, Oanda daemon `oanda_daemon`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, and the news at scan time (event risk + calendar events + scored headlines JSON, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid on the report and `agent_panel.attach_panel` writes the verdict back onto the same row via `log_panel_update` (no rowid — backtest replay or logging disabled — is a silent no-op).
|
||||||
- **`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.
|
- **`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. The technical persona's payload is enriched (2026-08-15): raw indicator values plus ATR-normalized recent candles per TF (`candles_enabled`/`candle_bars_primary` 20/`candle_bars_confirm` 10 config keys; code does the arithmetic — raw prices are the model's weakest input, per `backtest/research/llm_news_eval_2026-08-06.md`). Prompts forbid training-data/outside knowledge and cap notes at 15 words / rationales at 30 to bound output tokens.
|
||||||
- **`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`.
|
||||||
|
|||||||
+163
-20
@@ -32,10 +32,26 @@ layers bound the spend on top of that cap:
|
|||||||
|
|
||||||
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
|
Prompts are STATIC module constants (DeepSeek prefix-cache pricing —
|
||||||
see news_engine.py); all variable content travels in the user payload.
|
see news_engine.py); all variable content travels in the user payload.
|
||||||
|
|
||||||
|
Payload enrichment (2026-08-15, owner-approved, advisory-only): the
|
||||||
|
technical persona also receives raw indicator VALUES (with a units
|
||||||
|
legend in the prompt) and ATR-NORMALIZED recent candles per timeframe
|
||||||
|
(code does the arithmetic — raw prices are the model's weakest input;
|
||||||
|
see backtest/research/llm_news_eval_2026-08-06.md). Sized by config
|
||||||
|
``candle_bars_primary`` (default 20) / ``candle_bars_confirm``
|
||||||
|
(default 10); ``candles_enabled: false`` reverts to the pre-enrichment
|
||||||
|
payload. Prompts forbid outside/training-data knowledge and cap notes
|
||||||
|
at 15 words / rationales at 30 (output tokens are the pricey side).
|
||||||
|
|
||||||
|
Verdict logging (2026-08-15): after a successful attach, the verdict is
|
||||||
|
written back onto the scan's signals.db row via signal_log's rowid
|
||||||
|
hand-off — building the "does OPPOSE predict losers?" dataset without
|
||||||
|
changing any live behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -44,6 +60,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from app_config import get_app_config
|
from app_config import get_app_config
|
||||||
|
from config import Timeframe
|
||||||
from llm_client import DEFAULT_TIMEOUT, get_llm_client
|
from llm_client import DEFAULT_TIMEOUT, get_llm_client
|
||||||
|
|
||||||
if TYPE_CHECKING: # type hints only — keep the coupling one-way
|
if TYPE_CHECKING: # type hints only — keep the coupling one-way
|
||||||
@@ -55,6 +72,8 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_MIN_QUALITY = "B"
|
DEFAULT_MIN_QUALITY = "B"
|
||||||
DEFAULT_MAX_PANELS_PER_DAY = 20
|
DEFAULT_MAX_PANELS_PER_DAY = 20
|
||||||
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
DEFAULT_REUSE_TTL_SECONDS = 3600.0
|
||||||
|
DEFAULT_CANDLE_BARS_PRIMARY = 20
|
||||||
|
DEFAULT_CANDLE_BARS_CONFIRM = 10
|
||||||
|
|
||||||
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
# Quality ranks for the min_quality gate. Deliberately a local copy —
|
||||||
# telegram_bot's ranking is private and dashboard_publisher must not
|
# telegram_bot's ranking is private and dashboard_publisher must not
|
||||||
@@ -78,9 +97,11 @@ _TAKE_CONTRACT = """\
|
|||||||
Your review is advisory research on an already-generated signal; you are
|
Your review is advisory research on an already-generated signal; you are
|
||||||
one voice on a panel, not the decision maker.
|
one voice on a panel, not the decision maker.
|
||||||
Hard rules: use ONLY the data in the JSON you receive — do not invent
|
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
|
numbers, events, or price levels. Your training data about gold, the
|
||||||
thin or missing, answer "neutral" with confidence below 30, unless your
|
markets, and current events is stale and OFF-LIMITS: never use outside
|
||||||
role's instructions say otherwise.
|
knowledge; if a fact is not in the JSON, treat it as unknown. 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
|
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
|
case with some doubt; 30-49 mixed evidence; below 30 little basis to
|
||||||
judge.
|
judge.
|
||||||
@@ -91,7 +112,7 @@ not "H4"; describe what an indicator MEANS ("the price has climbed
|
|||||||
unusually fast"), never its name or number.
|
unusually fast"), never its name or number.
|
||||||
Reply with ONLY JSON:
|
Reply with ONLY JSON:
|
||||||
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
|
{"stance": "support"|"oppose"|"neutral", "confidence": <0-100>,
|
||||||
"note": "<max 25 words>"}
|
"note": "<max 15 words>"}
|
||||||
"stance" says whether YOUR analysis supports the proposed trade
|
"stance" says whether YOUR analysis supports the proposed trade
|
||||||
direction, opposes it, or is genuinely mixed; "confidence" is how sure
|
direction, opposes it, or is genuinely mixed; "confidence" is how sure
|
||||||
you are of your stance; "note" is your single most important point.
|
you are of your stance; "note" is your single most important point.
|
||||||
@@ -125,15 +146,26 @@ and a soft jobs report -> {"stance": "support", "confidence": 75,
|
|||||||
_TECH_SYSTEM = """\
|
_TECH_SYSTEM = """\
|
||||||
You are the technical analyst on a gold (XAU/USD) trade-review panel.
|
You are the technical analyst on a gold (XAU/USD) trade-review panel.
|
||||||
You receive JSON: a proposed trade brief ("brief") plus the technical
|
You receive JSON: a proposed trade brief ("brief") plus the technical
|
||||||
evidence — per-"indicators" signal/confidence, multi-timeframe
|
evidence — per-"indicators" signal/confidence/value, multi-timeframe
|
||||||
"mtf" signals and scores, "key_levels" versus the current price, and an
|
"mtf" signals and scores, "key_levels" versus the current price, an
|
||||||
"exhaustion" count of consecutive same-direction bars flagged by the
|
"exhaustion" count of consecutive same-direction bars flagged by the
|
||||||
engine.
|
engine, and optionally "candles".
|
||||||
|
Indicator "value" units: rsi 0-100 (above 70 overbought, below 30
|
||||||
|
oversold); adx 0-100 (above 25 = strong trend); macd = histogram in
|
||||||
|
dollars (momentum); roc = percent; bollinger = position 0-1 (0 = lower
|
||||||
|
band, 1 = upper band); keltner = dollar distance from the mid-line;
|
||||||
|
ema_trend = 0-1 alignment score; divergence and candlestick = pattern
|
||||||
|
counts.
|
||||||
|
"candles" is per timeframe: {"ref_close", "atr", "bars"} — bars are
|
||||||
|
oldest-first, each [open, high, low, close] expressed in ATR units
|
||||||
|
relative to ref_close: 0.0 = the latest close, +1.0 = one ATR above it,
|
||||||
|
-2.0 = two ATR below it. Read the SHAPE (drift, swings, wicks); never
|
||||||
|
quote these as prices.
|
||||||
Judge ONE thing: is the technical picture internally coherent for the
|
Judge ONE thing: is the technical picture internally coherent for the
|
||||||
proposed direction? Look for indicators fighting each other, the signal
|
proposed direction? Look for indicators fighting each other, the signal
|
||||||
fighting the regime (e.g. a BUY in trending_down), higher timeframes
|
fighting the regime (e.g. a BUY in trending_down), higher timeframes
|
||||||
disagreeing, price pressed into a key level, or an exhausted move being
|
disagreeing, price pressed into a key level, an exhausted move being
|
||||||
chased.
|
chased, or the candle path contradicting the indicator readings.
|
||||||
Stance rules:
|
Stance rules:
|
||||||
- support: the indicators, higher timeframes, and regime mostly agree
|
- support: the indicators, higher timeframes, and regime mostly agree
|
||||||
with the proposed direction and none of the conflicts above appear.
|
with the proposed direction and none of the conflicts above appear.
|
||||||
@@ -213,7 +245,7 @@ possible profit is too small and the price has already climbed
|
|||||||
unusually fast. Only the news backdrop supports buying."
|
unusually fast. Only the news backdrop supports buying."
|
||||||
Reply with ONLY JSON:
|
Reply with ONLY JSON:
|
||||||
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
|
{"verdict": "AGREE"|"NEUTRAL"|"OPPOSE", "confidence": <0-100>,
|
||||||
"rationale": "<max 40 words>"}
|
"rationale": "<max 30 words>"}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +270,16 @@ class PanelVerdict:
|
|||||||
|
|
||||||
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
# ── Payload builders (pure functions; llm_client json-dumps sorted) ──
|
||||||
|
|
||||||
|
def _finite(x: Any) -> Optional[float]:
|
||||||
|
"""float(x) or None for non-numeric / non-finite (NaN/inf breaks the
|
||||||
|
strict-JSON wire format the client sends)."""
|
||||||
|
try:
|
||||||
|
f = float(x)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return f if math.isfinite(f) else None
|
||||||
|
|
||||||
|
|
||||||
def _direction(report: "SignalReport") -> Optional[str]:
|
def _direction(report: "SignalReport") -> Optional[str]:
|
||||||
"""BUY/SELL the panel should review; None = nothing to review."""
|
"""BUY/SELL the panel should review; None = nothing to review."""
|
||||||
if report.trade_setup is not None:
|
if report.trade_setup is not None:
|
||||||
@@ -286,21 +328,26 @@ def _macro_payload(report: "SignalReport",
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
|
def _technical_payload(report: "SignalReport",
|
||||||
|
candles: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
indicators: Dict[str, Dict[str, Any]] = {}
|
indicators: Dict[str, Dict[str, Any]] = {}
|
||||||
key_levels: Dict[str, float] = {}
|
key_levels: Dict[str, float] = {}
|
||||||
analysis = report.primary_analysis
|
analysis = report.primary_analysis
|
||||||
if analysis is not None:
|
if analysis is not None:
|
||||||
# Same visibility filter as main.report_to_dict: private series
|
# Same visibility filter as main.report_to_dict: private series
|
||||||
# ride as _-prefixed pseudo-indicators, weight-0 = context-only.
|
# ride as _-prefixed pseudo-indicators, weight-0 = context-only.
|
||||||
indicators = {
|
for name, ind in analysis.indicators.items():
|
||||||
name: {"signal": ind.signal.name,
|
if name.startswith("_") or ind.weight == 0:
|
||||||
"confidence": round(ind.confidence)}
|
continue
|
||||||
for name, ind in analysis.indicators.items()
|
entry: Dict[str, Any] = {"signal": ind.signal.name,
|
||||||
if not name.startswith("_") and ind.weight != 0
|
"confidence": round(ind.confidence)}
|
||||||
}
|
value = _finite(getattr(ind, "value", None))
|
||||||
|
if value is not None:
|
||||||
|
entry["value"] = round(value, 2)
|
||||||
|
indicators[name] = entry
|
||||||
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
|
key_levels = {k: round(v, 2) for k, v in analysis.key_levels.items()}
|
||||||
return {
|
payload: Dict[str, Any] = {
|
||||||
"brief": _brief(report),
|
"brief": _brief(report),
|
||||||
"indicators": indicators,
|
"indicators": indicators,
|
||||||
"mtf": {tf: {"signal": sig.name,
|
"mtf": {tf: {"signal": sig.name,
|
||||||
@@ -309,6 +356,9 @@ def _technical_payload(report: "SignalReport") -> Dict[str, Any]:
|
|||||||
"key_levels": key_levels,
|
"key_levels": key_levels,
|
||||||
"exhaustion": report.exhaustion_count,
|
"exhaustion": report.exhaustion_count,
|
||||||
}
|
}
|
||||||
|
if candles:
|
||||||
|
payload["candles"] = candles
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
def _risk_payload(report: "SignalReport") -> Dict[str, Any]:
|
||||||
@@ -344,6 +394,81 @@ def _contrarian_payload(report: "SignalReport") -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Candle context (ATR-normalized; technical persona only) ─────────
|
||||||
|
#
|
||||||
|
# Raw prices are the LLM's weakest input modality (measured in-repo:
|
||||||
|
# numeric-comparison misreads, backtest/research/llm_news_eval_2026-08-06.md),
|
||||||
|
# so code does the arithmetic and the model reads a normalized SHAPE:
|
||||||
|
# each bar's [o,h,l,c] as offsets from the latest close in ATR units.
|
||||||
|
# A panel-private provider singleton keeps the TTL cache warm across
|
||||||
|
# attaches; any failure degrades to "no candles", never a failed panel.
|
||||||
|
|
||||||
|
_TF_BY_VALUE = {tf.value: tf for tf in Timeframe}
|
||||||
|
|
||||||
|
# Patchable for tests (mirrors the llm_client._client stub pattern).
|
||||||
|
_dp: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
def _panel_provider() -> Any:
|
||||||
|
global _dp
|
||||||
|
if _dp is None:
|
||||||
|
from config import PlatformConfig
|
||||||
|
from providers import make_data_provider
|
||||||
|
_dp = make_data_provider(PlatformConfig())
|
||||||
|
return _dp
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_candles(df: Any, atr: float, bars: int) -> Dict[str, Any]:
|
||||||
|
"""Last `bars` closed candles as ATR-unit offsets from the final
|
||||||
|
close: 0.0 = latest close, +1.0 = one ATR above it. Oldest-first."""
|
||||||
|
tail = df.tail(bars)
|
||||||
|
ref = float(tail["Close"].iloc[-1])
|
||||||
|
out = []
|
||||||
|
for _, row in tail.iterrows():
|
||||||
|
out.append([round((float(row["Open"]) - ref) / atr, 1),
|
||||||
|
round((float(row["High"]) - ref) / atr, 1),
|
||||||
|
round((float(row["Low"]) - ref) / atr, 1),
|
||||||
|
round((float(row["Close"]) - ref) / atr, 1)])
|
||||||
|
return {"ref_close": round(ref, 2), "atr": round(float(atr), 2),
|
||||||
|
"bars": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _candle_context(report: "SignalReport",
|
||||||
|
settings: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Best-effort {"<tf>": _normalize_candles(...)} per analysis TF;
|
||||||
|
None = omit the key from the payload. NEVER raises."""
|
||||||
|
try:
|
||||||
|
if not settings.get("candles_enabled", True):
|
||||||
|
return None
|
||||||
|
confirmations = list(report.confirmation_analyses or [])
|
||||||
|
analyses = [report.primary_analysis] + confirmations
|
||||||
|
counts = [int(settings.get("candle_bars_primary",
|
||||||
|
DEFAULT_CANDLE_BARS_PRIMARY))] + [
|
||||||
|
int(settings.get("candle_bars_confirm",
|
||||||
|
DEFAULT_CANDLE_BARS_CONFIRM))
|
||||||
|
] * len(confirmations)
|
||||||
|
dp = _panel_provider()
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for analysis, n in zip(analyses, counts):
|
||||||
|
if analysis is None:
|
||||||
|
continue
|
||||||
|
atr = _finite(getattr(
|
||||||
|
(analysis.indicators or {}).get("atr"), "value", None))
|
||||||
|
if not atr or atr <= 0:
|
||||||
|
continue
|
||||||
|
tf = _TF_BY_VALUE.get(analysis.timeframe)
|
||||||
|
if tf is None:
|
||||||
|
continue
|
||||||
|
df = dp.fetch_ohlcv(report.symbol, tf)
|
||||||
|
if df is None or len(df) < 2:
|
||||||
|
continue
|
||||||
|
out[analysis.timeframe] = _normalize_candles(df, atr, n)
|
||||||
|
return out or None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("agent panel: candle context unavailable: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Orchestration ────────────────────────────────────────────────────
|
# ── Orchestration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
|
# Panel-private pool: the 4 persona calls run concurrently WITHOUT
|
||||||
@@ -385,6 +510,7 @@ def _ask_persona(persona: str, system: str,
|
|||||||
|
|
||||||
def run_panel(report: "SignalReport",
|
def run_panel(report: "SignalReport",
|
||||||
context: Optional["FundamentalContext"] = None,
|
context: Optional["FundamentalContext"] = None,
|
||||||
|
candles: Optional[Dict[str, Any]] = None,
|
||||||
) -> Optional[PanelVerdict]:
|
) -> Optional[PanelVerdict]:
|
||||||
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
|
"""Run 4 personas + synthesizer; None = no verdict (fail-safe).
|
||||||
|
|
||||||
@@ -398,7 +524,7 @@ def run_panel(report: "SignalReport",
|
|||||||
return None
|
return None
|
||||||
jobs = (
|
jobs = (
|
||||||
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
("macro", _MACRO_SYSTEM, _macro_payload(report, context)),
|
||||||
("technical", _TECH_SYSTEM, _technical_payload(report)),
|
("technical", _TECH_SYSTEM, _technical_payload(report, candles)),
|
||||||
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
("risk", _RISK_SYSTEM, _risk_payload(report)),
|
||||||
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
|
("contrarian", _CONTRARIAN_SYSTEM, _contrarian_payload(report)),
|
||||||
)
|
)
|
||||||
@@ -502,6 +628,20 @@ def _write_fields(report: "SignalReport", v: PanelVerdict) -> None:
|
|||||||
"confidence": t.confidence, "note": t.note} for t in v.takes]
|
"confidence": t.confidence, "note": t.note} for t in v.takes]
|
||||||
|
|
||||||
|
|
||||||
|
def _log_panel_verdict(report: "SignalReport") -> None:
|
||||||
|
"""Persist the verdict onto the scan's signals.db row (fail-safe).
|
||||||
|
|
||||||
|
The row is inserted inside analyze_pair — BEFORE the panel attaches
|
||||||
|
post-hoc — so panel columns arrive via this update. A report that was
|
||||||
|
never logged (backtest replay, logging disabled) is a silent no-op.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from signal_log import log_panel_update
|
||||||
|
log_panel_update(report)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("agent panel: verdict logging failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def attach_panel(report: "SignalReport",
|
def attach_panel(report: "SignalReport",
|
||||||
settings: Optional[Dict[str, Any]] = None,
|
settings: Optional[Dict[str, Any]] = None,
|
||||||
surface: str = "telegram",
|
surface: str = "telegram",
|
||||||
@@ -548,6 +688,7 @@ def attach_panel(report: "SignalReport",
|
|||||||
hit = prior
|
hit = prior
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
_write_fields(report, hit)
|
_write_fields(report, hit)
|
||||||
|
_log_panel_verdict(report)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
cap = int(settings.get("max_panels_per_day",
|
cap = int(settings.get("max_panels_per_day",
|
||||||
@@ -567,7 +708,8 @@ def attach_panel(report: "SignalReport",
|
|||||||
logger.debug("agent panel: news context unavailable: %s", e)
|
logger.debug("agent panel: news context unavailable: %s", e)
|
||||||
context = None
|
context = None
|
||||||
|
|
||||||
verdict = run_panel(report, context)
|
candles = _candle_context(report, settings)
|
||||||
|
verdict = run_panel(report, context, candles=candles)
|
||||||
if verdict is None:
|
if verdict is None:
|
||||||
return False
|
return False
|
||||||
_write_fields(report, verdict)
|
_write_fields(report, verdict)
|
||||||
@@ -576,6 +718,7 @@ def attach_panel(report: "SignalReport",
|
|||||||
while len(_cache) > _CACHE_MAX:
|
while len(_cache) > _CACHE_MAX:
|
||||||
_cache.popitem(last=False)
|
_cache.popitem(last=False)
|
||||||
_last_by_dir[dir_key] = verdict
|
_last_by_dir[dir_key] = verdict
|
||||||
|
_log_panel_verdict(report)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("agent panel: attach failed — signal unaffected")
|
logger.exception("agent panel: attach failed — signal unaffected")
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ _KNOWN_DEEPSEEK = {
|
|||||||
_KNOWN_AGENT_PANEL = {
|
_KNOWN_AGENT_PANEL = {
|
||||||
"enabled", "telegram", "dashboard", "min_quality",
|
"enabled", "telegram", "dashboard", "min_quality",
|
||||||
"max_panels_per_day", "reuse_ttl_seconds",
|
"max_panels_per_day", "reuse_ttl_seconds",
|
||||||
|
"candles_enabled", "candle_bars_primary", "candle_bars_confirm",
|
||||||
}
|
}
|
||||||
_KNOWN_SIGNAL_LOG = {"enabled", "path"}
|
_KNOWN_SIGNAL_LOG = {"enabled", "path"}
|
||||||
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
||||||
@@ -306,6 +307,18 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
|||||||
raise AppConfigError(
|
raise AppConfigError(
|
||||||
"config.json: agent_panel.reuse_ttl_seconds must be a "
|
"config.json: agent_panel.reuse_ttl_seconds must be a "
|
||||||
"non-negative number")
|
"non-negative number")
|
||||||
|
if "candles_enabled" in panel \
|
||||||
|
and not isinstance(panel["candles_enabled"], bool):
|
||||||
|
raise AppConfigError(
|
||||||
|
"config.json: agent_panel.candles_enabled must be true or false")
|
||||||
|
for key in ("candle_bars_primary", "candle_bars_confirm"):
|
||||||
|
if key in panel:
|
||||||
|
n = panel[key]
|
||||||
|
if isinstance(n, bool) or not isinstance(n, int) \
|
||||||
|
or not 1 <= n <= 100:
|
||||||
|
raise AppConfigError(
|
||||||
|
f"config.json: agent_panel.{key} must be an integer "
|
||||||
|
"between 1 and 100")
|
||||||
app.agent_panel_settings = dict(panel)
|
app.agent_panel_settings = dict(panel)
|
||||||
|
|
||||||
slog = raw.get("signal_log", {})
|
slog = raw.get("signal_log", {})
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# AI signal enrichment — analysis + build record (2026-08-15)
|
||||||
|
|
||||||
|
Owner questions:
|
||||||
|
1. If the AI panel also received indicator values and (limited) candle data, would the system be more accurate?
|
||||||
|
2. If AI reviewed every candle alongside the local score system, would prediction accuracy justify the token burn — or is there a middle ground?
|
||||||
|
|
||||||
|
## What the panel already received (pre-change baseline)
|
||||||
|
|
||||||
|
The four personas (`agent_panel.py`) already got a *distilled* picture:
|
||||||
|
per-indicator signal+confidence, MTF signals/scores, key levels,
|
||||||
|
exhaustion count, full trade-setup math, warnings, headlines/events/
|
||||||
|
sentiment. Missing: raw indicator **values** and any **candle** data.
|
||||||
|
|
||||||
|
Cost guardrails already in place: 5 requests/panel, `max_panels_per_day`
|
||||||
|
20 (≤100 requests/day), 3600 s reuse-TTL, per-bar exact cache, shared
|
||||||
|
500 req/day DeepSeek cap, static prompts on the ~50× prefix cache.
|
||||||
|
|
||||||
|
## Evidence used
|
||||||
|
|
||||||
|
- **LLM is strong at text, weak at raw numerics** (measured in-repo,
|
||||||
|
`llm_news_eval_2026-08-06.md`): headline sentiment 100% (78/78) vs
|
||||||
|
keyword scorer 31%; but ~11% of event-direction calls misread close
|
||||||
|
numeric comparisons until code pre-computed the comparison
|
||||||
|
(`vs_forecast`) and the model only mapped it to doctrine.
|
||||||
|
- **The book any filter would act on** (M5 canonical,
|
||||||
|
`oanda_m5_12mo_065_nopb_20260814.csv`): 577 trades, +106.82R,
|
||||||
|
PF 1.51, win rate 52.9%; A-grade 59.5%, B-grade 51.4% (+67.8R of the
|
||||||
|
total), C-grade 43.8% (−0.23R, 16 trades). Average edge ≈ 0.185R/trade.
|
||||||
|
|
||||||
|
## Answer 1 — enrich the existing panel? YES (cheap, safe)
|
||||||
|
|
||||||
|
- A compact candle is ~15–25 tokens; 20 primary-TF + 10 confirm-TF bars
|
||||||
|
≈ 500–1,500 tokens per persona call → cents/day at the 20-panel cap.
|
||||||
|
**Tokens are not the constraint; the request cap is.**
|
||||||
|
- Measured after the build (dry-test wire log): enriched technical
|
||||||
|
payload with 20 candles ≈ **~380 tokens**; replies ≈ 25–35 tokens
|
||||||
|
under the new 15-word note cap.
|
||||||
|
- Caveats: (a) the panel is advisory-only — richer payloads improve the
|
||||||
|
human's context, not the book's P&L; (b) raw prices are the model's
|
||||||
|
weakest input, so candles are sent **ATR-normalized** (code does the
|
||||||
|
arithmetic, LLM reads the shape) — the same pattern that fixed the
|
||||||
|
event-direction misreads.
|
||||||
|
|
||||||
|
## Answer 2 — AI on every candle? NO
|
||||||
|
|
||||||
|
- **Feasibility**: M5+M15+H1+H4 ≈ 414 bars/day → one call/bar nearly
|
||||||
|
exhausts the shared 500/day cap (news needs it too); a panel per bar
|
||||||
|
≈ 2,070/day = 4× over. Most of that spend reviews HOLD bars.
|
||||||
|
- **Parity (rule 4)**: an AI gate cannot be replayed by the backtester
|
||||||
|
(news off; no recorded verdicts) — every pinned book becomes
|
||||||
|
non-comparable, and model-version drift silently mutates the strategy.
|
||||||
|
- **Edge**: the engine already compresses 380 bars into 9 tuned
|
||||||
|
indicators + regime + MTF validated over 5 years; an LLM's generic
|
||||||
|
prior on raw candles won't beat fitted thresholds, and the margin it
|
||||||
|
would filter is ~0.185R/trade. B-grade holds +67.8R at 51.4% win —
|
||||||
|
a blunt veto is net-negative.
|
||||||
|
|
||||||
|
## The middle ground (adopted)
|
||||||
|
|
||||||
|
1. **Enrich payloads** (built today, see below) — near-zero cost, no
|
||||||
|
rule-1 exposure.
|
||||||
|
2. **Log panel verdicts to signals.db** (built today) — creates the
|
||||||
|
"does OPPOSE predict losers?" dataset with zero behavior change.
|
||||||
|
3. **After 4–8 weeks**: join logged verdicts to outcomes; simulate an
|
||||||
|
OPPOSE-veto against the pinned book offline. Only then consider any
|
||||||
|
gating, as a full rule-1 revalidation.
|
||||||
|
|
||||||
|
## Build record (2026-08-15, owner-approved)
|
||||||
|
|
||||||
|
- `agent_panel.py`
|
||||||
|
- Technical persona payload now carries indicator **values** (units
|
||||||
|
legend added to the prompt) and **ATR-normalized candles** per TF:
|
||||||
|
`{"ref_close", "atr", "bars": [[o,h,l,c]…]}` oldest-first, each price
|
||||||
|
as `(price − ref_close) / atr`, 1 decimal. Window: 20 bars primary /
|
||||||
|
10 per confirmation TF.
|
||||||
|
- New config keys (validated in `app_config.py`, in
|
||||||
|
`config.json.example`): `candles_enabled` (default true),
|
||||||
|
`candle_bars_primary` (20), `candle_bars_confirm` (10).
|
||||||
|
- Panel-private provider singleton (`_dp`, patchable in tests);
|
||||||
|
TTL cache keeps fetches near-free; any failure → candles silently
|
||||||
|
omitted, panel unaffected.
|
||||||
|
- Prompt hardening: training data declared stale and OFF-LIMITS;
|
||||||
|
"if a fact is not in the JSON, treat it as unknown"; thin evidence
|
||||||
|
→ neutral < 30 (unchanged); note cap 25→**15 words**, rationale
|
||||||
|
40→**30 words** (output-token savings).
|
||||||
|
- `attach_panel` now calls `signal_log.log_panel_update(report)` after
|
||||||
|
every successful attach (fresh, exact-cache, and reuse-TTL paths).
|
||||||
|
- `signal_log.py`
|
||||||
|
- `log()` returns the rowid; `log_signal` stashes it on the report as
|
||||||
|
`_signal_log_rowid`; new `log_panel_update()` fills the 4 panel
|
||||||
|
columns on that row post-hoc. No rowid (backtest replay, logging
|
||||||
|
disabled, other process) → silent no-op. Fail-safe contract kept.
|
||||||
|
- Tests: 381 → **388 passed** (7 new: candle normalization math, payload
|
||||||
|
values/candles, context windows + ATR gating + disable flag, attach
|
||||||
|
injection (technical persona only), prompt-contract assertions,
|
||||||
|
panel row update, rowid hand-off + no-op paths). Existing attach tests
|
||||||
|
stub `ap._dp` to stay off the network.
|
||||||
|
- **Live dry test** (`panel_payload_drytest_2026-08-15.py`, fabricated
|
||||||
|
numbers only, 13 real DeepSeek requests total): **7/7 probes pass** —
|
||||||
|
coherent-bullish → support@70; bearish-vs-BUY → oppose@85; genuinely
|
||||||
|
mixed → neutral@30; empty payload → neutral@20 (contract); no invented
|
||||||
|
numbers in any note; all notes ≤ 15 words; synthesizer AGREE with a
|
||||||
|
21-word rationale. Raw wire log:
|
||||||
|
`backtest/research/panel_payload_drytest_2026-08-15.jsonl`.
|
||||||
|
- Notable: an early P3 variant with H1 SELL against a BUY brief got
|
||||||
|
oppose@60 — *correct* per the prompt (higher-TF disagreement is a
|
||||||
|
listed conflict), so the probe was tightened to a genuinely mixed
|
||||||
|
scenario. Documented so nobody "fixes" the prompt for it later.
|
||||||
|
|
||||||
|
## What this deliberately did NOT change
|
||||||
|
|
||||||
|
- No gate reads panel fields; the panel remains advisory-only.
|
||||||
|
- No backtest-path change (`analyze_from_dataframes` untouched; parity
|
||||||
|
test still locks panel fields empty in replay).
|
||||||
|
- No verdict-based filtering — that decision waits for the signals.db
|
||||||
|
dataset (4–8 weeks) and an offline veto replay against the pinned book.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{"probe": "P1 coherent-bullish", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[-2.4, -2.2, -2.4, -2.3], [-2.3, -2.1, -2.3, -2.2], [-2.2, -2.0, -2.2, -2.0], [-2.0, -1.9, -2.1, -1.9], [-1.9, -1.8, -2.0, -1.8], [-1.8, -1.6, -1.8, -1.7], [-1.7, -1.5, -1.7, -1.6], [-1.6, -1.4, -1.6, -1.4], [-1.4, -1.3, -1.5, -1.3], [-1.3, -1.2, -1.4, -1.2], [-1.2, -1.0, -1.2, -1.1], [-1.1, -0.9, -1.1, -1.0], [-1.0, -0.8, -1.0, -0.8], [-0.8, -0.7, -0.9, -0.7], [-0.7, -0.6, -0.8, -0.6], [-0.6, -0.4, -0.6, -0.5], [-0.5, -0.3, -0.5, -0.4], [-0.4, -0.2, -0.4, -0.2], [-0.2, -0.1, -0.3, -0.1], [-0.1, 0.0, -0.2, 0.0]]}}}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
|
||||||
|
{"probe": "P2 bearish-vs-BUY-brief", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_down", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1}, "rsi": {"signal": "SELL", "confidence": 70, "value": 74.0}, "macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4}, "bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95}, "adx": {"signal": "SELL", "confidence": 70, "value": 35.0}, "roc": {"signal": "SELL", "confidence": 60, "value": -0.5}, "keltner": {"signal": "SELL", "confidence": 65, "value": -2.8}, "candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0}}, "mtf": {"5min": {"signal": "SELL", "score": -28.0}, "1h": {"signal": "STRONG_SELL", "score": -41.0}}, "key_levels": {"resistance": 2012.0, "support": 1985.0}, "exhaustion": 3, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[2.4, 2.4, 2.2, 2.3], [2.3, 2.3, 2.1, 2.2], [2.2, 2.2, 2.0, 2.0], [2.0, 2.1, 1.9, 1.9], [1.9, 2.0, 1.8, 1.8], [1.8, 1.8, 1.6, 1.7], [1.7, 1.7, 1.5, 1.6], [1.6, 1.6, 1.4, 1.4], [1.4, 1.5, 1.3, 1.3], [1.3, 1.4, 1.2, 1.2], [1.2, 1.2, 1.0, 1.1], [1.1, 1.1, 0.9, 1.0], [1.0, 1.0, 0.8, 0.8], [0.8, 0.9, 0.7, 0.7], [0.7, 0.8, 0.6, 0.6], [0.6, 0.6, 0.4, 0.5], [0.5, 0.5, 0.3, 0.4], [0.4, 0.4, 0.2, 0.2], [0.2, 0.3, 0.1, 0.1], [0.1, 0.2, -0.0, 0.0]]}}}, "raw_reply": {"stance": "oppose", "confidence": 85, "note": "The price is falling and every indicator says sell, so buying fights the trend."}}
|
||||||
|
{"probe": "P3 conflicting", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 12.0, "confidence": 35, "price": 2000.0, "market_regime": "ranging", "volatility_regime": "normal", "mtf_agreement": false}, "indicators": {"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6}, "rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0}, "macd": {"signal": "SELL", "confidence": 55, "value": -0.3}, "bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5}, "adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0}, "roc": {"signal": "SELL", "confidence": 50, "value": -0.1}, "keltner": {"signal": "BUY", "confidence": 50, "value": 0.8}, "candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0}}, "mtf": {"5min": {"signal": "NEUTRAL", "score": 4.0}, "1h": {"signal": "NEUTRAL", "score": -3.0}}, "key_levels": {}, "exhaustion": 0, "candles": {"15min": {"ref_close": 2000.0, "atr": 5.0, "bars": [[0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0], [0.0, 0.4, -0.1, 0.3], [0.3, 0.4, -0.1, 0.0]]}}}, "raw_reply": {"stance": "neutral", "confidence": 30, "note": "Mixed signals with no clear direction, so cannot strongly support or oppose."}}
|
||||||
|
{"probe": "P4 empty-evidence", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {}, "mtf": {}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "neutral", "confidence": 20, "note": "Not enough data to judge the trade direction."}}
|
||||||
|
{"probe": "P5 hallucination-trap", "payload": {"brief": {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY", "quality": "A", "score": 38.5, "confidence": 62, "price": 2000.0, "market_regime": "trending_up", "volatility_regime": "normal", "mtf_agreement": true}, "indicators": {"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0}, "rsi": {"signal": "BUY", "confidence": 65, "value": 58.0}, "macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25}, "bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7}, "adx": {"signal": "BUY", "confidence": 70, "value": 32.0}, "roc": {"signal": "BUY", "confidence": 55, "value": 0.35}, "keltner": {"signal": "BUY", "confidence": 65, "value": 3.1}, "candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0}}, "mtf": {"5min": {"signal": "BUY", "score": 30.0}, "1h": {"signal": "STRONG_BUY", "score": 45.0}}, "key_levels": {}, "exhaustion": 0}, "raw_reply": {"stance": "support", "confidence": 70, "note": "The trend is up and all signs agree, so buying fits the flow."}}
|
||||||
|
{"probe": "P6 output-discipline", "payload": {}, "raw_reply": null}
|
||||||
|
{"probe": "P7 synthesizer", "payload": {"direction": "BUY", "takes": [{"persona": "macro", "stance": "support", "confidence": 70, "note": "evidence aligned"}, {"persona": "technical", "stance": "support", "confidence": 80, "note": "evidence aligned"}, {"persona": "risk", "stance": "support", "confidence": 65, "note": "evidence aligned"}, {"persona": "contrarian", "stance": "support", "confidence": 60, "note": "evidence aligned"}]}, "raw_reply": {"verdict": "AGREE", "confidence": 85, "rationale": "All four analysts support buying, with high confidence and consistent evidence. No one opposes, so the panel clearly backs the trade."}}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
panel_payload_drytest_2026-08-15.py — live DeepSeek validation of the
|
||||||
|
enriched agent-panel payloads + hardened prompts (2026-08-15).
|
||||||
|
|
||||||
|
Spends ~6 REAL DeepSeek requests (against the shared 500/day cap) with
|
||||||
|
FABRICATED payloads — made-up prices/indicators, no real market data —
|
||||||
|
to verify the new technical-persona contract end to end:
|
||||||
|
|
||||||
|
P1 coherent bullish evidence -> technical persona SUPPORTS
|
||||||
|
P2 bearish evidence vs BUY brief -> technical persona OPPOSES
|
||||||
|
P3 conflicting evidence -> NEUTRAL or low confidence
|
||||||
|
P4 empty evidence -> NEUTRAL with confidence < 30
|
||||||
|
(the anti-hallucination contract)
|
||||||
|
P5 hallucination trap -> note invents no price levels
|
||||||
|
P6 output discipline -> <=20-word notes, exact JSON keys
|
||||||
|
P7 synthesizer over 4 supports -> AGREE verdict
|
||||||
|
|
||||||
|
Usage: venv/bin/python backtest/research/panel_payload_drytest_2026-08-15.py
|
||||||
|
Exit code 0 = all probes passed. Raw replies land in
|
||||||
|
backtest/research/panel_drytest_2026-08-15.jsonl
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import agent_panel as ap # noqa: E402
|
||||||
|
from app_config import deepseek_api_key, get_app_config # noqa: E402
|
||||||
|
from llm_client import get_llm_client # noqa: E402
|
||||||
|
|
||||||
|
ARTIFACT = Path(__file__).with_suffix(".jsonl")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fabrication helpers (all numbers are invented) ───────────────────
|
||||||
|
|
||||||
|
def _staircase(start: float, step: float, n: int) -> pd.DataFrame:
|
||||||
|
"""n synthetic bars drifting `step` per bar; last close = start+n*step."""
|
||||||
|
rows, price = [], start
|
||||||
|
for _ in range(n):
|
||||||
|
o, c = price, price + step
|
||||||
|
rows.append({"Open": o, "Close": c,
|
||||||
|
"High": max(o, c) + abs(step) * 0.4,
|
||||||
|
"Low": min(o, c) - abs(step) * 0.4})
|
||||||
|
price = c
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _choppy(center: float, n: int) -> pd.DataFrame:
|
||||||
|
rows, price, sign = [], center, 1
|
||||||
|
for _ in range(n):
|
||||||
|
step = sign * 1.5
|
||||||
|
o, c = price, price + step
|
||||||
|
rows.append({"Open": o, "Close": c,
|
||||||
|
"High": max(o, c) + 0.6, "Low": min(o, c) - 0.6})
|
||||||
|
price, sign = c, -sign
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
_FAKE_PRICE = 2000.00 # deliberately made-up
|
||||||
|
_FAKE_ATR = 5.0
|
||||||
|
|
||||||
|
_BRIEF_BUY = {"pair": "XAU/USD", "timeframe": "15min", "direction": "BUY",
|
||||||
|
"quality": "A", "score": 38.5, "confidence": 62,
|
||||||
|
"price": _FAKE_PRICE, "market_regime": "trending_up",
|
||||||
|
"volatility_regime": "normal", "mtf_agreement": True}
|
||||||
|
|
||||||
|
_BULL_INDICATORS = {
|
||||||
|
"ema_trend": {"signal": "STRONG_BUY", "confidence": 90, "value": 1.0},
|
||||||
|
"rsi": {"signal": "BUY", "confidence": 65, "value": 58.0},
|
||||||
|
"macd": {"signal": "STRONG_BUY", "confidence": 80, "value": 1.25},
|
||||||
|
"bollinger": {"signal": "BUY", "confidence": 60, "value": 0.7},
|
||||||
|
"adx": {"signal": "BUY", "confidence": 70, "value": 32.0},
|
||||||
|
"roc": {"signal": "BUY", "confidence": 55, "value": 0.35},
|
||||||
|
"keltner": {"signal": "BUY", "confidence": 65, "value": 3.1},
|
||||||
|
"candlestick": {"signal": "BUY", "confidence": 60, "value": 2.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
_BEAR_INDICATORS = {
|
||||||
|
"ema_trend": {"signal": "STRONG_SELL", "confidence": 85, "value": 0.1},
|
||||||
|
"rsi": {"signal": "SELL", "confidence": 70, "value": 74.0},
|
||||||
|
"macd": {"signal": "STRONG_SELL", "confidence": 80, "value": -1.4},
|
||||||
|
"bollinger": {"signal": "SELL", "confidence": 60, "value": 0.95},
|
||||||
|
"adx": {"signal": "SELL", "confidence": 70, "value": 35.0},
|
||||||
|
"roc": {"signal": "SELL", "confidence": 60, "value": -0.5},
|
||||||
|
"keltner": {"signal": "SELL", "confidence": 65, "value": -2.8},
|
||||||
|
"candlestick": {"signal": "SELL", "confidence": 55, "value": -2.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
_MIXED_INDICATORS = {
|
||||||
|
"ema_trend": {"signal": "BUY", "confidence": 55, "value": 0.6},
|
||||||
|
"rsi": {"signal": "NEUTRAL", "confidence": 40, "value": 51.0},
|
||||||
|
"macd": {"signal": "SELL", "confidence": 55, "value": -0.3},
|
||||||
|
"bollinger": {"signal": "NEUTRAL", "confidence": 35, "value": 0.5},
|
||||||
|
"adx": {"signal": "NEUTRAL", "confidence": 40, "value": 18.0},
|
||||||
|
"roc": {"signal": "SELL", "confidence": 50, "value": -0.1},
|
||||||
|
"keltner": {"signal": "BUY", "confidence": 50, "value": 0.8},
|
||||||
|
"candlestick": {"signal": "NEUTRAL", "confidence": 30, "value": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tech_payload(indicators, mtf, candles_df=None, key_levels=None,
|
||||||
|
brief=None, exhaustion=0):
|
||||||
|
payload = {"brief": brief or dict(_BRIEF_BUY),
|
||||||
|
"indicators": indicators, "mtf": mtf,
|
||||||
|
"key_levels": key_levels or {}, "exhaustion": exhaustion}
|
||||||
|
if candles_df is not None:
|
||||||
|
# the REAL production normalizer, not a hand-rolled copy
|
||||||
|
payload["candles"] = {
|
||||||
|
"15min": ap._normalize_candles(candles_df, _FAKE_ATR,
|
||||||
|
len(candles_df))}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── Probe runner ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def _record(probe, payload, reply, take, passed, detail):
|
||||||
|
_RESULTS.append({"probe": probe, "pass": bool(passed),
|
||||||
|
"detail": detail,
|
||||||
|
"stance": getattr(take, "stance", None),
|
||||||
|
"confidence": getattr(take, "confidence", None),
|
||||||
|
"note": getattr(take, "note", None)})
|
||||||
|
with ARTIFACT.open("a") as fh:
|
||||||
|
fh.write(json.dumps({"probe": probe, "payload": payload,
|
||||||
|
"raw_reply": reply}, default=str) + "\n")
|
||||||
|
mark = "PASS" if passed else "FAIL"
|
||||||
|
print(f"[{mark}] {probe}: {detail}")
|
||||||
|
if take is not None:
|
||||||
|
print(f" -> {take.stance} @ {take.confidence:.0f} "
|
||||||
|
f"| {take.note}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ask(payload):
|
||||||
|
"""Real API call through the production client + production
|
||||||
|
validation (the same path run_panel uses)."""
|
||||||
|
reply = get_llm_client().chat_json(ap._TECH_SYSTEM, payload)
|
||||||
|
return reply, ap._validate_take("technical", reply)
|
||||||
|
|
||||||
|
|
||||||
|
def _want(probe: str) -> bool:
|
||||||
|
"""--only P3 runs just that probe (saves requests on re-runs)."""
|
||||||
|
if "--only" not in sys.argv:
|
||||||
|
return True
|
||||||
|
return sys.argv[sys.argv.index("--only") + 1] == probe
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
app = get_app_config()
|
||||||
|
if not deepseek_api_key(app):
|
||||||
|
print("ABORT: no DeepSeek API key (env DEEPSEEK_API_KEY / "
|
||||||
|
"config.json deepseek.api_key).")
|
||||||
|
return 2
|
||||||
|
if app.deepseek_settings.get("enabled", True) is False:
|
||||||
|
print("ABORT: deepseek.enabled is false in config.json.")
|
||||||
|
return 2
|
||||||
|
if ARTIFACT.exists() and "--only" not in sys.argv:
|
||||||
|
ARTIFACT.unlink() # full run = fresh wire log; --only appends
|
||||||
|
|
||||||
|
mtf_agree = {"5min": {"signal": "BUY", "score": 30.0},
|
||||||
|
"1h": {"signal": "STRONG_BUY", "score": 45.0}}
|
||||||
|
levels = {"resistance": 2012.0, "support": 1985.0}
|
||||||
|
|
||||||
|
# P1 — coherent bullish, with rising ATR staircase (last close 2000)
|
||||||
|
if _want("P1"):
|
||||||
|
p1 = _tech_payload(_BULL_INDICATORS, mtf_agree,
|
||||||
|
candles_df=_staircase(_FAKE_PRICE - 20 * 0.6,
|
||||||
|
0.6, 20),
|
||||||
|
key_levels=levels)
|
||||||
|
reply, take = _ask(p1)
|
||||||
|
_record("P1 coherent-bullish", p1, reply, take,
|
||||||
|
take is not None and take.stance == "support"
|
||||||
|
and take.confidence >= 50,
|
||||||
|
"expect support @ conf>=50")
|
||||||
|
|
||||||
|
# P2 — bearish everything against a BUY brief, falling staircase
|
||||||
|
if _want("P2"):
|
||||||
|
brief2 = dict(_BRIEF_BUY, market_regime="trending_down",
|
||||||
|
mtf_agreement=False)
|
||||||
|
mtf_sell = {"5min": {"signal": "SELL", "score": -28.0},
|
||||||
|
"1h": {"signal": "STRONG_SELL", "score": -41.0}}
|
||||||
|
p2 = _tech_payload(_BEAR_INDICATORS, mtf_sell, brief=brief2,
|
||||||
|
candles_df=_staircase(_FAKE_PRICE + 20 * 0.6,
|
||||||
|
-0.6, 20),
|
||||||
|
key_levels=levels, exhaustion=3)
|
||||||
|
reply, take = _ask(p2)
|
||||||
|
_record("P2 bearish-vs-BUY-brief", p2, reply, take,
|
||||||
|
take is not None and take.stance == "oppose",
|
||||||
|
"expect oppose (BUY brief fights all evidence)")
|
||||||
|
|
||||||
|
# P3 — genuinely mixed evidence: MTF neutral both ways, ranging
|
||||||
|
# regime, choppy candles. (An H1 SELL against a BUY is a *listed
|
||||||
|
# conflict* in the prompt — oppose is correct there, not mixed.)
|
||||||
|
if _want("P3"):
|
||||||
|
brief3 = dict(_BRIEF_BUY, market_regime="ranging", score=12.0,
|
||||||
|
confidence=35, mtf_agreement=False)
|
||||||
|
mtf_mixed = {"5min": {"signal": "NEUTRAL", "score": 4.0},
|
||||||
|
"1h": {"signal": "NEUTRAL", "score": -3.0}}
|
||||||
|
p3 = _tech_payload(_MIXED_INDICATORS, mtf_mixed, brief=brief3,
|
||||||
|
candles_df=_choppy(_FAKE_PRICE, 20))
|
||||||
|
reply, take = _ask(p3)
|
||||||
|
_record("P3 conflicting", p3, reply, take,
|
||||||
|
take is not None and (take.stance == "neutral"
|
||||||
|
or take.confidence < 60),
|
||||||
|
"expect neutral or conf<60 on genuinely mixed evidence")
|
||||||
|
|
||||||
|
# P4 — empty evidence: contract demands neutral @ conf<30
|
||||||
|
if _want("P4"):
|
||||||
|
p4 = _tech_payload({}, {}, key_levels=None)
|
||||||
|
reply, take = _ask(p4)
|
||||||
|
_record("P4 empty-evidence", p4, reply, take,
|
||||||
|
take is not None and take.stance == "neutral"
|
||||||
|
and take.confidence < 30,
|
||||||
|
"contract: thin/missing evidence -> neutral, conf<30")
|
||||||
|
|
||||||
|
# P5 — hallucination trap: no key_levels, no candles; the note must
|
||||||
|
# not cite any 3+-digit number absent from the payload
|
||||||
|
if _want("P5"):
|
||||||
|
p5 = _tech_payload(_BULL_INDICATORS, mtf_agree, key_levels=None)
|
||||||
|
reply, take = _ask(p5)
|
||||||
|
invented = []
|
||||||
|
if take is not None:
|
||||||
|
payload_blob = json.dumps(p5)
|
||||||
|
for num in re.findall(r"\d{3,}", take.note):
|
||||||
|
if num not in payload_blob:
|
||||||
|
invented.append(num)
|
||||||
|
_record("P5 hallucination-trap", p5, reply, take,
|
||||||
|
take is not None and not invented,
|
||||||
|
f"invented numbers in note: {invented or 'none'}")
|
||||||
|
|
||||||
|
# P6 — output discipline across the persona replies gathered above
|
||||||
|
notes = [r for r in _RESULTS if r["note"]]
|
||||||
|
wordy = [r["probe"] for r in notes if len(r["note"].split()) > 20]
|
||||||
|
_record("P6 output-discipline", {}, None, None,
|
||||||
|
notes and not wordy,
|
||||||
|
f"notes >20 words: {wordy or 'none'} "
|
||||||
|
f"(cap is 15; 20 = fail threshold)")
|
||||||
|
|
||||||
|
# P7 — synthesizer over four fabricated SUPPORT takes
|
||||||
|
if _want("P7"):
|
||||||
|
takes = [{"persona": p, "stance": "support", "confidence": c,
|
||||||
|
"note": "evidence aligned"}
|
||||||
|
for p, c in (("macro", 70), ("technical", 80),
|
||||||
|
("risk", 65), ("contrarian", 60))]
|
||||||
|
synth_payload = {"direction": "BUY", "takes": takes}
|
||||||
|
reply = get_llm_client().chat_json(ap._SYNTH_SYSTEM, synth_payload)
|
||||||
|
verdict = reply.get("verdict") if isinstance(reply, dict) else None
|
||||||
|
rationale = (reply or {}).get("rationale", "")
|
||||||
|
_record("P7 synthesizer", synth_payload, reply, None,
|
||||||
|
verdict == "AGREE" and len(str(rationale).split()) <= 40,
|
||||||
|
f"expect AGREE + <=40-word rationale; got {verdict!r} "
|
||||||
|
f"({len(str(rationale).split())} words)")
|
||||||
|
|
||||||
|
n_pass = sum(1 for r in _RESULTS if r["pass"])
|
||||||
|
n_api = sum(1 for r in _RESULTS if r["probe"] != "P6 output-discipline")
|
||||||
|
print(f"\n{n_pass}/{len(_RESULTS)} probes passed "
|
||||||
|
f"({n_api} API requests spent; "
|
||||||
|
f"raw wire log: {ARTIFACT.name})")
|
||||||
|
return 0 if n_pass == len(_RESULTS) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+4
-1
@@ -46,7 +46,10 @@
|
|||||||
"dashboard": false,
|
"dashboard": false,
|
||||||
"min_quality": "B",
|
"min_quality": "B",
|
||||||
"max_panels_per_day": 20,
|
"max_panels_per_day": 20,
|
||||||
"reuse_ttl_seconds": 3600
|
"reuse_ttl_seconds": 3600,
|
||||||
|
"candles_enabled": true,
|
||||||
|
"candle_bars_primary": 20,
|
||||||
|
"candle_bars_confirm": 10
|
||||||
},
|
},
|
||||||
"signal_log": {
|
"signal_log": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
+64
-4
@@ -10,6 +10,13 @@ Hooked ONLY into SignalAggregator.analyze_pair (the live entry point);
|
|||||||
the backtester calls analyze_from_dataframes directly and therefore
|
the backtester calls analyze_from_dataframes directly and therefore
|
||||||
never writes here (live == backtest parity — CLAUDE.md rule 4).
|
never writes here (live == backtest parity — CLAUDE.md rule 4).
|
||||||
|
|
||||||
|
Panel fields arrive POST-HOC: the row is inserted during analyze_pair
|
||||||
|
(before consumers attach the advisory panel), so log_signal stashes the
|
||||||
|
rowid on the report and agent_panel.attach_panel calls
|
||||||
|
log_panel_update() to fill panel_verdict / panel_confidence /
|
||||||
|
panel_rationale / panel_takes_json on that same row. A report without a
|
||||||
|
rowid (backtest replay, logging disabled) is a silent no-op.
|
||||||
|
|
||||||
Deployment settings come from the config.json "signal_log" block via
|
Deployment settings come from the config.json "signal_log" block via
|
||||||
app_config (env vars SIGNAL_LOG_ENABLED / SIGNAL_LOG_PATH win):
|
app_config (env vars SIGNAL_LOG_ENABLED / SIGNAL_LOG_PATH win):
|
||||||
|
|
||||||
@@ -292,10 +299,28 @@ class SignalLogger:
|
|||||||
context: Any = None,
|
context: Any = None,
|
||||||
source: str = "live",
|
source: str = "live",
|
||||||
timeframe: str = "",
|
timeframe: str = "",
|
||||||
) -> None:
|
) -> int:
|
||||||
|
"""Insert one scan row; returns the rowid (panel-update key)."""
|
||||||
row = _report_row(report, context, source, timeframe)
|
row = _report_row(report, context, source, timeframe)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._conn.execute(_INSERT, row)
|
cur = self._conn.execute(_INSERT, row)
|
||||||
|
self._conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
def update_panel(
|
||||||
|
self,
|
||||||
|
rowid: int,
|
||||||
|
verdict: str,
|
||||||
|
confidence: Optional[float],
|
||||||
|
rationale: str,
|
||||||
|
takes_json: str,
|
||||||
|
) -> None:
|
||||||
|
"""Fill the panel columns on an already-inserted scan row."""
|
||||||
|
with self._lock:
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE signals SET panel_verdict=?, panel_confidence=?,"
|
||||||
|
" panel_rationale=?, panel_takes_json=? WHERE id=?",
|
||||||
|
(verdict, confidence, rationale, takes_json, rowid))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
@@ -316,7 +341,9 @@ def log_signal(
|
|||||||
timeframe: str = "",
|
timeframe: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist one scan if the signal_log config block enables it.
|
"""Persist one scan if the signal_log config block enables it.
|
||||||
NEVER raises — a logging failure must not break a live scan."""
|
Stashes the rowid on the report (``_signal_log_rowid``) so a post-hoc
|
||||||
|
panel attach can update this same row. NEVER raises — a logging
|
||||||
|
failure must not break a live scan."""
|
||||||
try:
|
try:
|
||||||
if not app_config.signal_log_enabled():
|
if not app_config.signal_log_enabled():
|
||||||
return
|
return
|
||||||
@@ -326,12 +353,45 @@ def log_signal(
|
|||||||
if lg is None:
|
if lg is None:
|
||||||
lg = SignalLogger(path)
|
lg = SignalLogger(path)
|
||||||
_loggers[path] = lg
|
_loggers[path] = lg
|
||||||
lg.log(report, context=context, source=source, timeframe=timeframe)
|
rowid = lg.log(report, context=context, source=source,
|
||||||
|
timeframe=timeframe)
|
||||||
|
try:
|
||||||
|
report._signal_log_rowid = rowid
|
||||||
|
except Exception:
|
||||||
|
pass # exotic report object — panel update just won't happen
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("signal_log: failed to persist signal",
|
logger.warning("signal_log: failed to persist signal",
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def log_panel_update(report: Any) -> None:
|
||||||
|
"""Write post-hoc panel fields onto the row logged for this scan.
|
||||||
|
|
||||||
|
Matches on the private rowid hand-off from log_signal; a report that
|
||||||
|
was never logged (backtest replay, logging disabled, logger in
|
||||||
|
another process) is a silent no-op. NEVER raises."""
|
||||||
|
try:
|
||||||
|
rowid = getattr(report, "_signal_log_rowid", None)
|
||||||
|
if rowid is None or not app_config.signal_log_enabled():
|
||||||
|
return
|
||||||
|
verdict = getattr(report, "panel_verdict", "") or ""
|
||||||
|
if not verdict:
|
||||||
|
return
|
||||||
|
path = app_config.signal_log_path()
|
||||||
|
with _loggers_lock:
|
||||||
|
lg = _loggers.get(path)
|
||||||
|
if lg is None:
|
||||||
|
return
|
||||||
|
lg.update_panel(
|
||||||
|
int(rowid), verdict,
|
||||||
|
_finite(getattr(report, "panel_confidence", None)),
|
||||||
|
getattr(report, "panel_rationale", "") or "",
|
||||||
|
_dumps(getattr(report, "panel_takes", None) or []))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("signal_log: failed to persist panel verdict",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def reset_loggers_for_tests() -> None:
|
def reset_loggers_for_tests() -> None:
|
||||||
"""Close and drop all cached loggers (test isolation)."""
|
"""Close and drop all cached loggers (test isolation)."""
|
||||||
with _loggers_lock:
|
with _loggers_lock:
|
||||||
|
|||||||
+224
-1
@@ -6952,10 +6952,14 @@ def _():
|
|||||||
app = load_app_config(_write_json({"agent_panel": {
|
app = load_app_config(_write_json({"agent_panel": {
|
||||||
"enabled": True, "telegram": True, "dashboard": False,
|
"enabled": True, "telegram": True, "dashboard": False,
|
||||||
"min_quality": "A", "max_panels_per_day": 10,
|
"min_quality": "A", "max_panels_per_day": 10,
|
||||||
"reuse_ttl_seconds": 600}}))
|
"reuse_ttl_seconds": 600, "candles_enabled": False,
|
||||||
|
"candle_bars_primary": 30, "candle_bars_confirm": 12}}))
|
||||||
assert app.agent_panel_settings["enabled"] is True
|
assert app.agent_panel_settings["enabled"] is True
|
||||||
assert app.agent_panel_settings["min_quality"] == "A"
|
assert app.agent_panel_settings["min_quality"] == "A"
|
||||||
assert app.agent_panel_settings["max_panels_per_day"] == 10
|
assert app.agent_panel_settings["max_panels_per_day"] == 10
|
||||||
|
assert app.agent_panel_settings["candles_enabled"] is False
|
||||||
|
assert app.agent_panel_settings["candle_bars_primary"] == 30
|
||||||
|
assert app.agent_panel_settings["candle_bars_confirm"] == 12
|
||||||
assert load_app_config(_write_json({})).agent_panel_settings == {}
|
assert load_app_config(_write_json({})).agent_panel_settings == {}
|
||||||
assert AppConfig().agent_panel_settings == {}
|
assert AppConfig().agent_panel_settings == {}
|
||||||
|
|
||||||
@@ -6971,6 +6975,11 @@ def _():
|
|||||||
{"agent_panel": {"max_panels_per_day": 0}}, # not positive
|
{"agent_panel": {"max_panels_per_day": 0}}, # not positive
|
||||||
{"agent_panel": {"max_panels_per_day": True}}, # bool is not an int
|
{"agent_panel": {"max_panels_per_day": True}}, # bool is not an int
|
||||||
{"agent_panel": {"reuse_ttl_seconds": -5}}, # negative
|
{"agent_panel": {"reuse_ttl_seconds": -5}}, # negative
|
||||||
|
{"agent_panel": {"candles_enabled": "yes"}}, # not a bool
|
||||||
|
{"agent_panel": {"candle_bars_primary": 0}}, # not positive
|
||||||
|
{"agent_panel": {"candle_bars_primary": 20.5}}, # not an int
|
||||||
|
{"agent_panel": {"candle_bars_confirm": True}}, # bool is not an int
|
||||||
|
{"agent_panel": {"candle_bars_confirm": 500}}, # absurdly large
|
||||||
{"agent_panel": "oops"}, # not an object
|
{"agent_panel": "oops"}, # not an object
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -6998,6 +7007,23 @@ class _PanelStub:
|
|||||||
return reply(user_payload) if callable(reply) else reply
|
return reply(user_payload) if callable(reply) else reply
|
||||||
|
|
||||||
|
|
||||||
|
class _NullDP:
|
||||||
|
"""Provider stub: candle fetch blows up → _candle_context degrades to
|
||||||
|
None (and tests stay off the network)."""
|
||||||
|
def fetch_ohlcv(self, *args, **kwargs):
|
||||||
|
raise RuntimeError("no network in tests")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDP:
|
||||||
|
"""Provider stub serving synthetic OHLCV per timeframe."""
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def fetch_ohlcv(self, symbol, timeframe, lookback_days=None):
|
||||||
|
self.calls.append((symbol, timeframe))
|
||||||
|
return make_ohlcv(60, seed=7)
|
||||||
|
|
||||||
|
|
||||||
_TAKE_OK = {"stance": "support", "confidence": 70, "note": "fine"}
|
_TAKE_OK = {"stance": "support", "confidence": 70, "note": "fine"}
|
||||||
_SYNTH_OK = {"verdict": "AGREE", "confidence": 75, "rationale": "panel backs it"}
|
_SYNTH_OK = {"verdict": "AGREE", "confidence": 75, "rationale": "panel backs it"}
|
||||||
_ALL_OK = {ap._MACRO_SYSTEM: _TAKE_OK, ap._TECH_SYSTEM: _TAKE_OK,
|
_ALL_OK = {ap._MACRO_SYSTEM: _TAKE_OK, ap._TECH_SYSTEM: _TAKE_OK,
|
||||||
@@ -7148,6 +7174,7 @@ def _():
|
|||||||
try:
|
try:
|
||||||
stub = _PanelStub(dict(_ALL_OK))
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
lc._client = stub
|
lc._client = stub
|
||||||
|
ap._dp = _NullDP()
|
||||||
report = _panel_report()
|
report = _panel_report()
|
||||||
assert ap.attach_panel(report, _PANEL_ON,
|
assert ap.attach_panel(report, _PANEL_ON,
|
||||||
context=FundamentalContext()) is True
|
context=FundamentalContext()) is True
|
||||||
@@ -7175,6 +7202,7 @@ def _():
|
|||||||
assert len(stub.calls) == 10
|
assert len(stub.calls) == 10
|
||||||
finally:
|
finally:
|
||||||
lc._client = None
|
lc._client = None
|
||||||
|
ap._dp = None
|
||||||
ap.reset_panel_state()
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -7183,6 +7211,7 @@ def _():
|
|||||||
try:
|
try:
|
||||||
stub = _PanelStub(dict(_ALL_OK))
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
lc._client = stub
|
lc._client = stub
|
||||||
|
ap._dp = _NullDP()
|
||||||
settings = {**_PANEL_ON, "max_panels_per_day": 1,
|
settings = {**_PANEL_ON, "max_panels_per_day": 1,
|
||||||
"reuse_ttl_seconds": 0}
|
"reuse_ttl_seconds": 0}
|
||||||
assert ap.attach_panel(_panel_report(), settings,
|
assert ap.attach_panel(_panel_report(), settings,
|
||||||
@@ -7201,6 +7230,7 @@ def _():
|
|||||||
assert ap.panel_health()["used_today"] == 1
|
assert ap.panel_health()["used_today"] == 1
|
||||||
finally:
|
finally:
|
||||||
lc._client = None
|
lc._client = None
|
||||||
|
ap._dp = None
|
||||||
ap.reset_panel_state()
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -7209,6 +7239,7 @@ def _():
|
|||||||
try:
|
try:
|
||||||
stub = _PanelStub(dict(_ALL_OK))
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
lc._client = stub
|
lc._client = stub
|
||||||
|
ap._dp = _NullDP()
|
||||||
report = _panel_report()
|
report = _panel_report()
|
||||||
ctx = FundamentalContext()
|
ctx = FundamentalContext()
|
||||||
assert ap.attach_panel(report, {}, context=ctx) is False, \
|
assert ap.attach_panel(report, {}, context=ctx) is False, \
|
||||||
@@ -7233,6 +7264,7 @@ def _():
|
|||||||
"force bypasses enabled/surface/quality"
|
"force bypasses enabled/surface/quality"
|
||||||
finally:
|
finally:
|
||||||
lc._client = None
|
lc._client = None
|
||||||
|
ap._dp = None
|
||||||
ap.reset_panel_state()
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -7311,6 +7343,7 @@ def _():
|
|||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
try:
|
try:
|
||||||
lc._client = _Bomb()
|
lc._client = _Bomb()
|
||||||
|
ap._dp = _NullDP()
|
||||||
report = _panel_report()
|
report = _panel_report()
|
||||||
assert ap.attach_panel(report, _PANEL_ON,
|
assert ap.attach_panel(report, _PANEL_ON,
|
||||||
context=FundamentalContext()) is False
|
context=FundamentalContext()) is False
|
||||||
@@ -7323,9 +7356,113 @@ def _():
|
|||||||
logging.disable(logging.NOTSET)
|
logging.disable(logging.NOTSET)
|
||||||
finally:
|
finally:
|
||||||
lc._client = None
|
lc._client = None
|
||||||
|
ap._dp = None
|
||||||
ap.reset_panel_state()
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: candle normalization is ATR-relative to final close")
|
||||||
|
def _():
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"Open": [100.0, 110.0], "High": [112.0, 115.0],
|
||||||
|
"Low": [98.0, 108.0], "Close": [110.0, 112.0],
|
||||||
|
"Volume": [1, 1]})
|
||||||
|
out = ap._normalize_candles(df, atr=4.0, bars=2)
|
||||||
|
assert out["ref_close"] == 112.0 and out["atr"] == 4.0
|
||||||
|
assert out["bars"][0] == [-3.0, 0.0, -3.5, -0.5]
|
||||||
|
assert out["bars"][1] == [-0.5, 0.8, -1.0, 0.0]
|
||||||
|
capped = ap._normalize_candles(df, atr=4.0, bars=50)
|
||||||
|
assert len(capped["bars"]) == 2, "window capped at available bars"
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: technical payload carries values + optional candles")
|
||||||
|
def _():
|
||||||
|
report = _panel_report()
|
||||||
|
tech = ap._technical_payload(report)
|
||||||
|
assert tech["indicators"], "real analysis must yield indicators"
|
||||||
|
assert "value" in tech["indicators"]["rsi"]
|
||||||
|
assert "value" in tech["indicators"]["adx"]
|
||||||
|
assert "candles" not in tech, "candles only when provided"
|
||||||
|
fake = {"1h": {"ref_close": 2400.0, "atr": 5.0,
|
||||||
|
"bars": [[0.0, 0.5, -0.5, 0.2]]}}
|
||||||
|
tech2 = ap._technical_payload(report, candles=fake)
|
||||||
|
assert tech2["candles"] == fake
|
||||||
|
json.dumps(tech2, sort_keys=True, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: _candle_context windows, ATR gating, disable flag")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
ap._dp = _FakeDP()
|
||||||
|
report = _panel_report()
|
||||||
|
settings = {**_PANEL_ON, "candle_bars_primary": 20,
|
||||||
|
"candle_bars_confirm": 10}
|
||||||
|
ctx = ap._candle_context(report, settings)
|
||||||
|
tfs = [report.primary_analysis.timeframe] + [
|
||||||
|
a.timeframe for a in report.confirmation_analyses]
|
||||||
|
assert set(ctx) == set(tfs), f"one block per analysis TF: {set(ctx)}"
|
||||||
|
assert len(ctx[tfs[0]]["bars"]) == 20, "primary window"
|
||||||
|
assert len(ctx[tfs[1]]["bars"]) == 10, "confirm window"
|
||||||
|
assert all(len(b) == 4 for b in ctx[tfs[0]]["bars"])
|
||||||
|
json.dumps(ctx, allow_nan=False)
|
||||||
|
|
||||||
|
# disabled flag → omitted entirely, zero fetches
|
||||||
|
ap._dp = _FakeDP()
|
||||||
|
assert ap._candle_context(
|
||||||
|
report, {**settings, "candles_enabled": False}) is None
|
||||||
|
assert ap._dp.calls == [], "disabled must not fetch"
|
||||||
|
|
||||||
|
# degenerate ATR → that TF skipped, the rest survive
|
||||||
|
ap._dp = _FakeDP()
|
||||||
|
report.primary_analysis.indicators["atr"].value = 0.0
|
||||||
|
ctx2 = ap._candle_context(report, settings)
|
||||||
|
assert tfs[0] not in ctx2 and tfs[1] in ctx2
|
||||||
|
|
||||||
|
# provider explosion → None, never raises
|
||||||
|
ap._dp = _NullDP()
|
||||||
|
assert ap._candle_context(report, settings) is None
|
||||||
|
finally:
|
||||||
|
ap._dp = None
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: attach injects candles into the technical payload only")
|
||||||
|
def _():
|
||||||
|
try:
|
||||||
|
ap._dp = _FakeDP()
|
||||||
|
stub = _PanelStub(dict(_ALL_OK))
|
||||||
|
lc._client = stub
|
||||||
|
report = _panel_report()
|
||||||
|
assert ap.attach_panel(report, _PANEL_ON,
|
||||||
|
context=FundamentalContext()) is True
|
||||||
|
by_prompt = dict(stub.calls)
|
||||||
|
tech_payload = by_prompt[ap._TECH_SYSTEM]
|
||||||
|
assert "candles" in tech_payload
|
||||||
|
primary_tf = report.primary_analysis.timeframe
|
||||||
|
assert len(tech_payload["candles"][primary_tf]["bars"]) == \
|
||||||
|
ap.DEFAULT_CANDLE_BARS_PRIMARY
|
||||||
|
assert "value" in tech_payload["indicators"]["rsi"]
|
||||||
|
for sys_p in (ap._MACRO_SYSTEM, ap._RISK_SYSTEM,
|
||||||
|
ap._CONTRARIAN_SYSTEM):
|
||||||
|
assert "candles" not in by_prompt[sys_p], \
|
||||||
|
"candles are technical-persona evidence only"
|
||||||
|
finally:
|
||||||
|
lc._client = None
|
||||||
|
ap._dp = None
|
||||||
|
ap.reset_panel_state()
|
||||||
|
|
||||||
|
|
||||||
|
@test("agent_panel: prompts forbid outside knowledge and cap output words")
|
||||||
|
def _():
|
||||||
|
assert "OFF-LIMITS" in ap._TAKE_CONTRACT
|
||||||
|
assert "max 15 words" in ap._TAKE_CONTRACT
|
||||||
|
assert "max 30 words" in ap._SYNTH_SYSTEM
|
||||||
|
assert "ATR units" in ap._TECH_SYSTEM
|
||||||
|
for prompt in (ap._MACRO_SYSTEM, ap._TECH_SYSTEM, ap._RISK_SYSTEM,
|
||||||
|
ap._CONTRARIAN_SYSTEM):
|
||||||
|
assert ap._TAKE_CONTRACT in prompt, \
|
||||||
|
"every persona must carry the anti-hallucination contract"
|
||||||
|
|
||||||
|
|
||||||
print("\n═══ Module 24: signal_log.py (SQLite signal history) ═══")
|
print("\n═══ Module 24: signal_log.py (SQLite signal history) ═══")
|
||||||
|
|
||||||
|
|
||||||
@@ -7441,6 +7578,92 @@ def _():
|
|||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
@test("signal_log: panel verdict updates the scan row post-hoc")
|
||||||
|
def _():
|
||||||
|
import sqlite3
|
||||||
|
import signal_log as slog
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
lg = slog.SignalLogger(path)
|
||||||
|
report = _make_log_report()
|
||||||
|
rowid = lg.log(report, context=None, source="test", timeframe="M5")
|
||||||
|
assert rowid >= 1, "log() must return the rowid"
|
||||||
|
report.panel_verdict = "OPPOSE"
|
||||||
|
report.panel_confidence = 64.0
|
||||||
|
report.panel_rationale = "risk too thin"
|
||||||
|
report.panel_takes = [{"persona": "risk", "stance": "oppose",
|
||||||
|
"confidence": 70.0, "note": "n"}]
|
||||||
|
lg.update_panel(rowid, report.panel_verdict, 64.0,
|
||||||
|
report.panel_rationale,
|
||||||
|
slog._dumps(report.panel_takes))
|
||||||
|
lg.close()
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT panel_verdict, panel_confidence, panel_rationale,"
|
||||||
|
" panel_takes_json FROM signals WHERE id=?",
|
||||||
|
(rowid,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row[0] == "OPPOSE" and row[1] == 64.0
|
||||||
|
assert row[2] == "risk too thin"
|
||||||
|
assert json.loads(row[3])[0]["persona"] == "risk"
|
||||||
|
finally:
|
||||||
|
slog.reset_loggers_for_tests()
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
@test("signal_log: log_panel_update rowid hand-off; no-ops stay silent")
|
||||||
|
def _():
|
||||||
|
import sqlite3
|
||||||
|
import signal_log as slog
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
os.remove(path) # want a fresh file from SignalLogger
|
||||||
|
try:
|
||||||
|
os.environ["SIGNAL_LOG_PATH"] = path
|
||||||
|
os.environ.pop("SIGNAL_LOG_ENABLED", None)
|
||||||
|
# no rowid (backtest / unlogged report) → silent no-op
|
||||||
|
slog.log_panel_update(_make_log_report())
|
||||||
|
assert not os.path.exists(path), "no-op must not create the DB"
|
||||||
|
|
||||||
|
report = _make_log_report()
|
||||||
|
slog.log_signal(report, source="test", timeframe="M5")
|
||||||
|
assert getattr(report, "_signal_log_rowid", None) is not None, \
|
||||||
|
"log_signal must stash the rowid on the report"
|
||||||
|
|
||||||
|
# empty verdict → no-op (row keeps its empty insert values)
|
||||||
|
slog.log_panel_update(report)
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT panel_verdict, panel_takes_json FROM signals").fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row[0] in (None, "") and row[1] is None
|
||||||
|
|
||||||
|
report.panel_verdict = "AGREE"
|
||||||
|
report.panel_confidence = 81.0
|
||||||
|
report.panel_rationale = "all aligned"
|
||||||
|
report.panel_takes = []
|
||||||
|
slog.log_panel_update(report)
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT panel_verdict, panel_confidence, panel_rationale,"
|
||||||
|
" panel_takes_json FROM signals").fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row[0] == "AGREE" and row[1] == 81.0
|
||||||
|
assert row[2] == "all aligned"
|
||||||
|
assert json.loads(row[3]) == []
|
||||||
|
|
||||||
|
# disabled → no-op, never raises
|
||||||
|
os.environ["SIGNAL_LOG_ENABLED"] = "false"
|
||||||
|
slog.log_panel_update(report)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("SIGNAL_LOG_PATH", None)
|
||||||
|
os.environ.pop("SIGNAL_LOG_ENABLED", None)
|
||||||
|
slog.reset_loggers_for_tests()
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
@test("signal_log: null context → news JSON NULL, row still written")
|
@test("signal_log: null context → news JSON NULL, row still written")
|
||||||
def _():
|
def _():
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|||||||
Reference in New Issue
Block a user