audit: alert delivery, provider robustness, backtester NaN/cache guards (Batches C+E+F)

2026-08-04 Fable 5 QA audit. Three batches sharing one test file, all
verified by runnable repros before fixing, all gated below.

Batch C — telegram alert delivery (verified by h4 harness):
- Failed breach rescan shrinks the cooldown to 30 s instead of
  silencing the symbol for 5 min; breach during cooldown is PARKED and
  fired on the first tick after expiry (was dropped forever).
- Level cache keyed symbol→TF (M5 scans no longer wipe H1/H4 swing
  levels within minutes); breach labels TF-qualified.
- Markdown alert sends get a plain-text fallback (mirroring _safe_edit);
  fingerprint commits only when a message actually landed.
- Per-key asyncio.Lock closes the dedup TOCTOU between auto-scan and
  breach sends (forced-interleave repro produced duplicate alerts).
- _start_scheduler respawns dead per-TF loops (was all-or-nothing).
- Clean WS close now backs off (was a zero-delay reconnect hot loop);
  backoff resets only after a connection that received ticks; TD
  iter_ticks fires on_connect on the first RECEIVED frame (invalid key
  can no longer pin the backoff at minimum).
- Scan-failure logs carry the real traceback (was "NoneType: None").

Batch E — providers (verified by h5 harness):
- fetch_range: 3 consecutive chunk ERRORS raise instead of returning
  silently truncated history (verified 731-day silent gap repro; also
  covers 429 storms); pagination no-progress guard; head-truncation
  ERROR log.
- Single-flight per (symbol,TF): 4 concurrent scans = 1 API call (was
  4); cache records requested depth so a longer-lookback call refetches.
- Multi-bar forming/future tail fully dropped (was iloc[:-1] once).
- Oanda: malformed candles skipped, never 0.0-defaulted; cleaning drops
  non-positive-price rows BEFORE OHLC repair; inf returns neutralized so
  one corrupt bar can no longer disarm the whole 10-sigma filter.

Batch F — backtester internals (verified by h6 harness):
- NaN bar in a trailing window exits at the last valid bar (was NaN R
  poisoning every metric); static-path TIMEOUT NaN guard;
  compute_metrics excludes non-finite R loudly. Zero real-data exposure
  (0 NaN rows in all 10 Oanda parquets) — defensive.
- fetch_history ERROR-logs pre-2026-07-08 cache files (cleaned-bars era)
  when loaded for raw-path resolution.

Gates: suite 333/333 green; pinned M15 12mo book reproduced identically
(397 trades, +78.7111R) after E and after F; M5 HEAD-config baseline
reproduced identically (556 trades, +97.5604R) after F.

Claude-Session: https://claude.ai/code/session_01UnZkzeFcTu9agFg8BDh2tn
This commit is contained in:
Harwinder Sekhon
2026-08-04 20:02:57 -04:00
parent 18e4fd0403
commit 99eb268cf3
6 changed files with 1117 additions and 141 deletions
+691 -12
View File
@@ -1800,13 +1800,17 @@ def _():
assert result <= 240 * 60 + 10 + 1
# NOTE 2026-08-04 (audit H34): the level cache is keyed symbol → TF →
# levels and _check_level_breach returns "<tf> <name>" — these tests
# were updated to the new shape in the same change.
@test("_check_level_breach detects upward cross")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time()
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
_last_prices["TEST_PAIR"] = 1.0999
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result == "swing_high_1"
assert result == "15min swing_high_1"
# cleanup
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
@@ -1815,11 +1819,11 @@ def _():
@test("_check_level_breach detects downward cross")
def _():
_level_cache["TEST_PAIR"] = {"swing_low_1": 1.0900}
_level_cache_ts["TEST_PAIR"] = time.time()
_level_cache["TEST_PAIR"] = {"15min": {"swing_low_1": 1.0900}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
_last_prices["TEST_PAIR"] = 1.0901
result = _check_level_breach("TEST_PAIR", 1.0899)
assert result == "swing_low_1"
assert result == "15min swing_low_1"
del _level_cache["TEST_PAIR"]
del _level_cache_ts["TEST_PAIR"]
del _last_prices["TEST_PAIR"]
@@ -1827,8 +1831,8 @@ def _():
@test("_check_level_breach no breach when price stays same side")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time()
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
_last_prices["TEST_PAIR"] = 1.0990
result = _check_level_breach("TEST_PAIR", 1.0995)
assert result is None
@@ -1839,8 +1843,8 @@ def _():
@test("_check_level_breach returns None for expired cache")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time() - 7200 # 2h old
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time() - 7200} # 2h old
_last_prices["TEST_PAIR"] = 1.0999
result = _check_level_breach("TEST_PAIR", 1.1001)
assert result is None
@@ -1851,8 +1855,8 @@ def _():
@test("_check_level_breach returns None when no previous price")
def _():
_level_cache["TEST_PAIR"] = {"swing_high_1": 1.1000}
_level_cache_ts["TEST_PAIR"] = time.time()
_level_cache["TEST_PAIR"] = {"15min": {"swing_high_1": 1.1000}}
_level_cache_ts["TEST_PAIR"] = {"15min": time.time()}
if "TEST_PAIR" in _last_prices:
del _last_prices["TEST_PAIR"]
result = _check_level_breach("TEST_PAIR", 1.1001)
@@ -3424,7 +3428,9 @@ def _():
provider2._base_url = "https://test"
provider2._cache = {}
provider2._cache_timestamps = {}
provider2._cache_outputsize = {}
provider2._cache_lock = threading.Lock()
provider2._inflight = {}
provider2._spread_cache = {}
provider2._session = type("S", (), {
"get": lambda self, *a, **k: mock_resp,
@@ -4081,7 +4087,9 @@ def _():
provider.config = PlatformConfig()
provider._cache = {}
provider._cache_timestamps = {}
provider._cache_outputsize = {}
provider._cache_lock = threading.Lock()
provider._inflight = {}
provider._spread_cache = {}
provider.FRESH_FETCH_RETRY_SECONDS = 0 # instance override — no sleep
@@ -5637,6 +5645,677 @@ def _():
"first cache miss must emit a WARNING"
def _m21_stub_provider(bars=320, tf=Timeframe.H1):
"""Stub provider whose _fetch_raw serves a deterministic frame and
counts invocations (2026-08-04 audit, Batch E)."""
import providers.base as pbase
class _Stub(pbase.BaseDataProvider):
PROVIDER = "stub21"
calls = []
def _fetch_raw(self, symbol, timeframe, outputsize,
start=None, end=None):
self.calls.append(outputsize)
time_mod.sleep(0.05)
end_ts = pd.Timestamp.now(tz="UTC").floor("1h") - pd.Timedelta(hours=1)
idx = pd.date_range(end=end_ts, periods=min(outputsize, bars),
freq="1h", tz="UTC")
base = 2300 + np.cumsum(np.ones(len(idx)) * 0.1)
return pd.DataFrame(
{"Open": base, "High": base + 1, "Low": base - 1,
"Close": base, "Volume": 1.0},
index=pd.DatetimeIndex(idx, name="Datetime"))
def get_pair_info(self, symbol):
return PAIR_INFO[XAUUSD]
return _Stub(PlatformConfig())
import time as time_mod
@test("provider cache: longer-lookback request is NOT served the short frame")
def _():
from unittest.mock import patch
with patch("providers.base._is_forex_weekend", lambda now: False):
p = _m21_stub_provider()
p.fetch_ohlcv(XAUUSD, Timeframe.H1, lookback_days=1)
assert len(p.calls) == 1 and p.calls[0] == 300
p.fetch_ohlcv(XAUUSD, Timeframe.H1, lookback_days=60)
assert len(p.calls) == 2, \
"a longer-lookback call within TTL must refetch, not serve the " \
"short cached frame"
assert p.calls[1] == 1460
@test("provider cache: concurrent same-key fetches are single-flight")
def _():
from unittest.mock import patch
import threading as _t
with patch("providers.base._is_forex_weekend", lambda now: False):
p = _m21_stub_provider()
barrier = _t.Barrier(4)
results = []
def worker():
barrier.wait()
results.append(p.fetch_ohlcv(XAUUSD, Timeframe.H1))
threads = [_t.Thread(target=worker) for _ in range(4)]
for th in threads: th.start()
for th in threads: th.join()
assert len(results) == 4
assert len(p.calls) == 1, \
f"4 concurrent fetches must produce 1 API call, got {len(p.calls)}"
@test("provider: multi-bar forming/future tail is fully dropped")
def _():
p = _m21_stub_provider()
now = pd.Timestamp.now(tz="UTC")
idx = pd.DatetimeIndex(
[now - pd.Timedelta(hours=3), now - pd.Timedelta(hours=2),
now - pd.Timedelta(minutes=30), now + pd.Timedelta(minutes=30)],
name="Datetime")
df = pd.DataFrame({"Open": 1.0, "High": 2.0, "Low": 0.5, "Close": 1.5,
"Volume": 1.0}, index=idx)
out = p._drop_forming_candle(df, Timeframe.H1)
assert len(out) == 2, \
f"both not-yet-closed bars must be dropped, kept {len(out)}"
@test("provider fetch_range: 3 consecutive chunk ERRORS raise, no silent truncation")
def _():
import providers.base as pbase
class _Err(pbase.BaseDataProvider):
PROVIDER = "err21"
def _fetch_raw(self, symbol, timeframe, outputsize,
start=None, end=None):
raise self.DATA_ERROR("HTTP 429 simulated")
def get_pair_info(self, symbol):
return PAIR_INFO[XAUUSD]
p = _Err(PlatformConfig())
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
end = _dt.now(_tz.utc)
try:
# M5 → ~15-day chunks, so a 90-day window would walk ≥3 chunks.
p.fetch_range(XAUUSD, Timeframe.M5, end - _td(days=90), end)
assert False, "must raise after repeated chunk failures"
except pbase.DataIntegrityError as e:
assert "truncated" in str(e) or "consecutive" in str(e)
@test("provider cleaning: non-positive bar dropped, 10-sigma filter stays armed")
def _():
p = _m21_stub_provider()
n = 260
idx = pd.date_range("2026-01-05", periods=n, freq="1h", tz="UTC")
close = 2300 + np.cumsum(np.random.default_rng(5).normal(0, 1.0, n))
df = pd.DataFrame({"Open": close, "High": close + 1, "Low": close - 1,
"Close": close, "Volume": 1.0}, index=idx)
df.iloc[100, df.columns.get_loc("Close")] = 0.0 # corrupt mid-frame bar
df.iloc[200, df.columns.get_loc("Close")] = close[200] * 1.5 # true spike
out = p._validate_and_clean(df, XAUUSD, Timeframe.H1)
assert (out[["Open", "High", "Low", "Close"]] > 0).all().all(), \
"non-positive bar must be dropped"
assert idx[200] not in out.index, \
"the 10-sigma filter must survive the corrupt bar and still " \
"remove the genuine spike (inf-return kill-switch fixed)"
assert out["Low"].min() > 1000, "no zero may leak into Low via repair"
@test("oanda: malformed candle (missing OHLC field) is skipped, not zeroed")
def _():
from providers.oanda import OandaDataProvider
prov = OandaDataProvider.__new__(OandaDataProvider)
prov._spread_cache = {}
good = {"complete": True, "time": "2026-08-04T10:00:00Z", "volume": 10,
"mid": {"o": "3000.0", "h": "3001.0", "l": "2999.0", "c": "3000.5"}}
bad = {"complete": True, "time": "2026-08-04T11:00:00Z", "volume": 10,
"mid": {"o": "3000.5", "h": "3001.5", "c": "3000.9"}} # no "l"
good2 = {"complete": True, "time": "2026-08-04T12:00:00Z", "volume": 10,
"mid": {"o": "3000.9", "h": "3002.0", "l": "3000.0", "c": "3001.4"}}
class _Resp:
status_code = 200
text = ""
def json(self):
return {"candles": [good, bad, good2]}
class _Sess:
def get(self, url, params=None, timeout=None):
return _Resp()
prov._session = _Sess()
prov._base_url = "http://x"
df = prov._fetch_oanda_candles(XAUUSD, Timeframe.H1, 10)
assert len(df) == 2, "malformed candle must be skipped"
assert (df[["Open", "High", "Low", "Close"]] > 0).all().all(), \
"no 0.0-defaulted prices may appear"
@test("backtester: NaN bar inside a trailing window cannot poison R/metrics")
def _():
import gpu_backtest as _gb
t = _gb.Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=2400.0, sl=2384.0, tp1=2416.0, tp2=2432.0,
direction="BUY", pip_value=0.1, risk_pips=160.0,
reward_pips=160.0, rr_planned=1.0, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
idx = pd.date_range("2026-01-05 10:00", periods=10, freq="15min", tz="UTC")
o = np.full(10, 2401.0); h = np.full(10, 2402.0)
l = np.full(10, 2399.0); c = np.full(10, 2401.5)
for arr in (o, h, l, c):
arr[3] = np.nan # data gap mid-window
c[9] = np.nan
sim = pd.DataFrame({"Open": o, "High": h, "Low": l, "Close": c,
"Volume": 1.0}, index=idx)
_gb.simulate_trades([t], sim, max_bars=10, spread=0.30,
trail_arm_r=0.7, trail_dist_r=0.75)
assert t.exit_reason == "TIMEOUT" and t.bars_held == 3, \
f"must exit at last valid bar pre-gap, got {t.exit_reason}/{t.bars_held}"
assert np.isfinite(t.r_multiple), "NaN R must never be emitted"
m = _gb.compute_metrics([t])
assert np.isfinite(m.total_r) and np.isfinite(m.avg_r)
@test("backtester: compute_metrics excludes a non-finite R loudly, not NaN-ly")
def _():
import gpu_backtest as _gb
def mk(r):
t = _gb.Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=2400.0, sl=2384.0, tp1=2416.0, tp2=2432.0,
direction="BUY", pip_value=0.1, risk_pips=160.0,
reward_pips=160.0, rr_planned=1.0, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
t.exit_reason = "SL"; t.bars_held = 5; t.r_multiple = r
return t
m = _gb.compute_metrics([mk(1.0), mk(float("nan")), mk(-1.0)])
assert m.n_trades == 2, "non-finite R trade must be excluded"
assert np.isfinite(m.total_r) and abs(m.total_r) < 1e-9
# ── Batch C: telegram_bot delivery-path fixes (2026-08-04 audit) ─────
# Regression kernels from the H33–H38 verification harness. All hermetic:
# fake bots, fake streaming providers, patched run_signal — no network.
import asyncio as _aio21
import telegram_bot as _tb21
from telegram.error import BadRequest as _BadRequest21
def _m21c_report(tf="15min", quality="A", levels=None,
entry=3300.0, sl=3290.0):
from engine import TechnicalAnalysis
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
r.final_signal = SignalStrength.BUY
r.signal_quality = quality
r.final_score = 30.0
r.final_confidence = 60.0
r.market_regime = "trending_up"
r.volatility_regime = "normal"
r.current_price = entry
r.primary_analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe=tf, timestamp="t",
key_levels=dict(levels) if levels else {},
)
r.trade_setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=entry, stop_loss=sl, take_profit_1=entry + 15,
take_profit_2=entry + 25, risk_reward=1.5, risk_pips=100.0,
reward_pips=150.0, pip_value=0.10,
trail_arm_r=0.7, trail_dist_r=0.85,
)
return r
class _M21cBot:
"""send_message stub: optionally raise (exc / md_exc for Markdown
attempts only) or block on an asyncio.Event before returning."""
def __init__(self, exc=None, md_exc=None, gate=None):
self.exc = exc
self.md_exc = md_exc
self.gate = gate
self.attempts = []
self.sent = []
async def send_message(self, chat_id, text, parse_mode=None, **kw):
self.attempts.append(parse_mode)
if self.gate is not None:
await self.gate.wait()
if self.exc is not None:
raise self.exc
if self.md_exc is not None and parse_mode is not None:
raise self.md_exc
self.sent.append(text)
def _m21c_reset():
_tb21._scan_cooldowns.clear()
_tb21._pending_breach.clear()
_tb21._last_alert_fp.clear()
_tb21._last_alert_ts.clear()
_tb21._last_alert_setup.clear()
_tb21._level_cache.clear()
_tb21._level_cache_ts.clear()
_tb21._last_prices.clear()
_tb21._last_price_ts.clear()
_tb21._autoscan_chats.discard(911)
@test("H33a: failed breach rescan shrinks cooldown to the retry window")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
calls = {"n": 0}
async def failing(*a, **k):
calls["n"] += 1
raise RuntimeError("simulated rescan failure")
good = _m21c_report()
async def working(*a, **k):
calls["n"] += 1
return good
bot = _M21cBot()
with patch.object(_tb21, "_WS_COOLDOWN", 0.4), \
patch.object(_tb21, "_BREACH_RETRY_SECONDS", 0.05):
with patch.object(_tb21, "run_signal", failing):
await _tb21._handle_breach(bot, "XAU/USD", 3301.0, "swing_high_1")
assert calls["n"] == 1
remaining = _tb21._WS_COOLDOWN - (
time.time() - _tb21._scan_cooldowns["XAU/USD"])
assert remaining <= _tb21._BREACH_RETRY_SECONDS + 0.02, \
f"cooldown must be rolled back to the retry window, " \
f"got {remaining:.2f}s remaining"
assert "XAU/USD" in _tb21._pending_breach, \
"failed rescan must re-park the breach for retry"
await _aio21.sleep(0.07) # retry window elapses
with patch.object(_tb21, "run_signal", working):
await _tb21._handle_breach(bot, "XAU/USD", 3301.0, "swing_high_1")
assert calls["n"] == 2, "post-retry-window breach must rescan"
assert len(bot.sent) == 1, "and the alert must go out"
_m21c_reset()
_aio21.run(main())
@test("H33b: breach during cooldown is parked and fired after expiry")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
_tb21._update_level_cache(
[_m21c_report(levels={"swing_high_1": 3300.0})])
_tb21._scan_cooldowns["XAU/USD"] = time.time() # cooldown active
rescans = {"n": 0}
async def working(*a, **k):
rescans["n"] += 1
return _m21c_report()
bot = _M21cBot()
mid_state = {}
connects = {"n": 0}
class FakeDP:
name = "fake"
supports_streaming = True
_api_key = "k"
async def iter_ticks(self, symbols, on_connect=None):
# No asserts in here: an in-generator failure would be
# swallowed by the monitor's reconnect loop. Record state,
# assert after; the connect counter is a safety valve.
connects["n"] += 1
if connects["n"] > 1:
raise _aio21.CancelledError
yield "XAU/USD", 3299.0 # below level
yield "XAU/USD", 3301.0 # CROSS — inside cooldown → parked
await _aio21.sleep(0) # let the breach task park it
mid_state["rescans"] = rescans["n"]
mid_state["parked"] = "XAU/USD" in _tb21._pending_breach
await _aio21.sleep(0.1) # cooldown (0.08s) expires
yield "XAU/USD", 3302.0 # no crossing — pending must fire
await _aio21.sleep(0.05) # let the fired task run
raise _aio21.CancelledError
with patch.object(_tb21, "_WS_COOLDOWN", 0.08), \
patch.object(_tb21, "run_signal", working), \
patch.object(_tb21, "_get_streaming_dp", lambda: FakeDP()):
await _tb21._ws_monitor(bot)
if _tb21._breach_tasks:
await _aio21.gather(*_tb21._breach_tasks,
return_exceptions=True)
assert connects["n"] == 1, "monitor must stop on the first pass"
assert mid_state == {"rescans": 0, "parked": True}, \
f"cross inside cooldown must park, not rescan: {mid_state}"
assert rescans["n"] == 1, "parked breach must fire after expiry"
assert len(bot.sent) == 1
assert "3301" in bot.sent[0], \
"alert must carry the original crossing price"
assert "XAU/USD" not in _tb21._pending_breach, "pending consumed"
_m21c_reset()
_aio21.run(main())
@test("H34: level cache is per-timeframe — M5 scan no longer wipes H1 levels")
def _():
_m21c_reset()
_tb21._update_level_cache([_m21c_report(
tf="1h", levels={"swing_high_1": 3350.0, "ema_trend": 3310.0})])
_tb21._update_level_cache([_m21c_report(
tf="5min", levels={"swing_high_1": 3311.0})])
cache = _tb21._level_cache["XAU/USD"]
assert set(cache) == {"1h", "5min"}, f"both TF slots must exist: {cache}"
assert cache["1h"]["swing_high_1"] == 3350.0, \
"M5 write must not touch the H1 slot"
# Breach check scans the union: crossing the H1-only level fires
# with a TF-qualified name.
_tb21._last_prices["XAU/USD"] = 3349.0
assert _tb21._check_level_breach("XAU/USD", 3351.0) == "1h swing_high_1"
# A second M5 scan replaces only the M5 slot.
_tb21._update_level_cache([_m21c_report(
tf="5min", levels={"swing_low_1": 3290.0})])
assert _tb21._level_cache["XAU/USD"]["5min"] == {"swing_low_1": 3290.0}
assert _tb21._level_cache["XAU/USD"]["1h"]["swing_high_1"] == 3350.0
_m21c_reset()
@test("H35: Markdown parse failure falls back to plain text, then commits")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
rep = _m21c_report()
async def rs(*a, **k):
return rep
key = f"XAU/USD_{Timeframe.M15.value}"
with patch.object(_tb21, "run_signal", rs):
# Markdown attempt fails → plain-text retry succeeds → commit.
bot = _M21cBot(md_exc=_BadRequest21("Can't parse entities"))
await _tb21._scan_timeframe(
bot, Timeframe.M15, [Timeframe.M5, Timeframe.H1])
assert bot.attempts == [_tb21.ParseMode.MARKDOWN, None], \
f"expected md attempt then plain retry, got {bot.attempts}"
assert len(bot.sent) == 1
assert key in _tb21._last_alert_fp, \
"plain-text delivery must commit the fingerprint"
# Both attempts fail → NOT committed (commit-after-send holds).
_m21c_reset()
_tb21._autoscan_chats.add(911)
bot2 = _M21cBot(exc=_BadRequest21("Can't parse entities"))
await _tb21._scan_timeframe(
bot2, Timeframe.M15, [Timeframe.M5, Timeframe.H1])
assert key not in _tb21._last_alert_fp, \
"a fully failed send must stay eligible for the next scan"
# Breach path uses the same fallback helper.
_m21c_reset()
_tb21._autoscan_chats.add(911)
bot3 = _M21cBot(md_exc=_BadRequest21("Can't parse entities"))
await _tb21._handle_breach(bot3, "XAU/USD", 3301.0, "swing_high_1")
assert len(bot3.sent) == 1 and key in _tb21._last_alert_fp
_m21c_reset()
_aio21.run(main())
@test("H36: concurrent scan + breach on one key produce a single alert")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
rep = _m21c_report()
async def rs(*a, **k):
return rep
gate = _aio21.Event()
bot = _M21cBot(gate=gate)
with patch.object(_tb21, "run_signal", rs):
t1 = _aio21.create_task(_tb21._scan_timeframe(
bot, Timeframe.M15, [Timeframe.M5, Timeframe.H1]))
await _aio21.sleep(0.02) # scan holds the key lock, blocked in send
t2 = _aio21.create_task(_tb21._handle_breach(
bot, "XAU/USD", 3301.0, "swing_high_1"))
await _aio21.sleep(0.02) # breach is queued on the lock
assert len(bot.attempts) == 1, \
"breach must wait on the alert lock, not race into send"
gate.set()
await _aio21.gather(t1, t2)
assert len(bot.sent) == 1, \
f"same fingerprint must alert exactly once, got {len(bot.sent)}"
_m21c_reset()
_aio21.run(main())
@test("H37: _start_scheduler respawns only the dead TF loop")
def _():
async def main():
saved_tasks = list(_tb21._autoscan_tasks)
saved_ws = _tb21._ws_task
_tb21._autoscan_tasks.clear()
_tb21._ws_task = None
async def fake_loop(bot, tf, interval):
await _aio21.sleep(30)
class NoStream:
name = "stub"
supports_streaming = False
try:
with patch.object(_tb21, "_AUTOSCAN_LOOPS",
[(Timeframe.M5, 5), (Timeframe.M15, 15)]), \
patch.object(_tb21, "_candle_loop", fake_loop), \
patch.object(_tb21, "_get_streaming_dp", lambda: NoStream()):
_tb21._start_scheduler(None)
assert len(_tb21._autoscan_tasks) == 2
m5 = next(t for t in _tb21._autoscan_tasks
if t.get_name() == "candle_loop_M5")
m15 = next(t for t in _tb21._autoscan_tasks
if t.get_name() == "candle_loop_M15")
m5.cancel()
await _aio21.sleep(0.01) # let the cancellation land
assert m5.done() and not m15.done()
_tb21._start_scheduler(None)
names = sorted(t.get_name() for t in _tb21._autoscan_tasks)
assert names == ["candle_loop_M15", "candle_loop_M5"], names
assert m15 in _tb21._autoscan_tasks, \
"the live loop must be kept, not restarted"
assert m5 not in _tb21._autoscan_tasks, \
"the dead loop must be replaced by a fresh task"
finally:
for t in _tb21._autoscan_tasks:
t.cancel()
await _aio21.gather(*_tb21._autoscan_tasks,
return_exceptions=True)
if _tb21._ws_task is not None:
_tb21._ws_task.cancel()
await _aio21.gather(_tb21._ws_task, return_exceptions=True)
_tb21._autoscan_tasks[:] = saved_tasks
_tb21._ws_task = saved_ws
_aio21.run(main())
@test("H38a: clean WS close backs off; backoff resets only after ticks")
def _():
async def main():
_m21c_reset()
_tb21._autoscan_chats.add(911)
sleeps = []
real_asyncio = _aio21
class AsyncioProxy:
def __getattr__(self, name):
return getattr(real_asyncio, name)
@staticmethod
async def sleep(t):
sleeps.append(t)
await real_asyncio.sleep(0)
def make_dp(tick_per_conn):
connects = {"n": 0}
class FakeDP:
name = "fake"
supports_streaming = True
_api_key = "k"
async def iter_ticks(self, symbols, on_connect=None):
connects["n"] += 1
if connects["n"] >= 3:
raise real_asyncio.CancelledError
if tick_per_conn:
yield "XAU/USD", 3300.0
return # clean close, no exception
return FakeDP()
try:
# No ticks: backoff must GROW across clean closes (1, then 2).
with patch.object(_tb21, "_get_streaming_dp",
lambda: make_dp(False)):
_tb21.asyncio = AsyncioProxy()
await _tb21._ws_monitor(_M21cBot())
assert sleeps == [1, 2], \
f"clean closes without data must back off, got {sleeps}"
# One tick per connection: backoff resets, sleeps stay at 1.
sleeps.clear()
with patch.object(_tb21, "_get_streaming_dp",
lambda: make_dp(True)):
await _tb21._ws_monitor(_M21cBot())
assert sleeps == [1, 1], \
f"a connection that produced ticks must reset backoff, " \
f"got {sleeps}"
finally:
_tb21.asyncio = real_asyncio
_m21c_reset()
_aio21.run(main())
@test("H38b: TD iter_ticks fires on_connect on first RECEIVED frame only")
def _():
import types as _types
class FakeWS:
def __init__(self, messages):
self.messages = list(messages)
self.sent = []
async def send(self, m):
self.sent.append(m)
def __aiter__(self):
return self
async def __anext__(self):
if self.messages:
return self.messages.pop(0)
raise StopAsyncIteration
class CM:
def __init__(self, ws):
self.ws = ws
async def __aenter__(self):
return self.ws
async def __aexit__(self, *a):
return False
async def main():
from providers.twelve_data import TwelveDataProvider
fake_mod = _types.ModuleType("websockets")
saved = sys.modules.get("websockets")
sys.modules["websockets"] = fake_mod
try:
td = TwelveDataProvider(PlatformConfig())
# Server sends NOTHING (rejected key / silent close): the
# subscribe frame goes out but on_connect must NOT fire.
ws1 = FakeWS([])
fake_mod.connect = lambda url, **kw: CM(ws1)
fired = []
async for _ in td.iter_ticks(["XAU/USD"],
on_connect=lambda: fired.append(1)):
pass
await _aio21.sleep(0) # let the heartbeat task cancel cleanly
assert len(ws1.sent) == 1, "subscribe frame must have been sent"
assert not fired, \
"on_connect must not fire before any server frame (H38b)"
# First received frame (even a non-price one) → fires.
ws2 = FakeWS([json.dumps({"event": "subscribe-status",
"status": "ok"})])
fake_mod.connect = lambda url, **kw: CM(ws2)
async for _ in td.iter_ticks(["XAU/USD"],
on_connect=lambda: fired.append(1)):
pass
await _aio21.sleep(0)
assert fired == [1], "on_connect must fire on first received frame"
finally:
if saved is not None:
sys.modules["websockets"] = saved
else:
sys.modules.pop("websockets", None)
_aio21.run(main())
@test("scan-failure log carries the real traceback (not 'NoneType: None')")
def _():
async def main():
_m21c_reset()
async def rs(*a, **k):
raise RuntimeError("m21-traceback-marker")
import logging as _lg
records = []
h = _lg.Handler()
h.emit = lambda rec: records.append(rec)
_tb21.logger.addHandler(h)
try:
with patch.object(_tb21, "run_signal", rs):
await _tb21._scan_timeframe(_M21cBot(), Timeframe.M15, [])
finally:
_tb21.logger.removeHandler(h)
fails = [r for r in records if "Auto-scan failed" in r.getMessage()]
assert fails, "the per-pair failure must be logged"
text = _lg.Formatter("%(message)s").format(fails[0])
assert "NoneType: None" not in text
assert "m21-traceback-marker" in text and "RuntimeError" in text, \
f"log must include the real traceback, got: {text[-160:]!r}"
_m21c_reset()
_aio21.run(main())
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════