From 8ed6454ea56cd88492f7e9a99f93b33270b4ea97 Mon Sep 17 00:00:00 2001 From: harwinder Date: Fri, 15 May 2026 10:51:51 -0400 Subject: [PATCH] Refocus on XAU/USD: add momentum indicators, tune TP, exhaustion block Strip EUR/USD, GBP/USD, USD/JPY pairs and strategies from entire codebase. Add 3 new scored indicators (ROC, Keltner/TTM Squeeze, ADX slope) and enable MACD-as-pillar + divergence detection for early trend entry. Introduce "transitioning" regime (ADX < 25, slope > 3.0) to catch trend births before ADX confirms. Gold-specific composite weights per regime. Tune TP multipliers: quality-tiered scaling (A+/A get 1.5-2.0x ATR via XAU ATR method, B/C use swing-level targeting). Backtest: +24R total, 65% WR, PF 1.12 over 365 days. Add momentum-confirmed exhaustion block: hard-reject trades when 2+ static exhaustion signals fire AND ROC/MACD confirm fading momentum. Prevents late trend entries without killing strong trend trades. Telegram: simplify buttons to timeframe-only labels, add "Scan All TFs" multi-timeframe summary, add /scan command, fix BB/KC level breach keys, fix price formatting (.2f for gold), fix deprecated asyncio call, fix "1 pairs" grammar and 60s/30s dashboard refresh mismatch. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 55 ++-- DOCUMENTATION.md | 165 ++++++---- config.py | 121 ++----- data_provider.py | 14 +- engine.py | 220 ++++++++++++- main.py | 12 +- news_engine.py | 5 +- requirements.txt | 3 - signal_aggregator.py | 730 ++++++++++-------------------------------- signal_web.py | 303 ------------------ telegram_bot.py | 215 +++++++------ test_all.py | 459 +++++++++++--------------- web/app.py | 121 ------- web/static/index.html | 482 ---------------------------- ws_twelvedata.py | 55 ---- 15 files changed, 852 insertions(+), 2108 deletions(-) delete mode 100644 signal_web.py delete mode 100644 web/app.py delete mode 100644 web/static/index.html delete mode 100755 ws_twelvedata.py diff --git a/CLAUDE.md b/CLAUDE.md index 33c563c..e109cb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # ForexBot2 -Automated forex signal intelligence platform. Analyzes 4 currency pairs (EUR/USD, GBP/USD, USD/JPY, XAU/USD) across multiple timeframes using technical indicators, news sentiment, and economic calendar data. Delivers signals via Telegram with full trade setups (entry, SL, TP). +XAU/USD signal intelligence platform. Analyzes gold across multiple timeframes using 9 technical indicators with regime-adaptive weights, news sentiment, and economic calendar data. Delivers signals via Telegram with full trade setups (entry, SL, TP). ## Architecture @@ -9,7 +9,7 @@ Twelve Data REST API + WebSocket | data_provider.py (fetch, cache, validate OHLCV; rate-limited 55 calls/min) | - engine.py (6 indicators per timeframe → composite score) + engine.py (9 indicators per timeframe → composite score) | signal_aggregator.py (MTF agreement + news + final signal + trade setup) | @@ -26,31 +26,36 @@ telegram_bot.py main.py | File | Purpose | |------|---------| -| `config.py` | All configuration dataclasses, pair definitions, strategy configs | +| `config.py` | All configuration dataclasses, XAU/USD pair definition, strategy config | | `data_provider.py` | Twelve Data SDK wrapper with in-memory cache and rate limiting | -| `engine.py` | `TechnicalEngine` — computes EMA, RSI, BB, ADX, ATR, MACD, divergences, candlestick patterns, swing S/R | -| `signal_aggregator.py` | `SignalAggregator` — MTF analysis, news integration, final signal scoring, trade setup generation | +| `engine.py` | `TechnicalEngine` — computes EMA, RSI, BB, ADX, ATR, MACD, ROC, Keltner, divergences, candlestick patterns, swing S/R | +| `signal_aggregator.py` | `SignalAggregator` — MTF analysis, news integration, diminishing penalty cap, trade setup generation | | `news_engine.py` | Economic calendar (Forex Factory JSON) + RSS sentiment (keyword-based) | | `telegram_bot.py` | Async Telegram bot with auto-scanner (M5/M15/H1/H4), WebSocket price monitor, live dashboard | -| `main.py` | CLI entry point for single USD/JPY analysis | -| `test_all.py` | Comprehensive QA suite (~90 tests, no live API needed) | +| `main.py` | CLI entry point for XAU/USD analysis | +| `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) | ## Key Concepts -**Signal Pipeline:** OHLCV → indicators → regime classification → regime-weighted composite score → MTF agreement check → news/event adjustment → confidence penalties → final signal (STRONG_BUY/BUY/NEUTRAL/SELL/STRONG_SELL) → quality grade (A+/A/B/C/D/F) → trade setup if grade C+. +**Signal Pipeline:** OHLCV → indicators → regime classification → regime-weighted composite score → MTF agreement check → news/event adjustment → diminishing penalty cap → final signal (STRONG_BUY/BUY/NEUTRAL/SELL/STRONG_SELL) → quality grade (A+/A/B/C/D/F) → trade setup if grade C+. -**Regime-Adaptive Weights:** Market classified as `trending_up`, `trending_down`, or `ranging` via ADX + EMA. Trending boosts EMA weight (2.0x), ranging boosts RSI/BB (2.0x). This is the core edge. +**Regime-Adaptive Weights:** Market classified as `trending_up`, `trending_down`, `ranging`, or `transitioning` via ADX + EMA + ADX slope. Gold-specific weights per regime: +- **Trending:** MACD (2.0), EMA (2.0), Divergence (2.0), Keltner (1.5), ROC (1.5) +- **Ranging:** Divergence (2.5), Keltner (2.0), RSI (1.5), BB (1.5), MACD (1.5) +- **Transitioning:** Keltner (2.5), MACD (2.5), ROC (2.0), Divergence (2.0), EMA (1.5) -**Pair-Specific Strategies:** XAU/USD (oversold-bounce: RSI<38 + triple-TF EMA200 confirmation), EUR/USD and GBP/USD (pullback-buy: RSI 30-55 + BB<0.75 + dual-TF EMA200). Each has hour-blocked windows from backtest optimization. +**Transitioning Regime:** ADX < 25 but ADX slope > 3.0 = trend emerging. Catches trend birth 5-10 bars before ADX crosses 25. Boosts quality grade for early entries. -**SELL Suppression:** Forex SELL signals are suppressed for all pairs except XAU/USD. Backtesting showed 0% win rate for forex shorts. +**Diminishing Penalty Cap:** Penalties sorted by magnitude, applied with exponential decay (1st at 100%, 2nd at 50%, 3rd at 25%). Hard cap at 30% total. Replaces old additive stacking that could gut 30-50% confidence. -**Trade Setup:** SL = nearest swing low/high ± 1×ATR buffer (min 1.5×ATR). TP = nearest swing level if RR≥1.2, else calculated from ATR. Minimum RR 1.2:1. +**Indicators (9 scored):** EMA Trend, RSI, Bollinger Bands, MACD, ADX, Keltner Channels (TTM Squeeze), ROC (Rate of Change), Divergence, Candlestick Patterns. + +**Trade Setup:** SL = nearest swing low/high ± 1×ATR buffer (min 1.5×ATR). TP = nearest swing level if RR≥1.2, else calculated from ATR. Minimum RR 0.5:1. ## How to Run ```bash -# CLI (single USD/JPY analysis) +# CLI (XAU/USD analysis) python main.py # H1 default python main.py --timeframe M15 --detail --json @@ -59,6 +64,9 @@ TELEGRAM_TOKEN= TELEGRAM_CHAT_ID= python telegram_bot.py # Tests python test_all.py + +# Backtest +python backtest/gpu_backtest.py --pair XAUUSD --days 365 ``` ## Environment Variables @@ -68,7 +76,6 @@ python test_all.py | `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config | | `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback | | `TELEGRAM_CHAT_ID` | Auto-scan chat ID (enables boot auto-scan) | Hardcoded fallback | -| `FB2_EMA_LO` / `FB2_EMA_HI` | EMA200 distance thresholds (%) | 0.5 / 1.5 | ## Dependencies @@ -80,23 +87,19 @@ python test_all.py - **Rate limit:** 55 credits/min (Grow plan), enforced by sliding-window throttle - **Cache:** In-memory dict, TTL = candle duration of the timeframe - **Validation:** Drops NaN rows, forward-fills gaps (limit 3), fixes OHLC inconsistencies, removes outliers (>10σ), strips future timestamps, deduplicates -- **WebSocket:** EUR/USD and XAU/USD only (Grow plan WS limit); GBP/USD and USD/JPY via REST-only scans +- **WebSocket:** XAU/USD only ## Telegram Bot - **Auto-scan:** M5 (data-only, no alerts), M15, H1, H4 at candle boundaries + 10s buffer -- **WebSocket:** Streams live ticks for EUR/USD + XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, EMA200) with 5-min cooldown -- **Live dashboard:** Edit-in-place message, 30s refresh, shows all pairs/timeframes/prices/setups -- **Commands:** `/start`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help` -- **Correlation warning:** Flags same-direction signals on EUR/USD + GBP/USD (USD-weakness group) +- **Manual scan:** Buttons for M15/H1/H4 individual scans + "Scan All TFs" multi-timeframe summary +- **WebSocket:** Streams live ticks for XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, KC, EMA200) with 5-min cooldown +- **Live dashboard:** Edit-in-place message, 30s refresh, shows XAU/USD across timeframes +- **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help` ## Backtesting -The `backtest/` directory (gitignored) contains pair-specific backtesting scripts, GPU-accelerated optimizers, and historical parquet data. Strategies were optimized on 1-year M15 data. Results drove the current strategy configs in `config.py`. - -## Planned: MT5 Auto-Execution - -Documented in `mt5.md`. REST bridge architecture: Linux bot → HTTP → Windows FastAPI server wrapping MetaTrader5 COM. Not yet implemented. Key: `MT5Config` dataclass, idempotent orders, position-size from bridge-side tick values. +The `backtest/` directory contains `gpu_backtest.py` — a GPU-accelerated backtester (CuPy with NumPy fallback) that replays historical OHLCV through the real SignalAggregator. Pre-computes all indicator series including ROC, Keltner, ADX slope for 100x+ speedup. Uses parquet caching for data persistence. ## Code Conventions @@ -104,7 +107,7 @@ Documented in `mt5.md`. REST bridge architecture: Linux bot → HTTP → Windows - Dataclasses for all config and output structures - `SignalStrength` enum (-2 to +2) is the universal signal currency - Indicators stored as `Dict[str, IndicatorSignal]` in `TechnicalAnalysis` -- Key levels stored as `Dict[str, float]` — prefixed `swing_high_`, `swing_low_`, `ema_`, `bb_`, `atr` -- Private indicator data attached as `result._rsi_series`, `result._macd_histogram` (underscore prefix, skipped by composite scorer) +- Key levels stored as `Dict[str, float]` — prefixed `swing_high_`, `swing_low_`, `ema_`, `bb_`, `kc_`, `atr` +- Private indicator data attached as `result._rsi_series`, `result._macd_histogram`, `result._adx_slope`, `result._kc_squeeze` (underscore prefix, skipped by composite scorer) - Blocking signal computation runs in `ThreadPoolExecutor(max_workers=4)` to avoid blocking the async Telegram event loop - Shared `ForexDataProvider` instance across all scans for cache reuse diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 274f617..5a4645d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -6,17 +6,14 @@ ## What Is ForexBot2? -ForexBot2 is an automated forex analysis bot. It watches 4 currency pairs around the clock, analyzes them using 6 different technical indicators, checks the news, and sends you alerts on Telegram when it finds a good trading opportunity — complete with entry price, stop loss, and take profit levels. +ForexBot2 is an automated XAU/USD (gold) signal intelligence platform. It analyzes gold across multiple timeframes using 9 technical indicators with regime-adaptive weights, checks the news, and sends you alerts on Telegram when it finds a good trading opportunity — complete with entry price, stop loss, and take profit levels. Think of it as a tireless analyst who never sleeps, never gets emotional, and checks the charts every few minutes across multiple timeframes. -## What Pairs Does It Watch? +## What Does It Watch? | Pair | What It Is | |------|-----------| -| EUR/USD | Euro vs US Dollar — the world's most traded pair | -| GBP/USD | British Pound vs US Dollar | -| USD/JPY | US Dollar vs Japanese Yen | | XAU/USD | Gold vs US Dollar | ## What Timeframes Does It Scan? @@ -36,7 +33,7 @@ The M5 scan runs silently in the background — it gathers data to keep the bot' The bot uses a layered decision process. It is not based on a single indicator — it combines multiple tools, each looking at the market from a different angle. -### The 6 Active Indicators +### The 9 Active Indicators #### 1. EMA Trend (Exponential Moving Averages) @@ -141,22 +138,56 @@ The *direction* comes from two companion lines: +DI (bullish pressure) and -DI ( --- +#### 7. MACD (Moving Average Convergence Divergence) + +**What it is:** MACD tracks the difference between a fast (12-period) and slow (26-period) EMA, with a signal line (9-period EMA of the MACD). The histogram shows the difference between MACD and signal. + +**How the bot uses it:** Histogram crossing above zero = bullish momentum building. Crossing below zero = bearish. Histogram acceleration (getting larger) confirms trend strength. MACD is gold's best leading indicator — it catches momentum shifts before price breaks key levels. + +**Weight:** 2.0 in trending markets, 2.5 in transitioning markets, 1.5 in ranging markets. + +--- + +#### 8. ROC (Rate of Change) + +**What it is:** ROC measures the percentage change in price over the last N bars (default: 10). It quantifies momentum acceleration. + +**How to read it:** +- ROC > 0 and rising = bullish momentum accelerating +- ROC crossing zero = directional shift +- |ROC| > 2% = strong momentum (possible exhaustion) + +**What the bot does with it:** ROC catches the speed of trend moves. Combined with Keltner squeeze release, it identifies breakout moments. Zero-line crossovers provide high-confidence directional signals. + +**Weight:** 1.5 in trending, 2.0 in transitioning, 0.5 in ranging. + +--- + +#### 9. Keltner Channels (TTM Squeeze) + +**What it is:** Keltner Channels are an EMA(20) midline with bands at ±1.5×ATR(10). When Bollinger Bands contract inside Keltner Channels, that is a "TTM Squeeze" — volatility compression preceding a big move. + +**How to read it:** +- Squeeze ON = bands compressed, breakout imminent +- Squeeze RELEASED = explosion happening now, direction from MACD +- Price above upper channel = strong uptrend continuation +- Price below lower channel = strong downtrend continuation + +**What the bot does with it:** The squeeze release is the single best range-to-trend transition signal. When the squeeze fires and MACD agrees on direction, the bot generates a high-confidence signal. This is the most important indicator in the "transitioning" regime. + +**Weight:** 2.5 in transitioning (squeeze breakouts), 2.0 in ranging, 1.5 in trending. + +--- + ### Informational Indicators (Not Scored) These indicators do not contribute to the buy/sell score but provide context: - **ATR (Average True Range):** Measures volatility — how much the price moves on average per candle. Used for setting stop loss distances and classifying the market as low/normal/high/extreme volatility. -- **MACD Histogram:** Used internally by the divergence detector, not scored on its own. +- **ADX Slope:** Computed as ADX_now minus ADX_5_bars_ago. When ADX < 25 but slope > 3.0, the bot classifies the market as "transitioning" — a trend is being born. This catches trends 5-10 bars before traditional ADX threshold crossings. +- **EMA Ribbon Compression:** When the spread between EMA9/21/50 (normalized by ATR) is very tight, a trend change is imminent. The bot boosts EMA signal confidence when ribbon expansion is detected after compression. - **Swing Structure (Support/Resistance):** Identifies the 3 nearest swing highs and 3 nearest swing lows. Used for setting take profit targets and stop losses. -### Dormant Indicators (Implemented But Not Active) - -These are fully coded but currently switched off: -- **Stochastic Oscillator** — Similar to RSI but with a different calculation -- **Ichimoku Cloud** — Japanese trend-following system -- **Pivot Points** — Classic support/resistance levels -- **Volume Analysis** — Trading volume patterns - --- ## How Are The Indicators Combined? @@ -165,23 +196,27 @@ The bot does not just average the indicators. It uses a **regime-adaptive weight ### Step 1: Classify the Market Regime -Using ADX and EMA alignment, the bot first determines whether the market is: +Using ADX, EMA alignment, and ADX slope, the bot determines the market regime: - **Trending up** — ADX above 25 and EMAs are bullishly aligned - **Trending down** — ADX above 25 and EMAs are bearishly aligned -- **Ranging** — ADX below 25 (sideways/choppy market) +- **Transitioning** — ADX below 25 but ADX slope > 3.0 (trend is being born) +- **Ranging** — ADX below 25 and ADX slope ≤ 3.0 (sideways/choppy market) -### Step 2: Adjust Indicator Weights +### Step 2: Adjust Indicator Weights (Gold-Specific) Depending on the regime, certain indicators get louder or quieter: -| Indicator | Trending Weight | Ranging Weight | Why | -|-----------|----------------|---------------|-----| -| EMA Trend | 2.0x | 0.5x | EMAs excel in trends, fail in chop | -| RSI | 1.5x | 2.0x | RSI overbought/oversold works in ranges | -| Bollinger | 0.8x | 2.0x | Mean reversion is a ranging strategy | -| Candlestick | 1.5x | 1.5x | Patterns work in both | -| Divergence | 1.0x | 1.5x | Divergences mark range boundaries | -| ADX | 1.0x | 1.0x | Confirms the regime itself | +| Indicator | Trending | Ranging | Transitioning | Why | +|-----------|----------|---------|---------------|-----| +| EMA Trend | 2.0 | 0.3 | 1.5 | EMAs excel in trends, fail in chop | +| RSI | 1.0 | 1.5 | 1.0 | RSI OB/OS works in ranges; gold doesn't mean-revert cleanly in trends | +| Bollinger | 0.5 | 1.5 | 1.0 | Gold rides the band in trends | +| MACD | 2.0 | 1.5 | 2.5 | Gold's best leading indicator | +| Keltner | 1.5 | 2.0 | 2.5 | Squeeze breakouts catch range-to-trend transitions | +| ROC | 1.5 | 0.5 | 2.0 | Momentum acceleration matters most at trend birth | +| Candlestick | 0.8 | 1.0 | 1.0 | Patterns work everywhere | +| Divergence | 2.0 | 2.5 | 2.0 | Gold's best reversal detector | +| ADX | 0.8 | 0.5 | 1.0 | Confirms the regime itself | ### Step 3: Compute Composite Score @@ -203,7 +238,7 @@ This produces a score from -100 (maximum bearish) to +100 (maximum bullish). | +40 or higher | STRONG BUY | | +15 to +39 | BUY | | -14 to +14 | NEUTRAL | -| -15 to -39 | SELL | +| -39 to -15 | SELL | | -40 or lower | STRONG SELL | --- @@ -285,7 +320,7 @@ If the signal score is above 30 (strong conviction), the bot treats it as a "mat ## Real-Time Price Monitoring (WebSocket) -For EUR/USD and XAU/USD, the bot also streams live prices via WebSocket (the other two pairs are monitored via periodic scans only — a limitation of the current data plan). +The bot streams live XAU/USD prices via WebSocket for real-time level breach detection. ### Level Break Alerts @@ -305,26 +340,25 @@ When the live price crosses any of these levels, the bot: --- -## Correlation Warnings +## Confidence Adjustments (Diminishing Penalty Cap) -The bot tracks two correlation groups: -- **USD-weakness group:** EUR/USD and GBP/USD (both move inversely to USD) -- **Risk-sentiment group:** XAU/USD and USD/JPY (both respond to risk appetite) +Beyond indicators, MTF, and news, the bot applies penalties for risk factors. However, penalties use a **diminishing cap** to prevent over-suppression: -If both pairs in a group signal the same direction, the bot warns you: "Same exposure — you may be doubling your risk on the same theme." +1. All penalties are collected and sorted by magnitude (largest first) +2. Applied with exponential decay: 1st penalty at 100%, 2nd at 50%, 3rd at 25% +3. **Hard cap: total penalty never exceeds 30%** ---- - -## Additional Confidence Adjustments - -Beyond indicators, MTF, and news, the bot applies these final adjustments: - -| Factor | Effect | Why | -|--------|--------|-----| -| Counter-trend signal | -15% confidence | Trading against the trend is riskier | -| Extreme volatility | -10% confidence | Wild markets produce unreliable signals | -| Near EMA 200 (within 0.5%) | -10% confidence | Price tends to chop around EMA 200 | -| Far from EMA 200 (beyond 1.5%) | -10% confidence | Overextended from the mean | +| Factor | Base Penalty | Why | +|--------|-------------|-----| +| MTF disagreement | 15% | Trading against other timeframes is riskier | +| Counter-trend signal | 15% | Trading against the trend is riskier | +| Event caution | 15% | Upcoming economic event | +| Extreme volatility | 10% | Wild markets produce unreliable signals | +| News conflict | 10% | News sentiment opposes signal | +| Exhaustion warning | 8% | Momentum may be exhausted | +| No pullback | 8% | Entering without pullback reduces RR | +| Near EMA 200 | 5% | Price tends to chop around EMA 200 | +| Far from EMA 200 | 5% | Overextended from the mean | Final confidence is always clamped between 0% and 95% — the bot never claims 100% confidence. @@ -335,14 +369,14 @@ Final confidence is always clamped between 0% and 95% — the bot never claims 1 ### Commands | Command | What It Does | |---------|-------------| -| `/start` | Shows the scan button grid (4 pairs x 3 timeframes) | +| `/start` | Shows the scan button grid (XAU/USD × 3 timeframes) | | `/autoscan` | Turns automatic scanning on or off | | `/status` | Shows bot status, WebSocket connection, live prices, level cache | | `/help` | Lists all available commands | ### Auto-Scan Behavior - Starts automatically on boot if `TELEGRAM_CHAT_ID` is configured -- Scans all 4 pairs at each candle boundary (M5/M15/H1/H4) +- Scans XAU/USD at each candle boundary (M5/M15/H1/H4) - Only sends messages when actionable signals (grade A+/A/B/C) are found - If no actionable signals are found, it stays silent (logged internally only) @@ -372,7 +406,6 @@ ForexBot2/ news_engine.py # Economic calendar and news sentiment data_provider.py # Twelve Data API client with caching and rate limiting main.py # CLI entry point for single-pair analysis - ws_twelvedata.py # Standalone WebSocket test utility requirements.txt # Python dependencies ``` @@ -384,7 +417,7 @@ pandas>=2.0 # DataFrame handling for OHLCV data requests>=2.28 # HTTP client for news/calendar APIs twelvedata>=1.2.0 # Twelve Data SDK (REST OHLCV source) python-telegram-bot>=20.0 # Async Telegram bot framework -websockets>=12.0 # WebSocket client for live price streaming +websockets>=12.0 # WebSocket client for live price streaming (Telegram bot level-breach monitor) ``` ## Data Flow @@ -396,7 +429,7 @@ websockets>=12.0 # WebSocket client for live price streaming (fetch + cache + validate OHLCV) | engine.py - (compute 6 indicators per TF) + (compute 9 indicators per TF) | signal_aggregator.py (MTF agreement + news + final signal + trade setup) @@ -424,15 +457,11 @@ class SignalStrength(Enum): ### Pair Definitions ```python -USDJPY = "USDJPY=X" # Yahoo-style suffix for forex -EURUSD = "EURUSD=X" -GBPUSD = "GBPUSD=X" +XAUUSD = "XAU/USD" XAUUSD = "XAU/USD" # No suffix for commodities PAIR_INFO = { - USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True}, - EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False}, - GBPUSD: {"display": "GBP/USD", "pip_value": 0.0001, "is_jpy_pair": False}, + XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False}, XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False}, } ``` @@ -476,7 +505,7 @@ Maintains a sliding window of timestamps. If 55 calls were made in the last 60 s ### Symbol Conversion: `_to_td_symbol(symbol)` ``` -"EURUSD=X" -> "EUR/USD" (strip =X, insert /) +"XAU/USD" -> "XAU/USD" (pass-through for slash-format symbols) "XAU/USD" -> "XAU/USD" (already has /, pass through) ``` @@ -628,8 +657,8 @@ Signal mapping: >=40 STRONG_BUY, >=15 BUY, <=-40 STRONG_SELL, <=-15 SELL, else N ```python @dataclass class TradeSetup: - pair: str # "EURUSD=X" - display_name: str # "EUR/USD" + pair: str # "XAU/USD" + display_name: str # "XAU/USD" direction: str # "BUY" or "SELL" entry_price: float stop_loss: float @@ -814,10 +843,8 @@ CURRENCY_KEYWORDS = { ### Pair Impact Mapping ```python -"USD" -> [EURUSD, USDJPY, GBPUSD, XAUUSD] # USD events affect all pairs -"EUR" -> [EURUSD] -"JPY" -> [USDJPY] -"GBP" -> [GBPUSD] +"USD" -> [XAUUSD] # USD events affect gold +"XAU" -> [XAUUSD] "XAU" -> [XAUUSD] ``` @@ -898,7 +925,7 @@ def _run_signal_sync(symbol, timeframe, confirmation_tfs): ```python while _autoscan_chats: connect to wss://ws.twelvedata.com/v1/quotes/price?apikey=... - subscribe to ["EUR/USD", "XAU/USD"] + subscribe to ["XAU/USD"] for each incoming message: if event != "price": continue map ws_symbol -> pair_symbol via _WS_TO_PAIR @@ -947,14 +974,14 @@ Footer: "Educational / research use only." Timeframe Auto-Scan | HH:MM UTC Per pair: emoji, signal, quality, score, confidence, MTF status Actionable list -Correlation warnings (if any) +Actionable signals (if any) ``` ### Telegram Commands | Handler | Function | Registration | |---------|----------|-------------| -| `/start` | `cmd_start()` | Shows scan keyboard (12 buttons: 4 pairs x 3 TFs) | +| `/start` | `cmd_start()` | Shows scan keyboard (3 buttons: XAU/USD × 3 TFs) | | `/help` | `cmd_help()` | Lists all commands | | `/status` | `cmd_status()` | Auto-scan state, WS state, live prices, level cache stats, cooldowns | | `/autoscan` | `cmd_autoscan()` | Toggle: adds/removes chat from `_autoscan_chats`, starts/stops scheduler | @@ -967,8 +994,8 @@ Correlation warnings (if any) ### Usage ```bash -python main.py # Default: USD/JPY H1 -python main.py --timeframe M15 # USD/JPY M15 +python main.py # Default: XAU/USD H1 +python main.py --timeframe M15 # XAU/USD M15 python main.py --detail # Include full indicator breakdown python main.py --json # Output as JSON python main.py --verbose # Debug logging @@ -1000,7 +1027,7 @@ Terminal output includes color-coded sections: 2. **Shared data provider:** A single `ForexDataProvider` instance is reused across all signal computations (`_get_shared_dp()`). This maximizes cache hits — when M15 and M5 scans both need H1 data, it is fetched once. -3. **WebSocket limited to 2 symbols:** The Twelve Data Grow plan (8 trial WS credits) only supports EUR/USD and XAU/USD via WebSocket. GBP/USD and USD/JPY are monitored exclusively through periodic REST scans. +3. **WebSocket for XAU/USD only:** The bot streams live prices via WebSocket for real-time level breach detection on gold. 4. **M5 is data-only:** The M5 scan runs every 5 minutes but only updates the level cache (used by WebSocket breach detection). It does not send Telegram messages — this reduces noise while keeping the internal picture current. diff --git a/config.py b/config.py index 4dcd647..3c67e4c 100755 --- a/config.py +++ b/config.py @@ -1,5 +1,5 @@ """ -ForexBot2 — Configuration +ForexBot2 — Configuration (XAU/USD focused) ========================================== """ @@ -36,22 +36,12 @@ class MarketSession(Enum): # ── Supported pairs ────────────────────────────────────────────── -USDJPY = "USDJPY=X" -EURUSD = "EURUSD=X" -GBPUSD = "GBPUSD=X" XAUUSD = "XAU/USD" PAIR_INFO = { - USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True}, - EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False}, - GBPUSD: {"display": "GBP/USD", "pip_value": 0.0001, "is_jpy_pair": False}, XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False}, } -# Defaults (kept for backward compat) -DISPLAY = "USD/JPY" -PIP_VALUE = 0.01 - @dataclass class TechnicalConfig: @@ -83,11 +73,24 @@ class TechnicalConfig: divergence_lookback: int = 14 swing_sr_lookback: int = 50 - disabled_indicators: List[str] = field(default_factory=lambda: ["divergence"]) + disabled_indicators: List[str] = field(default_factory=list) ema_mode: str = "4ema" # "4ema" (9/21/50/200) or "2ema" (50/200) rsi_mode: str = "classic" # "classic" or "regime_aware" - macd_as_pillar: bool = False # promote MACD histogram to scored signal + macd_as_pillar: bool = True # MACD histogram is a scored signal + + # ROC (Rate of Change) + roc_period: int = 10 + roc_threshold: float = 1.0 # % threshold for strong signal + + # Keltner Channels + kc_period: int = 20 + kc_atr_period: int = 10 + kc_multiplier: float = 1.5 + + # ADX slope (trend acceleration detection) + adx_slope_lookback: int = 5 + adx_slope_emerging_threshold: float = 3.0 regime_weights_override: Dict[str, Dict[str, float]] = field(default_factory=dict) @@ -101,6 +104,9 @@ class SignalConfig: "ema_trend": 1.0, "rsi": 1.5, "bollinger": 1.5, + "macd": 1.5, + "keltner": 1.5, + "roc": 1.5, "candlestick": 1.0, "divergence": 1.5, }) @@ -117,7 +123,7 @@ class SignalConfig: @dataclass class NewsConfig: """Economic calendar & sentiment.""" - tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "JPY", "EUR", "GBP", "XAU"]) + tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"]) min_impact: str = "medium" pre_event_buffer_minutes: int = 30 post_event_buffer_minutes: int = 15 @@ -130,82 +136,27 @@ class NewsConfig: @dataclass class XauStrategyConfig: - """XAU/USD v3 weighted confluence strategy. - 1-year grid-optimized (768 combos): 52.4% WR, PF 2.34, +40.3R (63 trades). - Entry: M15 score>=80 + H4 RSI confirms + H1 MACD confirms + RSI 40-65 zone. - Exit: Partial — 1R→BE, 2R→close 50%, 3R→close rest. SL=2.5×ATR. - Blocked: Fri/Sat/Sun, hours 1/3/9/10/19/22/23. + """XAU/USD strategy config. + Legacy gates (H4 RSI, H1 MACD, M15 score) removed in favor of + new momentum indicators (ROC, Keltner squeeze, ADX slope). """ enabled: bool = True rsi_entry_threshold: float = 45.0 - tp_atr_multiplier: float = 0.9 + tp_atr_multiplier: float = 1.0 sl_atr_multiplier: float = 2.0 min_ema200_tfs: int = 2 allow_generic_trades: bool = True blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 5, 6, 21, 22, 23]) - # v2 confluence fixes weekend_filter: bool = True min_atr_threshold: float = 1.0 v2_sl_atr_multiplier: float = 2.5 buy_threshold_boost: int = 0 m5_confirmation_bars: int = 0 - # v3 gates (grid-optimized on 1Y Mon-Thu data) - h4_rsi_gate: bool = True # require H4 RSI >55 for BUY, <45 for SELL - h1_macd_gate: bool = True # require H1 MACD histogram >0 for BUY, <0 for SELL - rsi_zone: tuple = (40.0, 65.0) # only enter when M15 RSI in this zone + h4_rsi_gate: bool = False + h1_macd_gate: bool = False + rsi_zone: tuple = (40.0, 65.0) allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) # Mon-Fri - sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) # SELL needs B+ - - -@dataclass -class EurStrategyConfig: - """EUR/USD pullback-buy strategy (BUY only). - Fixes: no entries at BB resistance, no overbought RSI, no chasing. - Entry: M15 RSI 30–55 (pullback zone) + BB position < 0.75 - + price > EMA200 on M15 & H1 + near EMA21 (not chasing). - Exit: TP=0.7×ATR, SL=1.5×ATR. - """ - enabled: bool = True - rsi_lower: float = 30.0 - rsi_upper: float = 55.0 - bb_max_position: float = 0.75 - chase_max_atr_from_ema21: float = 1.5 - tp_atr_multiplier: float = 0.7 - sl_atr_multiplier: float = 1.0 - blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5, 6, 16, 17, 18, 19, 21, 22, 23]) - # If False, suppress generic trade setups when the EUR pullback gate - # didn't fire. Backtest (90-day M15): True → +4.70R, False → +9.50R. - # Generic momentum buys at trending_up extremes are pure drag. - allow_generic_trades: bool = False - # Minimum number of M15 scored indicators (ema_trend, rsi, bollinger, - # adx, candlestick) that must independently signal BUY for the - # pullback gate to fire. The strategy hardcodes Quality=A and - # Score=+40 — without this, the "A" grade fires on weak technical - # confluence. 0 = disabled (legacy behavior). - min_indicators_agree: int = 2 - - -@dataclass -class GbpStrategyConfig: - """GBP/USD pullback-buy strategy (BUY only). - 1-year optimized: hour 17 blocked. - Entry: M15 RSI 30–55 + BB < 0.75 + price > EMA200 on M15 & H1 - + within 1.5×ATR of EMA21. - Exit: TP=0.7×ATR, SL=1.0×ATR. - """ - enabled: bool = True - rsi_lower: float = 30.0 - rsi_upper: float = 55.0 - bb_min_position: float = 0.0 - bb_max_position: float = 0.75 - chase_max_atr_from_ema21: float = 1.5 - tp_atr_multiplier: float = 0.7 - sl_atr_multiplier: float = 1.0 - blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5, 6, 14, 15, 16, 17, 19, 21, 22, 23]) - # See EurStrategyConfig.allow_generic_trades — same rationale. - allow_generic_trades: bool = False - # See EurStrategyConfig.min_indicators_agree — same rationale. - min_indicators_agree: int = 2 + sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) @dataclass @@ -220,14 +171,8 @@ class FilterConfig: session_end_hour: int = 16 blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 5, 6, 8, 12, 13, 14, 15, 16, 17, 21, 22, 23]) - # Universal weekday filter (0=Mon ... 6=Sun). Default Mon-Thu — Fridays - # have wide spreads from US session ending and weekend gap risk; XAU/USD - # has its own stricter allowed_weekdays in xau_strategy that overrides. allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3]) - sell_filter: bool = True - sell_allowed_pairs: List[str] = field(default_factory=lambda: ["XAU/USD"]) - @dataclass class PlatformConfig: @@ -237,12 +182,10 @@ class PlatformConfig: news: NewsConfig = field(default_factory=NewsConfig) backtest: FilterConfig = field(default_factory=FilterConfig) xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig) - eur_strategy: EurStrategyConfig = field(default_factory=EurStrategyConfig) - gbp_strategy: GbpStrategyConfig = field(default_factory=GbpStrategyConfig) - symbol: str = USDJPY - display_name: str = DISPLAY - pip_value: float = PIP_VALUE + symbol: str = XAUUSD + display_name: str = "XAU/USD" + pip_value: float = 0.10 primary_timeframe: Timeframe = Timeframe.H1 confirmation_timeframes: List[Timeframe] = field( @@ -256,7 +199,7 @@ class PlatformConfig: refresh_interval: int = 300 scan_pairs: List[str] = field( - default_factory=lambda: [EURUSD, GBPUSD, XAUUSD] + default_factory=lambda: [XAUUSD] ) scan_timeframes: List[Timeframe] = field( default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4] diff --git a/data_provider.py b/data_provider.py index 33c717a..76be6ce 100755 --- a/data_provider.py +++ b/data_provider.py @@ -13,7 +13,7 @@ from typing import Dict, Optional import pandas as pd from twelvedata import TDClient -from config import PlatformConfig, Timeframe, USDJPY, PAIR_INFO +from config import PlatformConfig, Timeframe, XAUUSD, PAIR_INFO logger = logging.getLogger(__name__) @@ -76,7 +76,7 @@ class ForexDataProvider: def fetch_ohlcv( self, - symbol: str = USDJPY, + symbol: str = XAUUSD, timeframe: Timeframe = Timeframe.H1, lookback_days: Optional[int] = None, ) -> pd.DataFrame: @@ -150,7 +150,7 @@ class ForexDataProvider: logger.info(f"Cache cleared: {count} entries wiped") return count - def get_latest_price(self, symbol: str = USDJPY) -> Optional[float]: + def get_latest_price(self, symbol: str = XAUUSD) -> Optional[float]: try: df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) if df is not None and not df.empty: @@ -159,18 +159,18 @@ class ForexDataProvider: pass return None - def get_spread_estimate(self, symbol: str = USDJPY) -> float: + def get_spread_estimate(self, symbol: str = XAUUSD) -> float: try: df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) if df is not None and not df.empty: return float((df["High"] - df["Low"]).median()) except Exception: pass - info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY]) + info = PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD]) return info["pip_value"] - def get_pair_info(self, symbol: str = USDJPY) -> Dict: - info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY]) + def get_pair_info(self, symbol: str = XAUUSD) -> Dict: + info = PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD]) return {"symbol": symbol, **info} # ── Private ──────────────────────────────────────────────────── diff --git a/engine.py b/engine.py index 51e2b89..3d63999 100755 --- a/engine.py +++ b/engine.py @@ -93,6 +93,9 @@ class TechnicalEngine: self._compute_macd(df, result) + if "roc" not in disabled: self._compute_roc(df, result) + if "keltner" not in disabled: self._compute_keltner(df, result) + if "divergence" not in disabled: self._detect_divergences(df, result) if "structure" not in disabled: self._compute_structure_sr(df, result) if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result) @@ -247,7 +250,22 @@ class TechnicalEngine: crossover_desc = " | Death cross (fast/medium)" conf = min(conf + 10, 95) - desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}" + # EMA ribbon compression/expansion detection + ribbon_desc = "" + atr_val = result.key_levels.get("atr", 0) + ema_values = [f, m, s] + if atr_val > 0: + ribbon_width = (max(ema_values) - min(ema_values)) / atr_val + if ribbon_width < 1.0: + ribbon_desc = " | Ribbon COMPRESSED (trend change imminent)" + if len(ema_fast) >= 6: + prev_vals = [ema_fast.iloc[-5], ema_medium.iloc[-5], ema_slow.iloc[-5]] + prev_width = (max(prev_vals) - min(prev_vals)) / atr_val if atr_val > 0 else 0 + if prev_width < 1.0 and ribbon_width >= 1.0: + ribbon_desc = " | Ribbon EXPANDING (new trend starting)" + conf = min(conf + 10, 95) + + desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}{ribbon_desc}" result.indicators["ema_trend"] = IndicatorSignal( name="EMA Trend", value=normalized, signal=signal, confidence=conf, @@ -499,12 +517,33 @@ class TechnicalEngine: signal = SignalStrength.SELL if adx_val >= c.adx_strong_trend else SignalStrength.NEUTRAL direction = "bearish" - desc = f"ADX {adx_val:.1f} ({trend_strength} {direction}) | +DI={plus_di_val:.1f} -DI={minus_di_val:.1f}" + # ADX slope — trend acceleration detection + slope_lookback = self.config.adx_slope_lookback + adx_slope = 0.0 + if len(adx) > slope_lookback: + prev_adx = adx.iloc[-slope_lookback - 1] + if not pd.isna(prev_adx): + adx_slope = adx_val - prev_adx + + slope_desc = "" + emerging_threshold = self.config.adx_slope_emerging_threshold + if adx_val < self.config.adx_strong_trend and adx_slope > emerging_threshold: + slope_desc = " | TREND EMERGING (ADX rising fast)" + conf = min(conf + 15, 85) + elif adx_val >= self.config.adx_strong_trend and adx_slope > 0: + slope_desc = f" | Trend strengthening (slope +{adx_slope:.1f})" + conf = min(conf + 5, 85) + elif adx_val >= self.config.adx_strong_trend and adx_slope < -emerging_threshold: + slope_desc = f" | Trend weakening (slope {adx_slope:.1f})" + conf = max(conf - 10, 30) + + desc = f"ADX {adx_val:.1f} ({trend_strength} {direction}) | +DI={plus_di_val:.1f} -DI={minus_di_val:.1f}{slope_desc}" result.indicators["adx"] = IndicatorSignal( name="ADX", value=adx_val, signal=signal, confidence=conf, description=desc, weight=0.5, ) + result._adx_slope = adx_slope # type: ignore[attr-defined] def _compute_atr(self, df: pd.DataFrame, result: TechnicalAnalysis): """ATR volatility measurement and regime classification.""" @@ -550,6 +589,153 @@ class TechnicalEngine: weight=0.0, # ATR is context, not a directional signal ) + def _compute_roc(self, df: pd.DataFrame, result: TechnicalAnalysis): + """Rate of Change — momentum acceleration indicator.""" + c = self.config + close = df["Close"] + n = c.roc_period + + if len(close) < n + 5: + return + + roc_val = ((close.iloc[-1] - close.iloc[-n - 1]) / close.iloc[-n - 1]) * 100 + roc_prev = ((close.iloc[-2] - close.iloc[-n - 2]) / close.iloc[-n - 2]) * 100 + + if pd.isna(roc_val) or pd.isna(roc_prev): + return + + roc_rising = roc_val > roc_prev + roc_abs = abs(roc_val) + threshold = c.roc_threshold + + if roc_val > 0 and roc_rising: + if roc_abs > threshold * 2: + signal = SignalStrength.STRONG_BUY + conf = min(80, 60 + roc_abs * 5) + desc = f"ROC +{roc_val:.2f}% — Strong momentum acceleration" + else: + signal = SignalStrength.BUY + conf = min(70, 50 + roc_abs * 10) + desc = f"ROC +{roc_val:.2f}% — Rising momentum" + elif roc_val < 0 and not roc_rising: + if roc_abs > threshold * 2: + signal = SignalStrength.STRONG_SELL + conf = min(80, 60 + roc_abs * 5) + desc = f"ROC {roc_val:.2f}% — Strong bearish momentum" + else: + signal = SignalStrength.SELL + conf = min(70, 50 + roc_abs * 10) + desc = f"ROC {roc_val:.2f}% — Falling momentum" + elif roc_prev <= 0 < roc_val: + signal = SignalStrength.BUY + conf = 65 + desc = f"ROC crossed above zero (+{roc_val:.2f}%)" + elif roc_prev >= 0 > roc_val: + signal = SignalStrength.SELL + conf = 65 + desc = f"ROC crossed below zero ({roc_val:.2f}%)" + else: + signal = SignalStrength.NEUTRAL + conf = 30 + desc = f"ROC {roc_val:+.2f}% — No clear momentum" + + result.indicators["roc"] = IndicatorSignal( + name="ROC", value=roc_val, signal=signal, + confidence=conf, description=desc, weight=1.5, + ) + + def _compute_keltner(self, df: pd.DataFrame, result: TechnicalAnalysis): + """Keltner Channels + Bollinger-Keltner squeeze detection (TTM Squeeze).""" + c = self.config + close = df["Close"] + high, low = df["High"], df["Low"] + + if len(close) < max(c.kc_period, c.kc_atr_period) + 5: + return + + kc_mid = close.ewm(span=c.kc_period, adjust=False).mean() + + tr1 = high - low + tr2 = (high - close.shift()).abs() + tr3 = (low - close.shift()).abs() + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + kc_atr = tr.ewm(alpha=1/c.kc_atr_period, min_periods=c.kc_atr_period, adjust=False).mean() + + kc_upper = kc_mid + c.kc_multiplier * kc_atr + kc_lower = kc_mid - c.kc_multiplier * kc_atr + + latest = close.iloc[-1] + kc_upper_val = kc_upper.iloc[-1] + kc_lower_val = kc_lower.iloc[-1] + kc_mid_val = kc_mid.iloc[-1] + + if pd.isna(kc_upper_val) or pd.isna(kc_lower_val): + return + + bb_upper = result.key_levels.get("bb_upper") + bb_lower = result.key_levels.get("bb_lower") + + is_squeeze = False + squeeze_released = False + if bb_upper is not None and bb_lower is not None: + is_squeeze = bb_lower > kc_lower_val and bb_upper < kc_upper_val + + if len(close) >= 3: + bb_sma = close.rolling(c.bb_period).mean() + bb_std = close.rolling(c.bb_period).std(ddof=0) + if len(bb_sma) >= 2 and not pd.isna(bb_sma.iloc[-2]): + prev_bb_upper = bb_sma.iloc[-2] + c.bb_std * bb_std.iloc[-2] + prev_bb_lower = bb_sma.iloc[-2] - c.bb_std * bb_std.iloc[-2] + prev_kc_upper = kc_upper.iloc[-2] + prev_kc_lower = kc_lower.iloc[-2] + was_squeeze = prev_bb_lower > prev_kc_lower and prev_bb_upper < prev_kc_upper + squeeze_released = was_squeeze and not is_squeeze + + macd_hist = getattr(result, "_macd_histogram", None) + macd_direction = 0 + if macd_hist is not None and len(macd_hist) >= 1 and not pd.isna(macd_hist.iloc[-1]): + macd_direction = 1 if macd_hist.iloc[-1] > 0 else -1 + + if squeeze_released: + if macd_direction > 0: + signal = SignalStrength.STRONG_BUY + desc = "Keltner SQUEEZE RELEASED — bullish breakout" + elif macd_direction < 0: + signal = SignalStrength.STRONG_SELL + desc = "Keltner SQUEEZE RELEASED — bearish breakout" + else: + signal = SignalStrength.NEUTRAL + desc = "Keltner squeeze released — direction unclear" + conf = 80 + elif is_squeeze: + signal = SignalStrength.NEUTRAL + conf = 60 + desc = "Keltner SQUEEZE active — breakout imminent" + elif latest > kc_upper_val: + signal = SignalStrength.BUY + conf = 65 + desc = f"Price above Keltner upper — strong uptrend continuation" + elif latest < kc_lower_val: + signal = SignalStrength.SELL + conf = 65 + desc = f"Price below Keltner lower — strong downtrend continuation" + else: + signal = SignalStrength.NEUTRAL + conf = 30 + desc = "Price within Keltner Channels" + + result.indicators["keltner"] = IndicatorSignal( + name="Keltner", value=float(latest - kc_mid_val), + signal=signal, confidence=conf, + description=desc, weight=1.5, + ) + + result.key_levels["kc_upper"] = float(kc_upper_val) + result.key_levels["kc_lower"] = float(kc_lower_val) + result.key_levels["kc_mid"] = float(kc_mid_val) + result._kc_squeeze = is_squeeze # type: ignore[attr-defined] + result._kc_squeeze_released = squeeze_released # type: ignore[attr-defined] + def _detect_divergences(self, df: pd.DataFrame, result: TechnicalAnalysis): """ Detect RSI and MACD divergences (powerful reversal signals). @@ -818,9 +1004,10 @@ class TechnicalEngine: ) def _classify_regime(self, df: pd.DataFrame, result: TechnicalAnalysis): - """Classify the market regime (trending vs ranging).""" + """Classify the market regime (trending vs ranging vs transitioning).""" adx_sig = result.indicators.get("adx") ema_sig = result.indicators.get("ema_trend") + adx_slope = getattr(result, "_adx_slope", 0.0) if adx_sig and adx_sig.value >= self.config.adx_strong_trend: if ema_sig and ema_sig.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): @@ -829,6 +1016,8 @@ class TechnicalEngine: result.market_regime = "trending_down" else: result.market_regime = "trending" + elif adx_sig and adx_slope > self.config.adx_slope_emerging_threshold: + result.market_regime = "transitioning" else: result.market_regime = "ranging" @@ -844,21 +1033,28 @@ class TechnicalEngine: total_weight = 0.0 total_confidence = 0.0 - # Use override weights if provided, else defaults + # Use override weights if provided, else gold-optimized defaults if self.config.regime_weights_override: regime_weights = self.config.regime_weights_override else: _trending = { - "ema_trend": 2.0, "rsi": 1.5, "bollinger": 0.8, - "candlestick": 1.0, "divergence": 1.5, + "ema_trend": 2.0, "rsi": 1.0, "macd": 2.0, + "bollinger": 0.5, "keltner": 1.5, "roc": 1.5, + "candlestick": 0.8, "divergence": 2.0, "adx": 0.8, } regime_weights = { "trending_up": _trending, "trending_down": _trending, "trending": _trending, "ranging": { - "ema_trend": 0.5, "rsi": 2.0, "bollinger": 2.0, - "candlestick": 1.0, "divergence": 2.0, + "ema_trend": 0.3, "rsi": 1.5, "macd": 1.5, + "bollinger": 1.5, "keltner": 2.0, "roc": 0.5, + "candlestick": 1.0, "divergence": 2.5, "adx": 0.5, + }, + "transitioning": { + "ema_trend": 1.5, "rsi": 1.0, "macd": 2.5, + "bollinger": 1.0, "keltner": 2.5, "roc": 2.0, + "candlestick": 1.0, "divergence": 2.0, "adx": 1.0, }, } @@ -888,13 +1084,13 @@ class TechnicalEngine: normalized_score = max(-100, min(100, normalized_score)) - if normalized_score >= 45: + if normalized_score >= 40: result.composite_signal = SignalStrength.STRONG_BUY - elif normalized_score >= 20: + elif normalized_score >= 15: result.composite_signal = SignalStrength.BUY - elif normalized_score <= -45: + elif normalized_score <= -40: result.composite_signal = SignalStrength.STRONG_SELL - elif normalized_score <= -20: + elif normalized_score <= -15: result.composite_signal = SignalStrength.SELL else: result.composite_signal = SignalStrength.NEUTRAL diff --git a/main.py b/main.py index 066988c..f6766f5 100755 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -ForexBot2 — USD/JPY Signal Intelligence (single-pair) -======================================================= +ForexBot2 — XAU/USD Signal Intelligence +======================================== Usage: python main.py # Live signal on H1 python main.py --timeframe H4 --detail # Detailed H4 signal @@ -21,7 +21,7 @@ if hasattr(sys.stdout, "reconfigure"): if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8") -from config import PlatformConfig, Timeframe, SignalStrength, USDJPY, DISPLAY +from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD from signal_aggregator import SignalAggregator, SignalReport @@ -65,7 +65,7 @@ def quality_color(q: str) -> str: }.get(q, Colors.RESET) -def print_header(title: str = "USD/JPY SIGNAL INTELLIGENCE"): +def print_header(title: str = "XAU/USD SIGNAL INTELLIGENCE"): now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}") print(f"{Colors.BOLD}{Colors.CYAN} {title}{Colors.RESET}") @@ -239,7 +239,7 @@ def report_to_dict(report: SignalReport) -> dict: def parse_args(): parser = argparse.ArgumentParser( - description="ForexBot2 — USD/JPY Signal Intelligence", + description="ForexBot2 — XAU/USD Signal Intelligence", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -295,7 +295,7 @@ def run_live(args, config: PlatformConfig): print(json.dumps(report_to_dict(report), indent=2)) return - print_header("USD/JPY SIGNAL INTELLIGENCE") + print_header("XAU/USD SIGNAL INTELLIGENCE") print_signal_report(report, detail=args.detail) print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}") print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}") diff --git a/news_engine.py b/news_engine.py index 28155f6..1d284f2 100755 --- a/news_engine.py +++ b/news_engine.py @@ -541,10 +541,7 @@ class NewsEngine: @staticmethod def _get_affected_pairs(currency: str) -> List[str]: _MAP = { - "USD": ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "XAU/USD"], - "EUR": ["EURUSD=X"], - "JPY": ["USDJPY=X"], - "GBP": ["GBPUSD=X"], + "USD": ["XAU/USD"], "XAU": ["XAU/USD"], } return _MAP.get(currency, []) diff --git a/requirements.txt b/requirements.txt index 305806c..19ed6a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,3 @@ requests>=2.28 twelvedata>=1.2.0 python-telegram-bot>=20.0 websockets>=12.0 -fastapi>=0.100 -uvicorn>=0.20 -redis>=5.0 diff --git a/signal_aggregator.py b/signal_aggregator.py index 7e37ad2..3aea209 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -176,21 +176,10 @@ class SignalAggregator: if bt.session_filter: xau_active = ( - report.display_name == "XAU/USD" - and self.config.xau_strategy.enabled + self.config.xau_strategy.enabled and any("XAU STRATEGY" in r for r in report.bullish_reasons) ) - eur_active = ( - report.display_name == "EUR/USD" - and self.config.eur_strategy.enabled - and any("EUR STRATEGY" in r for r in report.bullish_reasons) - ) - gbp_active = ( - report.display_name == "GBP/USD" - and self.config.gbp_strategy.enabled - and any("GBP STRATEGY" in r for r in report.bullish_reasons) - ) - if not xau_active and not eur_active and not gbp_active: + if not xau_active: self._apply_session_filter_live(report) return report @@ -398,182 +387,38 @@ class SignalAggregator: except Exception: pass - # ── 6c. XAU/USD oversold bounce strategy (DISABLED — replaced by v2 confluence) ── - # if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled: - # if self._check_xau_oversold_bounce(report, dfs): - # report.final_signal = SignalStrength.STRONG_BUY - # report.final_score = 50.0 - # report.final_confidence = 85.0 - # report.signal_quality = "A+" - # report.action_recommendation = ( - # "STRONG BUY — XAU oversold bounce " - # "(RSI<30 + M15/H1/H4 all above EMA200)" - # ) - # report.bullish_reasons.insert( - # 0, - # "XAU STRATEGY: M15 RSI oversold + triple-TF bullish trend confirmation", - # ) - # report.trade_setup = self._generate_xau_trade_setup(report, dfs) - - # ── 6d. EUR/USD pullback-buy strategy ───────────────────── - if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled: - if self._check_eur_pullback_buy(report, dfs): - report.final_signal = SignalStrength.BUY - report.final_score = 40.0 - report.final_confidence = 75.0 + # ── 6c. Transitioning regime quality boost ────────────────── + if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"): + abs_score = abs(report.final_score) + if abs_score >= 30 and report.final_confidence >= 50 and report.mtf_agreement: report.signal_quality = "A" - report.action_recommendation = ( - "BUY — EUR pullback entry " - "(RSI pullback + not at resistance + M15/H1 above EMA200)" - ) - report.bullish_reasons.insert( - 0, - "EUR STRATEGY: M15 RSI pullback + BB mid-band + dual-TF EMA200 confirmation", - ) - report.trade_setup = self._generate_eur_trade_setup(report, dfs) + report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend emerging, early entry" + elif abs_score >= 25 and report.final_confidence >= 45: + report.signal_quality = "B" + report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely" - # ── 6e. GBP/USD pullback-buy strategy ───────────────────── - if report.display_name == "GBP/USD" and self.config.gbp_strategy.enabled: - if self._check_gbp_pullback_buy(report, dfs): - report.final_signal = SignalStrength.BUY - report.final_score = 40.0 - report.final_confidence = 75.0 - report.signal_quality = "A" - report.action_recommendation = ( - "BUY — GBP pullback entry " - "(RSI pullback + not at resistance + M15/H1 above EMA200)" - ) - report.bullish_reasons.insert( - 0, - "GBP STRATEGY: M15 RSI pullback + BB mid-band + dual-TF EMA200 confirmation", - ) - report.trade_setup = self._generate_gbp_trade_setup(report, dfs) - - # ── 6d. XAU/USD BUY threshold boost (Fix 5) ────────────── - if (report.display_name == "XAU/USD" - and self.config.xau_strategy.buy_threshold_boost > 0 - and report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY) - and report.signal_quality in ("B", "C")): - report.warnings.append( - f"XAU BUY FILTER: BUY requires higher confidence (quality {report.signal_quality} insufficient)" - ) - report.signal_quality = "D" - report.trade_setup = None - - # ── 6f. XAU/USD H4 RSI + H1 MACD directional gates ────── - if (report.display_name == "XAU/USD" - and self.config.xau_strategy.enabled - and report.signal_quality in ("A+", "A", "B", "C") - and report.final_signal != SignalStrength.NEUTRAL): - xau_cfg = self.config.xau_strategy - is_buy = report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY) - - if xau_cfg.h4_rsi_gate and Timeframe.H4 in dfs: - h4_close = dfs[Timeframe.H4]["Close"] - delta = h4_close.diff() - gain = delta.clip(lower=0).ewm(alpha=1/14, min_periods=14, adjust=False).mean() - loss = (-delta.clip(upper=0)).ewm(alpha=1/14, min_periods=14, adjust=False).mean() - h4_rsi = 100 - (100 / (1 + gain / loss)) - if len(h4_rsi) > 0: - h4_rsi_val = float(h4_rsi.iloc[-1]) - if is_buy and h4_rsi_val <= 55: - report.warnings.append( - f"H4 RSI GATE: RSI {h4_rsi_val:.0f} ≤55 — no BUY confirmation" - ) - report.signal_quality = "D" - report.trade_setup = None - elif not is_buy and h4_rsi_val >= 45: - report.warnings.append( - f"H4 RSI GATE: RSI {h4_rsi_val:.0f} ≥45 — no SELL confirmation" - ) - report.signal_quality = "D" - report.trade_setup = None - - if (xau_cfg.h1_macd_gate - and Timeframe.H1 in dfs - and report.signal_quality in ("A+", "A", "B", "C")): - h1_df = dfs[Timeframe.H1] - h1_close = h1_df["Close"] - h1_ema_fast = h1_close.ewm(span=12, adjust=False).mean() - h1_ema_slow = h1_close.ewm(span=26, adjust=False).mean() - h1_macd_line = h1_ema_fast - h1_ema_slow - h1_signal_line = h1_macd_line.ewm(span=9, adjust=False).mean() - h1_macd_hist = h1_macd_line - h1_signal_line - if len(h1_macd_hist) > 0: - h1_hist_val = float(h1_macd_hist.iloc[-1]) - if is_buy and h1_hist_val <= 0: - report.warnings.append( - f"H1 MACD GATE: histogram {h1_hist_val:.2f} ≤0 — no BUY confirmation" - ) - report.signal_quality = "D" - report.trade_setup = None - elif not is_buy and h1_hist_val >= 0: - report.warnings.append( - f"H1 MACD GATE: histogram {h1_hist_val:.2f} ≥0 — no SELL confirmation" - ) - report.signal_quality = "D" - report.trade_setup = None - - # ── RSI zone gate ────────────────────────────────────── - if (xau_cfg.rsi_zone - and report.signal_quality in ("A+", "A", "B", "C")): - m15_analysis = report.primary_analysis - if m15_analysis and hasattr(m15_analysis, '_rsi_series'): - rsi_val = float(m15_analysis._rsi_series.iloc[-1]) - lo, hi = xau_cfg.rsi_zone - if not (lo <= rsi_val <= hi): - report.warnings.append( - f"RSI ZONE GATE: RSI {rsi_val:.0f} outside [{lo:.0f}-{hi:.0f}]" - ) - report.signal_quality = "D" - report.trade_setup = None - - # ── 6g. XAU/USD SELL quality gate ───────────────────────── + # ── 6d. SELL quality gate ───────────────────────────────── xau_is_sell_dir = ( report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL) or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0) ) - if (report.display_name == "XAU/USD" - and self.config.xau_strategy.enabled + if (self.config.xau_strategy.enabled and xau_is_sell_dir and report.signal_quality not in self.config.xau_strategy.sell_min_quality): report.warnings.append( - f"XAU SELL FILTER: quality {report.signal_quality} below minimum for SELL" + f"SELL FILTER: quality {report.signal_quality} below minimum for SELL" ) report.signal_quality = "D" report.trade_setup = None # ── 7. Trade setup for actionable signals ────────────────── - # Pair-specific generic-trade suppression: when a pair has a dedicated - # strategy and that strategy didn't fire, falling through to a generic - # swing-based setup tends to chase momentum at unfavorable spots. - # 90-day EUR M15 backtest: allow=True → +4.7R, allow=False → +9.5R. - suppress_generic = ( - (report.display_name == "XAU/USD" - and not self.config.xau_strategy.allow_generic_trades) - or (report.display_name == "EUR/USD" - and self.config.eur_strategy.enabled - and not self.config.eur_strategy.allow_generic_trades) - or (report.display_name == "GBP/USD" - and self.config.gbp_strategy.enabled - and not self.config.gbp_strategy.allow_generic_trades) - ) - if report.trade_setup is None and suppress_generic: - pass # generic trade setup suppressed for this pair - elif report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"): - is_sell_direction = ( - report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL) - or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0) - ) - if is_sell_direction and self.config.backtest.sell_filter: - if report.display_name in self.config.backtest.sell_allowed_pairs: - report.trade_setup = self._generate_trade_setup(report, pip_value) - elif self._forex_sell_qualifies(report): - report.trade_setup = self._generate_trade_setup(report, pip_value) - else: - report.trade_setup = None - else: - report.trade_setup = self._generate_trade_setup(report, pip_value) + if report.trade_setup is None and report.signal_quality in ("A+", "A"): + atr = (report.primary_analysis.key_levels.get("atr", 0) + if report.primary_analysis else 0) + if atr > 0: + report.trade_setup = self._generate_xau_atr_trade_setup(report, atr) + if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"): + report.trade_setup = self._generate_trade_setup(report, pip_value) # ── 8. Reasons ───────────────────────────────────────────── self._collect_reasons(report) @@ -582,21 +427,10 @@ class SignalAggregator: # ── Internals ────────────────────────────────────────────────── - def _forex_sell_qualifies(self, report: SignalReport) -> bool: - """Always returns False — forex SELL is structurally unprofitable. - 0/4 trades won even under best conditions (trending_down + MTF + ADX>=25 + RSI<=50).""" - return False - def _check_xau_oversold_bounce( self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] ) -> bool: - """Return True when XAU/USD oversold-bounce conditions are met. - - Conditions: - 1. M15 RSI(14) < rsi_entry_threshold - 2. Price > EMA(200) on at least min_ema200_tfs of {M15, H1, H4} - 3. Current bar hour not in blocked list - """ + """Return True when XAU/USD oversold-bounce conditions are met.""" cfg = self.config.xau_strategy try: @@ -671,9 +505,11 @@ class SignalAggregator: price = report.current_price pip_value = 0.10 - tp1 = price + cfg.tp_atr_multiplier * atr + quality_scale = self._TP_QUALITY_SCALE.get(report.signal_quality, 0.75) + tp_mult = cfg.tp_atr_multiplier * quality_scale + tp1 = price + tp_mult * atr sl = price - cfg.sl_atr_multiplier * atr - tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr + tp2 = price + tp_mult * 1.5 * atr sl_distance = price - sl tp_distance = tp1 - price @@ -693,10 +529,12 @@ class SignalAggregator: pip_value=pip_value, ) + _TP_QUALITY_SCALE = {"A+": 2.0, "A": 1.5, "B": 1.0, "C": 1.0} + def _generate_xau_atr_trade_setup( self, report: SignalReport, atr: float ) -> Optional[TradeSetup]: - """XAU-aware exits for generic signals: wide SL, tight TP via ATR.""" + """XAU ATR-based trade setup with quality-tiered TP scaling.""" cfg = self.config.xau_strategy price = report.current_price pip_value = 0.10 @@ -708,21 +546,28 @@ class SignalAggregator: else: is_buy = report.final_score >= 0 + quality_scale = self._TP_QUALITY_SCALE.get(report.signal_quality, 0.75) + tp_mult = cfg.tp_atr_multiplier * quality_scale + if is_buy: sl = price - cfg.sl_atr_multiplier * atr - tp1 = price + cfg.tp_atr_multiplier * atr - tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr + tp1 = price + tp_mult * atr + tp2 = price + tp_mult * 1.5 * atr direction = "BUY" else: sl = price + cfg.sl_atr_multiplier * atr - tp1 = price - cfg.tp_atr_multiplier * atr - tp2 = price - cfg.tp_atr_multiplier * 2.0 * atr + tp1 = price - tp_mult * atr + tp2 = price - tp_mult * 1.5 * atr direction = "SELL" sl_distance = abs(price - sl) tp_distance = abs(tp1 - price) rr = tp_distance / sl_distance if sl_distance > 0 else 0 + min_rr = self.config.signals.min_rr_ratio + if rr < min_rr - 1e-9: + return None + return TradeSetup( pair=report.symbol, display_name=report.display_name, @@ -737,277 +582,6 @@ class SignalAggregator: pip_value=pip_value, ) - def _check_eur_pullback_buy( - self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] - ) -> bool: - """Return True when EUR/USD pullback-buy conditions are met. - - Conditions (all must be true): - 0. Bar's weekday is in the universal allowed_weekdays list - 1. Current bar hour not in blocked list - 2. M15 RSI(14) in 30–55 (pullback, not overbought) - 3. M15 BB position < 0.75 (not at resistance) - 4. M15 price > EMA200 (uptrend) - 5. H1 price > EMA200 (higher-TF confirmation) - 6. Price within 1.5×ATR of EMA21 (not chasing) - """ - cfg = self.config.eur_strategy - - try: - bar_ts = pd.Timestamp(report.timestamp) - bar_hour = bar_ts.hour - bar_wd = bar_ts.weekday() - if bar_hour in cfg.blocked_hours_utc: - return False - # Universal day filter — must be respected here because the - # strategy hook overwrites the section-6b filter's quality reset. - allowed_wd = self.config.backtest.allowed_weekdays - if allowed_wd and bar_wd not in allowed_wd: - return False - except Exception: - return False - - m15_analysis = None - h1_analysis = None - - if report.primary_analysis: - tf = report.primary_analysis.timeframe - if tf == Timeframe.M15.value: - m15_analysis = report.primary_analysis - elif tf == Timeframe.H1.value: - h1_analysis = report.primary_analysis - - for ca in report.confirmation_analyses: - if ca.timeframe == Timeframe.M15.value: - m15_analysis = ca - elif ca.timeframe == Timeframe.H1.value: - h1_analysis = ca - - if not m15_analysis or not h1_analysis: - return False - - m15_rsi = m15_analysis.indicators.get("rsi") - if not m15_rsi or m15_rsi.value < cfg.rsi_lower or m15_rsi.value > cfg.rsi_upper: - return False - - m15_bb = m15_analysis.indicators.get("bollinger") - if m15_bb and m15_bb.value > cfg.bb_max_position: - return False - - m15_ema200 = m15_analysis.key_levels.get("ema_trend") - m15_df = dfs.get(Timeframe.M15) - if m15_df is None or m15_ema200 is None: - return False - m15_price = float(m15_df["Close"].iloc[-1]) - if m15_price <= m15_ema200: - return False - - h1_ema200 = h1_analysis.key_levels.get("ema_trend") - h1_df = dfs.get(Timeframe.H1) - if h1_ema200 is None: - return False - h1_price = float(h1_df["Close"].iloc[-1]) if h1_df is not None else report.current_price - if h1_price <= h1_ema200: - return False - - m15_atr = m15_analysis.key_levels.get("atr", 0) - m15_ema21 = m15_analysis.key_levels.get("ema_medium") - if m15_atr > 0 and m15_ema21: - dist = abs(m15_price - m15_ema21) / m15_atr - if dist > cfg.chase_max_atr_from_ema21: - return False - - # Minimum indicator confluence — rejects gate-passes with weak - # technical agreement. The pullback gate alone fires Quality=A - # even when M15 indicators are mostly neutral; this enforces - # actual confluence. - if cfg.min_indicators_agree > 0: - agreeing = sum( - 1 - for name in ("ema_trend", "rsi", "bollinger", "adx", "candlestick") - if (ind := m15_analysis.indicators.get(name)) is not None - and ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY) - ) - if agreeing < cfg.min_indicators_agree: - return False - - return True - - def _generate_eur_trade_setup( - self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] - ) -> Optional[TradeSetup]: - """Generate EUR/USD-specific trade setup with tight TP / wide SL.""" - cfg = self.config.eur_strategy - - m15_analysis = None - if report.primary_analysis and report.primary_analysis.timeframe == Timeframe.M15.value: - m15_analysis = report.primary_analysis - else: - for ca in report.confirmation_analyses: - if ca.timeframe == Timeframe.M15.value: - m15_analysis = ca - break - - if not m15_analysis: - return None - - atr = m15_analysis.key_levels.get("atr", 0) - if atr <= 0: - return None - - price = report.current_price - pip_value = 0.0001 - - tp1 = price + cfg.tp_atr_multiplier * atr - sl = price - cfg.sl_atr_multiplier * atr - tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr - - sl_distance = price - sl - tp_distance = tp1 - price - rr = tp_distance / sl_distance if sl_distance > 0 else 0 - - return TradeSetup( - pair=report.symbol, - display_name=report.display_name, - direction="BUY", - entry_price=price, - stop_loss=round(sl, 5), - take_profit_1=round(tp1, 5), - take_profit_2=round(tp2 , 5), - risk_reward=round(rr, 2), - risk_pips=round(sl_distance / pip_value, 1), - reward_pips=round(tp_distance / pip_value, 1), - pip_value=pip_value, - ) - - def _check_gbp_pullback_buy( - self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] - ) -> bool: - """GBP/USD pullback-buy — same logic as EUR, different config.""" - cfg = self.config.gbp_strategy - - try: - bar_ts = pd.Timestamp(report.timestamp) - bar_hour = bar_ts.hour - bar_wd = bar_ts.weekday() - if bar_hour in cfg.blocked_hours_utc: - return False - allowed_wd = self.config.backtest.allowed_weekdays - if allowed_wd and bar_wd not in allowed_wd: - return False - except Exception: - return False - - m15_analysis = None - h1_analysis = None - - if report.primary_analysis: - tf = report.primary_analysis.timeframe - if tf == Timeframe.M15.value: - m15_analysis = report.primary_analysis - elif tf == Timeframe.H1.value: - h1_analysis = report.primary_analysis - - for ca in report.confirmation_analyses: - if ca.timeframe == Timeframe.M15.value: - m15_analysis = ca - elif ca.timeframe == Timeframe.H1.value: - h1_analysis = ca - - if not m15_analysis or not h1_analysis: - return False - - m15_rsi = m15_analysis.indicators.get("rsi") - if not m15_rsi or m15_rsi.value < cfg.rsi_lower or m15_rsi.value > cfg.rsi_upper: - return False - - m15_bb = m15_analysis.indicators.get("bollinger") - if m15_bb and (m15_bb.value < cfg.bb_min_position or m15_bb.value > cfg.bb_max_position): - return False - - m15_ema200 = m15_analysis.key_levels.get("ema_trend") - m15_df = dfs.get(Timeframe.M15) - if m15_df is None or m15_ema200 is None: - return False - m15_price = float(m15_df["Close"].iloc[-1]) - if m15_price <= m15_ema200: - return False - - h1_ema200 = h1_analysis.key_levels.get("ema_trend") - h1_df = dfs.get(Timeframe.H1) - if h1_ema200 is None: - return False - h1_price = float(h1_df["Close"].iloc[-1]) if h1_df is not None else report.current_price - if h1_price <= h1_ema200: - return False - - m15_atr = m15_analysis.key_levels.get("atr", 0) - m15_ema21 = m15_analysis.key_levels.get("ema_medium") - if m15_atr > 0 and m15_ema21: - dist = abs(m15_price - m15_ema21) / m15_atr - if dist > cfg.chase_max_atr_from_ema21: - return False - - # See EUR equivalent — enforce minimum indicator confluence. - if cfg.min_indicators_agree > 0: - agreeing = sum( - 1 - for name in ("ema_trend", "rsi", "bollinger", "adx", "candlestick") - if (ind := m15_analysis.indicators.get(name)) is not None - and ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY) - ) - if agreeing < cfg.min_indicators_agree: - return False - - return True - - def _generate_gbp_trade_setup( - self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] - ) -> Optional[TradeSetup]: - """Generate GBP/USD trade setup with tight TP / balanced SL.""" - cfg = self.config.gbp_strategy - - m15_analysis = None - if report.primary_analysis and report.primary_analysis.timeframe == Timeframe.M15.value: - m15_analysis = report.primary_analysis - else: - for ca in report.confirmation_analyses: - if ca.timeframe == Timeframe.M15.value: - m15_analysis = ca - break - - if not m15_analysis: - return None - - atr = m15_analysis.key_levels.get("atr", 0) - if atr <= 0: - return None - - price = report.current_price - pip_value = 0.0001 - - tp1 = price + cfg.tp_atr_multiplier * atr - sl = price - cfg.sl_atr_multiplier * atr - tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr - - sl_distance = price - sl - tp_distance = tp1 - price - rr = tp_distance / sl_distance if sl_distance > 0 else 0 - - return TradeSetup( - pair=report.symbol, - display_name=report.display_name, - direction="BUY", - entry_price=price, - stop_loss=round(sl, 5), - take_profit_1=round(tp1, 5), - take_profit_2=round(tp2, 5), - risk_reward=round(rr, 2), - risk_pips=round(sl_distance / pip_value, 1), - reward_pips=round(tp_distance / pip_value, 1), - pip_value=pip_value, - ) - def _check_mtf_agreement(self, report: SignalReport) -> bool: signals = list(report.mtf_signals.values()) if len(signals) < 2: @@ -1023,154 +597,184 @@ class SignalAggregator: score = report.primary_score confidence = report.primary_confidence + # ── Bonuses (additive, applied first) ───────────────────── if report.mtf_agreement: confidence += 10 all_scores = list(report.mtf_scores.values()) if all_scores: mtf_avg = sum(all_scores) / len(all_scores) score = score * 0.6 + mtf_avg * 0.4 - else: - confidence -= 15 - if len(report.mtf_scores) >= 2: - scores = list(report.mtf_scores.values()) - has_bull = any(s > 10 for s in scores) - has_bear = any(s < -10 for s in scores) - if has_bull and has_bear: - report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes") if abs(report.news_sentiment) > 0.2: if (report.news_sentiment > 0 and score > 0) or \ (report.news_sentiment < 0 and score < 0): confidence += 5 - elif (report.news_sentiment > 0 and score < -10) or \ - (report.news_sentiment < 0 and score > 10): - report.warnings.append("News sentiment CONFLICTS with technical signal") - confidence -= 10 + primary = report.primary_analysis + if primary and report.market_regime.startswith("trending"): + rsi_ind = primary.indicators.get("rsi") + bb_ind = primary.indicators.get("bollinger") + if rsi_ind: + rsi_val = rsi_ind.value + if report.market_regime in ("trending_up", "trending") and score > 0 and 38 <= rsi_val <= 55: + confidence += 5 + elif report.market_regime == "trending_down" and score < 0 and 45 <= rsi_val <= 62: + confidence += 5 + if bb_ind: + bb_pos = bb_ind.value + if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos < 0.4: + confidence += 5 + elif report.market_regime == "trending_down" and score < 0 and bb_pos > 0.6: + confidence += 5 + + # ── Event risk (hard cap, separate from penalty system) ─── if report.event_risk == "danger": report.warnings.append(f"EVENT RISK: {report.event_description}") confidence = min(confidence, 30) report.action_recommendation = f"WAIT — {report.event_description}" - elif report.event_risk == "caution": + + # ── Penalties (diminishing cap system) ──────────────────── + # Collect all applicable penalties, then apply with exponential + # decay so stacking never guts a signal beyond 30%. + penalties = [] + + if not report.mtf_agreement: + penalties.append(("mtf_disagreement", 15)) + if len(report.mtf_scores) >= 2: + scores_list = list(report.mtf_scores.values()) + if any(s > 10 for s in scores_list) and any(s < -10 for s in scores_list): + report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes") + + if abs(report.news_sentiment) > 0.2: + if (report.news_sentiment > 0 and score < -10) or \ + (report.news_sentiment < 0 and score > 10): + report.warnings.append("News sentiment CONFLICTS with technical signal") + penalties.append(("news_conflict", 10)) + + if report.event_risk == "caution": report.warnings.append(f"Upcoming event: {report.event_description}") - confidence -= 15 + penalties.append(("event_caution", 15)) is_counter_trend = ( (report.market_regime == "trending_up" and score < 0) or (report.market_regime == "trending_down" and score > 0) ) if is_counter_trend: - confidence -= 15 report.warnings.append("Counter-trend signal — reduced confidence") + penalties.append(("counter_trend", 15)) if report.volatility_regime == "extreme": - report.warnings.append("EXTREME volatility — wider stops needed, reduced position size") - confidence -= 10 + report.warnings.append("EXTREME volatility — wider stops needed") + penalties.append(("extreme_vol", 10)) elif report.volatility_regime == "low": - report.warnings.append("Low volatility — potential for breakout, tight ranges") + report.warnings.append("Low volatility — potential for breakout") - is_xau = report.display_name == "XAU/USD" - - primary = report.primary_analysis if primary is not None: ema200 = primary.key_levels.get("ema_trend") if ema200 and ema200 > 0 and report.current_price > 0: ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0 _lo = float(os.environ.get("FB2_EMA_LO", "0.5")) - _hi = float(os.environ.get("FB2_EMA_HI", "1.5")) if ema_dist_pct < _lo: - report.warnings.append( - f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk" - ) - confidence -= 3 if is_xau else 10 - elif ema_dist_pct > (2.5 if is_xau else _hi): - report.warnings.append( - f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended" - ) - confidence -= 5 if is_xau else 10 + report.warnings.append(f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk") + penalties.append(("ema200_near", 5)) + elif ema_dist_pct > 2.5: + report.warnings.append(f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended") + penalties.append(("ema200_far", 5)) atr = primary.key_levels.get("atr", 0) if atr > 0 and score != 0: - is_buy_signal = score > 0 - if is_buy_signal: + if score > 0: swing_lows = sorted( - v for k, v in primary.key_levels.items() - if k.startswith("swing_low_") + v for k, v in primary.key_levels.items() if k.startswith("swing_low_") ) - _exh_mult = 3.5 if is_xau else 2.5 - if swing_lows and report.current_price - swing_lows[0] > _exh_mult * atr: - confidence -= 5 if is_xau else 15 - report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult} ATR past swing low") + if swing_lows and report.current_price - swing_lows[0] > 3.5 * atr: + report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low") + penalties.append(("exhaustion", 8)) else: swing_highs = sorted( - (v for k, v in primary.key_levels.items() - if k.startswith("swing_high_")), + (v for k, v in primary.key_levels.items() if k.startswith("swing_high_")), reverse=True, ) - _exh_mult_s = 3.5 if is_xau else 2.5 - if swing_highs and swing_highs[0] - report.current_price > _exh_mult_s * atr: - confidence -= 5 if is_xau else 15 - report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult_s} ATR past swing high") + if swing_highs and swing_highs[0] - report.current_price > 3.5 * atr: + report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing high") + penalties.append(("exhaustion", 8)) ema_medium = primary.key_levels.get("ema_medium") if atr > 0 and ema_medium and report.market_regime.startswith("trending"): pullback_dist = abs(report.current_price - ema_medium) / atr - _pb_severe = 2.5 if is_xau else 2.0 - _pb_mild = 2.0 if is_xau else 1.5 - if pullback_dist > _pb_severe: - confidence -= 8 if is_xau else 20 - report.warnings.append( - f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21, chasing risk" - ) - elif pullback_dist > _pb_mild: - confidence -= 5 if is_xau else 10 - report.warnings.append( - f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21" - ) + if pullback_dist > 2.5: + report.warnings.append(f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21") + penalties.append(("no_pullback", 8)) + elif pullback_dist > 2.0: + report.warnings.append(f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21") + penalties.append(("weak_pullback", 5)) if report.market_regime.startswith("trending"): rsi_ind = primary.indicators.get("rsi") bb_ind = primary.indicators.get("bollinger") - if rsi_ind: rsi_val = rsi_ind.value - _rsi_pen = 5 if is_xau else 15 - if report.market_regime in ("trending_up", "trending") and score > 0: - if rsi_val > 65: - confidence -= _rsi_pen - report.warnings.append( - f"Extended entry — RSI {rsi_val:.0f} in uptrend (prefer pullback to 40-55)" - ) - elif 38 <= rsi_val <= 55: - confidence += 5 - elif report.market_regime == "trending_down" and score < 0: - if rsi_val < 35: - confidence -= _rsi_pen - report.warnings.append( - f"Extended entry — RSI {rsi_val:.0f} in downtrend (prefer bounce to 45-62)" - ) - elif 45 <= rsi_val <= 62: - confidence += 5 - + if report.market_regime in ("trending_up", "trending") and score > 0 and rsi_val > 65: + report.warnings.append(f"Extended — RSI {rsi_val:.0f} in uptrend") + penalties.append(("rsi_extended", 5)) + elif report.market_regime == "trending_down" and score < 0 and rsi_val < 35: + report.warnings.append(f"Extended — RSI {rsi_val:.0f} in downtrend") + penalties.append(("rsi_extended", 5)) if bb_ind: bb_pos = bb_ind.value - if report.market_regime in ("trending_up", "trending") and score > 0: - if bb_pos > 0.85: - confidence -= 10 - elif bb_pos < 0.4: - confidence += 5 - elif report.market_regime == "trending_down" and score < 0: - if bb_pos < 0.15: - confidence -= 10 - elif bb_pos > 0.6: - confidence += 5 + if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos > 0.85: + penalties.append(("bb_overextended", 8)) + elif report.market_regime == "trending_down" and score < 0 and bb_pos < 0.15: + penalties.append(("bb_overextended", 8)) + # Hard block: static exhaustion + fading momentum → kill the trade + # Static signals alone are normal in gold trends. Only block when + # price is extended AND momentum indicators confirm deceleration. + _EXHAUSTION_TAGS = {"exhaustion", "no_pullback", "rsi_extended", "bb_overextended"} + exhaustion_count = sum(1 for name, _ in penalties if name in _EXHAUSTION_TAGS) + momentum_fading = False + if primary is not None and exhaustion_count >= 2: + roc_ind = primary.indicators.get("roc") + macd_ind = primary.indicators.get("macd") + roc_fading = False + macd_fading = False + if roc_ind: + if score > 0 and roc_ind.value < 0: + roc_fading = True + elif score < 0 and roc_ind.value > 0: + roc_fading = True + if macd_ind: + if score > 0 and macd_ind.signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL): + macd_fading = True + elif score < 0 and macd_ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): + macd_fading = True + momentum_fading = roc_fading or macd_fading + exhaustion_blocked = exhaustion_count >= 2 and momentum_fading + + # Apply penalties with diminishing returns (cap at 30%) + MAX_PENALTY = 30.0 + penalties.sort(key=lambda x: x[1], reverse=True) + total_penalty = 0.0 + decay = 1.0 + for _name, amount in penalties: + applied = amount * decay + if total_penalty + applied > MAX_PENALTY: + applied = MAX_PENALTY - total_penalty + total_penalty += applied + decay *= 0.5 + if total_penalty >= MAX_PENALTY: + break + + confidence -= total_penalty confidence = max(0.0, min(95.0, confidence)) + if exhaustion_blocked: + report.warnings.append( + f"EXHAUSTION BLOCK — {exhaustion_count} exhaustion signals fired, trade rejected" + ) + confidence = 0.0 + # Final signal - if not is_xau and score > 42: - confidence -= 10 - report.warnings.append("EXHAUSTION: score > 42 — trend overextended") if score >= 35 and confidence >= 55: report.final_signal = SignalStrength.STRONG_BUY elif score >= 25 and confidence >= 50: @@ -1185,21 +789,9 @@ class SignalAggregator: report.final_score = score report.final_confidence = confidence - # SELL suppression (pair-aware) - sell_suppressed = False - if self.config.backtest.sell_filter and report.final_signal in ( - SignalStrength.SELL, SignalStrength.STRONG_SELL - ): - if report.display_name not in self.config.backtest.sell_allowed_pairs: - report.final_signal = SignalStrength.NEUTRAL - report.warnings.append(f"SELL SUPPRESSED for {report.display_name}") - sell_suppressed = True - # Quality grade abs_score = abs(score) - if sell_suppressed: - report.signal_quality = "F" - elif abs_score >= 40 and confidence >= 65 and report.mtf_agreement: + if abs_score >= 40 and confidence >= 65 and report.mtf_agreement: report.signal_quality = "A+" elif abs_score >= 33 and confidence >= 55 and report.mtf_agreement: report.signal_quality = "A" @@ -1252,8 +844,8 @@ class SignalAggregator: min_rr = self.config.signals.min_rr_ratio mature_move = abs(report.final_score) > 30 - tp_mult = 0.9 if mature_move else 1.2 - tp_swing_cap = 1.5 if mature_move else 2.0 + tp_mult = 1.0 if mature_move else 1.5 + tp_swing_cap = 2.0 if mature_move else 2.5 # Fix 3: Wider SL multiplier for XAU/USD (1.8x vs default 1.5x) sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier diff --git a/signal_web.py b/signal_web.py deleted file mode 100644 index 3bf5428..0000000 --- a/signal_web.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env python3 -""" -ForexBot2 — Signal Daemon -========================== -Candle-boundary auto-scanner that publishes signal data to Redis. -Mirrors telegram_bot.py scan loops but outputs to Redis instead of Telegram. - -Scan loop: M15/H1/H4 at candle boundaries + 10s buffer. -Data flow: SignalAggregator → serialize → Redis key + pub/sub channel. - -Usage: - python signal_web.py -""" - -import asyncio -import json -import logging -import os -import signal as unix_signal -import sys -import time -from concurrent.futures import ThreadPoolExecutor -from datetime import datetime, timezone -from typing import Dict, List, Optional - -import redis - -from config import ( - PlatformConfig, SignalStrength, Timeframe, - EURUSD, GBPUSD, XAUUSD, PAIR_INFO, -) -from data_provider import ForexDataProvider -from signal_aggregator import SignalAggregator, SignalReport - -if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8") - -logging.basicConfig( - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - level=logging.INFO, - datefmt="%H:%M:%S", -) -logging.getLogger("httpx").setLevel(logging.WARNING) -logging.getLogger("httpcore").setLevel(logging.WARNING) -logger = logging.getLogger("signal_web") - -# ── Config ──────────────────────────────────────────────────────────── - -SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD] - -AUTOSCAN_CONFIRMATIONS = { - Timeframe.M15: [Timeframe.M5, Timeframe.H1], - Timeframe.H1: [Timeframe.M15, Timeframe.H4], - Timeframe.H4: [Timeframe.H1, Timeframe.D1], -} - -AUTOSCAN_LOOPS = [ - (Timeframe.M15, 15), - (Timeframe.H1, 60), - (Timeframe.H4, 240), -] - -REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") -REDIS_KEY = "forexbot2:signals" -REDIS_CHANNEL = "forexbot2:updates" - -_executor = ThreadPoolExecutor(max_workers=4) - -# ── Shared data provider ────────────────────────────────────────────── - -_shared_dp: Optional[ForexDataProvider] = None - - -def _get_shared_dp() -> ForexDataProvider: - global _shared_dp - if _shared_dp is None: - _shared_dp = ForexDataProvider(PlatformConfig()) - return _shared_dp - - -# ── Redis client ────────────────────────────────────────────────────── - -_redis: Optional[redis.Redis] = None - - -def _init_redis() -> None: - global _redis - _redis = redis.from_url(REDIS_URL, decode_responses=True) - _redis.ping() - logger.info(f"Redis connected: {REDIS_URL}") - - -# ── Signal runner (mirrors telegram_bot.py) ─────────────────────────── - -TF_CONFIRMATIONS = { - Timeframe.M15: [Timeframe.M5, Timeframe.H1], - Timeframe.H1: [Timeframe.M15, Timeframe.H4], - Timeframe.H4: [Timeframe.H1, Timeframe.D1], -} - - -def _run_signal_sync( - symbol: str, - timeframe: Timeframe, - confirmation_tfs: Optional[List[Timeframe]] = None, -) -> SignalReport: - if confirmation_tfs is None: - confirmation_tfs = TF_CONFIRMATIONS.get( - timeframe, [Timeframe.M15, Timeframe.H4] - ) - config = PlatformConfig() - config.primary_timeframe = timeframe - config.confirmation_timeframes = confirmation_tfs - aggregator = SignalAggregator(config) - aggregator.data_provider = _get_shared_dp() - return aggregator.analyze_pair(symbol=symbol) - - -async def _run_signal( - symbol: str, - timeframe: Timeframe, - confirmation_tfs: Optional[List[Timeframe]] = None, -) -> SignalReport: - loop = asyncio.get_event_loop() - return await loop.run_in_executor( - _executor, _run_signal_sync, symbol, timeframe, confirmation_tfs - ) - - -# ── Serialization ───────────────────────────────────────────────────── - -def _serialize_report(report: SignalReport) -> dict: - indicators = {} - if report.primary_analysis: - for name, ind in report.primary_analysis.indicators.items(): - if name.startswith("_") or ind.weight == 0: - continue - indicators[name] = { - "signal": ind.signal.name, - "value": round(ind.value, 4), - "confidence": round(ind.confidence, 1), - "description": ind.description, - } - - confirmation_data = [] - for ca in report.confirmation_analyses: - cf_indicators = {} - for name, ind in ca.indicators.items(): - if name.startswith("_") or ind.weight == 0: - continue - cf_indicators[name] = { - "signal": ind.signal.name, - "value": round(ind.value, 4), - "confidence": round(ind.confidence, 1), - "description": ind.description, - } - confirmation_data.append({ - "timeframe": ca.timeframe, - "signal": ca.composite_signal.name, - "score": round(ca.composite_score, 1), - "confidence": round(ca.confidence, 1), - "regime": ca.market_regime, - "indicators": cf_indicators, - }) - - trade = None - if report.trade_setup and report.final_signal not in (SignalStrength.NEUTRAL,): - ts = report.trade_setup - trade = { - "direction": ts.direction, - "entry": round(ts.entry_price, 5), - "sl": round(ts.stop_loss, 5), - "tp1": round(ts.take_profit_1, 5), - "tp2": round(ts.take_profit_2, 5) if ts.take_profit_2 else None, - "rr": round(ts.risk_reward, 2), - "risk_pips": round(ts.risk_pips, 1), - "reward_pips": round(ts.reward_pips, 1), - } - - return { - "pair": report.display_name, - "symbol": report.symbol, - "timestamp": report.timestamp, - "signal": report.final_signal.name, - "score": round(report.final_score, 1), - "confidence": round(report.final_confidence, 1), - "quality": report.signal_quality, - "regime": report.market_regime, - "volatility": report.volatility_regime, - "price": report.current_price, - "mtf_agreement": report.mtf_agreement, - "mtf_signals": {k: v.name for k, v in report.mtf_signals.items()}, - "mtf_scores": {k: round(v, 1) for k, v in report.mtf_scores.items()}, - "indicators": indicators, - "confirmations": confirmation_data, - "trade_setup": trade, - "bullish_reasons": report.bullish_reasons, - "bearish_reasons": report.bearish_reasons, - "warnings": report.warnings, - "action": report.action_recommendation, - } - - -# ── Redis publish ───────────────────────────────────────────────────── - -def _publish_to_redis(snapshot: dict) -> None: - payload = json.dumps(snapshot) - _redis.set(REDIS_KEY, payload) - _redis.publish(REDIS_CHANNEL, payload) - - -# ── Scan logic ──────────────────────────────────────────────────────── - -_signal_store: Dict[str, Dict[str, dict]] = {} - - -async def _scan_timeframe(tf: Timeframe, confirmation_tfs: List[Timeframe]) -> None: - tasks = [_run_signal(symbol, tf, confirmation_tfs) for symbol in SCAN_PAIRS] - results = await asyncio.gather(*tasks, return_exceptions=True) - - for i, result in enumerate(results): - if isinstance(result, Exception): - logger.exception(f"Scan failed for {SCAN_PAIRS[i]} {tf.value}: {result}") - continue - - report: SignalReport = result - display = report.display_name - if display not in _signal_store: - _signal_store[display] = {} - _signal_store[display][tf.value] = _serialize_report(report) - - snapshot = { - "type": "update", - "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), - "data": _signal_store, - } - _publish_to_redis(snapshot) - ok = len([r for r in results if not isinstance(r, Exception)]) - logger.info(f"{tf.value} scan complete — {ok} pairs updated") - - -# ── Candle-boundary scheduler ──────────────────────────────────────── - -def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float: - now = datetime.now(timezone.utc) - total_minutes = now.hour * 60 + now.minute - elapsed_minutes = total_minutes % interval_minutes - elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6 - interval_seconds = interval_minutes * 60 - remaining = interval_seconds - elapsed_seconds + buffer - if remaining <= buffer: - remaining += interval_seconds - return remaining - - -async def _candle_loop(tf: Timeframe, interval_minutes: int) -> None: - label = tf.value - try: - while True: - wait = _seconds_until_boundary(interval_minutes, buffer=10) - logger.info(f"{label} scan: next in {wait:.0f}s") - await asyncio.sleep(wait) - logger.info(f"{label} scan: firing") - await _scan_timeframe(tf, AUTOSCAN_CONFIRMATIONS[tf]) - except asyncio.CancelledError: - pass - finally: - logger.info(f"{label} scan loop stopped") - - -# ── Main ────────────────────────────────────────────────────────────── - -async def main() -> None: - _init_redis() - - logger.info("Running initial M15 scan…") - await _scan_timeframe(Timeframe.M15, AUTOSCAN_CONFIRMATIONS[Timeframe.M15]) - logger.info("Initial scan complete") - - tasks = [] - for tf, interval in AUTOSCAN_LOOPS: - task = asyncio.create_task(_candle_loop(tf, interval)) - tasks.append(task) - logger.info(f"Started {tf.value} scan loop (every {interval}min)") - - stop = asyncio.Event() - loop = asyncio.get_event_loop() - for sig in (unix_signal.SIGINT, unix_signal.SIGTERM): - loop.add_signal_handler(sig, stop.set) - - logger.info("Signal daemon running — Ctrl+C to stop") - await stop.wait() - - logger.info("Shutting down…") - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - _executor.shutdown(wait=False) - logger.info("Signal daemon stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/telegram_bot.py b/telegram_bot.py index 50f78e4..ac016c2 100755 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -5,26 +5,18 @@ ForexBot2 — Telegram Bot Async Telegram interface with candle-boundary auto-scanner and real-time WebSocket level-breach monitor. -Manual scan: 4 pairs × 3 timeframes (M15/H1/H4) via inline buttons. +Manual scan: XAU/USD × 3 timeframes (M15/H1/H4) via inline buttons. Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set. /autoscan toggles for additional chats or to stop. M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals). M15 fires at :00/:15/:30/:45 + 10s buffer. H1 fires at :00 + 10s buffer. H4 fires at :00 every 4 hours + 10s buffer. -WebSocket: Streams live ticks from TwelveData for EUR/USD & XAU/USD - (Grow plan supports only these 2 WS symbols). +WebSocket: Streams live ticks from TwelveData for XAU/USD. When price crosses a key structure level (swing S/R, BB, EMA200) cached from the last boundary scan, triggers an immediate M15 scan and sends a Level Break Alert. - 5-minute cooldown per pair prevents spam. - GBP/USD & USD/JPY are monitored via REST scans only. - -Plan: TwelveData Grow (55 credits/min, 8 trial WS credits). -Credit budget (worst case, 4-hour boundary): - Unique fetches: 4 pairs × ~6 TFs = ~24 calls (rest cached) → fits in 55/min. - WebSocket: 2 symbols = 2 credits (one-time on connect). - Level breach scans: ~3 REST calls each, ~10/hour worst case. + 5-minute cooldown prevents spam. Usage: python telegram_bot.py @@ -55,7 +47,7 @@ from telegram.ext import ( from config import ( PlatformConfig, SignalStrength, Timeframe, - EURUSD, GBPUSD, XAUUSD, PAIR_INFO, + XAUUSD, PAIR_INFO, ) from data_provider import ForexDataProvider from signal_aggregator import SignalAggregator, SignalReport @@ -78,14 +70,9 @@ CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022") # ── Pairs & buttons ─────────────────────────────────────────────────── -SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD] - -CORRELATION_GROUPS = { - "usd_negative": {"pairs": {EURUSD, GBPUSD}, "label": "USD-weakness"}, -} +SCAN_PAIRS = [XAUUSD] _WS_TO_PAIR = { - "EUR/USD": EURUSD, "XAU/USD": XAUUSD, } _WS_SYMBOLS = list(_WS_TO_PAIR.keys()) @@ -93,7 +80,8 @@ _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", - "ema_trend", + "ema_trend", "bb_upper", "bb_lower", + "kc_upper", "kc_lower", ) # ── Live signal stats cache ────────────────────────────────────────── @@ -102,15 +90,10 @@ _signal_stats: Dict[str, Dict[str, SignalReport]] = {} _stats_updated: Optional[float] = None SCAN_BUTTONS = [ - {"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"}, - {"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"}, - {"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR/USD 4H"}, - {"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"}, - {"cb": "scan_gbpusd_h1", "symbol": GBPUSD, "tf": Timeframe.H1, "label": "GBP/USD 1H"}, - {"cb": "scan_gbpusd_h4", "symbol": GBPUSD, "tf": Timeframe.H4, "label": "GBP/USD 4H"}, - {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU/USD 15M"}, - {"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "XAU/USD 1H"}, - {"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "XAU/USD 4H"}, + {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"}, + {"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"}, + {"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"}, + {"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"}, ] SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS} @@ -139,6 +122,10 @@ def _escape_md(text: str) -> str: return text +def _fmt_price(price: float) -> str: + return f"{price:.2f}" if price >= 10 else f"{price:.5f}" + + # ── Shared data provider (single cache across all scans) ───────────── _shared_dp: Optional[ForexDataProvider] = None @@ -210,7 +197,7 @@ def format_signal_message(report: SignalReport) -> str: f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})", f"🕐 {now}", "", - f"💰 Price: *{report.current_price:.5f}*" if report.current_price < 10 else f"💰 Price: *{report.current_price:.3f}*", + f"💰 Price: *{_fmt_price(report.current_price)}*", f"📊 Regime: {regime_str} | Vol: {vol_str}", "", "━━━━━━━━━━━━━━━━━━━━", @@ -243,12 +230,12 @@ def format_signal_message(report: SignalReport) -> str: "━━━━━━━━━━━━━━━━━━━━", f"🚀 *TRADE SETUP*", f"{dir_emoji} Direction: *{ts.direction}*", - f"▶ Entry: `{ts.entry_price:.3f}`", - f"🛑 SL: `{ts.stop_loss:.3f}` ({ts.risk_pips:.1f} pips)", - f"🎯 TP1: `{ts.take_profit_1:.3f}` ({ts.reward_pips:.1f} pips)", + f"▶ Entry: `{_fmt_price(ts.entry_price)}`", + f"🛑 SL: `{_fmt_price(ts.stop_loss)}` ({ts.risk_pips:.1f} pips)", + f"🎯 TP1: `{_fmt_price(ts.take_profit_1)}` ({ts.reward_pips:.1f} pips)", ] if ts.take_profit_2: - lines.append(f"🎯 TP2: `{ts.take_profit_2:.3f}`") + lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`") lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}") lines += ["", "━━━━━━━━━━━━━━━━━━━━", @@ -257,33 +244,6 @@ def format_signal_message(report: SignalReport) -> str: return "\n".join(lines) -# ── Correlation filter ─────────────────────────────────────────────── - -def _detect_correlated_exposure(reports: List[SignalReport]) -> List[str]: - warnings = [] - for group_info in CORRELATION_GROUPS.values(): - group_pairs = group_info["pairs"] - group_label = group_info["label"] - active = [ - r for r in reports - if r.symbol in group_pairs - and r.final_signal not in (SignalStrength.NEUTRAL,) - ] - if len(active) >= 2: - directions = set() - for r in active: - if r.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): - directions.add("BUY") - else: - directions.add("SELL") - if len(directions) == 1: - names = ", ".join(r.display_name for r in active) - warnings.append( - f"⚠️ Same exposure ({group_label}): {names} both {directions.pop()}" - ) - return warnings - - # ── Auto-scan summary format ───────────────────────────────────────── def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str: @@ -310,10 +270,6 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str: else: lines.append("No actionable signals this cycle.") - corr_warnings = _detect_correlated_exposure(reports) - for w in corr_warnings: - lines.append(w) - return "\n".join(lines) @@ -358,10 +314,7 @@ def _format_stats_dashboard() -> str: break price = ws_price or (latest.current_price if latest else 0) - if price < 10: - price_str = f"`{price:.5f}`" - else: - price_str = f"`{price:.2f}`" + price_str = f"`{_fmt_price(price)}`" regime = latest.market_regime.replace("_", " ").title() if latest else "" @@ -383,8 +336,8 @@ def _format_stats_dashboard() -> str: ts = latest.trade_setup d = "📈" if ts.direction == "BUY" else "📉" lines.append( - f"{d} {ts.direction} `{ts.entry_price:.3f}` " - f"SL `{ts.stop_loss:.3f}` TP `{ts.take_profit_1:.3f}` " + f"{d} {ts.direction} `{_fmt_price(ts.entry_price)}` " + f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` " f"RR 1:{ts.risk_reward:.1f}" ) @@ -452,12 +405,14 @@ async def _dashboard_loop(bot) -> None: # ── Keyboard helper ─────────────────────────────────────────────────── def scan_keyboard() -> InlineKeyboardMarkup: - rows = [] - for i in range(0, len(SCAN_BUTTONS), 4): - rows.append([ - InlineKeyboardButton(b["label"], callback_data=b["cb"]) - for b in SCAN_BUTTONS[i:i + 4] - ]) + tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None] + all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None) + rows = [[ + InlineKeyboardButton(b["label"], callback_data=b["cb"]) + for b in tf_buttons + ]] + if all_button: + rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])]) return InlineKeyboardMarkup(rows) @@ -485,7 +440,7 @@ async def run_signal( timeframe: Timeframe, confirmation_tfs: Optional[List[Timeframe]] = None, ) -> SignalReport: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor( _executor, _run_signal_sync, symbol, timeframe, confirmation_tfs ) @@ -729,7 +684,7 @@ def _start_scheduler(bot) -> None: if _dashboard_task is None or _dashboard_task.done(): _dashboard_task = asyncio.create_task(_dashboard_loop(bot)) - logger.info("Dashboard 60s refresh loop started") + logger.info(f"Dashboard {_DASHBOARD_REFRESH_INTERVAL}s refresh loop started") def _stop_scheduler() -> None: @@ -755,10 +710,11 @@ def _stop_scheduler() -> None: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: scanning = "running" if _autoscan_chats else "off" text = ( - "👋 *ForexBot2 — Signal Intelligence*\n\n" - "Pick a pair and timeframe to scan.\n" + "👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n" + "Pick a timeframe to scan gold.\n" "Signals include a full trade setup with entry, SL and TP.\n\n" f"Auto-scan: *{scanning}* — /autoscan to toggle\n" + "/scan — Quick scan buttons\n" "/status — Bot & WebSocket status\n" "/help — All commands" ) @@ -772,15 +728,16 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = ( "📖 *ForexBot2 Commands*\n\n" - "/start — Show scan buttons\n" + "/start — Welcome & scan buttons\n" + "/scan — Quick XAU/USD scan buttons\n" "/autoscan — Toggle auto-scan on/off\n" "/status — Bot status, WS connection, live prices\n" "/cache — Show data cache age per pair/TF\n" "/clearcache — Wipe data cache, force fresh fetch\n" "/help — This message\n\n" "*Auto-scan* fires at candle close: M5/M15/H1/H4.\n" - "Live dashboard auto-refreshes every 60s.\n" - "*WebSocket* monitors live prices and updates level cache." + f"Live dashboard auto-refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" + "*WebSocket* monitors live prices and triggers breach scans." ) await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN) @@ -808,10 +765,8 @@ async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None age = time.time() - _last_price_ts.get(pair, 0) if price is None: lines.append(f" {display}: waiting...") - elif price < 10: - lines.append(f" {display}: `{price:.5f}` ({age:.0f}s ago)") else: - lines.append(f" {display}: `{price:.3f}` ({age:.0f}s ago)") + lines.append(f" {display}: `{_fmt_price(price)}` ({age:.0f}s ago)") else: lines.append("\n_No live prices yet (WS not streaming)_") @@ -897,8 +852,8 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No next_h4 = _seconds_until_boundary(240, buffer=10) await update.message.reply_text( "✅ *Auto-scan enabled*\n\n" - f"Scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4 at candle close.\n" - f"📊 Live dashboard refreshes every 60s.\n" + f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n" + f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" f"📡 Live price monitoring active.\n" f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n" @@ -907,6 +862,14 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No ) +async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.message.reply_text( + "🔍 *XAU/USD — Pick a timeframe*", + parse_mode=ParseMode.MARKDOWN, + reply_markup=scan_keyboard(), + ) + + async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() @@ -915,9 +878,12 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not btn: return - pair_display = btn["label"] + if btn["tf"] is None: + await _cb_scan_all(query) + return + await query.edit_message_text( - f"⏳ Scanning {pair_display}…", + f"⏳ Scanning XAU/USD {btn['label']}…", parse_mode=ParseMode.MARKDOWN, ) @@ -935,12 +901,74 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) +async def _cb_scan_all(query) -> None: + await query.edit_message_text( + "⏳ Scanning XAU/USD across M15 / H1 / H4…", + parse_mode=ParseMode.MARKDOWN, + ) + + tfs = [Timeframe.M15, Timeframe.H1, Timeframe.H4] + tasks = [run_signal(XAUUSD, tf) for tf in tfs] + results = await asyncio.gather(*tasks, return_exceptions=True) + + reports = [] + errors = [] + for tf, result in zip(tfs, results): + if isinstance(result, Exception): + errors.append(f"❌ {tf.value}: {result}") + else: + reports.append(result) + _update_signal_stats([result], tf) + + if reports: + _update_level_cache(reports) + + lines = [ + "📊 *XAU/USD — All Timeframes*", + f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", + "", + ] + + for r in reports: + emoji = _SIGNAL_EMOJI.get(r.final_signal, "⬜") + qe = _QUALITY_EMOJI.get(r.signal_quality, "") + tf_label = r.primary_analysis.timeframe if r.primary_analysis else "?" + sig = _signal_label(r.final_signal) + regime = r.market_regime.replace("_", " ").title() + lines.append( + f"{emoji} *{tf_label}* {sig} {qe}{r.signal_quality} " + f"Score: {r.final_score:+.1f} {r.final_confidence:.0f}%" + ) + lines.append(f" _{_escape_md(regime)}_ | {'✅ MTF' if r.mtf_agreement else '❌ MTF'}") + if r.trade_setup and r.final_signal != SignalStrength.NEUTRAL: + ts = r.trade_setup + d = "📈" if ts.direction == "BUY" else "📉" + lines.append( + f" {d} {ts.direction} `{_fmt_price(ts.entry_price)}` " + f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` " + f"RR 1:{ts.risk_reward:.1f}" + ) + lines.append("") + + for err in errors: + lines.append(err) + + lines.append("_Use buttons below for detailed single-TF view._") + + await query.edit_message_text( + "\n".join(lines), + parse_mode=ParseMode.MARKDOWN, + reply_markup=scan_keyboard(), + ) + + # ── Boot-time auto-start ────────────────────────────────────────────── async def _post_init(app: Application) -> None: """Register command menu and start scheduler on boot.""" await app.bot.set_my_commands([ - ("start", "Show scan buttons"), + ("start", "Welcome & scan buttons"), + ("scan", "Quick XAU/USD scan buttons"), ("autoscan", "Toggle auto-scan on/off"), ("status", "Bot status, WS connection, live prices"), ("cache", "Show data cache age per pair/TF"), @@ -989,8 +1017,8 @@ async def _post_init(app: Application) -> None: await app.bot.send_message( chat_id, f"🤖 *ForexBot2 started*\n\n" - f"Auto-scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4.\n" - f"📊 Live dashboard refreshes every 60s.\n" + f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n" + f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n" f"📡 Live price monitoring active.\n" f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n" @@ -1028,6 +1056,7 @@ def main() -> None: ) app.add_handler(CommandHandler("start", cmd_start)) + app.add_handler(CommandHandler("scan", cmd_scan)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("autoscan", cmd_autoscan)) diff --git a/test_all.py b/test_all.py index 500527f..665f98f 100644 --- a/test_all.py +++ b/test_all.py @@ -88,8 +88,8 @@ print("\n═══ Module 1: config.py ═══") from config import ( Timeframe, SignalStrength, MarketSession, PlatformConfig, TechnicalConfig, SignalConfig, NewsConfig, - FilterConfig, XauStrategyConfig, EurStrategyConfig, GbpStrategyConfig, - USDJPY, EURUSD, GBPUSD, XAUUSD, PAIR_INFO, + FilterConfig, XauStrategyConfig, + XAUUSD, PAIR_INFO, ) @@ -127,21 +127,13 @@ def _(): @test("PAIR_INFO completeness and correctness") def _(): - assert USDJPY in PAIR_INFO - assert EURUSD in PAIR_INFO - assert GBPUSD in PAIR_INFO assert XAUUSD in PAIR_INFO for pair, info in PAIR_INFO.items(): assert "display" in info, f"Missing 'display' for {pair}" assert "pip_value" in info, f"Missing 'pip_value' for {pair}" assert "is_jpy_pair" in info, f"Missing 'is_jpy_pair' for {pair}" assert info["pip_value"] > 0, f"pip_value must be > 0 for {pair}" - assert PAIR_INFO[USDJPY]["is_jpy_pair"] is True - assert PAIR_INFO[EURUSD]["is_jpy_pair"] is False - assert PAIR_INFO[GBPUSD]["is_jpy_pair"] is False assert PAIR_INFO[XAUUSD]["is_jpy_pair"] is False - assert PAIR_INFO[USDJPY]["pip_value"] == 0.01 - assert PAIR_INFO[EURUSD]["pip_value"] == 0.0001 assert PAIR_INFO[XAUUSD]["pip_value"] == 0.10 @@ -164,8 +156,11 @@ def _(): assert tc.adx_period == 14 assert tc.ema_mode == "4ema" assert tc.rsi_mode == "classic" - assert tc.macd_as_pillar is False - assert "divergence" in tc.disabled_indicators + assert tc.macd_as_pillar is True + assert tc.disabled_indicators == [] + assert tc.roc_period == 10 + assert tc.kc_period == 20 + assert tc.adx_slope_lookback == 5 @test("SignalConfig defaults") @@ -183,14 +178,12 @@ def _(): @test("PlatformConfig defaults") def _(): pc = PlatformConfig() - assert pc.symbol == USDJPY + assert pc.symbol == XAUUSD assert pc.primary_timeframe == Timeframe.H1 assert Timeframe.M15 in pc.confirmation_timeframes assert Timeframe.H4 in pc.confirmation_timeframes assert pc.data_lookback_days == 60 assert pc.refresh_interval == 300 - assert EURUSD in pc.scan_pairs - assert GBPUSD in pc.scan_pairs assert XAUUSD in pc.scan_pairs @@ -201,41 +194,16 @@ def _(): assert xc.rsi_entry_threshold == 45.0 assert xc.min_ema200_tfs == 2 assert xc.allow_generic_trades is True - assert xc.tp_atr_multiplier == 0.9 + assert xc.tp_atr_multiplier == 1.0 assert xc.sl_atr_multiplier == 2.0 assert isinstance(xc.blocked_hours_utc, list) -@test("EurStrategyConfig defaults") -def _(): - ec = EurStrategyConfig() - assert ec.enabled is True - assert ec.rsi_lower == 30.0 - assert ec.rsi_upper == 55.0 - assert ec.bb_max_position == 0.75 - assert ec.tp_atr_multiplier == 0.7 - assert ec.sl_atr_multiplier == 1.0 - - -@test("GbpStrategyConfig defaults") -def _(): - gc = GbpStrategyConfig() - assert gc.enabled is True - assert gc.rsi_lower == 30.0 - assert gc.rsi_upper == 55.0 - assert gc.bb_min_position == 0.0 - assert gc.bb_max_position == 0.75 - assert gc.tp_atr_multiplier == 0.7 - assert gc.sl_atr_multiplier == 1.0 - - @test("FilterConfig defaults") def _(): fc = FilterConfig() assert fc.session_filter is True assert fc.regime_filter is False - assert fc.sell_filter is True - assert "XAU/USD" in fc.sell_allowed_pairs @test("PlatformConfig dataclass mutable default independence") @@ -257,9 +225,6 @@ from data_provider import ForexDataProvider, DataIntegrityError @test("ForexDataProvider._to_td_symbol conversion") def _(): - assert ForexDataProvider._to_td_symbol("EURUSD=X") == "EUR/USD" - assert ForexDataProvider._to_td_symbol("USDJPY=X") == "USD/JPY" - assert ForexDataProvider._to_td_symbol("GBPUSD=X") == "GBP/USD" assert ForexDataProvider._to_td_symbol("XAU/USD") == "XAU/USD" @@ -294,7 +259,7 @@ def _(): def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) - result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert len(result) > 0 assert result.index.is_monotonic_increasing assert not result.index.has_duplicates @@ -304,7 +269,7 @@ def _(): def _(): dp = ForexDataProvider(PlatformConfig()) try: - dp._validate_and_clean(pd.DataFrame(), "EURUSD=X", Timeframe.H1) + dp._validate_and_clean(pd.DataFrame(), "XAU/USD", Timeframe.H1) assert False, "Should have raised DataIntegrityError" except DataIntegrityError: pass @@ -314,7 +279,7 @@ def _(): def _(): dp = ForexDataProvider(PlatformConfig()) try: - dp._validate_and_clean(None, "EURUSD=X", Timeframe.H1) + dp._validate_and_clean(None, "XAU/USD", Timeframe.H1) assert False, "Should have raised DataIntegrityError" except DataIntegrityError: pass @@ -325,7 +290,7 @@ def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) df.iloc[10, df.columns.get_loc("High")] = df.iloc[10]["Low"] - 1 - result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert (result["High"] >= result["Low"]).all() @@ -335,7 +300,7 @@ def _(): df = make_ohlcv(300) future = pd.Timestamp.now(tz="UTC") + pd.Timedelta(hours=10) df.loc[future] = df.iloc[-1] - result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert (result.index <= pd.Timestamp.utcnow() + pd.Timedelta(minutes=5)).all() @@ -345,7 +310,7 @@ def _(): df = make_ohlcv(300) dup = pd.concat([df, df.iloc[-5:]]) assert dup.index.has_duplicates, "Setup: should have duplicates" - result = dp._validate_and_clean(dup, "EURUSD=X", Timeframe.H1) + result = dp._validate_and_clean(dup, "XAU/USD", Timeframe.H1) assert not result.index.has_duplicates, "Should remove duplicate indices" @@ -355,7 +320,7 @@ def _(): df = make_ohlcv(300) df.iloc[100, df.columns.get_loc("Close")] = np.nan df.iloc[101, df.columns.get_loc("Close")] = np.nan - result = dp._validate_and_clean(df, "EURUSD=X", Timeframe.H1) + result = dp._validate_and_clean(df, "XAU/USD", Timeframe.H1) assert not result["Close"].isna().any() @@ -371,15 +336,15 @@ def _(): saturday = pd.Timestamp("2025-05-10 12:00:00", tz="UTC") original_now = pd.Timestamp.now with patch.object(pd.Timestamp, "now", return_value=saturday): - result = dp._is_data_fresh(df, "EURUSD=X", Timeframe.H1) + result = dp._is_data_fresh(df, "XAU/USD", Timeframe.H1) assert result is True, "Weekend data should always be considered fresh" @test("ForexDataProvider._is_data_fresh returns False for empty df") def _(): dp = ForexDataProvider(PlatformConfig()) - assert dp._is_data_fresh(pd.DataFrame(), "EURUSD=X", Timeframe.H1) is False - assert dp._is_data_fresh(None, "EURUSD=X", Timeframe.H1) is False + assert dp._is_data_fresh(pd.DataFrame(), "XAU/USD", Timeframe.H1) is False + assert dp._is_data_fresh(None, "XAU/USD", Timeframe.H1) is False @test("ForexDataProvider.clear_cache clears all entries") @@ -396,13 +361,13 @@ def _(): @test("ForexDataProvider.get_pair_info returns correct info") def _(): dp = ForexDataProvider(PlatformConfig()) - info = dp.get_pair_info(EURUSD) - assert info["symbol"] == EURUSD - assert info["display"] == "EUR/USD" - assert info["pip_value"] == 0.0001 + info = dp.get_pair_info(XAUUSD) + assert info["symbol"] == XAUUSD + assert info["display"] == "XAU/USD" + assert info["pip_value"] == 0.10 info_unknown = dp.get_pair_info("UNKNOWN") - assert info_unknown["display"] == "USD/JPY" + assert info_unknown["display"] == "XAU/USD" @test("ForexDataProvider._throttle rate limiting logic") @@ -418,9 +383,9 @@ def _(): def _(): dp = ForexDataProvider(PlatformConfig()) df = make_ohlcv(300) - dp._cache["EURUSD=X_1h"] = df - dp._cache_timestamps["EURUSD=X_1h"] = time.time() - cached = dp._cache.get("EURUSD=X_1h") + dp._cache["XAU/USD_1h"] = df + dp._cache_timestamps["XAU/USD_1h"] = time.time() + cached = dp._cache.get("XAU/USD_1h") assert cached is df @@ -437,7 +402,7 @@ from engine import TechnicalEngine, TechnicalAnalysis, IndicatorSignal def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(10) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.confidence == 0.0 @@ -445,7 +410,7 @@ def _(): @test("TechnicalEngine.analyze returns TechnicalAnalysis with None df") def _(): engine = TechnicalEngine(TechnicalConfig()) - result = engine.analyze(None, "EURUSD=X", "1h") + result = engine.analyze(None, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.confidence == 0.0 @@ -454,9 +419,9 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) - assert result.symbol == "EURUSD=X" + assert result.symbol == "XAU/USD" assert result.timeframe == "1h" assert result.composite_signal in list(SignalStrength) assert -100 <= result.composite_score <= 100 @@ -468,7 +433,7 @@ def _(): config = TechnicalConfig(ema_mode="4ema") engine = TechnicalEngine(config) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators ema_ind = result.indicators["ema_trend"] assert isinstance(ema_ind, IndicatorSignal) @@ -484,7 +449,7 @@ def _(): config = TechnicalConfig(ema_mode="2ema") engine = TechnicalEngine(config) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators assert "ema_trend" in result.key_levels or "ema_slow" in result.key_levels @@ -493,7 +458,7 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "rsi" in result.indicators rsi = result.indicators["rsi"] assert 0 <= rsi.value <= 100, f"RSI value {rsi.value} out of range" @@ -506,7 +471,7 @@ def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) df["Close"] = df["Close"].iloc[0] # flat price = 0 delta - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") rsi = result.indicators.get("rsi") if rsi: assert not pd.isna(rsi.value), "RSI should handle NaN gracefully" @@ -516,7 +481,7 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "bollinger" in result.indicators bb = result.indicators["bollinger"] assert isinstance(bb.value, float) @@ -530,7 +495,7 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "adx" in result.indicators adx = result.indicators["adx"] assert adx.value >= 0, f"ADX value {adx.value} should be >= 0" @@ -542,7 +507,7 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "atr" in result.indicators atr = result.indicators["atr"] assert atr.value > 0, "ATR should be positive" @@ -552,32 +517,31 @@ def _(): assert "atr_pct" in result.key_levels -@test("TechnicalEngine MACD (not as pillar by default)") +@test("TechnicalEngine MACD as pillar (enabled by default)") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") - assert "macd" not in result.indicators - assert hasattr(result, "_macd_histogram") - - -@test("TechnicalEngine MACD as pillar when enabled") -def _(): - config = TechnicalConfig(macd_as_pillar=True) - engine = TechnicalEngine(config) - df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "macd" in result.indicators macd = result.indicators["macd"] assert macd.weight == 1.5 +@test("TechnicalEngine MACD not as pillar when disabled") +def _(): + config = TechnicalConfig(macd_as_pillar=False) + engine = TechnicalEngine(config) + df = make_ohlcv(300) + result = engine.analyze(df, "XAU/USD", "1h") + assert "macd" not in result.indicators + assert hasattr(result, "_macd_histogram") + @test("TechnicalEngine disabled indicators are skipped") def _(): config = TechnicalConfig(disabled_indicators=["rsi", "bollinger", "divergence"]) engine = TechnicalEngine(config) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "rsi" not in result.indicators assert "bollinger" not in result.indicators @@ -593,7 +557,7 @@ def _(): df.iloc[idx, df.columns.get_loc("Close")] = close_val df.iloc[idx, df.columns.get_loc("High")] = close_val + 0.00005 df.iloc[idx, df.columns.get_loc("Low")] = close_val - 0.001 - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") # Pattern may or may not be detected depending on threshold, just ensure no crash @@ -620,7 +584,7 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300, trend=0.001) # strong uptrend - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert result.market_regime in ("trending_up", "trending_down", "trending", "ranging") @@ -628,7 +592,7 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert -100 <= result.composite_score <= 100 assert result.composite_signal in list(SignalStrength) @@ -638,7 +602,7 @@ def _(): config = TechnicalConfig(rsi_mode="regime_aware") engine = TechnicalEngine(config) df = make_ohlcv(300, trend=0.001) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") rsi = result.indicators.get("rsi") assert rsi is not None @@ -647,26 +611,25 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") swing_keys = [k for k in result.key_levels if k.startswith("swing_")] assert len(swing_keys) >= 0 # may or may not have swings -@test("TechnicalEngine divergence detection (disabled by default)") +@test("TechnicalEngine divergence detection (enabled by default)") def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") - assert "divergence" not in result.indicators + result = engine.analyze(df, "XAU/USD", "1h") + # divergence may or may not be detected depending on data, just ensure no crash - -@test("TechnicalEngine divergence detection (enabled)") +@test("TechnicalEngine divergence detection (disabled)") def _(): - config = TechnicalConfig(disabled_indicators=[]) + config = TechnicalConfig(disabled_indicators=["divergence"]) engine = TechnicalEngine(config) df = make_ohlcv(300) - result = engine.analyze(df, "EURUSD=X", "1h") - # divergence may or may not be detected, just ensure no crash + result = engine.analyze(df, "XAU/USD", "1h") + assert "divergence" not in result.indicators @test("TechnicalEngine EMA-2 golden cross detection") @@ -674,7 +637,7 @@ def _(): config = TechnicalConfig(ema_mode="2ema") engine = TechnicalEngine(config) df = make_ohlcv(300, trend=0.002) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert "ema_trend" in result.indicators @@ -682,10 +645,71 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(31) - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) +@test("TechnicalEngine ROC computation") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300, trend=0.002) + result = engine.analyze(df, "XAU/USD", "1h") + assert "roc" in result.indicators + roc = result.indicators["roc"] + assert roc.weight == 1.5 + assert roc.signal in list(SignalStrength) + + +@test("TechnicalEngine Keltner Channels computation") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300) + result = engine.analyze(df, "XAU/USD", "1h") + assert "keltner" in result.indicators + kc = result.indicators["keltner"] + assert kc.weight == 1.5 + assert "kc_upper" in result.key_levels + assert "kc_lower" in result.key_levels + assert "kc_mid" in result.key_levels + assert result.key_levels["kc_upper"] > result.key_levels["kc_lower"] + + +@test("TechnicalEngine ADX slope computation") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300, trend=0.002) + result = engine.analyze(df, "XAU/USD", "1h") + assert "adx" in result.indicators + assert hasattr(result, "_adx_slope") + + +@test("TechnicalEngine transitioning regime detection") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300) + result = engine.analyze(df, "XAU/USD", "1h") + assert result.market_regime in ( + "trending_up", "trending_down", "trending", "ranging", "transitioning" + ) + + +@test("TechnicalEngine gold-specific composite weights") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_xau_ohlcv(300) + result = engine.analyze(df, "XAU/USD", "1h") + assert -100 <= result.composite_score <= 100 + assert result.composite_signal in list(SignalStrength) + + +@test("TechnicalEngine EMA ribbon compression detection") +def _(): + engine = TechnicalEngine(TechnicalConfig()) + df = make_ohlcv(300) + result = engine.analyze(df, "XAU/USD", "1h") + assert "ema_trend" in result.indicators + + # ══════════════════════════════════════════════════════════════════════ # MODULE 4: news_engine.py # ══════════════════════════════════════════════════════════════════════ @@ -709,11 +733,11 @@ def _(): assert quote == "USD" -@test("NewsEngine._parse_pair for JPY pair") +@test("NewsEngine._parse_pair for XAU pair") def _(): - base, quote = NewsEngine._parse_pair("USDJPY=X") - assert base == "USD" - assert quote == "JPY" + base, quote = NewsEngine._parse_pair("XAU/USD") + assert base == "XAU" + assert quote == "USD" @test("NewsEngine._analyze_headline_sentiment bullish") @@ -768,9 +792,9 @@ def _(): @test("NewsEngine._extract_currencies multiple") def _(): ne = NewsEngine(NewsConfig()) - currencies = ne._extract_currencies("EUR/USD drops as Fed hawkish, ECB dovish") + currencies = ne._extract_currencies("Gold drops as Fed hawkish, dollar surges") assert "USD" in currencies - assert "EUR" in currencies + assert "XAU" in currencies @test("NewsEngine._extract_keywords") @@ -819,13 +843,10 @@ def _(): @test("NewsEngine._get_affected_pairs") def _(): pairs = NewsEngine._get_affected_pairs("USD") - assert "EURUSD=X" in pairs - assert "USDJPY=X" in pairs - assert "GBPUSD=X" in pairs assert "XAU/USD" in pairs - pairs_eur = NewsEngine._get_affected_pairs("EUR") - assert "EURUSD=X" in pairs_eur + pairs_xau = NewsEngine._get_affected_pairs("XAU") + assert "XAU/USD" in pairs_xau pairs_unknown = NewsEngine._get_affected_pairs("ARS") assert pairs_unknown == [] @@ -861,7 +882,7 @@ def _(): currency="USD", impact="high", event="NFP", ) ] - risk, desc = ne.get_event_risk_level("EURUSD=X", context) + risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "danger" assert "NFP" in desc @@ -876,7 +897,7 @@ def _(): currency="USD", impact="high", event="GDP", ) ] - risk, desc = ne.get_event_risk_level("EURUSD=X", context) + risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "caution" @@ -884,7 +905,7 @@ def _(): def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() - risk, desc = ne.get_event_risk_level("EURUSD=X", context) + risk, desc = ne.get_event_risk_level("XAU/USD", context) assert risk == "clear" @@ -898,7 +919,7 @@ def _(): currency="AUD", impact="high", event="RBA Rate", ) ] - risk, _ = ne.get_event_risk_level("EURUSD=X", context) + risk, _ = ne.get_event_risk_level("XAU/USD", context) assert risk == "clear" @@ -907,7 +928,7 @@ def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"EUR": 0.5, "USD": -0.1} - score, desc = ne.get_pair_sentiment("EURUSD=X", context) + score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score > 0, "EUR bullish + USD bearish should be net bullish for EUR/USD" assert "bullish" in desc.lower() @@ -917,7 +938,7 @@ def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"EUR": -0.5, "USD": 0.3} - score, desc = ne.get_pair_sentiment("EURUSD=X", context) + score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score < 0 @@ -926,7 +947,7 @@ def _(): ne = NewsEngine(NewsConfig()) context = FundamentalContext() context.news_sentiment = {"EUR": 0.0, "USD": 0.0} - score, desc = ne.get_pair_sentiment("EURUSD=X", context) + score, desc = ne.get_pair_sentiment("XAU/USD", context) assert score == 0.0 assert "neutral" in desc.lower() @@ -953,7 +974,7 @@ from signal_aggregator import SignalAggregator, SignalReport, TradeSetup @test("SignalReport defaults") def _(): - r = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") assert r.final_signal == SignalStrength.NEUTRAL assert r.final_score == 0.0 assert r.primary_signal == SignalStrength.NEUTRAL @@ -968,7 +989,7 @@ def _(): @test("TradeSetup dataclass creation") def _(): ts = TradeSetup( - pair="EURUSD=X", display_name="EUR/USD", direction="BUY", + pair="XAU/USD", display_name="XAU/USD", direction="BUY", entry_price=1.1000, stop_loss=1.0950, take_profit_1=1.1100, take_profit_2=1.1200, risk_reward=2.0, risk_pips=50, reward_pips=100, pip_value=0.0001, @@ -982,7 +1003,7 @@ def _(): def _(): config = PlatformConfig() agg = SignalAggregator(config) - report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = { "15min": SignalStrength.BUY, "1h": SignalStrength.STRONG_BUY, @@ -995,7 +1016,7 @@ def _(): def _(): config = PlatformConfig() agg = SignalAggregator(config) - report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = { "15min": SignalStrength.BUY, "1h": SignalStrength.SELL, @@ -1008,19 +1029,11 @@ def _(): def _(): config = PlatformConfig() agg = SignalAggregator(config) - report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = {"1h": SignalStrength.BUY} assert agg._check_mtf_agreement(report) is False -@test("SignalAggregator._forex_sell_qualifies always returns False") -def _(): - config = PlatformConfig() - agg = SignalAggregator(config) - report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") - assert agg._forex_sell_qualifies(report) is False - - @test("SignalAggregator._latest_adx computes valid value") def _(): df = make_ohlcv(300) @@ -1048,10 +1061,10 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert isinstance(report, SignalReport) - assert report.symbol == "EURUSD=X" - assert report.display_name == "EUR/USD" + assert report.symbol == "XAU/USD" + assert report.display_name == "XAU/USD" assert report.final_signal in list(SignalStrength) assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") assert report.current_price > 0 @@ -1066,28 +1079,11 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") assert report.action_recommendation != "" -@test("SignalAggregator SELL suppression for forex pairs") -def _(): - config = PlatformConfig() - config.backtest.sell_filter = True - agg = SignalAggregator(config) - dfs = { - Timeframe.H1: make_ohlcv(300, trend=-0.005, seed=42), - Timeframe.M15: make_ohlcv(300, trend=-0.005, seed=43), - Timeframe.H4: make_ohlcv(300, trend=-0.005, seed=44), - } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) - if report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL): - # Should have been suppressed - pass # sell suppression happened during _compute_final_signal - # At minimum, verify no crash - - @test("SignalAggregator._generate_trade_setup BUY direction") def _(): config = PlatformConfig() @@ -1097,7 +1093,7 @@ def _(): Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43), Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) if report.trade_setup: ts = report.trade_setup if ts.direction == "BUY": @@ -1115,7 +1111,7 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) total_reasons = len(report.bullish_reasons) + len(report.bearish_reasons) assert total_reasons > 0, "Should have at least one reason" @@ -1124,17 +1120,15 @@ def _(): def _(): config = PlatformConfig() config.backtest.session_filter = True - config.backtest.blocked_hours_utc = list(range(24)) # block all hours - config.eur_strategy.enabled = False - config.gbp_strategy.enabled = False + config.xau_strategy.blocked_hours_utc = list(range(24)) config.xau_strategy.enabled = False agg = SignalAggregator(config) dfs = { - Timeframe.H1: make_ohlcv(300, seed=42), - Timeframe.M15: make_ohlcv(300, seed=43), - Timeframe.H4: make_ohlcv(300, seed=44), + Timeframe.H1: make_xau_ohlcv(300, seed=42), + Timeframe.M15: make_xau_ohlcv(300, seed=43), + Timeframe.H4: make_xau_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.signal_quality == "F" @@ -1145,7 +1139,7 @@ def _(): dfs = { Timeframe.M15: make_ohlcv(300, seed=43), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) assert report.signal_quality == "F" assert any("unavailable" in w.lower() for w in report.warnings) @@ -1164,34 +1158,6 @@ def _(): assert isinstance(report, SignalReport) -@test("SignalAggregator EUR strategy check (conditions not met)") -def _(): - config = PlatformConfig() - config.eur_strategy.enabled = True - agg = SignalAggregator(config) - dfs = { - Timeframe.H1: make_ohlcv(300, seed=42), - Timeframe.M15: make_ohlcv(300, seed=43), - Timeframe.H4: make_ohlcv(300, seed=44), - } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) - assert isinstance(report, SignalReport) - - -@test("SignalAggregator GBP strategy check (conditions not met)") -def _(): - config = PlatformConfig() - config.gbp_strategy.enabled = True - agg = SignalAggregator(config) - dfs = { - Timeframe.H1: make_ohlcv(300, seed=42), - Timeframe.M15: make_ohlcv(300, seed=43), - Timeframe.H4: make_ohlcv(300, seed=44), - } - report = agg.analyze_from_dataframes("GBPUSD=X", dfs, use_news=False) - assert isinstance(report, SignalReport) - - @test("SignalAggregator event risk affects confidence") def _(): config = PlatformConfig() @@ -1208,7 +1174,7 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, context=context, use_news=True) + report = agg.analyze_from_dataframes("XAU/USD", dfs, context=context, use_news=True) assert any("EVENT RISK" in w or "event" in w.lower() for w in report.warnings) @@ -1271,7 +1237,7 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) assert "symbol" in d assert "signal" in d @@ -1283,8 +1249,8 @@ def _(): assert "volatility_regime" in d assert "mtf_agreement" in d assert "mtf_signals" in d - assert d["symbol"] == "EURUSD=X" - assert d["display_name"] == "EUR/USD" + assert d["symbol"] == "XAU/USD" + assert d["display_name"] == "XAU/USD" @test("report_to_dict includes trade_setup when present") @@ -1296,7 +1262,7 @@ def _(): Timeframe.M15: make_ohlcv(300, trend=0.003, seed=43), Timeframe.H4: make_ohlcv(300, trend=0.003, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) if report.trade_setup: assert "trade_setup" in d @@ -1317,7 +1283,7 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) assert "indicators" in d assert "key_levels" in d @@ -1356,7 +1322,7 @@ from telegram_bot import ( _escape_md, _signal_label, _mtf_line, format_signal_message, _format_scan_summary, _seconds_until_boundary, _check_level_breach, - _detect_correlated_exposure, _format_stats_dashboard, + _format_stats_dashboard, scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP, _update_level_cache, _last_prices, _level_cache, _level_cache_ts, _WS_TO_PAIR, @@ -1382,7 +1348,7 @@ def _(): @test("_mtf_line formatting with signals") def _(): - report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") report.mtf_signals = { "15min": SignalStrength.BUY, "1h": SignalStrength.NEUTRAL, @@ -1396,7 +1362,7 @@ def _(): @test("_mtf_line empty when no signals") def _(): - report = SignalReport(symbol="EURUSD=X", display_name="EUR/USD", timestamp="now") + report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now") assert _mtf_line(report) == "" @@ -1409,11 +1375,11 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) msg = format_signal_message(report) assert isinstance(msg, str) assert len(msg) > 0 - assert "EUR/USD" in msg + assert "XAU/USD" in msg assert "Signal Analysis" in msg assert "Price" in msg assert "Indicators" in msg @@ -1428,10 +1394,10 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - reports = [agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False)] + reports = [agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)] summary = _format_scan_summary(reports, Timeframe.H1) assert "Auto-Scan" in summary - assert "EUR/USD" in summary + assert "XAU/USD" in summary @test("_seconds_until_boundary returns positive value") @@ -1523,46 +1489,9 @@ def _(): del _level_cache_ts["TEST_PAIR"] -@test("_detect_correlated_exposure warns on same-direction signals") -def _(): - reports = [ - SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="now", - final_signal=SignalStrength.BUY), - SignalReport(symbol=GBPUSD, display_name="GBP/USD", timestamp="now", - final_signal=SignalStrength.BUY), - ] - warnings = _detect_correlated_exposure(reports) - assert len(warnings) > 0 - assert "Same exposure" in warnings[0] - - -@test("_detect_correlated_exposure no warning for opposite directions") -def _(): - reports = [ - SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="now", - final_signal=SignalStrength.BUY), - SignalReport(symbol=GBPUSD, display_name="GBP/USD", timestamp="now", - final_signal=SignalStrength.SELL), - ] - warnings = _detect_correlated_exposure(reports) - assert len(warnings) == 0 - - -@test("_detect_correlated_exposure no warning for neutral signals") -def _(): - reports = [ - SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="now", - final_signal=SignalStrength.NEUTRAL), - SignalReport(symbol=GBPUSD, display_name="GBP/USD", timestamp="now", - final_signal=SignalStrength.NEUTRAL), - ] - warnings = _detect_correlated_exposure(reports) - assert len(warnings) == 0 - - @test("SCAN_BUTTONS and SCAN_LOOKUP consistency") def _(): - assert len(SCAN_BUTTONS) == 9 # 3 pairs × 3 TFs + assert len(SCAN_BUTTONS) == 4 # 3 TFs + "Scan All" for btn in SCAN_BUTTONS: assert btn["cb"] in SCAN_LOOKUP assert "symbol" in btn @@ -1585,19 +1514,18 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) _level_cache.clear() _level_cache_ts.clear() _update_level_cache([report]) if report.primary_analysis and report.primary_analysis.key_levels: - assert "EURUSD=X" in _level_cache or len(report.primary_analysis.key_levels) == 0 + assert "XAU/USD" in _level_cache or len(report.primary_analysis.key_levels) == 0 _level_cache.clear() _level_cache_ts.clear() @test("_WS_TO_PAIR mapping is correct") def _(): - assert _WS_TO_PAIR["EUR/USD"] == EURUSD assert _WS_TO_PAIR["XAU/USD"] == XAUUSD @@ -1623,7 +1551,7 @@ def _(): config = PlatformConfig() engine = TechnicalEngine(config.technical) df = make_ohlcv(300, seed=99) - analysis = engine.analyze(df, "EURUSD=X", "1h") + analysis = engine.analyze(df, "XAU/USD", "1h") assert isinstance(analysis, TechnicalAnalysis) agg = SignalAggregator(config) @@ -1632,10 +1560,10 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=100), Timeframe.H4: make_ohlcv(300, seed=101), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) d = report_to_dict(report) assert isinstance(d, dict) - assert d["symbol"] == "EURUSD=X" + assert d["symbol"] == "XAU/USD" @test("Full pipeline: XAU/USD analysis") @@ -1661,31 +1589,24 @@ def _(): Timeframe.M15: make_ohlcv(300, seed=43), Timeframe.H4: make_ohlcv(300, seed=44), } - report = agg.analyze_from_dataframes("EURUSD=X", dfs, use_news=False) + report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False) msg = format_signal_message(report) assert isinstance(msg, str) assert len(msg) > 100 -@test("Multiple pair analysis consistency") +@test("XAU/USD analysis with different seeds") def _(): config = PlatformConfig() - for pair, seed_base in [(EURUSD, 10), (GBPUSD, 20), (XAUUSD, 30)]: + for seed_base in [10, 20, 30]: agg = SignalAggregator(config) - if pair == XAUUSD: - dfs = { - Timeframe.H1: make_xau_ohlcv(300, seed=seed_base), - Timeframe.M15: make_xau_ohlcv(300, seed=seed_base+1), - Timeframe.H4: make_xau_ohlcv(300, seed=seed_base+2), - } - else: - dfs = { - Timeframe.H1: make_ohlcv(300, seed=seed_base), - Timeframe.M15: make_ohlcv(300, seed=seed_base+1), - Timeframe.H4: make_ohlcv(300, seed=seed_base+2), - } - report = agg.analyze_from_dataframes(pair, dfs, use_news=False) - assert report.symbol == pair + dfs = { + Timeframe.H1: make_xau_ohlcv(300, seed=seed_base), + Timeframe.M15: make_xau_ohlcv(300, seed=seed_base+1), + Timeframe.H4: make_xau_ohlcv(300, seed=seed_base+2), + } + report = agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False) + assert report.symbol == XAUUSD assert report.current_price > 0 assert report.signal_quality in ("A+", "A", "B", "C", "D", "F") @@ -1698,7 +1619,7 @@ def _(): "Open": 1.1, "High": 1.1001, "Low": 1.0999, "Close": 1.1, "Volume": 1000.0, }, index=dates) df.index.name = "Datetime" - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert result.composite_signal == SignalStrength.NEUTRAL @@ -1716,7 +1637,7 @@ def _(): "Volume": np.random.randint(100, 50000, 300).astype(float), }, index=dates) df.index.name = "Datetime" - result = engine.analyze(df, "EURUSD=X", "1h") + result = engine.analyze(df, "XAU/USD", "1h") assert isinstance(result, TechnicalAnalysis) assert result.volatility_regime in ("low", "normal", "high", "extreme") @@ -1725,8 +1646,8 @@ def _(): def _(): engine = TechnicalEngine(TechnicalConfig()) df = make_ohlcv(300, seed=42) - r1 = engine.analyze(df.copy(), "EURUSD=X", "1h") - r2 = engine.analyze(df.copy(), "EURUSD=X", "1h") + r1 = engine.analyze(df.copy(), "XAU/USD", "1h") + r2 = engine.analyze(df.copy(), "XAU/USD", "1h") assert r1.composite_score == r2.composite_score assert r1.composite_signal == r2.composite_signal assert r1.market_regime == r2.market_regime diff --git a/web/app.py b/web/app.py deleted file mode 100644 index b0dcb4a..0000000 --- a/web/app.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -""" -ForexBot2 — Web API -==================== -Thin FastAPI process that reads signal data from Redis and serves it -via REST + WebSocket. Apache reverse-proxies to this. - -Endpoints: - GET /api/signals — current snapshot from Redis - WS /ws — real-time updates via Redis pub/sub - -No signal computation here — signal_web.py daemon handles that. - -Usage: - uvicorn web.app:app --host 127.0.0.1 --port 8000 -""" - -import asyncio -import json -import logging -import os -from contextlib import asynccontextmanager -from typing import Optional, Set - -import redis.asyncio as aioredis -import uvicorn -from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from fastapi.middleware.cors import CORSMiddleware - -logging.basicConfig( - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - level=logging.INFO, - datefmt="%H:%M:%S", -) -logger = logging.getLogger("web_api") - -REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") -REDIS_KEY = "forexbot2:signals" -REDIS_CHANNEL = "forexbot2:updates" - -_redis: Optional[aioredis.Redis] = None -_ws_clients: Set[WebSocket] = set() -_subscriber_task: Optional[asyncio.Task] = None - - -async def _redis_subscriber() -> None: - pubsub = _redis.pubsub() - await pubsub.subscribe(REDIS_CHANNEL) - try: - async for msg in pubsub.listen(): - if msg["type"] != "message": - continue - payload = msg["data"] - dead = [] - for ws in _ws_clients: - try: - await ws.send_text(payload) - except Exception: - dead.append(ws) - for ws in dead: - _ws_clients.discard(ws) - except asyncio.CancelledError: - pass - finally: - await pubsub.unsubscribe(REDIS_CHANNEL) - await pubsub.close() - - -@asynccontextmanager -async def lifespan(application: FastAPI): - global _redis, _subscriber_task - _redis = aioredis.from_url(REDIS_URL, decode_responses=True) - await _redis.ping() - logger.info(f"Redis connected: {REDIS_URL}") - _subscriber_task = asyncio.create_task(_redis_subscriber()) - logger.info("Redis subscriber started") - yield - _subscriber_task.cancel() - await asyncio.gather(_subscriber_task, return_exceptions=True) - await _redis.close() - logger.info("Web API stopped") - - -app = FastAPI(title="ForexBot2 API", lifespan=lifespan) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["GET"], - allow_headers=["*"], -) - - -@app.get("/api/signals") -async def api_signals(): - raw = await _redis.get(REDIS_KEY) - if raw: - return json.loads(raw) - return {"type": "update", "timestamp": "", "data": {}} - - -@app.websocket("/ws") -async def ws_endpoint(ws: WebSocket): - await ws.accept() - _ws_clients.add(ws) - try: - raw = await _redis.get(REDIS_KEY) - if raw: - await ws.send_text(raw) - while True: - await ws.receive_text() - except WebSocketDisconnect: - pass - finally: - _ws_clients.discard(ws) - - -if __name__ == "__main__": - port = int(os.environ.get("WEB_PORT", "8000")) - logger.info(f"Starting ForexBot2 API on http://127.0.0.1:{port}") - uvicorn.run(app, host="127.0.0.1", port=port, log_level="info") diff --git a/web/static/index.html b/web/static/index.html deleted file mode 100644 index d4075b0..0000000 --- a/web/static/index.html +++ /dev/null @@ -1,482 +0,0 @@ - - - - - -ForexBot2 — Live Dashboard - - - - -
-

ForexBot2 Live Dashboard

-
- Connecting… - -
-
- -
-
-
- Running initial scan — this takes 30–60 seconds… -
-
- - - - diff --git a/ws_twelvedata.py b/ws_twelvedata.py deleted file mode 100755 index cfc59c0..0000000 --- a/ws_twelvedata.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Live tick stream from Twelve Data WebSocket API.""" - -import json -import asyncio -import websockets -from datetime import datetime, timezone - - -API_KEY = "f563b22ffdfd4924955decbe1a8c8701" -WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={API_KEY}" -SYMBOLS = ["EUR/USD", "XAU/USD"] - - -async def stream_ticks(): - async with websockets.connect(WS_URL) as ws: - subscribe = { - "action": "subscribe", - "params": { - "symbols": ",".join(SYMBOLS), - }, - } - await ws.send(json.dumps(subscribe)) - print(f"Subscribed to: {', '.join(SYMBOLS)}") - print("-" * 60) - - async for raw in ws: - msg = json.loads(raw) - - if msg.get("event") == "subscribe-status": - print(f"Status: {msg.get('status')} — {msg.get('message', '')}") - continue - - if msg.get("event") == "heartbeat": - continue - - if msg.get("event") == "price": - ts = datetime.fromtimestamp(msg["timestamp"], tz=timezone.utc) - symbol = msg["symbol"] - price = msg["price"] - print(f"{ts:%H:%M:%S} | {symbol:<8} | {price}") - - -async def main(): - print(f"Connecting to Twelve Data WebSocket...") - print(f"API key: ...{API_KEY[-6:]}") - try: - await stream_ticks() - except KeyboardInterrupt: - print("\nStopped.") - except websockets.exceptions.ConnectionClosed as e: - print(f"\nConnection closed: {e}") - - -if __name__ == "__main__": - asyncio.run(main())