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.
This commit is contained in:
harwinder
2026-05-15 10:51:51 -04:00
parent bb3c475f22
commit 04328eaeab
15 changed files with 852 additions and 2108 deletions
+29 -26
View File
@@ -1,6 +1,6 @@
# ForexBot2 # 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 ## Architecture
@@ -9,7 +9,7 @@ Twelve Data REST API + WebSocket
| |
data_provider.py (fetch, cache, validate OHLCV; rate-limited 55 calls/min) 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) signal_aggregator.py (MTF agreement + news + final signal + trade setup)
| |
@@ -26,31 +26,36 @@ telegram_bot.py main.py
| File | Purpose | | 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 | | `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 | | `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, final signal scoring, trade setup generation | | `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) | | `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 | | `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 | | `main.py` | CLI entry point for XAU/USD analysis |
| `test_all.py` | Comprehensive QA suite (~90 tests, no live API needed) | | `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) |
## Key Concepts ## 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 ## How to Run
```bash ```bash
# CLI (single USD/JPY analysis) # CLI (XAU/USD analysis)
python main.py # H1 default python main.py # H1 default
python main.py --timeframe M15 --detail --json python main.py --timeframe M15 --detail --json
@@ -59,6 +64,9 @@ TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
# Tests # Tests
python test_all.py python test_all.py
# Backtest
python backtest/gpu_backtest.py --pair XAUUSD --days 365
``` ```
## Environment Variables ## Environment Variables
@@ -68,7 +76,6 @@ python test_all.py
| `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config | | `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config |
| `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback | | `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback |
| `TELEGRAM_CHAT_ID` | Auto-scan chat ID (enables boot auto-scan) | 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 ## Dependencies
@@ -80,23 +87,19 @@ python test_all.py
- **Rate limit:** 55 credits/min (Grow plan), enforced by sliding-window throttle - **Rate limit:** 55 credits/min (Grow plan), enforced by sliding-window throttle
- **Cache:** In-memory dict, TTL = candle duration of the timeframe - **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 - **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 ## Telegram Bot
- **Auto-scan:** M5 (data-only, no alerts), M15, H1, H4 at candle boundaries + 10s buffer - **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 - **Manual scan:** Buttons for M15/H1/H4 individual scans + "Scan All TFs" multi-timeframe summary
- **Live dashboard:** Edit-in-place message, 30s refresh, shows all pairs/timeframes/prices/setups - **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
- **Commands:** `/start`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help` - **Live dashboard:** Edit-in-place message, 30s refresh, shows XAU/USD across timeframes
- **Correlation warning:** Flags same-direction signals on EUR/USD + GBP/USD (USD-weakness group) - **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help`
## Backtesting ## 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`. 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.
## 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.
## Code Conventions ## Code Conventions
@@ -104,7 +107,7 @@ Documented in `mt5.md`. REST bridge architecture: Linux bot → HTTP → Windows
- Dataclasses for all config and output structures - Dataclasses for all config and output structures
- `SignalStrength` enum (-2 to +2) is the universal signal currency - `SignalStrength` enum (-2 to +2) is the universal signal currency
- Indicators stored as `Dict[str, IndicatorSignal]` in `TechnicalAnalysis` - Indicators stored as `Dict[str, IndicatorSignal]` in `TechnicalAnalysis`
- Key levels stored as `Dict[str, float]` — prefixed `swing_high_`, `swing_low_`, `ema_`, `bb_`, `atr` - 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` (underscore prefix, skipped by composite scorer) - 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 - 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 - Shared `ForexDataProvider` instance across all scans for cache reuse
+96 -69
View File
@@ -6,17 +6,14 @@
## What Is ForexBot2? ## 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. 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 | | 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 | | XAU/USD | Gold vs US Dollar |
## What Timeframes Does It Scan? ## 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 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) #### 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) ### Informational Indicators (Not Scored)
These indicators do not contribute to the buy/sell score but provide context: 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. - **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. - **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? ## 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 ### 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 up** — ADX above 25 and EMAs are bullishly aligned
- **Trending down** — ADX above 25 and EMAs are bearishly 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: Depending on the regime, certain indicators get louder or quieter:
| Indicator | Trending Weight | Ranging Weight | Why | | Indicator | Trending | Ranging | Transitioning | Why |
|-----------|----------------|---------------|-----| |-----------|----------|---------|---------------|-----|
| EMA Trend | 2.0x | 0.5x | EMAs excel in trends, fail in chop | | EMA Trend | 2.0 | 0.3 | 1.5 | EMAs excel in trends, fail in chop |
| RSI | 1.5x | 2.0x | RSI overbought/oversold works in ranges | | RSI | 1.0 | 1.5 | 1.0 | RSI OB/OS works in ranges; gold doesn't mean-revert cleanly in trends |
| Bollinger | 0.8x | 2.0x | Mean reversion is a ranging strategy | | Bollinger | 0.5 | 1.5 | 1.0 | Gold rides the band in trends |
| Candlestick | 1.5x | 1.5x | Patterns work in both | | MACD | 2.0 | 1.5 | 2.5 | Gold's best leading indicator |
| Divergence | 1.0x | 1.5x | Divergences mark range boundaries | | Keltner | 1.5 | 2.0 | 2.5 | Squeeze breakouts catch range-to-trend transitions |
| ADX | 1.0x | 1.0x | Confirms the regime itself | | 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 ### 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 | | +40 or higher | STRONG BUY |
| +15 to +39 | BUY | | +15 to +39 | BUY |
| -14 to +14 | NEUTRAL | | -14 to +14 | NEUTRAL |
| -15 to -39 | SELL | | -39 to -15 | SELL |
| -40 or lower | STRONG 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) ## 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 ### 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: Beyond indicators, MTF, and news, the bot applies penalties for risk factors. However, penalties use a **diminishing cap** to prevent over-suppression:
- **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)
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%**
--- | Factor | Base Penalty | Why |
|--------|-------------|-----|
## Additional Confidence Adjustments | MTF disagreement | 15% | Trading against other timeframes is riskier |
| Counter-trend signal | 15% | Trading against the trend is riskier |
Beyond indicators, MTF, and news, the bot applies these final adjustments: | Event caution | 15% | Upcoming economic event |
| Extreme volatility | 10% | Wild markets produce unreliable signals |
| Factor | Effect | Why | | News conflict | 10% | News sentiment opposes signal |
|--------|--------|-----| | Exhaustion warning | 8% | Momentum may be exhausted |
| Counter-trend signal | -15% confidence | Trading against the trend is riskier | | No pullback | 8% | Entering without pullback reduces RR |
| Extreme volatility | -10% confidence | Wild markets produce unreliable signals | | Near EMA 200 | 5% | Price tends to chop around EMA 200 |
| Near EMA 200 (within 0.5%) | -10% confidence | Price tends to chop around EMA 200 | | Far from EMA 200 | 5% | Overextended from the mean |
| Far from EMA 200 (beyond 1.5%) | -10% confidence | Overextended from the mean |
Final confidence is always clamped between 0% and 95% — the bot never claims 100% confidence. 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 ### Commands
| Command | What It Does | | 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 | | `/autoscan` | Turns automatic scanning on or off |
| `/status` | Shows bot status, WebSocket connection, live prices, level cache | | `/status` | Shows bot status, WebSocket connection, live prices, level cache |
| `/help` | Lists all available commands | | `/help` | Lists all available commands |
### Auto-Scan Behavior ### Auto-Scan Behavior
- Starts automatically on boot if `TELEGRAM_CHAT_ID` is configured - 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 - 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) - 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 news_engine.py # Economic calendar and news sentiment
data_provider.py # Twelve Data API client with caching and rate limiting data_provider.py # Twelve Data API client with caching and rate limiting
main.py # CLI entry point for single-pair analysis main.py # CLI entry point for single-pair analysis
ws_twelvedata.py # Standalone WebSocket test utility
requirements.txt # Python dependencies 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 requests>=2.28 # HTTP client for news/calendar APIs
twelvedata>=1.2.0 # Twelve Data SDK (REST OHLCV source) twelvedata>=1.2.0 # Twelve Data SDK (REST OHLCV source)
python-telegram-bot>=20.0 # Async Telegram bot framework 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 ## Data Flow
@@ -396,7 +429,7 @@ websockets>=12.0 # WebSocket client for live price streaming
(fetch + cache + validate OHLCV) (fetch + cache + validate OHLCV)
| |
engine.py engine.py
(compute 6 indicators per TF) (compute 9 indicators per TF)
| |
signal_aggregator.py signal_aggregator.py
(MTF agreement + news + final signal + trade setup) (MTF agreement + news + final signal + trade setup)
@@ -424,15 +457,11 @@ class SignalStrength(Enum):
### Pair Definitions ### Pair Definitions
```python ```python
USDJPY = "USDJPY=X" # Yahoo-style suffix for forex XAUUSD = "XAU/USD"
EURUSD = "EURUSD=X"
GBPUSD = "GBPUSD=X"
XAUUSD = "XAU/USD" # No suffix for commodities XAUUSD = "XAU/USD" # No suffix for commodities
PAIR_INFO = { PAIR_INFO = {
USDJPY: {"display": "USD/JPY", "pip_value": 0.01, "is_jpy_pair": True}, XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
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)` ### 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) "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 ```python
@dataclass @dataclass
class TradeSetup: class TradeSetup:
pair: str # "EURUSD=X" pair: str # "XAU/USD"
display_name: str # "EUR/USD" display_name: str # "XAU/USD"
direction: str # "BUY" or "SELL" direction: str # "BUY" or "SELL"
entry_price: float entry_price: float
stop_loss: float stop_loss: float
@@ -814,10 +843,8 @@ CURRENCY_KEYWORDS = {
### Pair Impact Mapping ### Pair Impact Mapping
```python ```python
"USD" -> [EURUSD, USDJPY, GBPUSD, XAUUSD] # USD events affect all pairs "USD" -> [XAUUSD] # USD events affect gold
"EUR" -> [EURUSD] "XAU" -> [XAUUSD]
"JPY" -> [USDJPY]
"GBP" -> [GBPUSD]
"XAU" -> [XAUUSD] "XAU" -> [XAUUSD]
``` ```
@@ -898,7 +925,7 @@ def _run_signal_sync(symbol, timeframe, confirmation_tfs):
```python ```python
while _autoscan_chats: while _autoscan_chats:
connect to wss://ws.twelvedata.com/v1/quotes/price?apikey=... connect to wss://ws.twelvedata.com/v1/quotes/price?apikey=...
subscribe to ["EUR/USD", "XAU/USD"] subscribe to ["XAU/USD"]
for each incoming message: for each incoming message:
if event != "price": continue if event != "price": continue
map ws_symbol -> pair_symbol via _WS_TO_PAIR map ws_symbol -> pair_symbol via _WS_TO_PAIR
@@ -947,14 +974,14 @@ Footer: "Educational / research use only."
Timeframe Auto-Scan | HH:MM UTC Timeframe Auto-Scan | HH:MM UTC
Per pair: emoji, signal, quality, score, confidence, MTF status Per pair: emoji, signal, quality, score, confidence, MTF status
Actionable list Actionable list
Correlation warnings (if any) Actionable signals (if any)
``` ```
### Telegram Commands ### Telegram Commands
| Handler | Function | Registration | | 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 | | `/help` | `cmd_help()` | Lists all commands |
| `/status` | `cmd_status()` | Auto-scan state, WS state, live prices, level cache stats, cooldowns | | `/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 | | `/autoscan` | `cmd_autoscan()` | Toggle: adds/removes chat from `_autoscan_chats`, starts/stops scheduler |
@@ -967,8 +994,8 @@ Correlation warnings (if any)
### Usage ### Usage
```bash ```bash
python main.py # Default: USD/JPY H1 python main.py # Default: XAU/USD H1
python main.py --timeframe M15 # USD/JPY M15 python main.py --timeframe M15 # XAU/USD M15
python main.py --detail # Include full indicator breakdown python main.py --detail # Include full indicator breakdown
python main.py --json # Output as JSON python main.py --json # Output as JSON
python main.py --verbose # Debug logging 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. 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. 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.
+32 -89
View File
@@ -1,5 +1,5 @@
""" """
ForexBot2 — Configuration ForexBot2 — Configuration (XAU/USD focused)
========================================== ==========================================
""" """
@@ -36,22 +36,12 @@ class MarketSession(Enum):
# ── Supported pairs ────────────────────────────────────────────── # ── Supported pairs ──────────────────────────────────────────────
USDJPY = "USDJPY=X"
EURUSD = "EURUSD=X"
GBPUSD = "GBPUSD=X"
XAUUSD = "XAU/USD" XAUUSD = "XAU/USD"
PAIR_INFO = { 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},
} }
# Defaults (kept for backward compat)
DISPLAY = "USD/JPY"
PIP_VALUE = 0.01
@dataclass @dataclass
class TechnicalConfig: class TechnicalConfig:
@@ -83,11 +73,24 @@ class TechnicalConfig:
divergence_lookback: int = 14 divergence_lookback: int = 14
swing_sr_lookback: int = 50 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) ema_mode: str = "4ema" # "4ema" (9/21/50/200) or "2ema" (50/200)
rsi_mode: str = "classic" # "classic" or "regime_aware" 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) regime_weights_override: Dict[str, Dict[str, float]] = field(default_factory=dict)
@@ -101,6 +104,9 @@ class SignalConfig:
"ema_trend": 1.0, "ema_trend": 1.0,
"rsi": 1.5, "rsi": 1.5,
"bollinger": 1.5, "bollinger": 1.5,
"macd": 1.5,
"keltner": 1.5,
"roc": 1.5,
"candlestick": 1.0, "candlestick": 1.0,
"divergence": 1.5, "divergence": 1.5,
}) })
@@ -117,7 +123,7 @@ class SignalConfig:
@dataclass @dataclass
class NewsConfig: class NewsConfig:
"""Economic calendar & sentiment.""" """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" min_impact: str = "medium"
pre_event_buffer_minutes: int = 30 pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15 post_event_buffer_minutes: int = 15
@@ -130,82 +136,27 @@ class NewsConfig:
@dataclass @dataclass
class XauStrategyConfig: class XauStrategyConfig:
"""XAU/USD v3 weighted confluence strategy. """XAU/USD strategy config.
1-year grid-optimized (768 combos): 52.4% WR, PF 2.34, +40.3R (63 trades). Legacy gates (H4 RSI, H1 MACD, M15 score) removed in favor of
Entry: M15 score>=80 + H4 RSI confirms + H1 MACD confirms + RSI 40-65 zone. new momentum indicators (ROC, Keltner squeeze, ADX slope).
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.
""" """
enabled: bool = True enabled: bool = True
rsi_entry_threshold: float = 45.0 rsi_entry_threshold: float = 45.0
tp_atr_multiplier: float = 0.9 tp_atr_multiplier: float = 1.0
sl_atr_multiplier: float = 2.0 sl_atr_multiplier: float = 2.0
min_ema200_tfs: int = 2 min_ema200_tfs: int = 2
allow_generic_trades: bool = True allow_generic_trades: bool = True
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 5, 6, 21, 22, 23]) blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 5, 6, 21, 22, 23])
# v2 confluence fixes
weekend_filter: bool = True weekend_filter: bool = True
min_atr_threshold: float = 1.0 min_atr_threshold: float = 1.0
v2_sl_atr_multiplier: float = 2.5 v2_sl_atr_multiplier: float = 2.5
buy_threshold_boost: int = 0 buy_threshold_boost: int = 0
m5_confirmation_bars: int = 0 m5_confirmation_bars: int = 0
# v3 gates (grid-optimized on 1Y Mon-Thu data) h4_rsi_gate: bool = False
h4_rsi_gate: bool = True # require H4 RSI >55 for BUY, <45 for SELL h1_macd_gate: bool = False
h1_macd_gate: bool = True # require H1 MACD histogram >0 for BUY, <0 for SELL rsi_zone: tuple = (40.0, 65.0)
rsi_zone: tuple = (40.0, 65.0) # only enter when M15 RSI in this zone
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) # Mon-Fri 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+ sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "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
@dataclass @dataclass
@@ -220,14 +171,8 @@ class FilterConfig:
session_end_hour: int = 16 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]) 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]) 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 @dataclass
class PlatformConfig: class PlatformConfig:
@@ -237,12 +182,10 @@ class PlatformConfig:
news: NewsConfig = field(default_factory=NewsConfig) news: NewsConfig = field(default_factory=NewsConfig)
backtest: FilterConfig = field(default_factory=FilterConfig) backtest: FilterConfig = field(default_factory=FilterConfig)
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig) xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
eur_strategy: EurStrategyConfig = field(default_factory=EurStrategyConfig)
gbp_strategy: GbpStrategyConfig = field(default_factory=GbpStrategyConfig)
symbol: str = USDJPY symbol: str = XAUUSD
display_name: str = DISPLAY display_name: str = "XAU/USD"
pip_value: float = PIP_VALUE pip_value: float = 0.10
primary_timeframe: Timeframe = Timeframe.H1 primary_timeframe: Timeframe = Timeframe.H1
confirmation_timeframes: List[Timeframe] = field( confirmation_timeframes: List[Timeframe] = field(
@@ -256,7 +199,7 @@ class PlatformConfig:
refresh_interval: int = 300 refresh_interval: int = 300
scan_pairs: List[str] = field( scan_pairs: List[str] = field(
default_factory=lambda: [EURUSD, GBPUSD, XAUUSD] default_factory=lambda: [XAUUSD]
) )
scan_timeframes: List[Timeframe] = field( scan_timeframes: List[Timeframe] = field(
default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4] default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
+7 -7
View File
@@ -13,7 +13,7 @@ from typing import Dict, Optional
import pandas as pd import pandas as pd
from twelvedata import TDClient from twelvedata import TDClient
from config import PlatformConfig, Timeframe, USDJPY, PAIR_INFO from config import PlatformConfig, Timeframe, XAUUSD, PAIR_INFO
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ class ForexDataProvider:
def fetch_ohlcv( def fetch_ohlcv(
self, self,
symbol: str = USDJPY, symbol: str = XAUUSD,
timeframe: Timeframe = Timeframe.H1, timeframe: Timeframe = Timeframe.H1,
lookback_days: Optional[int] = None, lookback_days: Optional[int] = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
@@ -150,7 +150,7 @@ class ForexDataProvider:
logger.info(f"Cache cleared: {count} entries wiped") logger.info(f"Cache cleared: {count} entries wiped")
return count return count
def get_latest_price(self, symbol: str = USDJPY) -> Optional[float]: def get_latest_price(self, symbol: str = XAUUSD) -> Optional[float]:
try: try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty: if df is not None and not df.empty:
@@ -159,18 +159,18 @@ class ForexDataProvider:
pass pass
return None return None
def get_spread_estimate(self, symbol: str = USDJPY) -> float: def get_spread_estimate(self, symbol: str = XAUUSD) -> float:
try: try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
if df is not None and not df.empty: if df is not None and not df.empty:
return float((df["High"] - df["Low"]).median()) return float((df["High"] - df["Low"]).median())
except Exception: except Exception:
pass pass
info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY]) info = PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD])
return info["pip_value"] return info["pip_value"]
def get_pair_info(self, symbol: str = USDJPY) -> Dict: def get_pair_info(self, symbol: str = XAUUSD) -> Dict:
info = PAIR_INFO.get(symbol, PAIR_INFO[USDJPY]) info = PAIR_INFO.get(symbol, PAIR_INFO[XAUUSD])
return {"symbol": symbol, **info} return {"symbol": symbol, **info}
# ── Private ──────────────────────────────────────────────────── # ── Private ────────────────────────────────────────────────────
+208 -12
View File
@@ -93,6 +93,9 @@ class TechnicalEngine:
self._compute_macd(df, result) 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 "divergence" not in disabled: self._detect_divergences(df, result)
if "structure" not in disabled: self._compute_structure_sr(df, result) if "structure" not in disabled: self._compute_structure_sr(df, result)
if "candlestick" not in disabled: self._detect_candlestick_patterns(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)" crossover_desc = " | Death cross (fast/medium)"
conf = min(conf + 10, 95) 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( result.indicators["ema_trend"] = IndicatorSignal(
name="EMA Trend", value=normalized, name="EMA Trend", value=normalized,
signal=signal, confidence=conf, signal=signal, confidence=conf,
@@ -499,12 +517,33 @@ class TechnicalEngine:
signal = SignalStrength.SELL if adx_val >= c.adx_strong_trend else SignalStrength.NEUTRAL signal = SignalStrength.SELL if adx_val >= c.adx_strong_trend else SignalStrength.NEUTRAL
direction = "bearish" 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( result.indicators["adx"] = IndicatorSignal(
name="ADX", value=adx_val, signal=signal, name="ADX", value=adx_val, signal=signal,
confidence=conf, description=desc, weight=0.5, 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): def _compute_atr(self, df: pd.DataFrame, result: TechnicalAnalysis):
"""ATR volatility measurement and regime classification.""" """ATR volatility measurement and regime classification."""
@@ -550,6 +589,153 @@ class TechnicalEngine:
weight=0.0, # ATR is context, not a directional signal 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): def _detect_divergences(self, df: pd.DataFrame, result: TechnicalAnalysis):
""" """
Detect RSI and MACD divergences (powerful reversal signals). Detect RSI and MACD divergences (powerful reversal signals).
@@ -818,9 +1004,10 @@ class TechnicalEngine:
) )
def _classify_regime(self, df: pd.DataFrame, result: TechnicalAnalysis): 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") adx_sig = result.indicators.get("adx")
ema_sig = result.indicators.get("ema_trend") 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 adx_sig and adx_sig.value >= self.config.adx_strong_trend:
if ema_sig and ema_sig.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): if ema_sig and ema_sig.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
@@ -829,6 +1016,8 @@ class TechnicalEngine:
result.market_regime = "trending_down" result.market_regime = "trending_down"
else: else:
result.market_regime = "trending" result.market_regime = "trending"
elif adx_sig and adx_slope > self.config.adx_slope_emerging_threshold:
result.market_regime = "transitioning"
else: else:
result.market_regime = "ranging" result.market_regime = "ranging"
@@ -844,21 +1033,28 @@ class TechnicalEngine:
total_weight = 0.0 total_weight = 0.0
total_confidence = 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: if self.config.regime_weights_override:
regime_weights = self.config.regime_weights_override regime_weights = self.config.regime_weights_override
else: else:
_trending = { _trending = {
"ema_trend": 2.0, "rsi": 1.5, "bollinger": 0.8, "ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
"candlestick": 1.0, "divergence": 1.5, "bollinger": 0.5, "keltner": 1.5, "roc": 1.5,
"candlestick": 0.8, "divergence": 2.0, "adx": 0.8,
} }
regime_weights = { regime_weights = {
"trending_up": _trending, "trending_up": _trending,
"trending_down": _trending, "trending_down": _trending,
"trending": _trending, "trending": _trending,
"ranging": { "ranging": {
"ema_trend": 0.5, "rsi": 2.0, "bollinger": 2.0, "ema_trend": 0.3, "rsi": 1.5, "macd": 1.5,
"candlestick": 1.0, "divergence": 2.0, "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)) normalized_score = max(-100, min(100, normalized_score))
if normalized_score >= 45: if normalized_score >= 40:
result.composite_signal = SignalStrength.STRONG_BUY result.composite_signal = SignalStrength.STRONG_BUY
elif normalized_score >= 20: elif normalized_score >= 15:
result.composite_signal = SignalStrength.BUY result.composite_signal = SignalStrength.BUY
elif normalized_score <= -45: elif normalized_score <= -40:
result.composite_signal = SignalStrength.STRONG_SELL result.composite_signal = SignalStrength.STRONG_SELL
elif normalized_score <= -20: elif normalized_score <= -15:
result.composite_signal = SignalStrength.SELL result.composite_signal = SignalStrength.SELL
else: else:
result.composite_signal = SignalStrength.NEUTRAL result.composite_signal = SignalStrength.NEUTRAL
+6 -6
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ForexBot2 — USD/JPY Signal Intelligence (single-pair) ForexBot2 — XAU/USD Signal Intelligence
======================================================= ========================================
Usage: Usage:
python main.py # Live signal on H1 python main.py # Live signal on H1
python main.py --timeframe H4 --detail # Detailed H4 signal python main.py --timeframe H4 --detail # Detailed H4 signal
@@ -21,7 +21,7 @@ if hasattr(sys.stdout, "reconfigure"):
if hasattr(sys.stderr, "reconfigure"): if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8") 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 from signal_aggregator import SignalAggregator, SignalReport
@@ -65,7 +65,7 @@ def quality_color(q: str) -> str:
}.get(q, Colors.RESET) }.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") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}") print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN} {title}{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(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="ForexBot2 — USD/JPY Signal Intelligence", description="ForexBot2 — XAU/USD Signal Intelligence",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Examples:
@@ -295,7 +295,7 @@ def run_live(args, config: PlatformConfig):
print(json.dumps(report_to_dict(report), indent=2)) print(json.dumps(report_to_dict(report), indent=2))
return return
print_header("USD/JPY SIGNAL INTELLIGENCE") print_header("XAU/USD SIGNAL INTELLIGENCE")
print_signal_report(report, detail=args.detail) print_signal_report(report, detail=args.detail)
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}") print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}") print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
+1 -4
View File
@@ -541,10 +541,7 @@ class NewsEngine:
@staticmethod @staticmethod
def _get_affected_pairs(currency: str) -> List[str]: def _get_affected_pairs(currency: str) -> List[str]:
_MAP = { _MAP = {
"USD": ["EURUSD=X", "USDJPY=X", "GBPUSD=X", "XAU/USD"], "USD": ["XAU/USD"],
"EUR": ["EURUSD=X"],
"JPY": ["USDJPY=X"],
"GBP": ["GBPUSD=X"],
"XAU": ["XAU/USD"], "XAU": ["XAU/USD"],
} }
return _MAP.get(currency, []) return _MAP.get(currency, [])
-3
View File
@@ -4,6 +4,3 @@ requests>=2.28
twelvedata>=1.2.0 twelvedata>=1.2.0
python-telegram-bot>=20.0 python-telegram-bot>=20.0
websockets>=12.0 websockets>=12.0
fastapi>=0.100
uvicorn>=0.20
redis>=5.0
+161 -569
View File
@@ -176,21 +176,10 @@ class SignalAggregator:
if bt.session_filter: if bt.session_filter:
xau_active = ( xau_active = (
report.display_name == "XAU/USD" self.config.xau_strategy.enabled
and self.config.xau_strategy.enabled
and any("XAU STRATEGY" in r for r in report.bullish_reasons) and any("XAU STRATEGY" in r for r in report.bullish_reasons)
) )
eur_active = ( if not xau_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:
self._apply_session_filter_live(report) self._apply_session_filter_live(report)
return report return report
@@ -398,182 +387,38 @@ class SignalAggregator:
except Exception: except Exception:
pass pass
# ── 6c. XAU/USD oversold bounce strategy (DISABLED — replaced by v2 confluence) ── # ── 6c. Transitioning regime quality boost ──────────────────
# if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled: if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"):
# if self._check_xau_oversold_bounce(report, dfs): abs_score = abs(report.final_score)
# report.final_signal = SignalStrength.STRONG_BUY if abs_score >= 30 and report.final_confidence >= 50 and report.mtf_agreement:
# 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
report.signal_quality = "A" report.signal_quality = "A"
report.action_recommendation = ( report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend emerging, early entry"
"BUY — EUR pullback entry " elif abs_score >= 25 and report.final_confidence >= 45:
"(RSI pullback + not at resistance + M15/H1 above EMA200)" report.signal_quality = "B"
) report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely"
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)
# ── 6e. GBP/USD pullback-buy strategy ───────────────────── # ── 6d. SELL quality gate ─────────────────────────────────
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 ─────────────────────────
xau_is_sell_dir = ( xau_is_sell_dir = (
report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL) report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL)
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0) or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
) )
if (report.display_name == "XAU/USD" if (self.config.xau_strategy.enabled
and self.config.xau_strategy.enabled
and xau_is_sell_dir and xau_is_sell_dir
and report.signal_quality not in self.config.xau_strategy.sell_min_quality): and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
report.warnings.append( 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.signal_quality = "D"
report.trade_setup = None report.trade_setup = None
# ── 7. Trade setup for actionable signals ────────────────── # ── 7. Trade setup for actionable signals ──────────────────
# Pair-specific generic-trade suppression: when a pair has a dedicated if report.trade_setup is None and report.signal_quality in ("A+", "A"):
# strategy and that strategy didn't fire, falling through to a generic atr = (report.primary_analysis.key_levels.get("atr", 0)
# swing-based setup tends to chase momentum at unfavorable spots. if report.primary_analysis else 0)
# 90-day EUR M15 backtest: allow=True → +4.7R, allow=False → +9.5R. if atr > 0:
suppress_generic = ( report.trade_setup = self._generate_xau_atr_trade_setup(report, atr)
(report.display_name == "XAU/USD" if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
and not self.config.xau_strategy.allow_generic_trades) report.trade_setup = self._generate_trade_setup(report, pip_value)
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)
# ── 8. Reasons ───────────────────────────────────────────── # ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report) self._collect_reasons(report)
@@ -582,21 +427,10 @@ class SignalAggregator:
# ── Internals ────────────────────────────────────────────────── # ── 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( def _check_xau_oversold_bounce(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> bool: ) -> bool:
"""Return True when XAU/USD oversold-bounce conditions are met. """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
"""
cfg = self.config.xau_strategy cfg = self.config.xau_strategy
try: try:
@@ -671,9 +505,11 @@ class SignalAggregator:
price = report.current_price price = report.current_price
pip_value = 0.10 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 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 sl_distance = price - sl
tp_distance = tp1 - price tp_distance = tp1 - price
@@ -693,10 +529,12 @@ class SignalAggregator:
pip_value=pip_value, 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( def _generate_xau_atr_trade_setup(
self, report: SignalReport, atr: float self, report: SignalReport, atr: float
) -> Optional[TradeSetup]: ) -> 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 cfg = self.config.xau_strategy
price = report.current_price price = report.current_price
pip_value = 0.10 pip_value = 0.10
@@ -708,21 +546,28 @@ class SignalAggregator:
else: else:
is_buy = report.final_score >= 0 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: if is_buy:
sl = price - cfg.sl_atr_multiplier * atr sl = price - cfg.sl_atr_multiplier * atr
tp1 = price + cfg.tp_atr_multiplier * atr tp1 = price + tp_mult * atr
tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr tp2 = price + tp_mult * 1.5 * atr
direction = "BUY" direction = "BUY"
else: else:
sl = price + cfg.sl_atr_multiplier * atr sl = price + cfg.sl_atr_multiplier * atr
tp1 = price - cfg.tp_atr_multiplier * atr tp1 = price - tp_mult * atr
tp2 = price - cfg.tp_atr_multiplier * 2.0 * atr tp2 = price - tp_mult * 1.5 * atr
direction = "SELL" direction = "SELL"
sl_distance = abs(price - sl) sl_distance = abs(price - sl)
tp_distance = abs(tp1 - price) tp_distance = abs(tp1 - price)
rr = tp_distance / sl_distance if sl_distance > 0 else 0 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( return TradeSetup(
pair=report.symbol, pair=report.symbol,
display_name=report.display_name, display_name=report.display_name,
@@ -737,277 +582,6 @@ class SignalAggregator:
pip_value=pip_value, 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: def _check_mtf_agreement(self, report: SignalReport) -> bool:
signals = list(report.mtf_signals.values()) signals = list(report.mtf_signals.values())
if len(signals) < 2: if len(signals) < 2:
@@ -1023,154 +597,184 @@ class SignalAggregator:
score = report.primary_score score = report.primary_score
confidence = report.primary_confidence confidence = report.primary_confidence
# ── Bonuses (additive, applied first) ─────────────────────
if report.mtf_agreement: if report.mtf_agreement:
confidence += 10 confidence += 10
all_scores = list(report.mtf_scores.values()) all_scores = list(report.mtf_scores.values())
if all_scores: if all_scores:
mtf_avg = sum(all_scores) / len(all_scores) mtf_avg = sum(all_scores) / len(all_scores)
score = score * 0.6 + mtf_avg * 0.4 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 abs(report.news_sentiment) > 0.2:
if (report.news_sentiment > 0 and score > 0) or \ if (report.news_sentiment > 0 and score > 0) or \
(report.news_sentiment < 0 and score < 0): (report.news_sentiment < 0 and score < 0):
confidence += 5 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": if report.event_risk == "danger":
report.warnings.append(f"EVENT RISK: {report.event_description}") report.warnings.append(f"EVENT RISK: {report.event_description}")
confidence = min(confidence, 30) confidence = min(confidence, 30)
report.action_recommendation = f"WAIT — {report.event_description}" 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}") report.warnings.append(f"Upcoming event: {report.event_description}")
confidence -= 15 penalties.append(("event_caution", 15))
is_counter_trend = ( is_counter_trend = (
(report.market_regime == "trending_up" and score < 0) or (report.market_regime == "trending_up" and score < 0) or
(report.market_regime == "trending_down" and score > 0) (report.market_regime == "trending_down" and score > 0)
) )
if is_counter_trend: if is_counter_trend:
confidence -= 15
report.warnings.append("Counter-trend signal — reduced confidence") report.warnings.append("Counter-trend signal — reduced confidence")
penalties.append(("counter_trend", 15))
if report.volatility_regime == "extreme": if report.volatility_regime == "extreme":
report.warnings.append("EXTREME volatility — wider stops needed, reduced position size") report.warnings.append("EXTREME volatility — wider stops needed")
confidence -= 10 penalties.append(("extreme_vol", 10))
elif report.volatility_regime == "low": 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: if primary is not None:
ema200 = primary.key_levels.get("ema_trend") ema200 = primary.key_levels.get("ema_trend")
if ema200 and ema200 > 0 and report.current_price > 0: if ema200 and ema200 > 0 and report.current_price > 0:
ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0 ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0
_lo = float(os.environ.get("FB2_EMA_LO", "0.5")) _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: if ema_dist_pct < _lo:
report.warnings.append( report.warnings.append(f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk")
f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk" penalties.append(("ema200_near", 5))
) elif ema_dist_pct > 2.5:
confidence -= 3 if is_xau else 10 report.warnings.append(f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended")
elif ema_dist_pct > (2.5 if is_xau else _hi): penalties.append(("ema200_far", 5))
report.warnings.append(
f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended"
)
confidence -= 5 if is_xau else 10
atr = primary.key_levels.get("atr", 0) atr = primary.key_levels.get("atr", 0)
if atr > 0 and score != 0: if atr > 0 and score != 0:
is_buy_signal = score > 0 if score > 0:
if is_buy_signal:
swing_lows = sorted( swing_lows = sorted(
v for k, v in primary.key_levels.items() v for k, v in primary.key_levels.items() if k.startswith("swing_low_")
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] > 3.5 * atr:
if swing_lows and report.current_price - swing_lows[0] > _exh_mult * atr: report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low")
confidence -= 5 if is_xau else 15 penalties.append(("exhaustion", 8))
report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult} ATR past swing low")
else: else:
swing_highs = sorted( swing_highs = sorted(
(v for k, v in primary.key_levels.items() (v for k, v in primary.key_levels.items() if k.startswith("swing_high_")),
if k.startswith("swing_high_")),
reverse=True, reverse=True,
) )
_exh_mult_s = 3.5 if is_xau else 2.5 if swing_highs and swing_highs[0] - report.current_price > 3.5 * atr:
if swing_highs and swing_highs[0] - report.current_price > _exh_mult_s * atr: report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing high")
confidence -= 5 if is_xau else 15 penalties.append(("exhaustion", 8))
report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult_s} ATR past swing high")
ema_medium = primary.key_levels.get("ema_medium") ema_medium = primary.key_levels.get("ema_medium")
if atr > 0 and ema_medium and report.market_regime.startswith("trending"): if atr > 0 and ema_medium and report.market_regime.startswith("trending"):
pullback_dist = abs(report.current_price - ema_medium) / atr pullback_dist = abs(report.current_price - ema_medium) / atr
_pb_severe = 2.5 if is_xau else 2.0 if pullback_dist > 2.5:
_pb_mild = 2.0 if is_xau else 1.5 report.warnings.append(f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21")
if pullback_dist > _pb_severe: penalties.append(("no_pullback", 8))
confidence -= 8 if is_xau else 20 elif pullback_dist > 2.0:
report.warnings.append( report.warnings.append(f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21")
f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21, chasing risk" penalties.append(("weak_pullback", 5))
)
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 report.market_regime.startswith("trending"): if report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi") rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger") bb_ind = primary.indicators.get("bollinger")
if rsi_ind: if rsi_ind:
rsi_val = rsi_ind.value rsi_val = rsi_ind.value
_rsi_pen = 5 if is_xau else 15 if report.market_regime in ("trending_up", "trending") and score > 0 and rsi_val > 65:
if report.market_regime in ("trending_up", "trending") and score > 0: report.warnings.append(f"Extended — RSI {rsi_val:.0f} in uptrend")
if rsi_val > 65: penalties.append(("rsi_extended", 5))
confidence -= _rsi_pen elif report.market_regime == "trending_down" and score < 0 and rsi_val < 35:
report.warnings.append( report.warnings.append(f"Extended — RSI {rsi_val:.0f} in downtrend")
f"Extended entry — RSI {rsi_val:.0f} in uptrend (prefer pullback to 40-55)" penalties.append(("rsi_extended", 5))
)
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 bb_ind: if bb_ind:
bb_pos = bb_ind.value bb_pos = bb_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0: if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos > 0.85:
if bb_pos > 0.85: penalties.append(("bb_overextended", 8))
confidence -= 10 elif report.market_regime == "trending_down" and score < 0 and bb_pos < 0.15:
elif bb_pos < 0.4: penalties.append(("bb_overextended", 8))
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
# 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)) 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 # 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: if score >= 35 and confidence >= 55:
report.final_signal = SignalStrength.STRONG_BUY report.final_signal = SignalStrength.STRONG_BUY
elif score >= 25 and confidence >= 50: elif score >= 25 and confidence >= 50:
@@ -1185,21 +789,9 @@ class SignalAggregator:
report.final_score = score report.final_score = score
report.final_confidence = confidence 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 # Quality grade
abs_score = abs(score) abs_score = abs(score)
if sell_suppressed: if abs_score >= 40 and confidence >= 65 and report.mtf_agreement:
report.signal_quality = "F"
elif abs_score >= 40 and confidence >= 65 and report.mtf_agreement:
report.signal_quality = "A+" report.signal_quality = "A+"
elif abs_score >= 33 and confidence >= 55 and report.mtf_agreement: elif abs_score >= 33 and confidence >= 55 and report.mtf_agreement:
report.signal_quality = "A" report.signal_quality = "A"
@@ -1252,8 +844,8 @@ class SignalAggregator:
min_rr = self.config.signals.min_rr_ratio min_rr = self.config.signals.min_rr_ratio
mature_move = abs(report.final_score) > 30 mature_move = abs(report.final_score) > 30
tp_mult = 0.9 if mature_move else 1.2 tp_mult = 1.0 if mature_move else 1.5
tp_swing_cap = 1.5 if mature_move else 2.0 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) # 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 sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
-303
View File
@@ -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())
+122 -93
View File
@@ -5,26 +5,18 @@ ForexBot2 — Telegram Bot
Async Telegram interface with candle-boundary auto-scanner and Async Telegram interface with candle-boundary auto-scanner and
real-time WebSocket level-breach monitor. 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. Auto-scan: Starts automatically on boot if TELEGRAM_CHAT_ID is set.
/autoscan toggles for additional chats or to stop. /autoscan toggles for additional chats or to stop.
M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals). M5 fires at :00/:05/:10/… + 10s buffer (data only, no signals).
M15 fires at :00/:15/:30/:45 + 10s buffer. M15 fires at :00/:15/:30/:45 + 10s buffer.
H1 fires at :00 + 10s buffer. H1 fires at :00 + 10s buffer.
H4 fires at :00 every 4 hours + 10s buffer. H4 fires at :00 every 4 hours + 10s buffer.
WebSocket: Streams live ticks from TwelveData for EUR/USD & XAU/USD WebSocket: Streams live ticks from TwelveData for XAU/USD.
(Grow plan supports only these 2 WS symbols).
When price crosses a key structure level (swing S/R, BB, When price crosses a key structure level (swing S/R, BB,
EMA200) cached from the last boundary scan, triggers an EMA200) cached from the last boundary scan, triggers an
immediate M15 scan and sends a Level Break Alert. immediate M15 scan and sends a Level Break Alert.
5-minute cooldown per pair prevents spam. 5-minute cooldown 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.
Usage: Usage:
python telegram_bot.py python telegram_bot.py
@@ -55,7 +47,7 @@ from telegram.ext import (
from config import ( from config import (
PlatformConfig, SignalStrength, Timeframe, PlatformConfig, SignalStrength, Timeframe,
EURUSD, GBPUSD, XAUUSD, PAIR_INFO, XAUUSD, PAIR_INFO,
) )
from data_provider import ForexDataProvider from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator, SignalReport from signal_aggregator import SignalAggregator, SignalReport
@@ -78,14 +70,9 @@ CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# ── Pairs & buttons ─────────────────────────────────────────────────── # ── Pairs & buttons ───────────────────────────────────────────────────
SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD] SCAN_PAIRS = [XAUUSD]
CORRELATION_GROUPS = {
"usd_negative": {"pairs": {EURUSD, GBPUSD}, "label": "USD-weakness"},
}
_WS_TO_PAIR = { _WS_TO_PAIR = {
"EUR/USD": EURUSD,
"XAU/USD": XAUUSD, "XAU/USD": XAUUSD,
} }
_WS_SYMBOLS = list(_WS_TO_PAIR.keys()) _WS_SYMBOLS = list(_WS_TO_PAIR.keys())
@@ -93,7 +80,8 @@ _WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = ( _LEVEL_KEYS = (
"swing_high_1", "swing_high_2", "swing_high_3", "swing_high_1", "swing_high_2", "swing_high_3",
"swing_low_1", "swing_low_2", "swing_low_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 ────────────────────────────────────────── # ── Live signal stats cache ──────────────────────────────────────────
@@ -102,15 +90,10 @@ _signal_stats: Dict[str, Dict[str, SignalReport]] = {}
_stats_updated: Optional[float] = None _stats_updated: Optional[float] = None
SCAN_BUTTONS = [ SCAN_BUTTONS = [
{"cb": "scan_eurusd_m15", "symbol": EURUSD, "tf": Timeframe.M15, "label": "EUR/USD 15M"}, {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"},
{"cb": "scan_eurusd_h1", "symbol": EURUSD, "tf": Timeframe.H1, "label": "EUR/USD 1H"}, {"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"},
{"cb": "scan_eurusd_h4", "symbol": EURUSD, "tf": Timeframe.H4, "label": "EUR/USD 4H"}, {"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"},
{"cb": "scan_gbpusd_m15", "symbol": GBPUSD, "tf": Timeframe.M15, "label": "GBP/USD 15M"}, {"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"},
{"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"},
] ]
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS} SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
@@ -139,6 +122,10 @@ def _escape_md(text: str) -> str:
return text 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 data provider (single cache across all scans) ─────────────
_shared_dp: Optional[ForexDataProvider] = None _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"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})",
f"🕐 {now}", 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}", f"📊 Regime: {regime_str} | Vol: {vol_str}",
"", "",
"━━━━━━━━━━━━━━━━━━━━", "━━━━━━━━━━━━━━━━━━━━",
@@ -243,12 +230,12 @@ def format_signal_message(report: SignalReport) -> str:
"━━━━━━━━━━━━━━━━━━━━", "━━━━━━━━━━━━━━━━━━━━",
f"🚀 *TRADE SETUP*", f"🚀 *TRADE SETUP*",
f"{dir_emoji} Direction: *{ts.direction}*", f"{dir_emoji} Direction: *{ts.direction}*",
f"▶ Entry: `{ts.entry_price:.3f}`", f"▶ Entry: `{_fmt_price(ts.entry_price)}`",
f"🛑 SL: `{ts.stop_loss:.3f}` ({ts.risk_pips:.1f} pips)", f"🛑 SL: `{_fmt_price(ts.stop_loss)}` ({ts.risk_pips:.1f} pips)",
f"🎯 TP1: `{ts.take_profit_1:.3f}` ({ts.reward_pips:.1f} pips)", f"🎯 TP1: `{_fmt_price(ts.take_profit_1)}` ({ts.reward_pips:.1f} pips)",
] ]
if ts.take_profit_2: 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.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}")
lines += ["", "━━━━━━━━━━━━━━━━━━━━", lines += ["", "━━━━━━━━━━━━━━━━━━━━",
@@ -257,33 +244,6 @@ def format_signal_message(report: SignalReport) -> str:
return "\n".join(lines) 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 ───────────────────────────────────────── # ── Auto-scan summary format ─────────────────────────────────────────
def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str: 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: else:
lines.append("No actionable signals this cycle.") 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) return "\n".join(lines)
@@ -358,10 +314,7 @@ def _format_stats_dashboard() -> str:
break break
price = ws_price or (latest.current_price if latest else 0) price = ws_price or (latest.current_price if latest else 0)
if price < 10: price_str = f"`{_fmt_price(price)}`"
price_str = f"`{price:.5f}`"
else:
price_str = f"`{price:.2f}`"
regime = latest.market_regime.replace("_", " ").title() if latest else "" regime = latest.market_regime.replace("_", " ").title() if latest else ""
@@ -383,8 +336,8 @@ def _format_stats_dashboard() -> str:
ts = latest.trade_setup ts = latest.trade_setup
d = "📈" if ts.direction == "BUY" else "📉" d = "📈" if ts.direction == "BUY" else "📉"
lines.append( lines.append(
f"{d} {ts.direction} `{ts.entry_price:.3f}` " f"{d} {ts.direction} `{_fmt_price(ts.entry_price)}` "
f"SL `{ts.stop_loss:.3f}` TP `{ts.take_profit_1:.3f}` " f"SL `{_fmt_price(ts.stop_loss)}` TP `{_fmt_price(ts.take_profit_1)}` "
f"RR 1:{ts.risk_reward:.1f}" f"RR 1:{ts.risk_reward:.1f}"
) )
@@ -452,12 +405,14 @@ async def _dashboard_loop(bot) -> None:
# ── Keyboard helper ─────────────────────────────────────────────────── # ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup: def scan_keyboard() -> InlineKeyboardMarkup:
rows = [] tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
for i in range(0, len(SCAN_BUTTONS), 4): all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
rows.append([ rows = [[
InlineKeyboardButton(b["label"], callback_data=b["cb"]) InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in SCAN_BUTTONS[i:i + 4] for b in tf_buttons
]) ]]
if all_button:
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
return InlineKeyboardMarkup(rows) return InlineKeyboardMarkup(rows)
@@ -485,7 +440,7 @@ async def run_signal(
timeframe: Timeframe, timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None, confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport: ) -> SignalReport:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor( return await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs _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(): if _dashboard_task is None or _dashboard_task.done():
_dashboard_task = asyncio.create_task(_dashboard_loop(bot)) _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: def _stop_scheduler() -> None:
@@ -755,10 +710,11 @@ def _stop_scheduler() -> None:
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off" scanning = "running" if _autoscan_chats else "off"
text = ( text = (
"👋 *ForexBot2 — Signal Intelligence*\n\n" "👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n"
"Pick a pair and timeframe to scan.\n" "Pick a timeframe to scan gold.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n" "Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n" f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
"/scan — Quick scan buttons\n"
"/status — Bot & WebSocket status\n" "/status — Bot & WebSocket status\n"
"/help — All commands" "/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: async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = ( text = (
"📖 *ForexBot2 Commands*\n\n" "📖 *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" "/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n" "/status — Bot status, WS connection, live prices\n"
"/cache — Show data cache age per pair/TF\n" "/cache — Show data cache age per pair/TF\n"
"/clearcache — Wipe data cache, force fresh fetch\n" "/clearcache — Wipe data cache, force fresh fetch\n"
"/help — This message\n\n" "/help — This message\n\n"
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n" "*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
"Live dashboard auto-refreshes every 60s.\n" f"Live dashboard auto-refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
"*WebSocket* monitors live prices and updates level cache." "*WebSocket* monitors live prices and triggers breach scans."
) )
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN) 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) age = time.time() - _last_price_ts.get(pair, 0)
if price is None: if price is None:
lines.append(f" {display}: waiting...") lines.append(f" {display}: waiting...")
elif price < 10:
lines.append(f" {display}: `{price:.5f}` ({age:.0f}s ago)")
else: else:
lines.append(f" {display}: `{price:.3f}` ({age:.0f}s ago)") lines.append(f" {display}: `{_fmt_price(price)}` ({age:.0f}s ago)")
else: else:
lines.append("\n_No live prices yet (WS not streaming)_") 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) next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text( await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n" "✅ *Auto-scan enabled*\n\n"
f"Scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4 at candle close.\n" f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n"
f"📊 Live dashboard refreshes every 60s.\n" f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
f"📡 Live price monitoring active.\n" f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " 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" 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: async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
@@ -915,9 +878,12 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not btn: if not btn:
return return
pair_display = btn["label"] if btn["tf"] is None:
await _cb_scan_all(query)
return
await query.edit_message_text( await query.edit_message_text(
f"⏳ Scanning {pair_display}…", f"⏳ Scanning XAU/USD {btn['label']}…",
parse_mode=ParseMode.MARKDOWN, 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 ────────────────────────────────────────────── # ── Boot-time auto-start ──────────────────────────────────────────────
async def _post_init(app: Application) -> None: async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot.""" """Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([ 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"), ("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"), ("status", "Bot status, WS connection, live prices"),
("cache", "Show data cache age per pair/TF"), ("cache", "Show data cache age per pair/TF"),
@@ -989,8 +1017,8 @@ async def _post_init(app: Application) -> None:
await app.bot.send_message( await app.bot.send_message(
chat_id, chat_id,
f"🤖 *ForexBot2 started*\n\n" f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning {len(SCAN_PAIRS)} pairs on M5/M15/H1/H4.\n" f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
f"📊 Live dashboard refreshes every 60s.\n" f"📊 Live dashboard refreshes every {_DASHBOARD_REFRESH_INTERVAL}s.\n"
f"📡 Live price monitoring active.\n" f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | " 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" 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("start", cmd_start))
app.add_handler(CommandHandler("scan", cmd_scan))
app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("autoscan", cmd_autoscan)) app.add_handler(CommandHandler("autoscan", cmd_autoscan))
+190 -269
View File
File diff suppressed because it is too large Load Diff
-121
View File
@@ -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")
-482
View File
@@ -1,482 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ForexBot2 — Live Dashboard</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d27;
--border: #2a2d3a;
--text: #e1e4eb;
--text-dim: #8b8fa3;
--buy: #22c55e;
--buy-bg: rgba(34, 197, 94, 0.08);
--sell: #ef4444;
--sell-bg: rgba(239, 68, 68, 0.08);
--neutral: #6b7280;
--neutral-bg: rgba(107, 114, 128, 0.06);
--strong-buy: #16a34a;
--strong-sell: #dc2626;
--accent: #6366f1;
--gold: #eab308;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
header h1 {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--text-dim);
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--sell);
display: inline-block;
}
.status-dot.connected { background: var(--buy); }
#last-update { color: var(--text-dim); }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 20px 24px;
max-width: 1600px;
margin: 0 auto;
}
@media (max-width: 1100px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.pair-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: border-color 0.3s;
}
.pair-card.buy { border-color: var(--buy); background: var(--buy-bg); }
.pair-card.sell { border-color: var(--sell); background: var(--sell-bg); }
.pair-card.neutral { border-color: var(--border); }
.card-header {
padding: 16px 18px 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border);
}
.pair-name {
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
}
.pair-price {
font-size: 14px;
color: var(--text-dim);
margin-top: 2px;
}
.signal-badge {
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.signal-badge.buy, .signal-badge.strong_buy { background: var(--buy); color: #000; }
.signal-badge.sell, .signal-badge.strong_sell { background: var(--sell); color: #fff; }
.signal-badge.neutral { background: var(--neutral); color: #fff; }
.card-meta {
padding: 10px 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.meta-item { display: flex; flex-direction: column; gap: 2px; }
.meta-label { color: var(--text-dim); text-transform: uppercase; font-size: 9px; letter-spacing: 0.5px; }
.meta-value { font-weight: 600; font-size: 13px; }
.quality-A\+, .quality-A { color: var(--buy); }
.quality-B { color: var(--gold); }
.quality-C { color: #f97316; }
.quality-D, .quality-F { color: var(--sell); }
.tf-section {
padding: 8px 18px;
border-bottom: 1px solid var(--border);
}
.tf-section:last-of-type { border-bottom: none; }
.tf-header {
font-size: 11px;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.tf-signal-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
}
.tf-signal-tag.buy { background: rgba(34,197,94,0.2); color: var(--buy); }
.tf-signal-tag.sell { background: rgba(239,68,68,0.2); color: var(--sell); }
.tf-signal-tag.neutral { background: rgba(107,114,128,0.2); color: var(--neutral); }
.indicator-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
font-size: 12px;
}
.ind-name { color: var(--text-dim); text-transform: uppercase; font-size: 10px; min-width: 80px; }
.ind-desc { flex: 1; margin: 0 8px; color: var(--text); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ind-signal { font-weight: 600; font-size: 11px; min-width: 50px; text-align: right; }
.ind-signal.buy, .ind-signal.strong_buy { color: var(--buy); }
.ind-signal.sell, .ind-signal.strong_sell { color: var(--sell); }
.ind-signal.neutral { color: var(--neutral); }
.trade-setup {
padding: 12px 18px;
border-top: 2px solid var(--border);
background: rgba(99, 102, 241, 0.05);
}
.trade-setup h4 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--accent);
margin-bottom: 8px;
}
.trade-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
font-size: 12px;
}
.trade-grid .label { color: var(--text-dim); }
.trade-grid .val { font-weight: 600; text-align: right; }
.trade-grid .val.buy-dir { color: var(--buy); }
.trade-grid .val.sell-dir { color: var(--sell); }
.reasons-section {
padding: 8px 18px 12px;
font-size: 11px;
}
.reasons-section .bull { color: var(--buy); }
.reasons-section .bear { color: var(--sell); }
.reasons-section .warn { color: var(--gold); }
.reason-line { padding: 1px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.loading {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
font-size: 14px;
}
.loading .spinner {
width: 32px; height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.flash { animation: pulse 0.6s ease-in-out; }
.mtf-bar {
display: flex;
gap: 6px;
padding: 6px 18px 10px;
font-size: 10px;
}
.mtf-chip {
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
letter-spacing: 0.3px;
}
.mtf-chip.buy { background: rgba(34,197,94,0.15); color: var(--buy); }
.mtf-chip.sell { background: rgba(239,68,68,0.15); color: var(--sell); }
.mtf-chip.neutral { background: rgba(107,114,128,0.12); color: var(--neutral); }
</style>
</head>
<body>
<header>
<h1>ForexBot2 Live Dashboard</h1>
<div class="status-bar">
<span><span class="status-dot" id="ws-dot"></span> <span id="ws-status">Connecting…</span></span>
<span id="last-update"></span>
</div>
</header>
<div class="grid" id="grid">
<div class="loading" id="loading">
<div class="spinner"></div>
Running initial scan — this takes 30–60 seconds…
</div>
</div>
<script>
const PAIRS = ["EUR/USD", "GBP/USD", "XAU/USD"];
const TF_ORDER = ["15min", "1h", "4h"];
const TF_LABELS = {"5min":"M5","15min":"M15","1h":"H1","4h":"H4","1d":"D1"};
let ws = null;
let reconnectDelay = 1000;
function signalClass(sig) {
if (!sig) return "neutral";
const s = sig.toLowerCase();
if (s === "strong_buy" || s === "buy") return "buy";
if (s === "strong_sell" || s === "sell") return "sell";
return "neutral";
}
function signalLabel(sig) {
if (!sig) return "NEUTRAL";
return sig.replace("_", " ");
}
function fmtPrice(price, pair) {
if (!price) return "—";
if (pair === "XAU/USD") return price.toFixed(2);
return price.toFixed(5);
}
function buildIndicatorRows(indicators) {
if (!indicators || Object.keys(indicators).length === 0) return "";
let html = "";
for (const [name, ind] of Object.entries(indicators)) {
const cls = signalClass(ind.signal);
html += `<div class="indicator-row">
<span class="ind-name">${name.replace(/_/g, " ")}</span>
<span class="ind-desc">${ind.description || ""}</span>
<span class="ind-signal ${cls}">${signalLabel(ind.signal)}</span>
</div>`;
}
return html;
}
function buildCard(pair, tfData) {
if (!tfData || Object.keys(tfData).length === 0) return "";
let best = null;
for (const tf of TF_ORDER) {
if (tfData[tf]) { best = tfData[tf]; break; }
}
if (!best) return "";
const sc = signalClass(best.signal);
const priceStr = fmtPrice(best.price, pair);
let mtfHtml = "";
for (const tf of TF_ORDER) {
const d = tfData[tf];
if (!d) continue;
const cls = signalClass(d.signal);
mtfHtml += `<span class="mtf-chip ${cls}">${TF_LABELS[tf] || tf} ${signalLabel(d.signal)}</span>`;
}
let metaHtml = `<div class="card-meta">
<div class="meta-item"><span class="meta-label">Score</span><span class="meta-value">${best.score >= 0 ? "+" : ""}${best.score}</span></div>
<div class="meta-item"><span class="meta-label">Confidence</span><span class="meta-value">${best.confidence}%</span></div>
<div class="meta-item"><span class="meta-label">Quality</span><span class="meta-value quality-${best.quality}">${best.quality || "—"}</span></div>
</div>
<div class="card-meta">
<div class="meta-item"><span class="meta-label">Regime</span><span class="meta-value">${(best.regime || "—").replace(/_/g, " ")}</span></div>
<div class="meta-item"><span class="meta-label">Volatility</span><span class="meta-value">${best.volatility || "—"}</span></div>
<div class="meta-item"><span class="meta-label">MTF</span><span class="meta-value">${best.mtf_agreement ? "✓ Aligned" : "✗ Diverged"}</span></div>
</div>`;
let tfSections = "";
for (const tf of TF_ORDER) {
const d = tfData[tf];
if (!d) continue;
let indicators = d.indicators || {};
const tfCls = signalClass(d.signal);
tfSections += `<div class="tf-section">
<div class="tf-header">
<span>${TF_LABELS[tf] || tf} — Score: ${d.score >= 0 ? "+" : ""}${d.score} | ${d.confidence}%</span>
<span class="tf-signal-tag ${tfCls}">${signalLabel(d.signal)}</span>
</div>
${buildIndicatorRows(indicators)}`;
if (d.confirmations) {
for (const cf of d.confirmations) {
const cfCls = signalClass(cf.signal);
tfSections += `<div class="tf-header" style="margin-top:6px;">
<span>${TF_LABELS[cf.timeframe] || cf.timeframe} (confirm) — ${cf.score >= 0 ? "+" : ""}${cf.score} | ${cf.confidence}%</span>
<span class="tf-signal-tag ${cfCls}">${signalLabel(cf.signal)}</span>
</div>`;
tfSections += buildIndicatorRows(cf.indicators);
}
}
tfSections += `</div>`;
}
let tradeHtml = "";
if (best.trade_setup) {
const t = best.trade_setup;
const dirCls = t.direction === "BUY" ? "buy-dir" : "sell-dir";
tradeHtml = `<div class="trade-setup">
<h4>Trade Setup</h4>
<div class="trade-grid">
<span class="label">Direction</span><span class="val ${dirCls}">${t.direction}</span>
<span class="label">Entry</span><span class="val">${t.entry}</span>
<span class="label">Stop Loss</span><span class="val">${t.sl}</span>
<span class="label">Take Profit</span><span class="val">${t.tp1}</span>
<span class="label">Risk/Reward</span><span class="val">1:${t.rr}</span>
<span class="label">Risk</span><span class="val">${t.risk_pips} pips</span>
</div>
</div>`;
}
let reasonsHtml = "";
const bulls = best.bullish_reasons || [];
const bears = best.bearish_reasons || [];
const warns = best.warnings || [];
if (bulls.length + bears.length + warns.length > 0) {
reasonsHtml = `<div class="reasons-section">`;
for (const r of bulls.slice(0, 3)) reasonsHtml += `<div class="reason-line bull">+ ${r}</div>`;
for (const r of bears.slice(0, 3)) reasonsHtml += `<div class="reason-line bear">- ${r}</div>`;
for (const w of warns.slice(0, 2)) reasonsHtml += `<div class="reason-line warn">⚠ ${w}</div>`;
reasonsHtml += `</div>`;
}
return `<div class="pair-card ${sc}" id="card-${pair.replace("/","")}">
<div class="card-header">
<div>
<div class="pair-name">${pair}</div>
<div class="pair-price">${priceStr}</div>
</div>
<span class="signal-badge ${sc}">${signalLabel(best.signal)}</span>
</div>
${metaHtml}
<div class="mtf-bar">${mtfHtml}</div>
${tfSections}
${tradeHtml}
${reasonsHtml}
</div>`;
}
function render(data) {
const grid = document.getElementById("grid");
const loading = document.getElementById("loading");
if (loading) loading.remove();
let html = "";
for (const pair of PAIRS) {
const tfData = data[pair] || {};
html += buildCard(pair, tfData);
}
grid.innerHTML = html;
grid.querySelectorAll(".pair-card").forEach(c => {
c.classList.add("flash");
setTimeout(() => c.classList.remove("flash"), 600);
});
}
function connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById("ws-dot").classList.add("connected");
document.getElementById("ws-status").textContent = "Connected";
reconnectDelay = 1000;
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "update" && msg.data) {
render(msg.data);
document.getElementById("last-update").textContent = msg.timestamp || "";
}
} catch (err) {
console.error("Parse error:", err);
}
};
ws.onclose = () => {
document.getElementById("ws-dot").classList.remove("connected");
document.getElementById("ws-status").textContent = "Reconnecting…";
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = () => { ws.close(); };
}
connect();
</script>
</body>
</html>
-55
View File
@@ -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())