From 3737cf4e71704a32ae54ddbac11a38562388f978 Mon Sep 17 00:00:00 2001 From: Harwinder Date: Tue, 5 May 2026 16:37:11 -0400 Subject: [PATCH] Migrate to yfinance and parallelize analysis --- .claude/settings.json | 43 ++ CLAUDE.md | 61 +-- __pycache__/config.cpython-314.pyc | Bin 8349 -> 8874 bytes __pycache__/data_provider.cpython-314.pyc | Bin 32766 -> 21764 bytes __pycache__/signal_aggregator.cpython-314.pyc | Bin 22350 -> 23226 bytes config.py | 3 +- data_provider.py | 451 +++++------------- signal_aggregator.py | 20 +- 8 files changed, 183 insertions(+), 395 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1b02605 --- /dev/null +++ b/.claude/settings.json @@ -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'\\)\")" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 432f714..e9161a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,64 +1,21 @@ # 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:** -``` -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). +**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. --- -## 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:** -``` -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. +**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. --- ## Notes -- Free tier: 8 credits/min, 800 credits/day. Each full scan = ~21 credits. -- Supported pairs (live data on free tier): EURUSD, GBPUSD, USDJPY, USDCHF, - AUDUSD, USDCAD, NZDUSD, EURGBP, EURJPY, GBPJPY, AUDJPY, EURCHF, CADJPY. -- All other symbols (XAU, XAG, commodities, indices) fall back to synthetic GBM data. +- **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. +- **Parallelization**: Pair analysis is now multithreaded using `ThreadPoolExecutor` in `signal_aggregator.py`, maximizing the speed of data fetching and processing. +- **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`). diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc index 886d755739f9f832be56d600524ba805ecff663f..5809deb661ba24ad0120f351d5bb422387511645 100644 GIT binary patch delta 863 zcmZXR%WD%+6voe;OeT|>#8lf%GI`nrX=+Ho3Tjh|0fh>pbz*(S#t>5yC~cUeZWEzh z)Qy1)VnM|$e9(#EZVif%kNO@>r2+~0iXeBb4snR%R8Ib%Cw zv-SWkd-8YTy8OnrVAwxCl1dbdrBuFrdoEi_HR^^(ePoS}IhPOIG#&y)kU-&%6Lt!q z@yU6YcsCT!#f3Phh==j2B)MV2ZNx1Ma}hRMdtt)d zPVlIE+FGPp_hQhl=BCwLDW5LPmUHTz%*TcWX()%1Z=f zUy~1MldbYH0f(!}4{l!}bkch)^bAK<=NO(dEHb=cc*)TE6Ec149S~lz`ZcXfLHf&U zBloDucP(ZCZ(yD8(<~4j#t}{v;Rl$bCTwjv!QrW%-3Wx%d1=EvSiSHQZ(Q*GA@3Sh Xzd)oR65ayDjsduR+Z-zV)Bt}0$at&z delta 425 zcmZ4GI@gg;n~#@^0SJUyer7He-^ka(%%5Qu<5*Bote;VmlAm0xzj-0^91+HSlkFsC zG6k`1eksw&sCr8w&D6{|$;c=zEhR0*#L~#b($q91H90BOFwr8}!rZ_xQ-^VKgp3a3 zsmb?507bcW;Q#;t diff --git a/__pycache__/data_provider.cpython-314.pyc b/__pycache__/data_provider.cpython-314.pyc index 61a6bc31bfaf5633d80d37bd8a8a8b1e34aeeed9..930dda6c547f893a03690472e43090422692780d 100644 GIT binary patch literal 21764 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 literal 32766 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 delta 1991 zcmZXUeN0nV6u|Fo`(6vQrG8@tmsjjFxxY|ww$R{xzxPcmCPr*G`z;8<+T~83Ax$I z6jv&rD$4RoolDQ_T?XDDt`(I=mx(vI%)B`Rd2p>0w02#i$Ku5F0EGB-k5rGQAa{Zh zxf7j|ajkQFLcDdtnnbM1XRJ1Hr;6CrlQuph?!RC44F7pp9B|zK{HzX7CGIAx{wF)*o}Belb@FwP&;yC zURH;Suv=n8VTD^tsNoq=BNH_YRB78V3umV9sesHyg`I6v_=!5S~_4~>YK@e zD&XUN-YD1J5efu3$k)x*N(5Of7;fPjiBpWmg6+ColX&i2vg$%ZhV)mHZd>z?bzHa3 zhEH{;Nzj!-&^vWVSXlUh*-JLIsz+yOtEo3R(F)4X^OypxuT)gYwR;%3OPOi?L)5ns4-YtS3@ zg*bTI;=pnkv`jZ}FOUQTmJ3GPIbOiQn4GM$U~c*nxmfsb5$sIgFz2ozt+%zO^_F4g zpkd~oieU+(G7p)px6Iks%-P3gUpLP?zz%7Qz3LwI!H%1ntPwdf<_)L7DXXD+?4b(j ztPhbyWxlO3^eTIlS2GGuW}P(kr}UTf7hSb38q_TQMq~POm?18YVkVNoMRR7UUUEsF zRK_xw?3OajT*eHMYhbm_k9WWs+kA#Tj_&v)+kph!3f(iJY>X(lX26b8%tN`OD`P8F zY@^stv4WzLqKaZOeDAndu!D#~0=D}Tnzl#d+VR+t|jqkuZ=S6a= zZPpC&)cf(OS;v!7o;?ZJl7*Oo@{(}Q=OjqYPrpBY{Frmlc+Wah?G*wRZkJf_7xA%@ z_1N$l)$XI%Paxn@0he)mAzFI6+P|i*)|yqS~OlEK?t+=A{(xQG7@-02j;hRN@^mF@HN3qr_DWt5pe*nVO0gWh%ksE2e3vjnOH$|+qova0h zT)aQX{Rp<2E%+!2YJ?LtI=nJ|sb&*qKc-tgAT!bn4JI} zb#~kby>(f5SNwck57snOV}N2kg%`Z_&!xRg#Vr(vDUMJarRalSlWj0ipWyJ5Wo#Y6 z*lyB(M~8~oOgo2bAD bkspe^0y%4KuzamYyH$p3@B<`HVsHKjv~Aw! diff --git a/config.py b/config.py index 955ad2e..0a934e1 100644 --- a/config.py +++ b/config.py @@ -193,8 +193,7 @@ class PlatformConfig: # Data source 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 # timeframe isn't covered by cache_ttl_per_tf below. diff --git a/data_provider.py b/data_provider.py index aa24073..86c2a57 100644 --- a/data_provider.py +++ b/data_provider.py @@ -1,21 +1,18 @@ """ Forex Signal Intelligence Platform - Data Provider ==================================================== -Retrieves and validates market data from Twelve Data API. -Raises DataUnavailableError when the API is unreachable or credits are exhausted. -No synthetic/simulated data fallback. +Retrieves and validates market data from Yahoo Finance API. +Raises DataUnavailableError when the API is unreachable. """ import logging import os -import threading import time from collections import deque from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional import pandas as pd -import requests try: import yfinance as yf @@ -27,10 +24,8 @@ except ImportError: from config import PlatformConfig, Timeframe, SYMBOL_DISPLAY, PIP_VALUES 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. -# Gold/silver futures are the most reliable Yahoo symbols (full OHLCV history). +# Fallbacks for specific symbols to get accurate Forex/Commodity data from yfinance. _YF_FALLBACK_MAP = { "XAUUSD=X": "GC=F", "XAGUSD=X": "SI=F", @@ -42,88 +37,28 @@ _YF_INTERVALS = { Timeframe.M15: "15m", Timeframe.M30: "30m", Timeframe.H1: "1h", + Timeframe.H4: "1h", # Resampled in code Timeframe.D1: "1d", Timeframe.W1: "1wk", } - class DataIntegrityError(Exception): """Raised when data fails validation checks.""" pass - 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 - -# ── 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: """ - Forex data provider backed by Twelve Data 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. + Forex data provider backed by yfinance API. """ - 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): 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_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: """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: 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) self._cache[cache_key] = df @@ -172,11 +107,6 @@ class ForexDataProvider: ) -> None: """ 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: lookback_days = self.config.data_lookback_days @@ -191,11 +121,10 @@ class ForexDataProvider: for s in symbols ) 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 try: self._batch_fetch_timeframe(symbols, tf, lookback_days) - time.sleep(1.0) # brief pause between TF requests except Exception as 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.""" display = SYMBOL_DISPLAY.get(symbol, 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) return { "symbol": symbol, @@ -257,27 +185,32 @@ class ForexDataProvider: # ── Private Methods ──────────────────────────────────────────── - def _fetch_yfinance_fallback( - 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. - """ + def _fetch_yfinance(self, symbol: str, timeframe: Timeframe, lookback_days: int) -> pd.DataFrame: if not _YF_AVAILABLE: - return None - 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 + raise DataUnavailableError("yfinance is not installed.") - # Yahoo intraday limits: 1m → last 7d, 15m/30m/1h → last 60d - if yf_interval in ("1m", "2m"): - period = "7d" - elif yf_interval in ("5m", "15m", "30m", "60m", "1h"): + yf_symbol = _YF_FALLBACK_MAP.get(symbol, symbol) + + # Determine actual interval and resampling needs + 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" - else: + elif yf_interval in ("1d", "1wk"): period = "2y" + else: + period = f"{lookback_days}d" try: df = yf.download( @@ -285,272 +218,122 @@ class ForexDataProvider: progress=False, auto_adjust=False, threads=False, ) 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): df.columns = df.columns.get_level_values(0) - # Normalise to UTC tz-aware index if df.index.tz is None: df.index = df.index.tz_localize("UTC") else: df.index = df.index.tz_convert("UTC") df.index.name = "Datetime" - df = df[["Open", "High", "Low", "Close", "Volume"]].copy() - logger.info( - f"[yf] {yf_symbol} {yf_interval}: {len(df)} bars, " - f"last close={df['Close'].iloc[-1]:.5f}" - ) + + if resample_needed and timeframe == Timeframe.H4: + # 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 except Exception as e: - logger.warning(f"yfinance fallback failed for {symbol} {timeframe.value}: {e}") - return None + raise DataUnavailableError(f"yfinance fetch failed for {symbol} {timeframe.value}: {e}") - def _throttle(self, credits: int = 1) -> None: - """ - 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: + def _batch_fetch_timeframe(self, symbols: List[str], timeframe: Timeframe, lookback_days: int) -> None: + if not _YF_AVAILABLE: return - td_symbols = [self._to_td_symbol(s) for s in symbols] - interval = self.TD_INTERVALS[timeframe] - outputsize = min( - int(lookback_days * self.BARS_PER_DAY[timeframe]) + 20, 5000 - ) - outputsize = max(outputsize, 300) + actual_timeframe = timeframe + resample_needed = False + if timeframe == Timeframe.H4: + actual_timeframe = Timeframe.H1 + resample_needed = True - # Each symbol in a batch request costs 1 credit — raises DataUnavailableError - # if daily limit reached; per-minute waits are handled inside _throttle. - self._throttle(credits=len(symbols)) + yf_interval = _YF_INTERVALS.get(actual_timeframe) + if not yf_interval: + return - resp = self._session.get( - 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() + yf_symbols = [_YF_FALLBACK_MAP.get(s, s) for s in symbols] - # If the whole batch request returned a top-level error, raise it - if isinstance(data, dict) and data.get("status") == "error": - raise DataIntegrityError( - f"Batch API error ({timeframe.value}): {data.get('message', 'unknown')}" - ) - - # Batch response: {"EUR/USD": {...}, "GBP/USD": {...}, ...} - now = time.time() - 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) + 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" + elif yf_interval in ("1d", "1wk"): + period = "2y" + else: + period = f"{lookback_days}d" try: - resp = self._session.get( - f"{self.TD_BASE_URL}/time_series", - params={ - "symbol": td_symbol, - "interval": interval, - "outputsize": outputsize, - "timezone": "UTC", - "format": "json", - }, - timeout=15, + df_multi = yf.download( + " ".join(yf_symbols), interval=yf_interval, period=period, + progress=False, auto_adjust=False, threads=True, group_by="ticker" ) - 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": - raise DataUnavailableError( - f"Twelve Data API error for {td_symbol}: {data.get('message', 'unknown')}" - ) + if df_multi.index.tz is None: + df_multi.index = df_multi.index.tz_localize("UTC") + else: + df_multi.index = df_multi.index.tz_convert("UTC") + df_multi.index.name = "Datetime" - values = data.get("values") - if not values: - yf_df = self._fetch_yfinance_fallback(symbol, timeframe) - if yf_df is not None: - return yf_df - raise DataUnavailableError( - f"No data returned for {td_symbol} — symbol may require a paid plan." - ) + now = time.time() + for symbol, yf_sym in zip(symbols, yf_symbols): + try: + if len(yf_symbols) == 1: + df_sym = df_multi.copy() + if isinstance(df_sym.columns, pd.MultiIndex): + 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 = [ - { - "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") - ] + # Ignore if column names are not Open/High/Low/Close + if 'Close' not in df_sym.columns: + continue - df = pd.DataFrame(records).set_index("Datetime") - df.index.name = "Datetime" - logger.info(f"[live] {td_symbol} {interval}: {len(df)} bars, last close={df['Close'].iloc[-1]:.5f}") - return df + df_sym = df_sym[["Open", "High", "Low", "Close", "Volume"]].copy() + + if resample_needed and timeframe == Timeframe.H4: + 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: - raise DataUnavailableError( - f"Failed to fetch {td_symbol}: {e}" - ) from e + logger.warning(f"Batch fetch failed for {timeframe.value}: {e}") def _validate_and_clean( self, df: pd.DataFrame, symbol: str, timeframe: Timeframe ) -> pd.DataFrame: """ 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: raise DataIntegrityError(f"Empty dataset for {symbol}") @@ -564,7 +347,7 @@ class ForexDataProvider: now = pd.Timestamp.utcnow() future_mask = df.index > now + pd.Timedelta(minutes=5) 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] invalid_ohlc = ( @@ -575,7 +358,7 @@ class ForexDataProvider: (df["Low"] > df["Close"]) ) 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: vals = [df.loc[idx, "Open"], df.loc[idx, "Close"]] df.loc[idx, "High"] = max(vals + [df.loc[idx, "High"]]) @@ -596,7 +379,7 @@ class ForexDataProvider: ) + 10 if len(df) < min_required: - logger.warning( + logger.debug( f"{symbol} {timeframe.value}: Only {len(df)} bars " f"(need {min_required}). Some indicators will be partial." ) @@ -606,6 +389,6 @@ class ForexDataProvider: cleaned = original_len - len(df) if cleaned > 0: - logger.info(f"{symbol}: Cleaned {cleaned}/{original_len} bars") + logger.debug(f"{symbol}: Cleaned {cleaned}/{original_len} bars") return df diff --git a/signal_aggregator.py b/signal_aggregator.py index 7b07e3c..fff4fd5 100644 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -17,6 +17,7 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Dict, List, Optional +import concurrent.futures import pandas as pd @@ -124,13 +125,18 @@ class SignalAggregator: ) self.data_provider.prefetch_batch(self.config.watch_pairs, all_timeframes) - for symbol in self.config.watch_pairs: - try: - report = self.analyze_pair(symbol, context) - reports.append(report) - except Exception as e: - logger.error(f"Failed to analyze {symbol}: {e}") - continue + with concurrent.futures.ThreadPoolExecutor(max_workers=min(32, len(self.config.watch_pairs) or 1)) as executor: + future_to_symbol = { + executor.submit(self.analyze_pair, symbol, context): symbol + for symbol in self.config.watch_pairs + } + for future in concurrent.futures.as_completed(future_to_symbol): + 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) reports.sort(key=lambda r: abs(r.final_score), reverse=True)