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