Files
ForexBot2/providers/oanda.py
T
Harwinder Sekhon 99eb268cf3 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
2026-08-04 20:02:57 -04:00

207 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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