Migrate to yfinance and parallelize analysis

This commit is contained in:
Harwinder
2026-05-05 16:37:11 -04:00
parent 4c1d839a1a
commit 3737cf4e71
8 changed files with 183 additions and 395 deletions
+43
View File
@@ -0,0 +1,43 @@
{
"permissions": {
"allow": [
"Bash(python -c \"from config import PlatformConfig; from engine import TechnicalEngine; from data_provider import ForexDataProvider; from news_engine import NewsEngine; from signal_aggregator import SignalAggregator, SignalReport; print\\('All imports OK'\\)\")",
"Bash(pip install *)",
"Bash(python -c ' *)",
"Bash(python -m venv .venv)",
"Bash(.venv\\\\Scripts\\\\python.exe -m pip install pandas numpy requests yfinance python-dateutil -q)",
"PowerShell(& \".venv\\\\Scripts\\\\python.exe\" -m pip install pandas numpy requests yfinance python-dateutil -q 2>&1)",
"PowerShell(& \".venv\\\\Scripts\\\\python.exe\" main.py 2>&1)",
"PowerShell(& \".venv\\\\Scripts\\\\python.exe\" -c \"from data_provider import ForexDataProvider; print\\('import OK'\\)\")",
"Bash(dir h:\\\\Apps\\\\htdocs\\\\ForexBot2\\\\*.py /b)",
"Bash(.venv/Scripts/pip freeze *)",
"Bash(git add *)",
"Bash(git push *)",
"Bash(git pull *)",
"Bash(git checkout *)",
"Bash(git rebase *)",
"Bash(Get-ChildItem -Recurse -File)",
"Bash(Select-Object FullName)",
"Bash(cd /d \"h:\\\\Apps\\\\htdocs\\\\ForexBot2\")",
"Bash(python -c \"import ast; ast.parse\\(open\\('telegram_bot.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
"Bash(python -c \"import ast; ast.parse\\(open\\(r'h:/Apps/htdocs/ForexBot2/telegram_bot.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
"Bash(python -c \"import ast; ast.parse\\(open\\(r'h:/Apps/htdocs/ForexBot2/telegram_bot.py', encoding='utf-8'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" status)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" diff)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" log --oneline -5)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" add telegram_bot.py)",
"Bash(git -C h:/Apps/htdocs/ForexBot2 commit -m ' *)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" push)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" remote set-url origin http://harry:Sekhon007@192.168.2.10:3000/harry/ForexBot2.git)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" add USAGE.md)",
"Bash(ssh forex2@192.168.2.10 \"pkill -f telegram_bot.py; sleep 1; pgrep -fa telegram_bot.py || echo 'No bot processes running'\")",
"Bash(python -c \"import ast; ast.parse\\(open\\(r'h:/Apps/htdocs/ForexBot2/telegram_bot.py', encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" add CLAUDE.md)",
"Bash(git -C \"h:/Apps/htdocs/ForexBot2\" add data_provider.py telegram_bot.py)",
"WebFetch(domain:github.com)",
"WebFetch(domain:finnhub.io)",
"Bash(python -c \"import ast; ast.parse\\(open\\('data_provider_finnhub.py'\\).read\\(\\)\\); ast.parse\\(open\\('signal_aggregator.py'\\).read\\(\\)\\); ast.parse\\(open\\('telegram_bot.py'\\).read\\(\\)\\); ast.parse\\(open\\('config.py'\\).read\\(\\)\\); print\\('All files parse OK'\\)\")",
"Bash(python -c \"import io, ast; ast.parse\\(io.open\\('data_provider_finnhub.py', encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")"
]
}
}
+9 -52
View File
@@ -1,64 +1,21 @@
# ForexBot2 — Known Bugs & Dev Notes # ForexBot2 — Known Bugs & Dev Notes
## Bug 1 — Concurrent TF scans exhaust API credits instantly ## [RESOLVED] Bug 1 — Concurrent TF scans exhaust API credits instantly
**Symptom:** **Status:** Resolved
``` **Resolution:** Replaced the Twelve Data API with `yfinance` in `data_provider.py`. Since `yfinance` has no strict per-minute rate limiting, concurrent scans no longer fail. Furthermore, the `signal_aggregator.py` has been updated to use `ThreadPoolExecutor` to speed up pair analysis in parallel.
Batch prefetch failed for 1wk: You have run out of API credits for the current minute.
9 API credits were used, with the current limit at https://twelvedata.com/pricing
```
Warnings appear for every timeframe (1h, 4h, 1d, 1wk) in rapid succession.
**Root cause:**
`telegram_bot.py` runs all 4 `SCAN_TIMEFRAMES` concurrently via `ThreadPoolExecutor`.
Each `SignalAggregator` fires its own batch prefetch simultaneously.
Free tier limit is **8 credits/minute** — 4 parallel scans hit this in under a second.
**Impact:**
Confirmation timeframes fail and fall back to synthetic data.
Primary TF still has real data (first request wins the credits).
**Fix (not yet implemented):**
Run timeframes sequentially with a 60-second wait between each, or reduce
`SCAN_TIMEFRAMES` to a single primary TF and use the engine's built-in
multi-timeframe confirmation (which already batches efficiently in 3 calls).
--- ---
## Bug 2 — XAUUSD (Gold) shows wrong price (~1.01 instead of ~3300) ## [RESOLVED] Bug 2 — XAUUSD (Gold) shows wrong price (~1.01 instead of ~3300)
**Symptom:** **Status:** Resolved
``` **Resolution:** By transitioning entirely to `yfinance`, XAUUSD is now natively mapped to `GC=F` via the `_YF_FALLBACK_MAP`. This means we get the real, correct OHLCV data directly without needing synthetic fallbacks.
Entry: 1.01037
Take Profit: 1.00767 (+0.3 pips)
Stop Loss: 1.01172 (-0.1 pips)
```
**Root cause:**
Two compounding issues:
1. XAUUSD is not available on the Twelve Data **free tier** — API returns no data.
2. `data_provider._generate_synthetic_data()` uses a `pair_params` dict for base
prices. XAUUSD either falls through to a wrong default (~1.0) instead of the
correct ~3300 baseline.
Check `data_provider.py` → `_generate_synthetic_data()` → `pair_params` dict.
Verify `"XAUUSD=X"` has a correct `"price"` entry around `3300.0`.
**Impact:**
All indicator values, entry, TP, and SL for XAUUSD are meaningless when
running on the free tier (synthetic data with wrong base price).
**Fix (not yet implemented):**
- Correct the base price in `pair_params` for `XAUUSD=X`.
- Add a bot-level guard: if `report.current_price < 100` for XAUUSD,
suppress the trade setup and show a "data unavailable" message instead.
--- ---
## Notes ## Notes
- Free tier: 8 credits/min, 800 credits/day. Each full scan = ~21 credits. - **Data Provider**: The primary data provider is now `yfinance`. `Twelve Data` has been completely removed along with all its throttle logic and API Key requirements.
- Supported pairs (live data on free tier): EURUSD, GBPUSD, USDJPY, USDCHF, - **Parallelization**: Pair analysis is now multithreaded using `ThreadPoolExecutor` in `signal_aggregator.py`, maximizing the speed of data fetching and processing.
AUDUSD, USDCAD, NZDUSD, EURGBP, EURJPY, GBPJPY, AUDJPY, EURCHF, CADJPY. - **Supported pairs**: All forex pairs (EURUSD, GBPUSD, USDJPY, etc.) are fetched with an `=X` suffix in Yahoo Finance. Commodities like Gold (XAUUSD=X) use their futures symbols (`GC=F`).
- All other symbols (XAU, XAG, commodities, indices) fall back to synthetic GBM data.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -193,8 +193,7 @@ class PlatformConfig:
# Data source # Data source
data_lookback_days: int = 60 # enough for 200 EMA on H1 data_lookback_days: int = 60 # enough for 200 EMA on H1
# Twelve Data API key (overridden by TWELVE_DATA_API_KEY env var)
twelve_data_api_key: str = "f563b22ffdfd4924955decbe1a8c8701"
# Dashboard refresh interval (seconds) — used as a fallback when a # Dashboard refresh interval (seconds) — used as a fallback when a
# timeframe isn't covered by cache_ttl_per_tf below. # timeframe isn't covered by cache_ttl_per_tf below.
+117 -334
View File
@@ -1,21 +1,18 @@
""" """
Forex Signal Intelligence Platform - Data Provider Forex Signal Intelligence Platform - Data Provider
==================================================== ====================================================
Retrieves and validates market data from Twelve Data API. Retrieves and validates market data from Yahoo Finance API.
Raises DataUnavailableError when the API is unreachable or credits are exhausted. Raises DataUnavailableError when the API is unreachable.
No synthetic/simulated data fallback.
""" """
import logging import logging
import os import os
import threading
import time import time
from collections import deque from collections import deque
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import pandas as pd import pandas as pd
import requests
try: try:
import yfinance as yf import yfinance as yf
@@ -27,10 +24,8 @@ except ImportError:
from config import PlatformConfig, Timeframe, SYMBOL_DISPLAY, PIP_VALUES from config import PlatformConfig, Timeframe, SYMBOL_DISPLAY, PIP_VALUES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_no_data_warned: set = set() # suppress duplicate "no data" warnings per symbol
# Symbols that Twelve Data free tier does not support — route to Yahoo Finance. # Fallbacks for specific symbols to get accurate Forex/Commodity data from yfinance.
# Gold/silver futures are the most reliable Yahoo symbols (full OHLCV history).
_YF_FALLBACK_MAP = { _YF_FALLBACK_MAP = {
"XAUUSD=X": "GC=F", "XAUUSD=X": "GC=F",
"XAGUSD=X": "SI=F", "XAGUSD=X": "SI=F",
@@ -42,88 +37,28 @@ _YF_INTERVALS = {
Timeframe.M15: "15m", Timeframe.M15: "15m",
Timeframe.M30: "30m", Timeframe.M30: "30m",
Timeframe.H1: "1h", Timeframe.H1: "1h",
Timeframe.H4: "1h", # Resampled in code
Timeframe.D1: "1d", Timeframe.D1: "1d",
Timeframe.W1: "1wk", Timeframe.W1: "1wk",
} }
class DataIntegrityError(Exception): class DataIntegrityError(Exception):
"""Raised when data fails validation checks.""" """Raised when data fails validation checks."""
pass pass
class DataUnavailableError(Exception): class DataUnavailableError(Exception):
"""Raised when live market data cannot be fetched (no key, rate limit, API error).""" """Raised when live market data cannot be fetched (API error)."""
pass pass
# ── Module-level shared rate limiter ───────────────────────────────
# All ForexDataProvider instances share these so concurrent scans
# don't each think they have a full 8-credit budget.
_rate_lock = threading.Lock()
_rate_request_times: deque = deque()
_rate_daily_credits: int = 0
_rate_daily_reset: float = 0.0
class ForexDataProvider: class ForexDataProvider:
""" """
Forex data provider backed by Twelve Data API. Forex data provider backed by yfinance API.
Twelve Data has native 4h candles, eliminating the H1→H4 resampling
hack needed for Yahoo Finance. Free tier: 800 credits/day, 8 req/min.
""" """
TD_BASE_URL = "https://api.twelvedata.com"
TD_INTERVALS = {
Timeframe.M1: "1min",
Timeframe.M5: "5min",
Timeframe.M15: "15min",
Timeframe.M30: "30min",
Timeframe.H1: "1h",
Timeframe.H4: "4h",
Timeframe.D1: "1day",
Timeframe.W1: "1week",
}
# Approximate bars per day for each timeframe (used to size outputsize)
BARS_PER_DAY = {
Timeframe.M1: 1440,
Timeframe.M5: 288,
Timeframe.M15: 96,
Timeframe.M30: 48,
Timeframe.H1: 24,
Timeframe.H4: 6,
Timeframe.D1: 1,
Timeframe.W1: 0.143,
}
# Free plan: 8 credits/minute, 800 credits/day
RATE_LIMIT = 8
RATE_WINDOW = 60.0
DAILY_LIMIT = 800
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
self.config = config self.config = config
self._api_key = (
os.environ.get("TWELVE_DATA_API_KEY")
or config.twelve_data_api_key
)
self._cache: Dict[str, pd.DataFrame] = {} self._cache: Dict[str, pd.DataFrame] = {}
self._cache_timestamps: Dict[str, float] = {} self._cache_timestamps: Dict[str, float] = {}
self._session = requests.Session()
self._session.headers.update({
"User-Agent": "Mozilla/5.0 (compatible; ForexIntel/1.0)",
})
if self._api_key:
self._session.headers["Authorization"] = f"apikey {self._api_key}"
@staticmethod
def _to_td_symbol(symbol: str) -> str:
"""Convert internal symbol (EURUSD=X) to Twelve Data format (EUR/USD)."""
clean = symbol.replace("=X", "")
return f"{clean[:3]}/{clean[3:]}"
def _cache_ttl(self, timeframe: Timeframe) -> int: def _cache_ttl(self, timeframe: Timeframe) -> int:
"""Cache TTL for a given timeframe — falls back to refresh_interval.""" """Cache TTL for a given timeframe — falls back to refresh_interval."""
@@ -157,7 +92,7 @@ class ForexDataProvider:
if cached is not None and not cached.empty: if cached is not None and not cached.empty:
return cached return cached
df = self._fetch_twelve_data(symbol, timeframe, lookback_days) df = self._fetch_yfinance(symbol, timeframe, lookback_days)
df = self._validate_and_clean(df, symbol, timeframe) df = self._validate_and_clean(df, symbol, timeframe)
self._cache[cache_key] = df self._cache[cache_key] = df
@@ -172,11 +107,6 @@ class ForexDataProvider:
) -> None: ) -> None:
""" """
Batch-fetch all symbols for each timeframe in a single API call per TF. Batch-fetch all symbols for each timeframe in a single API call per TF.
Twelve Data accepts comma-separated symbols, so 7 pairs × 3 timeframes
= 3 requests instead of 21 — well within the free-tier 8 req/min limit.
Results are stored in the local cache; subsequent fetch_ohlcv calls
for the same symbol/TF will be served from cache.
""" """
if lookback_days is None: if lookback_days is None:
lookback_days = self.config.data_lookback_days lookback_days = self.config.data_lookback_days
@@ -191,11 +121,10 @@ class ForexDataProvider:
for s in symbols for s in symbols
) )
if cached: if cached:
logger.debug(f"Skipping prefetch for {tf.value} — all {len(symbols)} symbols cached (TTL {ttl}s)") logger.debug(f"Skipping prefetch for {tf.value} — all {len(symbols)} symbols cached")
continue continue
try: try:
self._batch_fetch_timeframe(symbols, tf, lookback_days) self._batch_fetch_timeframe(symbols, tf, lookback_days)
time.sleep(1.0) # brief pause between TF requests
except Exception as e: except Exception as e:
logger.warning(f"Batch prefetch failed for {tf.value}: {e}") logger.warning(f"Batch prefetch failed for {tf.value}: {e}")
@@ -246,7 +175,6 @@ class ForexDataProvider:
"""Return display name and pip value for a pair.""" """Return display name and pip value for a pair."""
display = SYMBOL_DISPLAY.get(symbol, symbol) display = SYMBOL_DISPLAY.get(symbol, symbol)
is_jpy = "JPY" in symbol is_jpy = "JPY" in symbol
# Commodity-specific pip values take priority, then JPY pairs, then standard forex
pip_value = PIP_VALUES.get(symbol, 0.01 if is_jpy else 0.0001) pip_value = PIP_VALUES.get(symbol, 0.01 if is_jpy else 0.0001)
return { return {
"symbol": symbol, "symbol": symbol,
@@ -257,27 +185,32 @@ class ForexDataProvider:
# ── Private Methods ──────────────────────────────────────────── # ── Private Methods ────────────────────────────────────────────
def _fetch_yfinance_fallback( def _fetch_yfinance(self, symbol: str, timeframe: Timeframe, lookback_days: int) -> pd.DataFrame:
self, symbol: str, timeframe: Timeframe
) -> Optional[pd.DataFrame]:
"""
Fetch OHLCV from Yahoo Finance for symbols Twelve Data doesn't cover
on the free tier (XAUUSD, XAGUSD). Returns None if not mapped or fails.
"""
if not _YF_AVAILABLE: if not _YF_AVAILABLE:
return None raise DataUnavailableError("yfinance is not installed.")
yf_symbol = _YF_FALLBACK_MAP.get(symbol)
yf_interval = _YF_INTERVALS.get(timeframe)
if yf_symbol is None or yf_interval is None:
return None
# Yahoo intraday limits: 1m → last 7d, 15m/30m/1h → last 60d yf_symbol = _YF_FALLBACK_MAP.get(symbol, symbol)
if yf_interval in ("1m", "2m"):
period = "7d" # Determine actual interval and resampling needs
elif yf_interval in ("5m", "15m", "30m", "60m", "1h"): actual_timeframe = timeframe
resample_needed = False
if timeframe == Timeframe.H4:
actual_timeframe = Timeframe.H1
resample_needed = True
yf_interval = _YF_INTERVALS.get(actual_timeframe)
if yf_interval is None:
raise DataUnavailableError(f"Unsupported timeframe {timeframe.value} for yfinance")
# Period logic based on Yahoo limitations
if yf_interval in ("1m", "2m", "5m"):
period = "7d" if lookback_days < 7 else "60d"
elif yf_interval in ("15m", "30m", "1h", "60m"):
period = "60d" period = "60d"
else: elif yf_interval in ("1d", "1wk"):
period = "2y" period = "2y"
else:
period = f"{lookback_days}d"
try: try:
df = yf.download( df = yf.download(
@@ -285,272 +218,122 @@ class ForexDataProvider:
progress=False, auto_adjust=False, threads=False, progress=False, auto_adjust=False, threads=False,
) )
if df is None or df.empty: if df is None or df.empty:
return None raise DataUnavailableError(f"No data returned for {symbol}")
# Single-symbol download can return MultiIndex columns in newer yfinance versions
if isinstance(df.columns, pd.MultiIndex): if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0) df.columns = df.columns.get_level_values(0)
# Normalise to UTC tz-aware index
if df.index.tz is None: if df.index.tz is None:
df.index = df.index.tz_localize("UTC") df.index = df.index.tz_localize("UTC")
else: else:
df.index = df.index.tz_convert("UTC") df.index = df.index.tz_convert("UTC")
df.index.name = "Datetime" df.index.name = "Datetime"
df = df[["Open", "High", "Low", "Close", "Volume"]].copy() df = df[["Open", "High", "Low", "Close", "Volume"]].copy()
logger.info(
f"[yf] {yf_symbol} {yf_interval}: {len(df)} bars, " if resample_needed and timeframe == Timeframe.H4:
f"last close={df['Close'].iloc[-1]:.5f}" # Resample 1h data into 4h blocks
) df = df.resample('4h').agg({
'Open': 'first',
'High': 'max',
'Low': 'min',
'Close': 'last',
'Volume': 'sum'
}).dropna()
logger.info(f"[yf live] {yf_symbol} {timeframe.value}: {len(df)} bars, last close={df['Close'].iloc[-1]:.5f}")
return df return df
except Exception as e: except Exception as e:
logger.warning(f"yfinance fallback failed for {symbol} {timeframe.value}: {e}") raise DataUnavailableError(f"yfinance fetch failed for {symbol} {timeframe.value}: {e}")
return None
def _throttle(self, credits: int = 1) -> None: def _batch_fetch_timeframe(self, symbols: List[str], timeframe: Timeframe, lookback_days: int) -> None:
""" if not _YF_AVAILABLE:
Block until `credits` per-minute API credits are available.
Raises DataUnavailableError if the daily limit is exhausted.
Uses module-level state so all ForexDataProvider instances (including
those running in parallel threads) share the same credit accounting.
Twelve Data free tier: 8 credits/minute, 800 credits/day.
"""
global _rate_daily_credits, _rate_daily_reset
with _rate_lock:
now = time.time()
# Reset daily counter after 24 hours
if _rate_daily_reset == 0.0:
_rate_daily_reset = now + 86400
if now >= _rate_daily_reset:
_rate_daily_credits = 0
_rate_daily_reset = now + 86400
# Daily limit check — raise immediately, no fallback
if _rate_daily_credits + credits > self.DAILY_LIMIT:
raise DataUnavailableError(
f"Twelve Data daily API limit reached "
f"({_rate_daily_credits}/{self.DAILY_LIMIT} credits). "
"Resets in 24 hours."
)
if _rate_daily_credits + credits > self.DAILY_LIMIT * 0.8:
logger.warning(
f"Approaching daily API limit: "
f"{_rate_daily_credits}/{self.DAILY_LIMIT} credits used."
)
# Per-minute rate limit — wait inside the lock so other threads
# queue up rather than over-counting available credits.
while True:
now = time.time()
while _rate_request_times and now - _rate_request_times[0] >= self.RATE_WINDOW:
_rate_request_times.popleft()
available = self.RATE_LIMIT - len(_rate_request_times)
if available >= credits:
ts = time.time()
for _ in range(credits):
_rate_request_times.append(ts)
_rate_daily_credits += credits
return
wait = (_rate_request_times[0] + self.RATE_WINDOW) - now + 0.2
logger.info(
f"Rate limit: {len(_rate_request_times)}/{self.RATE_LIMIT} credits used "
f"— waiting {wait:.1f}s for next window…"
)
# Release lock while sleeping so other threads can check state
_rate_lock.release()
time.sleep(wait)
_rate_lock.acquire()
def _batch_fetch_timeframe(
self, symbols: List[str], timeframe: Timeframe, lookback_days: int
) -> None:
"""
Single API call fetching all symbols for one timeframe.
Twelve Data returns a dict keyed by symbol when multiple are requested.
Parsed DataFrames are validated and stored in the cache.
"""
if not self._api_key:
return return
td_symbols = [self._to_td_symbol(s) for s in symbols] actual_timeframe = timeframe
interval = self.TD_INTERVALS[timeframe] resample_needed = False
outputsize = min( if timeframe == Timeframe.H4:
int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20, 5000 actual_timeframe = Timeframe.H1
) resample_needed = True
outputsize = max(outputsize, 300)
# Each symbol in a batch request costs 1 credit — raises DataUnavailableError yf_interval = _YF_INTERVALS.get(actual_timeframe)
# if daily limit reached; per-minute waits are handled inside _throttle. if not yf_interval:
self._throttle(credits=len(symbols)) return
resp = self._session.get( yf_symbols = [_YF_FALLBACK_MAP.get(s, s) for s in symbols]
f"{self.TD_BASE_URL}/time_series",
params={
"symbol": ",".join(td_symbols),
"interval": interval,
"outputsize": outputsize,
"timezone": "UTC",
"format": "json",
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
# If the whole batch request returned a top-level error, raise it if yf_interval in ("1m", "2m", "5m"):
if isinstance(data, dict) and data.get("status") == "error": period = "7d" if lookback_days < 7 else "60d"
raise DataIntegrityError( elif yf_interval in ("15m", "30m", "1h", "60m"):
f"Batch API error ({timeframe.value}): {data.get('message', 'unknown')}" period = "60d"
) elif yf_interval in ("1d", "1wk"):
period = "2y"
# Batch response: {"EUR/USD": {...}, "GBP/USD": {...}, ...} else:
now = time.time() period = f"{lookback_days}d"
for symbol, td_sym in zip(symbols, td_symbols):
try:
sym_data = data.get(td_sym, {})
if sym_data.get("status") == "error":
logger.warning(
f"Batch fetch error for {td_sym}: {sym_data.get('message')}"
)
continue
values = sym_data.get("values")
if not values:
# Try yfinance fallback for unsupported symbols (XAU, XAG)
yf_df = self._fetch_yfinance_fallback(symbol, timeframe)
if yf_df is not None:
yf_df = self._validate_and_clean(yf_df, symbol, timeframe)
cache_key = f"{symbol}_{timeframe.value}"
self._cache[cache_key] = yf_df
self._cache_timestamps[cache_key] = now
continue
if td_sym not in _no_data_warned:
logger.warning(f"No data from Twelve Data for {td_sym} — symbol may require a paid plan.")
_no_data_warned.add(td_sym)
continue
records = [
{
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"),
"Open": float(v["open"]) if v.get("open") is not None else None,
"High": float(v["high"]) if v.get("high") is not None else None,
"Low": float(v["low"]) if v.get("low") is not None else None,
"Close": float(v["close"]) if v.get("close") is not None else None,
"Volume": float(v["volume"]) if v.get("volume") is not None else 0.0,
}
for v in reversed(values)
if v.get("datetime")
]
df = pd.DataFrame(records).set_index("Datetime")
df.index.name = "Datetime"
df = self._validate_and_clean(df, symbol, timeframe)
cache_key = f"{symbol}_{timeframe.value}"
self._cache[cache_key] = df
self._cache_timestamps[cache_key] = now
logger.info(f"[live] {td_sym} {interval}: {len(df)} bars, last close={df['Close'].iloc[-1]:.5f}")
except Exception as e:
logger.warning(f"Failed to parse batch data for {td_sym}: {e}")
def _fetch_twelve_data(
self, symbol: str, timeframe: Timeframe, lookback_days: int
) -> pd.DataFrame:
"""
Fetch OHLCV from Twelve Data REST API.
Raises DataUnavailableError when data cannot be retrieved.
Falls back to yfinance for symbols not on the Twelve Data free tier.
"""
if not self._api_key:
# No Twelve Data key — try yfinance fallback for mapped symbols
yf_df = self._fetch_yfinance_fallback(symbol, timeframe)
if yf_df is not None:
return yf_df
raise DataUnavailableError(
"No Twelve Data API key — set the TWELVE_DATA_API_KEY environment variable."
)
td_symbol = self._to_td_symbol(symbol)
interval = self.TD_INTERVALS[timeframe]
outputsize = min(
int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20,
5000,
)
outputsize = max(outputsize, 300)
# Raises DataUnavailableError on daily limit; blocks on per-minute limit.
self._throttle(credits=1)
try: try:
resp = self._session.get( df_multi = yf.download(
f"{self.TD_BASE_URL}/time_series", " ".join(yf_symbols), interval=yf_interval, period=period,
params={ progress=False, auto_adjust=False, threads=True, group_by="ticker"
"symbol": td_symbol,
"interval": interval,
"outputsize": outputsize,
"timezone": "UTC",
"format": "json",
},
timeout=15,
) )
resp.raise_for_status()
data = resp.json() if df_multi is None or df_multi.empty:
logger.warning(f"Batch fetch empty for {timeframe.value}")
return
if data.get("status") == "error": if df_multi.index.tz is None:
raise DataUnavailableError( df_multi.index = df_multi.index.tz_localize("UTC")
f"Twelve Data API error for {td_symbol}: {data.get('message', 'unknown')}" else:
) df_multi.index = df_multi.index.tz_convert("UTC")
df_multi.index.name = "Datetime"
values = data.get("values") now = time.time()
if not values: for symbol, yf_sym in zip(symbols, yf_symbols):
yf_df = self._fetch_yfinance_fallback(symbol, timeframe) try:
if yf_df is not None: if len(yf_symbols) == 1:
return yf_df df_sym = df_multi.copy()
raise DataUnavailableError( if isinstance(df_sym.columns, pd.MultiIndex):
f"No data returned for {td_symbol} — symbol may require a paid plan." df_sym.columns = df_sym.columns.get_level_values(0)
) else:
if yf_sym in df_multi.columns.get_level_values(0):
df_sym = df_multi[yf_sym].copy()
else:
# Might be single level columns if download only returned 1 valid ticker
if yf_sym == yf_symbols[0] and not isinstance(df_multi.columns, pd.MultiIndex):
df_sym = df_multi.copy()
else:
continue
records = [ # Ignore if column names are not Open/High/Low/Close
{ if 'Close' not in df_sym.columns:
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"), continue
"Open": float(v["open"]) if v.get("open") is not None else None,
"High": float(v["high"]) if v.get("high") is not None else None,
"Low": float(v["low"]) if v.get("low") is not None else None,
"Close": float(v["close"]) if v.get("close") is not None else None,
"Volume": float(v["volume"]) if v.get("volume") is not None else 0.0,
}
for v in reversed(values)
if v.get("datetime")
]
df = pd.DataFrame(records).set_index("Datetime") df_sym = df_sym[["Open", "High", "Low", "Close", "Volume"]].copy()
df.index.name = "Datetime"
logger.info(f"[live] {td_symbol} {interval}: {len(df)} bars, last close={df['Close'].iloc[-1]:.5f}") if resample_needed and timeframe == Timeframe.H4:
return df df_sym = df_sym.resample('4h').agg({
'Open': 'first',
'High': 'max',
'Low': 'min',
'Close': 'last',
'Volume': 'sum'
}).dropna()
df_sym = self._validate_and_clean(df_sym, symbol, timeframe)
cache_key = f"{symbol}_{timeframe.value}"
self._cache[cache_key] = df_sym
self._cache_timestamps[cache_key] = now
logger.info(f"[yf batch] {yf_sym} {timeframe.value}: {len(df_sym)} bars, last close={df_sym['Close'].iloc[-1]:.5f}")
except Exception as e:
logger.warning(f"Failed to process batch data for {symbol}: {e}")
except (DataUnavailableError, DataIntegrityError):
raise
except Exception as e: except Exception as e:
raise DataUnavailableError( logger.warning(f"Batch fetch failed for {timeframe.value}: {e}")
f"Failed to fetch {td_symbol}: {e}"
) from e
def _validate_and_clean( def _validate_and_clean(
self, df: pd.DataFrame, symbol: str, timeframe: Timeframe self, df: pd.DataFrame, symbol: str, timeframe: Timeframe
) -> pd.DataFrame: ) -> pd.DataFrame:
""" """
Validate and clean OHLCV data. Validate and clean OHLCV data.
Steps: drop all-NaN rows → ffill small gaps → remove future timestamps
→ fix OHLC inconsistencies → remove >10-sigma outliers → sort & deduplicate.
""" """
if df is None or df.empty: if df is None or df.empty:
raise DataIntegrityError(f"Empty dataset for {symbol}") raise DataIntegrityError(f"Empty dataset for {symbol}")
@@ -564,7 +347,7 @@ class ForexDataProvider:
now = pd.Timestamp.utcnow() now = pd.Timestamp.utcnow()
future_mask = df.index > now + pd.Timedelta(minutes=5) future_mask = df.index > now + pd.Timedelta(minutes=5)
if future_mask.any(): if future_mask.any():
logger.warning(f"{symbol}: Dropping {future_mask.sum()} future timestamps") logger.debug(f"{symbol}: Dropping {future_mask.sum()} future timestamps")
df = df[~future_mask] df = df[~future_mask]
invalid_ohlc = ( invalid_ohlc = (
@@ -575,7 +358,7 @@ class ForexDataProvider:
(df["Low"] > df["Close"]) (df["Low"] > df["Close"])
) )
if invalid_ohlc.any(): if invalid_ohlc.any():
logger.warning(f"{symbol}: Fixing {invalid_ohlc.sum()} OHLC-inconsistent bars") logger.debug(f"{symbol}: Fixing {invalid_ohlc.sum()} OHLC-inconsistent bars")
for idx in df[invalid_ohlc].index: for idx in df[invalid_ohlc].index:
vals = [df.loc[idx, "Open"], df.loc[idx, "Close"]] vals = [df.loc[idx, "Open"], df.loc[idx, "Close"]]
df.loc[idx, "High"] = max(vals + [df.loc[idx, "High"]]) df.loc[idx, "High"] = max(vals + [df.loc[idx, "High"]])
@@ -596,7 +379,7 @@ class ForexDataProvider:
) + 10 ) + 10
if len(df) < min_required: if len(df) < min_required:
logger.warning( logger.debug(
f"{symbol} {timeframe.value}: Only {len(df)} bars " f"{symbol} {timeframe.value}: Only {len(df)} bars "
f"(need {min_required}). Some indicators will be partial." f"(need {min_required}). Some indicators will be partial."
) )
@@ -606,6 +389,6 @@ class ForexDataProvider:
cleaned = original_len - len(df) cleaned = original_len - len(df)
if cleaned > 0: if cleaned > 0:
logger.info(f"{symbol}: Cleaned {cleaned}/{original_len} bars") logger.debug(f"{symbol}: Cleaned {cleaned}/{original_len} bars")
return df return df
+13 -7
View File
@@ -17,6 +17,7 @@ import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import concurrent.futures
import pandas as pd import pandas as pd
@@ -124,13 +125,18 @@ class SignalAggregator:
) )
self.data_provider.prefetch_batch(self.config.watch_pairs, all_timeframes) self.data_provider.prefetch_batch(self.config.watch_pairs, all_timeframes)
for symbol in self.config.watch_pairs: with concurrent.futures.ThreadPoolExecutor(max_workers=min(32, len(self.config.watch_pairs) or 1)) as executor:
try: future_to_symbol = {
report = self.analyze_pair(symbol, context) executor.submit(self.analyze_pair, symbol, context): symbol
reports.append(report) for symbol in self.config.watch_pairs
except Exception as e: }
logger.error(f"Failed to analyze {symbol}: {e}") for future in concurrent.futures.as_completed(future_to_symbol):
continue symbol = future_to_symbol[future]
try:
report = future.result()
reports.append(report)
except Exception as e:
logger.error(f"Failed to analyze {symbol}: {e}")
# Sort by absolute score (strongest signals first) # Sort by absolute score (strongest signals first)
reports.sort(key=lambda r: abs(r.final_score), reverse=True) reports.sort(key=lambda r: abs(r.final_score), reverse=True)