Provider abstraction: providers/ package with BaseDataProvider template

One class per provider (TwelveData, Oanda, Tiingo), all subclassing
BaseDataProvider which owns cache, validation/cleaning, freshness +
boundary guarantees, fetch_range pagination, and streaming stubs —
subclasses implement only _fetch_raw. config.json data_providers.active
selects the live feed; data_provider.py / oanda_provider.py stay as
back-compat shims. Module 16 provider-abstraction tests (253 total green).

Claude-Session: https://claude.ai/code/session_01Mc7XKYyPySVXiUpcN82QEK
This commit is contained in:
Harwinder Sekhon
2026-07-28 20:09:00 -04:00
parent f760e9070a
commit 083a4e8d63
17 changed files with 1573 additions and 935 deletions
+34 -75
View File
@@ -38,7 +38,6 @@ Usage:
import asyncio
import functools
import json
import logging
import logging.handlers
import os
@@ -49,8 +48,6 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
import websockets
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.error import BadRequest
@@ -66,10 +63,10 @@ from config import (
XAUUSD, PAIR_INFO,
)
from app_config import (
get_app_config, provider_api_key, telegram_token, telegram_chat_id,
get_app_config, telegram_token, telegram_chat_id,
telegram_channel_id,
)
from data_provider import ForexDataProvider, make_data_provider
from providers import BaseDataProvider, make_data_provider
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
@@ -127,9 +124,6 @@ SCAN_PAIRS = list(_APP.scan_pairs)
_PAIRS_LABEL = " + ".join(PAIR_INFO[p]["display"] for p in SCAN_PAIRS)
_WS_TO_PAIR = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
"swing_high_1", "swing_high_2", "swing_high_3",
"swing_low_1", "swing_low_2", "swing_low_3",
@@ -199,10 +193,10 @@ def _fmt_price(price: float) -> str:
# ── Shared data provider (single cache across all scans) ─────────────
_shared_dp: Optional[ForexDataProvider] = None
_shared_dp: Optional[BaseDataProvider] = None
def _get_shared_dp() -> ForexDataProvider:
def _get_shared_dp() -> BaseDataProvider:
global _shared_dp
if _shared_dp is None:
_shared_dp = make_data_provider(PlatformConfig())
@@ -663,8 +657,6 @@ _last_price_ts: Dict[str, float] = {}
_scan_cooldowns: Dict[str, float] = {}
_cfg = PlatformConfig()
_WS_API_KEY = provider_api_key()
_WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={_WS_API_KEY}"
_WS_COOLDOWN = _cfg.ws_cooldown_seconds
_breach_tasks: Set[asyncio.Task] = set()
@@ -710,13 +702,13 @@ def _cleanup_cooldowns() -> None:
del _scan_cooldowns[k]
async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, level_name: str) -> None:
async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -> None:
now = time.time()
if now - _scan_cooldowns.get(pair_symbol, 0) < _WS_COOLDOWN:
return
_scan_cooldowns[pair_symbol] = now
display = PAIR_INFO.get(pair_symbol, {}).get("display", ws_symbol)
display = PAIR_INFO.get(pair_symbol, {}).get("display", pair_symbol)
label = level_name.replace("_", " ").title()
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
@@ -759,71 +751,41 @@ def _breach_task_done(task: asyncio.Task) -> None:
logger.error("Breach alert task failed", exc_info=task.exception())
async def _ws_heartbeat(ws) -> None:
"""Twelve Data drops idle WS connections; heartbeat every 10s keeps it
alive through quiet periods (weekends, low-liquidity hours)."""
while True:
await asyncio.sleep(10)
await ws.send(json.dumps({"action": "heartbeat"}))
async def _ws_monitor(bot) -> None:
"""Level-breach tick monitor. The provider owns the connection and
protocol (`iter_ticks`); this loop owns reconnect/backoff and breach
dispatch. Providers without streaming quotes simply disable it."""
dp = _get_shared_dp()
if not dp.supports_streaming:
logger.info(
"Data provider %r has no streaming quotes — "
"level-breach monitor disabled", dp.name)
return
backoff = 1
while _feed_active():
hb_task: Optional[asyncio.Task] = None
try:
async with websockets.connect(_WS_URL) as ws:
subscribe = {
"action": "subscribe",
"params": {"symbols": ",".join(_WS_SYMBOLS)},
}
await ws.send(json.dumps(subscribe))
logger.info(f"WebSocket connected, subscribed to {len(_WS_SYMBOLS)} symbols")
def _on_connect() -> None:
nonlocal backoff
backoff = 1
hb_task = asyncio.create_task(_ws_heartbeat(ws))
async for raw in ws:
if not _feed_active():
break
async for pair_symbol, price in dp.iter_ticks(
SCAN_PAIRS, on_connect=_on_connect):
if not _feed_active():
break
try:
msg = json.loads(raw)
except json.JSONDecodeError:
logger.warning(f"WebSocket: malformed JSON, skipping")
continue
breached = _check_level_breach(pair_symbol, price)
_last_prices[pair_symbol] = price
_last_price_ts[pair_symbol] = time.time()
event = msg.get("event")
if event == "subscribe-status":
fails = msg.get("fails") or []
if fails:
logger.warning(f"WebSocket: failed to subscribe: {fails}")
continue
if event != "price":
continue
if breached:
task = asyncio.create_task(
_handle_breach(bot, pair_symbol, price, breached)
)
_breach_tasks.add(task)
task.add_done_callback(_breach_task_done)
ws_symbol = msg.get("symbol")
pair_symbol = _WS_TO_PAIR.get(ws_symbol)
if not pair_symbol:
continue
try:
price = float(msg["price"])
except (KeyError, ValueError, TypeError) as e:
logger.warning(f"WebSocket: bad price data for {ws_symbol}: {e}")
continue
breached = _check_level_breach(pair_symbol, price)
_last_prices[pair_symbol] = price
_last_price_ts[pair_symbol] = time.time()
if breached:
task = asyncio.create_task(
_handle_breach(bot, ws_symbol, pair_symbol, price, breached)
)
_breach_tasks.add(task)
task.add_done_callback(_breach_task_done)
_cleanup_cooldowns()
_cleanup_cooldowns()
except asyncio.CancelledError:
break
@@ -831,9 +793,6 @@ async def _ws_monitor(bot) -> None:
logger.exception(f"WebSocket error, reconnecting in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
finally:
if hb_task is not None:
hb_task.cancel()
logger.info("WebSocket monitor stopped")
@@ -998,9 +957,9 @@ def _status_text() -> str:
if _last_prices:
lines.append("\n*Live Prices (WebSocket):*")
for ws_sym, pair in _WS_TO_PAIR.items():
for pair in SCAN_PAIRS:
price = _last_prices.get(pair)
display = PAIR_INFO.get(pair, {}).get("display", ws_sym)
display = PAIR_INFO.get(pair, {}).get("display", pair)
age = time.time() - _last_price_ts.get(pair, 0)
if price is None:
lines.append(f" {display}: waiting...")