From f5d6d3506338fa97807f34b7031563a6b780374d Mon Sep 17 00:00:00 2001 From: Harwinder Date: Tue, 5 May 2026 20:12:22 -0400 Subject: [PATCH] XAU/XAG: 3-TF analysis (M15+H1+H4 resampled) via per-symbol override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New _PER_SYMBOL_TFS map in signal_aggregator routes gold/silver to M15+H1+H4 confirmation; forex pairs untouched. - H4 for XAU/XAG is resampled from yfinance H1 bars (yfinance has no native 4h candle). New _resample_h1_to_h4 helper. - Batch prefetch unions per-symbol TFs so future per-pair overrides work without extra plumbing. XAU/XAG still excluded from Twelve Data batch — H4 resample triggers a single yfinance H1 fetch. - Telegram SCAN_TIMEFRAMES restored to [M15, H1] so /scan emits both M15-primary and H1-primary cards. Shared cache means no extra credits. Co-Authored-By: Claude Opus 4.7 --- data_provider.py | 28 +++++++++++++++++++++++++--- signal_aggregator.py | 31 +++++++++++++++++++++++++------ telegram_bot.py | 2 +- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/data_provider.py b/data_provider.py index 77460d7..325d9b0 100644 --- a/data_provider.py +++ b/data_provider.py @@ -169,13 +169,24 @@ class ForexDataProvider: return cached # Symbols not on Twelve Data (gold, silver) always use yfinance. - # D1 is intentionally skipped for these — yfinance daily for futures - # is unreliable and the engine drops missing confirmation TFs cleanly. + # D1 is skipped (yfinance daily for futures is unreliable). + # H4 is resampled from H1 since yfinance has no native 4h candle. if symbol in _YF_FALLBACK_MAP: if timeframe == Timeframe.D1: raise DataUnavailableError( - f"Daily chart skipped for {symbol} — gold/silver use M15/H1 only." + f"Daily chart skipped for {symbol} — gold/silver use M15/H1/H4 only." ) + if timeframe == Timeframe.H4: + h1_df = self._fetch_yfinance_fallback(symbol, Timeframe.H1) + if h1_df is None: + raise DataUnavailableError( + f"Yahoo Finance unavailable for {symbol} (H4 resample source H1)." + ) + df = self._resample_h1_to_h4(h1_df) + df = self._validate_and_clean(df, symbol, timeframe) + self._cache[cache_key] = df + self._cache_timestamps[cache_key] = now + return df yf_df = self._fetch_yfinance_fallback(symbol, timeframe) if yf_df is None: raise DataUnavailableError( @@ -277,6 +288,17 @@ class ForexDataProvider: } return fallback_spreads.get(symbol, 0.00020) + @staticmethod + def _resample_h1_to_h4(h1_df: pd.DataFrame) -> pd.DataFrame: + """Resample 1-hour OHLCV to 4-hour bars (yfinance has no native 4h).""" + return h1_df.resample("4h", label="right", closed="right").agg({ + "Open": "first", + "High": "max", + "Low": "min", + "Close": "last", + "Volume": "sum", + }).dropna() + def get_pair_info(self, symbol: str) -> Dict: """Return display name and pip value for a pair.""" display = SYMBOL_DISPLAY.get(symbol, symbol) diff --git a/signal_aggregator.py b/signal_aggregator.py index 7b07e3c..7cec105 100644 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -31,6 +31,16 @@ from news_engine import NewsEngine, FundamentalContext logger = logging.getLogger(__name__) +# Per-symbol full TF list (primary + confirmations). Overrides the global +# config.confirmation_timeframes for these symbols only — forex pairs are +# unaffected. XAU/XAG use M15+H1+H4 (no D1 since yfinance daily for futures +# is unreliable; H4 is resampled from H1 in data_provider). +_PER_SYMBOL_TFS: Dict[str, List[Timeframe]] = { + "XAUUSD=X": [Timeframe.M15, Timeframe.H1, Timeframe.H4], + "XAGUSD=X": [Timeframe.M15, Timeframe.H1, Timeframe.H4], +} + + @dataclass class TradeSetup: """A complete trade setup recommendation.""" @@ -118,11 +128,12 @@ class SignalAggregator: reports = [] context = self.news_engine.get_fundamental_context() - # Batch-prefetch all pairs × all needed timeframes (3 API calls instead of 21) - all_timeframes = ( - [self.config.primary_timeframe] + self.config.confirmation_timeframes - ) - self.data_provider.prefetch_batch(self.config.watch_pairs, all_timeframes) + # Batch-prefetch all pairs × all needed timeframes (3 API calls instead of 21). + # Union of primary + per-symbol confirmation TFs across the watchlist. + tf_set = {self.config.primary_timeframe, *self.config.confirmation_timeframes} + for sym in self.config.watch_pairs: + tf_set.update(self._confirmation_tfs(sym)) + self.data_provider.prefetch_batch(self.config.watch_pairs, list(tf_set)) for symbol in self.config.watch_pairs: try: @@ -179,7 +190,7 @@ class SignalAggregator: return report # ── 2. Confirmation Timeframe Analyses ───────────────────── - for tf in self.config.confirmation_timeframes: + for tf in self._confirmation_tfs(symbol): try: cf_df = self.data_provider.fetch_ohlcv(symbol, tf) cf_analysis = self.tech_engine.analyze(cf_df, symbol, tf.value) @@ -220,6 +231,14 @@ class SignalAggregator: return report + def _confirmation_tfs(self, symbol: str) -> List[Timeframe]: + """Return confirmation TFs for a symbol, honouring per-symbol overrides.""" + override = _PER_SYMBOL_TFS.get(symbol) + if override is None: + return self.config.confirmation_timeframes + primary = self.config.primary_timeframe + return [tf for tf in override if tf != primary] + def _check_mtf_agreement(self, report: SignalReport) -> bool: """Check if multiple timeframes agree on direction.""" signals = list(report.mtf_signals.values()) diff --git a/telegram_bot.py b/telegram_bot.py index 6bf5d70..2de480f 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -40,7 +40,7 @@ logger = logging.getLogger(__name__) # ── Config ────────────────────────────────────────────────────────── -SCAN_TIMEFRAMES = ["H1"] # engine pulls M15 + H1 + D1 via confirmations +SCAN_TIMEFRAMES = ["M15", "H1"] # two cards (M15 and H1) per /scan; cache shared # Pairs analyzed by the /scan command. Limited to the most liquid majors # plus gold to keep credits low and signal quality high.