Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit. Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule, written before the runs): - SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier) is removed as the last step of the core. One definition (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's host side; D1 is a required frame of the M15 scan; mirrored in cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs 1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs 2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R. - FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1 (−9.7R), buy-only and sell-only on M15, the wide-stop cap (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob). - New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15 pins byte for byte. Twin and reference script MD5-equal on every window; recorder and feature-export parity re-proved; base-rate table rebuilt. - DISPLAY (books byte-identical): exposure.py — the lot size for trade_size.risk_per_trade (new config.json block, off by default, private to the owner) and the count of alerted same-direction plans still open (signals.alerted_at, read-only open_alerted_setups); the daily trend in words on Telegram, the CLI and the dashboard; signals.daily_trend logged. - providers/oanda: a date window with no bars is an empty page, not an error (a D1 pad starting on a Saturday could not be fetched). - Reports only, nothing shipped: seven context screens (no state nominated) and eight outside data sources (none adds a forecast to gold's own daily trend). - Independent verification (report section 5.13): every figure reproduced on both scripts, scoring identical to the previous commit on all four timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022; the neighbour clause is thin (a simple 100-day average misses the R line by 1.2R); "never adds a trade" is an observation, not a guarantee. Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects elsewhere, plus 4 found by an independent review of the fixes; all fixed, each with a test; no trade book changed: - telegram_bot: one _deliver_alert for the candle scan and the level break. The row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and un-stamped when no chat received it or the task is cancelled mid-stamp, so two alerts at one candle close count each other and no SQLite write runs on the event loop. The 5-bar cooldown counts bars of the scan's own frame (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after the settlement pause) and is restored from signals.db at start-up. The lot-size line goes to the owner's own chat only (a group the owner ran /autoscan in used to receive it). /status gains a news-model line and escapes failure details. - signal_log: mark_alerted returns the stamp it wrote; unmark_alerted; last_alerted_bars (read-only). - advisory_outcome: candle continuity is judged bar by bar inside the walk; a hole after a trade's exit no longer voids it. The stored-candle replay resolves 108 of 111 logged setups instead of 99, all equal to the live resolver's R. - providers/oanda: an empty date window is accepted only when it is 5 days or shorter and no malformed candle was skipped; a longer one is retried, then refused, instead of being cached as history with a hole. - news_engine: blank RSS titles skipped; one unreadable headline no longer costs the local model its whole batch (checked on the real bundle); model health recorded; no DeepSeek audit call in a process that does not archive; event-direction cache keyed per release; peek_week_events never fetches. - llm_client: chat_json never raises and never logs the API key (requests quotes the Authorization value in InvalidHeader; error bodies are redacted). - signal_aggregator: the scan's news block is guarded; the replay path (use_news=False) assigns exactly the values it did before. - agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input (event timing over the cached calendar week, headlines newest first). scripts/panel_replay.py replays the logged payload per persona; scripts/label_scan_outcomes.py counts with an ISO cutoff. - test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34); tests that write Parquet print a notice and return without pyarrow. Verification after the last code change: suite 632 passed, 0 failed (also with both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37 green; static Python 3.9 / SQLite 3.34 check clean on every touched file. Evidence: backtest/research/daily_trend_campaign_2026-09-30.md, context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6, 8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
This commit is contained in:
+60
-10
@@ -44,6 +44,10 @@ stop/target in ATR, the setup class's base rate from the two-year book,
|
||||
event timing against the expected hold) so the model judges facts
|
||||
instead of narrative; ``PANEL_PROMPT_VERSION`` is logged with every
|
||||
verdict and is part of the payload hash so prompt eras never mix.
|
||||
Since v2.2 (2026-10-01) the event timing reads the cached calendar week
|
||||
(a hold that runs past UTC midnight sees tomorrow's release) and the
|
||||
headline list is cut newest first; both change what the persona reads,
|
||||
hence the new era.
|
||||
|
||||
Two invariants, both load-bearing (CLAUDE.md rules 1 and 4):
|
||||
|
||||
@@ -126,7 +130,10 @@ VERDICT_SOURCES = ("mean", "macro", "chair")
|
||||
#: Logged with every verdict and folded into the payload hash. Bump on
|
||||
#: ANY prompt or context-schema change so the scorecard can split eras
|
||||
#: (analyze_signal_log splits by panel_prompt_version).
|
||||
PANEL_PROMPT_VERSION = "v2.1-2026-09-26" # v2.1: beginner wording rules in _TAKE_CONTRACT
|
||||
# v2.1: beginner wording rules in _TAKE_CONTRACT. v2.2: no prompt text
|
||||
# changed, the persona's INPUT did (event timing from the cached calendar
|
||||
# week, headlines newest first), so the scorecard starts a new era.
|
||||
PANEL_PROMPT_VERSION = "v2.2-2026-10-01"
|
||||
|
||||
#: Symmetric probability bands (percent). A persona's stance and the
|
||||
#: mean verdict are DERIVED from p_win with the same thresholds, so
|
||||
@@ -606,13 +613,31 @@ def _headline_gold_score(headline: Any) -> Optional[float]:
|
||||
return max(-1.0, min(1.0, score)) if math.isfinite(score) else None
|
||||
|
||||
|
||||
def _published_key(headline: Any) -> float:
|
||||
"""Sort key for "newest first": the publish time as epoch seconds,
|
||||
-inf when there is none. Never raises."""
|
||||
stamp = getattr(headline, "timestamp", None)
|
||||
try:
|
||||
if isinstance(stamp, str):
|
||||
stamp = _dt.datetime.fromisoformat(stamp.replace(" ", "T"))
|
||||
value = float(stamp.timestamp())
|
||||
return value if math.isfinite(value) else float("-inf")
|
||||
except Exception:
|
||||
return float("-inf")
|
||||
|
||||
|
||||
def _directional_news_payload(
|
||||
context: Optional["FundamentalContext"], limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
if context is None:
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for headline in context.news_headlines:
|
||||
# Newest first, THEN the cut: the context lists headlines feed by
|
||||
# feed, so the first `limit` were whatever the first feed carried and
|
||||
# a newer story from a later feed never reached the persona.
|
||||
ordered = sorted(context.news_headlines or [], key=_published_key,
|
||||
reverse=True)
|
||||
for headline in ordered:
|
||||
score = _headline_gold_score(headline)
|
||||
if score is None or abs(score) < 0.1:
|
||||
continue
|
||||
@@ -906,9 +931,14 @@ def _primary_frame(report: "SignalReport", frames: Any) -> Any:
|
||||
|
||||
|
||||
def _price_context(report: "SignalReport", frames: Any = None,
|
||||
context: Optional["FundamentalContext"] = None
|
||||
context: Optional["FundamentalContext"] = None,
|
||||
week_events: Optional[List[Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""The shared "context" block (see module docstring). {} on failure."""
|
||||
"""The shared "context" block (see module docstring). {} on failure.
|
||||
|
||||
``week_events`` is the cached calendar week (attach_panel hands it
|
||||
in); it is never fetched here, because the offline replay calls this
|
||||
function with an as-of context and must not see today's calendar."""
|
||||
out: Dict[str, Any] = {}
|
||||
try:
|
||||
direction = _direction(report)
|
||||
@@ -992,10 +1022,16 @@ def _price_context(report: "SignalReport", frames: Any = None,
|
||||
hold_bars = (out.get("base_rate") or {}).get("median_bars_held")
|
||||
if hold_bars and secs:
|
||||
out["expected_hold_minutes"] = int(hold_bars * secs // 60)
|
||||
if context is not None:
|
||||
# The context alone knows today's events (UTC date) and the next
|
||||
# hour: an H1 or H4 hold running past midnight into tomorrow's
|
||||
# release read as event-free. The calendar week covers it.
|
||||
pool = list(week_events or [])
|
||||
if not pool and context is not None:
|
||||
pool = list(getattr(context, "events_today", None) or []) + \
|
||||
list(getattr(context, "events_upcoming_1h", None) or [])
|
||||
if pool:
|
||||
minutes = []
|
||||
for ev in list(getattr(context, "events_today", None) or []) + \
|
||||
list(getattr(context, "events_upcoming_1h", None) or []):
|
||||
for ev in pool:
|
||||
try:
|
||||
if ev.is_high_impact and ev.is_upcoming:
|
||||
minutes.append(float(ev.minutes_until))
|
||||
@@ -1466,21 +1502,35 @@ def attach_panel(report: "SignalReport",
|
||||
if direction is None:
|
||||
return False
|
||||
|
||||
week_events: Optional[List[Any]] = None
|
||||
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()
|
||||
engine = NewsEngine(PlatformConfig().news)
|
||||
context = engine.get_fundamental_context()
|
||||
except Exception as e:
|
||||
logger.debug("agent panel: news context unavailable: %s", e)
|
||||
context = None
|
||||
else:
|
||||
# The calendar week for the event-timing check, read from
|
||||
# the class-level cache the context was just built from.
|
||||
# peek, not get: get_week_events refreshes an expired
|
||||
# cache, and with the calendar sources down that is a 40 s
|
||||
# wait under the news lock while this alert's lock is held.
|
||||
# Only on this path — a caller that passes its own context
|
||||
# (the replay) passes its own time.
|
||||
try:
|
||||
week_events = engine.peek_week_events()
|
||||
except Exception as e:
|
||||
logger.debug("agent panel: calendar week unavailable: %s", e)
|
||||
|
||||
personas = normalize_personas(settings.get("personas", DEFAULT_PERSONAS))
|
||||
candles = _candle_context(report, settings)
|
||||
ctx = _price_context(report, getattr(report, "_frames", None), context)
|
||||
ctx = _price_context(report, getattr(report, "_frames", None), context,
|
||||
week_events)
|
||||
jobs = _panel_jobs(report, context, candles, ctx, personas)
|
||||
payload_hash = _panel_payload_hash(jobs)
|
||||
reuse_hash = _panel_payload_hash(jobs, ignore_bar_timestamp=True)
|
||||
|
||||
Reference in New Issue
Block a user