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