99eb268cf3
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
207 lines
7.3 KiB
Python
207 lines
7.3 KiB
Python
"""
|
||
ForexBot2 — Oanda v20 data provider
|
||
====================================
|
||
OHLCV candles from the Oanda v20 REST API. All shared behavior (cache,
|
||
validation, boundary guarantees, range pagination) lives in
|
||
`BaseDataProvider`; this module only maps symbols/granularities, parses
|
||
Oanda's candle JSON, and exposes the real bid/ask spread from MBA candles.
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Dict, Optional
|
||
|
||
import pandas as pd
|
||
import requests
|
||
|
||
from app_config import oanda_api_token, oanda_base_url
|
||
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
||
|
||
from providers.base import BaseDataProvider, DataIntegrityError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OandaDataError(DataIntegrityError):
|
||
"""Raised when an Oanda data fetch fails or returns invalid data."""
|
||
|
||
|
||
class OandaDataProvider(BaseDataProvider):
|
||
"""Oanda v20 REST candle provider."""
|
||
|
||
name = "oanda"
|
||
DATA_ERROR = OandaDataError
|
||
|
||
OANDA_GRANULARITY = {
|
||
Timeframe.M1: "M1",
|
||
Timeframe.M5: "M5",
|
||
Timeframe.M15: "M15",
|
||
Timeframe.M30: "M30",
|
||
Timeframe.H1: "H1",
|
||
Timeframe.H4: "H4",
|
||
Timeframe.D1: "D",
|
||
Timeframe.W1: "W",
|
||
}
|
||
|
||
OANDA_INSTRUMENT = {
|
||
"XAU/USD": "XAU_USD",
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
config: PlatformConfig,
|
||
api_token: Optional[str] = None,
|
||
base_url: Optional[str] = None,
|
||
):
|
||
super().__init__(config)
|
||
# Credentials default to the config.json `oanda` block / env vars
|
||
# so the provider registry can build this class with config only.
|
||
if api_token is None:
|
||
api_token = oanda_api_token()
|
||
if base_url is None:
|
||
base_url = oanda_base_url()
|
||
self._base_url = base_url.rstrip("/")
|
||
self._session = requests.Session()
|
||
self._session.headers.update({
|
||
"Authorization": f"Bearer {api_token}",
|
||
"Accept-Datetime-Format": "RFC3339",
|
||
"Content-Type": "application/json",
|
||
})
|
||
self._spread_cache: Dict[str, float] = {}
|
||
|
||
# ── Public extras ──────────────────────────────────────────────
|
||
|
||
def get_spread(self, symbol: str = XAUUSD) -> float:
|
||
return self._spread_cache.get(
|
||
symbol, PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD])["default_spread"])
|
||
|
||
# ── Raw fetch ──────────────────────────────────────────────────
|
||
|
||
def _fetch_raw(
|
||
self,
|
||
symbol: str,
|
||
timeframe: Timeframe,
|
||
outputsize: int,
|
||
start: Optional[datetime] = None,
|
||
end: Optional[datetime] = None,
|
||
) -> pd.DataFrame:
|
||
if start is not None or end is not None:
|
||
return self._fetch_oanda_candles(
|
||
symbol, timeframe, outputsize, start, end)
|
||
return self._fetch_oanda_candles(symbol, timeframe, outputsize)
|
||
|
||
def _to_oanda_instrument(self, symbol: str) -> str:
|
||
if symbol in self.OANDA_INSTRUMENT:
|
||
return self.OANDA_INSTRUMENT[symbol]
|
||
return symbol.replace("/", "_")
|
||
|
||
def _fetch_oanda_candles(
|
||
self,
|
||
symbol: str,
|
||
timeframe: Timeframe,
|
||
count: int,
|
||
start: Optional[datetime] = None,
|
||
end: Optional[datetime] = None,
|
||
) -> pd.DataFrame:
|
||
instrument = self._to_oanda_instrument(symbol)
|
||
granularity = self.OANDA_GRANULARITY.get(timeframe)
|
||
if granularity is None:
|
||
raise OandaDataError(
|
||
f"Unsupported timeframe {timeframe} for Oanda")
|
||
|
||
url = f"{self._base_url}/v3/instruments/{instrument}/candles"
|
||
params = {
|
||
"granularity": granularity,
|
||
"price": "MBA",
|
||
"dailyAlignment": 0,
|
||
"alignmentTimezone": "Etc/UTC",
|
||
}
|
||
# v20 rejects `count` combined with both `from` and `to`; a date
|
||
# window replaces it (used by fetch_range pagination).
|
||
if start is not None and end is not None:
|
||
params["from"] = pd.Timestamp(start).isoformat()
|
||
params["to"] = pd.Timestamp(end).isoformat()
|
||
else:
|
||
params["count"] = min(count, self.MAX_BARS_PER_REQUEST)
|
||
|
||
try:
|
||
resp = self._session.get(url, params=params, timeout=30)
|
||
except requests.RequestException as e:
|
||
raise OandaDataError(
|
||
f"Oanda candle fetch failed for {instrument}: {e}") from e
|
||
|
||
if resp.status_code != 200:
|
||
raise OandaDataError(
|
||
f"Oanda candle fetch failed for {instrument}: "
|
||
f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||
|
||
try:
|
||
data = resp.json()
|
||
except ValueError as e:
|
||
raise OandaDataError(
|
||
f"Oanda candle response for {instrument} is not valid "
|
||
f"JSON: {e}") from e
|
||
candles = data.get("candles", [])
|
||
if not candles:
|
||
raise OandaDataError(
|
||
f"Empty candle response from Oanda for {instrument}")
|
||
|
||
rows = []
|
||
last_bid_close = None
|
||
last_ask_close = None
|
||
skipped_malformed = 0
|
||
for c in candles:
|
||
if not c.get("complete", False):
|
||
continue
|
||
mid = c.get("mid", {})
|
||
bid = c.get("bid", {})
|
||
ask = c.get("ask", {})
|
||
# A candle missing any OHLC field must be SKIPPED, never
|
||
# defaulted to 0.0: a zero price flows through cleaning as a
|
||
# "legit" bar and poisons ATR/Keltner/SL math — and a
|
||
# mid-frame zero Close makes pct_change() emit inf, which
|
||
# NaNs the std and disarms the 10σ outlier filter for the
|
||
# whole frame (2026-08-04 audit, I45).
|
||
if any(mid.get(k) is None for k in ("o", "h", "l", "c")):
|
||
skipped_malformed += 1
|
||
continue
|
||
rows.append({
|
||
"Datetime": pd.Timestamp(c["time"]).tz_convert("UTC"),
|
||
"Open": float(mid["o"]),
|
||
"High": float(mid["h"]),
|
||
"Low": float(mid["l"]),
|
||
"Close": float(mid["c"]),
|
||
"Volume": int(c.get("volume", 0)),
|
||
})
|
||
if bid and ask:
|
||
last_bid_close = float(bid.get("c", 0))
|
||
last_ask_close = float(ask.get("c", 0))
|
||
if skipped_malformed:
|
||
logger.warning(
|
||
"Oanda returned %d malformed candle(s) for %s (missing "
|
||
"OHLC fields) — skipped", skipped_malformed, instrument)
|
||
|
||
if not rows:
|
||
raise OandaDataError(
|
||
f"No complete candles from Oanda for {instrument}")
|
||
|
||
if last_bid_close and last_ask_close:
|
||
self._spread_cache[symbol] = round(
|
||
last_ask_close - last_bid_close, 4)
|
||
|
||
df = pd.DataFrame(rows)
|
||
df = df.set_index("Datetime")
|
||
df = df.sort_index()
|
||
if df.index.tz is None:
|
||
df.index = df.index.tz_localize("UTC")
|
||
df.index.name = "Datetime"
|
||
|
||
for col in ("Open", "High", "Low", "Close"):
|
||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||
df["Volume"] = pd.to_numeric(df["Volume"], errors="coerce").fillna(0).astype(float)
|
||
|
||
logger.debug(
|
||
"Fetched %d bars for %s @ %s from Oanda",
|
||
len(df), instrument, granularity)
|
||
return df
|