Add web dashboard (Redis + FastAPI + WebSocket), scope XAU penalties

Signal daemon (signal_web.py): candle-boundary scanner mirroring
telegram_bot.py, publishes serialized SignalReport JSON to Redis.
Web API (web/app.py): thin FastAPI reader behind Apache, serves
/api/signals REST + /ws WebSocket from Redis pub/sub.
Frontend (web/static/index.html): 3-pair grid, color-coded signals,
indicator breakdown per timeframe, trade setup display.

Also: XAU-specific penalty scoping in signal_aggregator (softer
confidence penalties for gold, original strict values for forex),
pair-aware session filter routing XAU to its own blocked hours.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-14 23:15:37 -04:00
parent 57769967d1
commit 98daf71a52
10 changed files with 1401 additions and 883 deletions
+110
View File
@@ -0,0 +1,110 @@
# 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).
## Architecture
```
Twelve Data REST API + WebSocket
|
data_provider.py (fetch, cache, validate OHLCV; rate-limited 55 calls/min)
|
engine.py (6 indicators per timeframe → composite score)
|
signal_aggregator.py (MTF agreement + news + final signal + trade setup)
|
+-----+--------+
| |
telegram_bot.py main.py
(Telegram alerts, (CLI single-pair
auto-scanner, analysis)
WS monitor,
live dashboard)
```
## Core Files
| File | Purpose |
|------|---------|
| `config.py` | All configuration dataclasses, pair definitions, strategy configs |
| `data_provider.py` | Twelve Data SDK wrapper with in-memory cache and rate limiting |
| `engine.py` | `TechnicalEngine` — computes EMA, RSI, BB, ADX, ATR, MACD, divergences, candlestick patterns, swing S/R |
| `signal_aggregator.py` | `SignalAggregator` — MTF analysis, news integration, final signal scoring, trade setup generation |
| `news_engine.py` | Economic calendar (Forex Factory JSON) + RSS sentiment (keyword-based) |
| `telegram_bot.py` | Async Telegram bot with auto-scanner (M5/M15/H1/H4), WebSocket price monitor, live dashboard |
| `main.py` | CLI entry point for single USD/JPY analysis |
| `test_all.py` | Comprehensive QA suite (~90 tests, no live API needed) |
## 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+.
**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.
**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.
**SELL Suppression:** Forex SELL signals are suppressed for all pairs except XAU/USD. Backtesting showed 0% win rate for forex shorts.
**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.
## How to Run
```bash
# CLI (single USD/JPY analysis)
python main.py # H1 default
python main.py --timeframe M15 --detail --json
# Telegram bot (auto-scanner)
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
# Tests
python test_all.py
```
## Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config |
| `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback |
| `TELEGRAM_CHAT_ID` | Auto-scan chat ID (enables boot auto-scan) | Hardcoded fallback |
| `FB2_EMA_LO` / `FB2_EMA_HI` | EMA200 distance thresholds (%) | 0.5 / 1.5 |
## Dependencies
`numpy`, `pandas`, `requests`, `twelvedata`, `python-telegram-bot`, `websockets` — see `requirements.txt`.
## Data Provider
- **Source:** Twelve Data REST API via `twelvedata` SDK
- **Rate limit:** 55 credits/min (Grow plan), enforced by sliding-window throttle
- **Cache:** In-memory dict, TTL = candle duration of the timeframe
- **Validation:** Drops NaN rows, forward-fills gaps (limit 3), fixes OHLC inconsistencies, removes outliers (>10σ), strips future timestamps, deduplicates
- **WebSocket:** EUR/USD and XAU/USD only (Grow plan WS limit); GBP/USD and USD/JPY via REST-only scans
## Telegram Bot
- **Auto-scan:** M5 (data-only, no alerts), M15, H1, H4 at candle boundaries + 10s buffer
- **WebSocket:** Streams live ticks for EUR/USD + XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, EMA200) with 5-min cooldown
- **Live dashboard:** Edit-in-place message, 30s refresh, shows all pairs/timeframes/prices/setups
- **Commands:** `/start`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help`
- **Correlation warning:** Flags same-direction signals on EUR/USD + GBP/USD (USD-weakness group)
## Backtesting
The `backtest/` directory (gitignored) contains pair-specific backtesting scripts, GPU-accelerated optimizers, and historical parquet data. Strategies were optimized on 1-year M15 data. Results drove the current strategy configs in `config.py`.
## Planned: MT5 Auto-Execution
Documented in `mt5.md`. REST bridge architecture: Linux bot → HTTP → Windows FastAPI server wrapping MetaTrader5 COM. Not yet implemented. Key: `MT5Config` dataclass, idempotent orders, position-size from bridge-side tick values.
## Code Conventions
- Python 3.12, type hints on public methods
- Dataclasses for all config and output structures
- `SignalStrength` enum (-2 to +2) is the universal signal currency
- Indicators stored as `Dict[str, IndicatorSignal]` in `TechnicalAnalysis`
- Key levels stored as `Dict[str, float]` — prefixed `swing_high_`, `swing_low_`, `ema_`, `bb_`, `atr`
- Private indicator data attached as `result._rsi_series`, `result._macd_histogram` (underscore prefix, skipped by composite scorer)
- 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
-618
View File
@@ -1,618 +0,0 @@
#!/usr/bin/env python3
"""
ForexBot2 — Historical Signal Evaluation
=========================================
Walks the engine over the last 7 days of market data for all scan pairs,
records every signal and trade setup, then checks outcomes on M5 data.
GPU-accelerated outcome evaluation via CuPy (RTX 4070 SUPER).
Falls back to NumPy if CuPy/CUDA is unavailable.
Usage:
python backtest/evaluate.py # full 7-day eval, M15+H1
python backtest/evaluate.py --tf H1 # single timeframe
python backtest/evaluate.py --days 3 # shorter window
python backtest/evaluate.py --no-cache # re-download all data
"""
import argparse
import csv
import os
import sys
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import (
PlatformConfig, Timeframe, SignalStrength,
EURUSD, USDJPY, GBPUSD, XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
from signal_aggregator import SignalAggregator
# ── GPU setup ────────────────────────────────────────────────────────
try:
if "CUDA_PATH" not in os.environ:
_nvidia_dir = os.path.join(
sys.prefix, "lib", "python3.12", "site-packages", "nvidia"
)
if os.path.isdir(_nvidia_dir):
os.environ["CUDA_PATH"] = _nvidia_dir
import cupy as cp
_test = cp.array([1.0, 2.0])
_ = cp.where(_test > 0.5)
GPU = True
GPU_NAME = cp.cuda.runtime.getDeviceProperties(0)["name"].decode()
print(f"GPU: {GPU_NAME} (CuPy {cp.__version__})")
del _test
except Exception:
import numpy as cp # type: ignore[no-redef]
GPU = False
print("GPU: not available — falling back to NumPy")
# ── Constants ────────────────────────────────────────────────────────
BACKTEST_DIR = Path(__file__).resolve().parent
DATA_DIR = BACKTEST_DIR / "data"
RESULTS_DIR = BACKTEST_DIR / "results"
SCAN_PAIRS = [EURUSD, GBPUSD, XAUUSD]
EVAL_TIMEFRAMES = [Timeframe.M15, Timeframe.H1]
TF_CONFIRMATIONS = {
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
}
ALL_TFS_NEEDED = {
Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4, Timeframe.D1,
}
OUTCOME_TF = Timeframe.M5
MIN_LOOKBACK_BARS = 250
@dataclass
class Trade:
pair: str
display: str
timeframe: str
entry_time: pd.Timestamp
direction: str
entry_price: float
stop_loss: float
take_profit_1: float
take_profit_2: Optional[float]
risk_reward: float
risk_pips: float
reward_pips: float
signal_quality: str
confidence: float
score: float
regime: str
outcome: str = "OPEN"
exit_price: float = 0.0
exit_time: Optional[pd.Timestamp] = None
pnl_pips: float = 0.0
bars_held: int = 0
# ══════════════════════════════════════════════════════════════════════
# DATA FETCHING & CACHING
# ══════════════════════════════════════════════════════════════════════
def _cache_path(symbol: str, tf: Timeframe) -> Path:
safe = symbol.replace("/", "_").replace("=", "")
return DATA_DIR / f"{safe}_{tf.value}.parquet"
def fetch_all_data(
pairs: List[str],
timeframes,
lookback_days: int = 67,
use_cache: bool = True,
) -> Dict[str, Dict[Timeframe, pd.DataFrame]]:
"""Fetch OHLCV for all pair×TF combos, cache to parquet."""
config = PlatformConfig()
config.data_lookback_days = lookback_days
dp = ForexDataProvider(config)
data: Dict[str, Dict[Timeframe, pd.DataFrame]] = {}
total = len(pairs) * len(timeframes)
done = 0
for symbol in pairs:
data[symbol] = {}
display = PAIR_INFO.get(symbol, {}).get("display", symbol)
for tf in timeframes:
done += 1
cache = _cache_path(symbol, tf)
if use_cache and cache.exists():
mtime = datetime.fromtimestamp(cache.stat().st_mtime, tz=timezone.utc)
age_hours = (datetime.now(timezone.utc) - mtime).total_seconds() / 3600
if age_hours < 4:
df = pd.read_parquet(cache)
data[symbol][tf] = df
print(f" [{done}/{total}] {display} {tf.value} — cached ({len(df)} bars)")
continue
print(f" [{done}/{total}] {display} {tf.value} — fetching...", end="", flush=True)
try:
df = dp.fetch_ohlcv(symbol, tf, lookback_days=lookback_days)
data[symbol][tf] = df
try:
df.to_parquet(cache)
except Exception:
pass
print(f" {len(df)} bars")
except Exception as e:
print(f" FAILED: {e}")
return data
# ══════════════════════════════════════════════════════════════════════
# WALK-FORWARD EVALUATION
# ══════════════════════════════════════════════════════════════════════
def walk_forward(
pair: str,
eval_tf: Timeframe,
all_data: Dict[Timeframe, pd.DataFrame],
eval_days: int,
) -> List[Trade]:
"""Walk through eval_days of candles, run the engine at each bar."""
primary_df = all_data.get(eval_tf)
if primary_df is None or primary_df.empty:
return []
conf_tfs = TF_CONFIRMATIONS.get(eval_tf, [Timeframe.M15, Timeframe.H4])
display = PAIR_INFO.get(pair, {}).get("display", pair)
cutoff = primary_df.index[-1] - pd.Timedelta(days=eval_days)
eval_indices = primary_df.index[primary_df.index >= cutoff]
if len(eval_indices) == 0:
return []
first_eval_pos = primary_df.index.get_loc(eval_indices[0])
if first_eval_pos < MIN_LOOKBACK_BARS:
first_eval_pos = MIN_LOOKBACK_BARS
eval_indices = primary_df.index[first_eval_pos:]
config = PlatformConfig()
config.primary_timeframe = eval_tf
config.confirmation_timeframes = conf_tfs
aggregator = SignalAggregator(config)
cooldown_bars = {
Timeframe.M5: 24, Timeframe.M15: 10,
Timeframe.H1: 5, Timeframe.H4: 3,
}.get(eval_tf, 6)
trades: List[Trade] = []
total = len(eval_indices)
bars_until_next = 0
for i, eval_time in enumerate(eval_indices):
if (i + 1) % max(1, total // 10) == 0:
print(f" {display} {eval_tf.value}: {i+1}/{total} candles...", flush=True)
if bars_until_next > 0:
bars_until_next -= 1
continue
dfs: Dict[Timeframe, pd.DataFrame] = {}
dfs[eval_tf] = primary_df.loc[:eval_time]
for ctf in conf_tfs:
ctf_df = all_data.get(ctf)
if ctf_df is not None and not ctf_df.empty:
dfs[ctf] = ctf_df.loc[:eval_time]
try:
report = aggregator.analyze_from_dataframes(
symbol=pair, dfs=dfs, use_news=False,
)
except Exception:
continue
if report.trade_setup and report.signal_quality in ("A+", "A", "B", "C"):
ts = report.trade_setup
trades.append(Trade(
pair=pair,
display=display,
timeframe=eval_tf.value,
entry_time=eval_time,
direction=ts.direction,
entry_price=ts.entry_price,
stop_loss=ts.stop_loss,
take_profit_1=ts.take_profit_1,
take_profit_2=ts.take_profit_2,
risk_reward=ts.risk_reward,
risk_pips=ts.risk_pips,
reward_pips=ts.reward_pips,
signal_quality=report.signal_quality,
confidence=report.final_confidence,
score=report.final_score,
regime=report.market_regime,
))
bars_until_next = cooldown_bars
return trades
def _walk_forward_worker(args):
"""Top-level worker for multiprocessing (must be picklable)."""
pair, tf, pair_data, eval_days = args
display = PAIR_INFO.get(pair, {}).get("display", pair)
trades = walk_forward(pair, tf, pair_data, eval_days)
return pair, tf, display, trades
# ══════════════════════════════════════════════════════════════════════
# GPU-ACCELERATED OUTCOME EVALUATION
# ══════════════════════════════════════════════════════════════════════
def evaluate_outcomes(
trades: List[Trade],
m5_data: Dict[str, pd.DataFrame],
max_bars: int = 500,
max_hours: int = 0,
) -> List[Trade]:
"""
For each trade, scan forward in M5 data to check if TP1 or SL hit first.
Uses GPU (CuPy) for vectorized parallel evaluation across all trades per pair.
"""
if not trades:
return trades
pairs_in_trades = set(t.pair for t in trades)
label = "GPU" if GPU else "CPU"
print(f"\nEvaluating {len(trades)} trade outcomes on {label}...")
for pair in pairs_in_trades:
df = m5_data.get(pair)
if df is None or df.empty:
continue
pair_trades = [t for t in trades if t.pair == pair]
if not pair_trades:
continue
highs_np = df["High"].values.astype(np.float64)
lows_np = df["Low"].values.astype(np.float64)
closes_np = df["Close"].values.astype(np.float64)
timestamps = df.index
highs_gpu = cp.asarray(highs_np)
lows_gpu = cp.asarray(lows_np)
for trade in pair_trades:
start_mask = timestamps > trade.entry_time
start_indices = np.where(start_mask)[0]
if len(start_indices) == 0:
continue
start = int(start_indices[0])
time_limit = max_hours * 12 if max_hours > 0 else max_bars
effective_max = min(time_limit, max_bars)
end = min(start + effective_max, len(highs_gpu))
if start >= end:
continue
h_slice = highs_gpu[start:end]
l_slice = lows_gpu[start:end]
if trade.direction == "BUY":
tp_mask = h_slice >= trade.take_profit_1
sl_mask = l_slice <= trade.stop_loss
else:
tp_mask = l_slice <= trade.take_profit_1
sl_mask = h_slice >= trade.stop_loss
tp_hits = cp.where(tp_mask)[0]
sl_hits = cp.where(sl_mask)[0]
tp_idx = int(tp_hits[0]) if len(tp_hits) > 0 else max_bars + 1
sl_idx = int(sl_hits[0]) if len(sl_hits) > 0 else max_bars + 1
pip_val = PAIR_INFO.get(pair, {}).get("pip_value", 0.0001)
if tp_idx <= sl_idx and tp_idx < max_bars + 1:
trade.outcome = "WIN"
trade.bars_held = tp_idx + 1
real_idx = start + tp_idx
trade.exit_price = trade.take_profit_1
trade.exit_time = timestamps[real_idx]
trade.pnl_pips = trade.reward_pips
elif sl_idx < tp_idx and sl_idx < max_bars + 1:
trade.outcome = "LOSS"
trade.bars_held = sl_idx + 1
real_idx = start + sl_idx
trade.exit_price = trade.stop_loss
trade.exit_time = timestamps[real_idx]
trade.pnl_pips = -trade.risk_pips
else:
trade.outcome = "OPEN"
last_idx = end - 1
trade.exit_time = timestamps[last_idx]
exit_p = float(closes_np[last_idx])
trade.exit_price = exit_p
if trade.direction == "BUY":
trade.pnl_pips = (exit_p - trade.entry_price) / pip_val
else:
trade.pnl_pips = (trade.entry_price - exit_p) / pip_val
trade.bars_held = end - start
return trades
# ══════════════════════════════════════════════════════════════════════
# REPORTING
# ══════════════════════════════════════════════════════════════════════
def print_report(trades: List[Trade], eval_days: int):
print("\n")
print("=" * 90)
print(f" FOREXBOT2 — ENGINE EVALUATION REPORT ({eval_days}-DAY)")
print(f" {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print("=" * 90)
if not trades:
print("\n No trades generated.\n")
return
closed = [t for t in trades if t.outcome in ("WIN", "LOSS")]
wins = [t for t in closed if t.outcome == "WIN"]
losses = [t for t in closed if t.outcome == "LOSS"]
still_open = [t for t in trades if t.outcome == "OPEN"]
# ── Overall ──────────────────────────────────────────────────
print(f"\n OVERALL")
print(f" {'─' * 50}")
print(f" Total signals with trade setup: {len(trades)}")
print(f" Closed: {len(closed)} | Still open: {len(still_open)}")
if closed:
win_rate = len(wins) / len(closed) * 100
total_pnl = sum(t.pnl_pips for t in closed)
avg_win = np.mean([t.pnl_pips for t in wins]) if wins else 0
avg_loss = np.mean([abs(t.pnl_pips) for t in losses]) if losses else 0
gross_profit = sum(t.pnl_pips for t in wins) if wins else 0
gross_loss = sum(abs(t.pnl_pips) for t in losses) if losses else 0
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
avg_rr = np.mean([t.risk_reward for t in closed])
avg_bars = np.mean([t.bars_held for t in closed])
print(f" Win rate: {win_rate:.1f}% ({len(wins)}W / {len(losses)}L)")
print(f" Profit factor: {profit_factor:.2f}")
print(f" Total P&L: {total_pnl:+.1f} pips")
print(f" Avg win: +{avg_win:.1f} pips | Avg loss: -{avg_loss:.1f} pips")
print(f" Avg R:R setup: 1:{avg_rr:.1f}")
print(f" Avg hold: {avg_bars:.0f} M5 bars ({avg_bars * 5:.0f} min)")
# Consecutive streaks
max_wins = max_losses = cur_wins = cur_losses = 0
for t in closed:
if t.outcome == "WIN":
cur_wins += 1
cur_losses = 0
max_wins = max(max_wins, cur_wins)
else:
cur_losses += 1
cur_wins = 0
max_losses = max(max_losses, cur_losses)
print(f" Max streak: {max_wins}W / {max_losses}L")
# ── By pair ──────────────────────────────────────────────────
print(f"\n BY PAIR")
print(f" {'─' * 50}")
print(f" {'Pair':<12} {'Trades':>7} {'Closed':>7} {'Win%':>6} {'P&L':>10} {'PF':>7}")
for pair in SCAN_PAIRS:
display = PAIR_INFO.get(pair, {}).get("display", pair)
pt = [t for t in trades if t.pair == pair]
pc = [t for t in pt if t.outcome in ("WIN", "LOSS")]
pw = [t for t in pc if t.outcome == "WIN"]
if not pt:
continue
wr = (len(pw) / len(pc) * 100) if pc else 0
pnl = sum(t.pnl_pips for t in pc)
gp = sum(t.pnl_pips for t in pw) if pw else 0
gl = sum(abs(t.pnl_pips) for t in pc if t.outcome == "LOSS") or 1
pf = gp / gl if gl > 0 else 0
print(f" {display:<12} {len(pt):>7} {len(pc):>7} {wr:>5.1f}% {pnl:>+9.1f}p {pf:>6.2f}")
# ── By timeframe ─────────────────────────────────────────────
print(f"\n BY TIMEFRAME")
print(f" {'─' * 50}")
print(f" {'TF':<8} {'Trades':>7} {'Closed':>7} {'Win%':>6} {'P&L':>10} {'PF':>7}")
tfs = sorted(set(t.timeframe for t in trades))
for tf in tfs:
tt = [t for t in trades if t.timeframe == tf]
tc = [t for t in tt if t.outcome in ("WIN", "LOSS")]
tw = [t for t in tc if t.outcome == "WIN"]
if not tt:
continue
wr = (len(tw) / len(tc) * 100) if tc else 0
pnl = sum(t.pnl_pips for t in tc)
gp = sum(t.pnl_pips for t in tw) if tw else 0
gl = sum(abs(t.pnl_pips) for t in tc if t.outcome == "LOSS") or 1
pf = gp / gl if gl > 0 else 0
print(f" {tf:<8} {len(tt):>7} {len(tc):>7} {wr:>5.1f}% {pnl:>+9.1f}p {pf:>6.2f}")
# ── By quality grade ─────────────────────────────────────────
print(f"\n BY QUALITY GRADE")
print(f" {'─' * 50}")
print(f" {'Grade':<8} {'Trades':>7} {'Closed':>7} {'Win%':>6} {'P&L':>10} {'Avg RR':>7}")
for grade in ("A+", "A", "B", "C"):
gt = [t for t in trades if t.signal_quality == grade]
gc = [t for t in gt if t.outcome in ("WIN", "LOSS")]
gw = [t for t in gc if t.outcome == "WIN"]
if not gt:
continue
wr = (len(gw) / len(gc) * 100) if gc else 0
pnl = sum(t.pnl_pips for t in gc)
arr = np.mean([t.risk_reward for t in gt])
print(f" {grade:<8} {len(gt):>7} {len(gc):>7} {wr:>5.1f}% {pnl:>+9.1f}p {arr:>6.1f}")
# ── By regime ────────────────────────────────────────────────
print(f"\n BY MARKET REGIME")
print(f" {'─' * 50}")
print(f" {'Regime':<16} {'Trades':>7} {'Win%':>6} {'P&L':>10}")
for regime in sorted(set(t.regime for t in trades)):
rt = [t for t in trades if t.regime == regime]
rc = [t for t in rt if t.outcome in ("WIN", "LOSS")]
rw = [t for t in rc if t.outcome == "WIN"]
wr = (len(rw) / len(rc) * 100) if rc else 0
pnl = sum(t.pnl_pips for t in rc)
label = regime.replace("_", " ").title()
print(f" {label:<16} {len(rt):>7} {wr:>5.1f}% {pnl:>+9.1f}p")
# ── Direction ────────────────────────────────────────────────
print(f"\n BY DIRECTION")
print(f" {'─' * 50}")
for d in ("BUY", "SELL"):
dt = [t for t in trades if t.direction == d]
dc = [t for t in dt if t.outcome in ("WIN", "LOSS")]
dw = [t for t in dc if t.outcome == "WIN"]
wr = (len(dw) / len(dc) * 100) if dc else 0
pnl = sum(t.pnl_pips for t in dc)
print(f" {d:<8} {len(dt):>7} trades {wr:>5.1f}% win {pnl:>+9.1f}p")
print(f"\n{'=' * 90}\n")
def save_trades_csv(trades: List[Trade], filename: str):
path = RESULTS_DIR / filename
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"pair", "display", "timeframe", "entry_time", "direction",
"entry_price", "stop_loss", "take_profit_1", "risk_reward",
"risk_pips", "reward_pips", "signal_quality", "confidence",
"score", "regime", "outcome", "exit_price", "exit_time",
"pnl_pips", "bars_held",
])
for t in trades:
writer.writerow([
t.pair, t.display, t.timeframe, t.entry_time, t.direction,
f"{t.entry_price:.5f}", f"{t.stop_loss:.5f}", f"{t.take_profit_1:.5f}",
f"{t.risk_reward:.2f}", f"{t.risk_pips:.1f}", f"{t.reward_pips:.1f}",
t.signal_quality, f"{t.confidence:.1f}", f"{t.score:.1f}",
t.regime, t.outcome, f"{t.exit_price:.5f}",
t.exit_time or "", f"{t.pnl_pips:.1f}", t.bars_held,
])
print(f"Trades saved to {path}")
# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(description="ForexBot2 signal engine evaluator")
parser.add_argument("--days", type=int, default=7, help="Evaluation window in days (default: 7)")
parser.add_argument("--tf", default=None, help="Single timeframe to evaluate (M15, H1)")
parser.add_argument("--no-cache", action="store_true", help="Re-download all data")
parser.add_argument("--max-outcome-bars", type=int, default=1000,
help="Max M5 bars to scan for SL/TP hit (default: 1000 = ~83h)")
parser.add_argument("--pair", default=None,
help="Single pair to evaluate (EURUSD, USDJPY, GBPUSD, XAUUSD)")
parser.add_argument("--max-hours", type=int, default=0,
help="Max hours to hold a trade before time-exit (0 = disabled)")
args = parser.parse_args()
eval_tfs = EVAL_TIMEFRAMES
if args.tf:
tf_map = {"M5": Timeframe.M5, "M15": Timeframe.M15, "H1": Timeframe.H1, "H4": Timeframe.H4}
if args.tf.upper() not in tf_map:
sys.exit(f"Invalid timeframe: {args.tf}. Choose from: {list(tf_map.keys())}")
eval_tfs = [tf_map[args.tf.upper()]]
pair_map = {"EURUSD": EURUSD, "USDJPY": USDJPY, "GBPUSD": GBPUSD, "XAUUSD": XAUUSD}
scan_pairs = SCAN_PAIRS
if args.pair:
key = args.pair.upper().replace("/", "")
if key not in pair_map:
sys.exit(f"Invalid pair: {args.pair}. Choose from: {list(pair_map.keys())}")
scan_pairs = [pair_map[key]]
DATA_DIR.mkdir(parents=True, exist_ok=True)
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
# ── 1. Fetch data ────────────────────────────────────────────
lookback = args.days + 60
print(f"\n{'=' * 60}")
print(f" Fetching {lookback} days of data for {len(scan_pairs)} pair(s)...")
print(f"{'=' * 60}")
t0 = time.time()
all_data = fetch_all_data(
scan_pairs, ALL_TFS_NEEDED,
lookback_days=lookback,
use_cache=not args.no_cache,
)
print(f" Data fetched in {time.time() - t0:.1f}s")
# ── 2. Walk-forward evaluation (multi-core) ──────────────────
print(f"\n{'=' * 60}")
n_workers = min(os.cpu_count() or 4, 16)
print(f" Walk-forward evaluation: {args.days} days, TFs: {[tf.value for tf in eval_tfs]}")
print(f" Using {n_workers} CPU cores")
print(f"{'=' * 60}")
t1 = time.time()
jobs = []
for pair in scan_pairs:
pair_data = all_data.get(pair, {})
if not pair_data:
display = PAIR_INFO.get(pair, {}).get("display", pair)
print(f" {display}: no data, skipping")
continue
for tf in eval_tfs:
jobs.append((pair, tf, pair_data, args.days))
all_trades: List[Trade] = []
with ProcessPoolExecutor(max_workers=n_workers) as pool:
futures = {pool.submit(_walk_forward_worker, job): job for job in jobs}
for future in as_completed(futures):
pair, tf, display, trades = future.result()
print(f" {display} {tf.value}: {len(trades)} trade setups")
all_trades.extend(trades)
print(f"\n Walk-forward complete in {time.time() - t1:.1f}s")
print(f" Total trade setups: {len(all_trades)}")
# ── 3. Outcome evaluation (GPU) ──────────────────────────────
m5_data = {pair: all_data.get(pair, {}).get(Timeframe.M5) for pair in scan_pairs}
m5_data = {k: v for k, v in m5_data.items() if v is not None}
t2 = time.time()
all_trades = evaluate_outcomes(all_trades, m5_data, max_bars=args.max_outcome_bars, max_hours=args.max_hours)
print(f" Outcomes evaluated in {time.time() - t2:.3f}s")
# ── 4. Report ────────────────────────────────────────────────
print_report(all_trades, args.days)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
save_trades_csv(all_trades, f"eval_{args.days}d_{ts}.csv")
if __name__ == "__main__":
main()
+41 -7
View File
@@ -111,7 +111,7 @@ class SignalConfig:
default_risk_pct: float = 1.0 default_risk_pct: float = 1.0
default_rr_ratio: float = 2.0 default_rr_ratio: float = 2.0
atr_sl_multiplier: float = 1.5 atr_sl_multiplier: float = 1.5
min_rr_ratio: float = 1.2 min_rr_ratio: float = 0.5
@dataclass @dataclass
@@ -130,16 +130,31 @@ class NewsConfig:
@dataclass @dataclass
class XauStrategyConfig: class XauStrategyConfig:
"""XAU/USD oversold-bounce strategy (BUY only). """XAU/USD v3 weighted confluence strategy.
1-year optimized: 89% WR, PF 4.58, +5041p (118 trades). 1-year grid-optimized (768 combos): 52.4% WR, PF 2.34, +40.3R (63 trades).
Entry: M15 RSI<38 + price above EMA200 on M15, H1, H4. Entry: M15 score>=80 + H4 RSI confirms + H1 MACD confirms + RSI 40-65 zone.
Exit: TP=0.9×ATR, SL=2.0×ATR. 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 = 38.0 # Wilder RSI scale; equivalent to standard RSI ~30 rsi_entry_threshold: float = 45.0
tp_atr_multiplier: float = 0.9 tp_atr_multiplier: float = 0.9
sl_atr_multiplier: float = 2.0 sl_atr_multiplier: float = 2.0
blocked_hours_utc: List[int] = field(default_factory=lambda: [8, 9, 11, 14, 15, 20]) min_ema200_tfs: int = 2
allow_generic_trades: bool = True
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 5, 6, 21, 22, 23])
# v2 confluence fixes
weekend_filter: bool = True
min_atr_threshold: float = 1.0
v2_sl_atr_multiplier: float = 2.5
buy_threshold_boost: int = 0
m5_confirmation_bars: int = 0
# v3 gates (grid-optimized on 1Y Mon-Thu data)
h4_rsi_gate: bool = True # require H4 RSI >55 for BUY, <45 for SELL
h1_macd_gate: bool = True # require H1 MACD histogram >0 for BUY, <0 for SELL
rsi_zone: tuple = (40.0, 65.0) # only enter when M15 RSI in this zone
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) # Mon-Fri
sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) # SELL needs B+
@dataclass @dataclass
@@ -158,6 +173,16 @@ class EurStrategyConfig:
tp_atr_multiplier: float = 0.7 tp_atr_multiplier: float = 0.7
sl_atr_multiplier: float = 1.0 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]) 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 @dataclass
@@ -177,6 +202,10 @@ class GbpStrategyConfig:
tp_atr_multiplier: float = 0.7 tp_atr_multiplier: float = 0.7
sl_atr_multiplier: float = 1.0 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]) 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
@@ -191,6 +220,11 @@ 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])
sell_filter: bool = True sell_filter: bool = True
sell_allowed_pairs: List[str] = field(default_factory=lambda: ["XAU/USD"]) sell_allowed_pairs: List[str] = field(default_factory=lambda: ["XAU/USD"])
-185
View File
@@ -1,185 +0,0 @@
# MT5 Auto-Execution Integration Plan
## Context
ForexBot2 generates trade signals with full setups (entry, SL, TP) but currently only displays them in Telegram. The user wants fully automatic order execution via MetaTrader 5.
**Constraint:** MT5 runs on a separate Windows machine. The Python `MetaTrader5` package requires Windows (COM interface). The bot runs on Linux. A network bridge is needed.
**Broker:** TIO Markets (demo first, live later via config change).
**Execution mode:** Fully automatic for actionable signals (A+/A/B grades).
## Architecture: REST API Bridge
```
Linux (telegram_bot.py) Windows (mt5_bridge.py)
======================== ========================
Signal fires with TradeSetup FastAPI server running
-> mt5_client.execute_trade() -> Wraps MetaTrader5 package
POST /api/order ─────── HTTP ───────> -> mt5.order_send()
<- {ticket: 12345} <──── HTTP ────── <- Returns ticket or error
-> Telegram: "ORDER PLACED" -> Idempotency via order_id
```
**Why REST over WebSocket/file-sync:** Stateless, debuggable, retry-safe. For a bot placing 1-5 trades/day, HTTP latency (5-50ms LAN) is negligible.
## Files to Create/Modify
### New Files
**1. `mt5_bridge/mt5_bridge.py`** — Windows-side FastAPI server (~200 lines)
- Wraps `MetaTrader5` Python package
- Endpoints: `/api/health`, `/api/account`, `/api/positions`, `/api/order` (POST), `/api/order/close` (POST)
- Lot size calculation on bridge side (has access to `mt5.symbol_info()` for tick value, contract size, volume step)
- Idempotency: stores `{order_id: ticket}` to prevent duplicate orders on retry
- API key auth via `X-API-Key` header
- MT5 credentials from env vars: `MT5_LOGIN`, `MT5_PASSWORD`, `MT5_SERVER`, `MT5_PATH`
**2. `mt5_bridge/requirements.txt`** — Windows dependencies
- `fastapi`, `uvicorn`, `MetaTrader5`, `pydantic`
**3. `mt5_client.py`** — Linux-side async HTTP client (~150 lines)
- `MT5ExecutionClient` class with `aiohttp`
- `execute_trade(setup: TradeSetup, quality: str)` — sends order to bridge
- Symbol mapping: `EURUSD=X` -> `EURUSD` (TIO Markets format, configurable)
- Duplicate prevention: tracks open positions per symbol
- Retry: 3 attempts, exponential backoff, same `order_id` for idempotency
- Position sync: periodically calls `/api/positions` to stay in sync with MT5
- Graceful degradation: if bridge offline, logs warning, bot continues signal-only
### Modified Files
**4. `config.py`** — Add `MT5Config` dataclass
```python
@dataclass
class MT5Config:
enabled: bool = False
bridge_url: str = "http://192.168.1.100:8080"
bridge_api_key: str = ""
risk_pct: float = 1.0 # % of balance risked per trade
max_open_positions: int = 3
max_daily_trades: int = 10 # Circuit breaker
magic_number: int = 234000 # Identifies ForexBot2 orders
slippage_points: int = 20 # 2 pips for 5-digit brokers
auto_execute_grades: List[str] # ["A+", "A", "B"]
symbol_map: Dict[str, str] # Internal -> broker symbol
```
All overridable via env vars (`MT5_BRIDGE_URL`, `MT5_RISK_PCT`, etc.)
**5. `telegram_bot.py`** — Wire execution into signal flow
- In `_scan_timeframe()`: after sending Telegram messages, call `mt5_client.execute_trade()` for actionable signals
- In `_handle_breach()`: same pattern after breach scan produces actionable signal
- Add background task `_mt5_position_sync_loop()` for periodic position sync
- Add Telegram commands: `/mt5status` (bridge health + account info), `/positions` (open trades)
- Register new commands in `set_my_commands()`
- Execution result messages sent to Telegram (ticket # on success, error on failure)
**6. `requirements.txt`** — Add `aiohttp>=3.9` (async HTTP for Linux-side client)
## Safety Layers
1. **Demo account first** — `MT5_SERVER=TIOMarkets-Demo`
2. **`enabled: False` default** — must explicitly opt-in via `MT5_ENABLED=true`
3. **Duplicate prevention** — 3 layers: client-side tracking, bridge-side position check, idempotent `order_id`
4. **Circuit breaker** — `max_daily_trades` stops execution if too many orders fire
5. **Position limit** — `max_open_positions` caps concurrent exposure
6. **Risk-based sizing** — lot size from `risk_pct` of account balance, calculated on bridge with live tick values
7. **Graceful degradation** — bridge offline = signal-only mode, no crash
## Position Sizing (calculated on Windows bridge)
```
risk_amount = balance * (risk_pct / 100)
tick_value = mt5.symbol_info(symbol).trade_tick_value
tick_size = mt5.symbol_info(symbol).trade_tick_size
sl_ticks = abs(entry - stop_loss) / tick_size
lot_size = risk_amount / (sl_ticks * tick_value)
lot_size = round_down(lot_size, volume_step)
lot_size = clamp(lot_size, volume_min, volume_max)
```
Bridge side is best because it has real-time `tick_value` (varies with exchange rates for cross pairs).
## Symbol Mapping (TIO Markets)
| Internal | MT5 Symbol |
|----------|-----------|
| `EURUSD=X` | `EURUSD` |
| `USDJPY=X` | `USDJPY` |
| `GBPUSD=X` | `GBPUSD` |
| `XAU/USD` | `XAUUSD` |
Configurable via `MT5_SYMBOL_MAP` env var (JSON) for broker variations.
## Integration Points
### In `_scan_timeframe()` (after line ~406):
```python
mt5 = _get_mt5_client()
if mt5:
for report in actionable:
if report.trade_setup and report.signal_quality in mt5.config.auto_execute_grades:
result = await mt5.execute_trade(report.trade_setup, report.signal_quality)
# Send execution confirmation/error to Telegram
```
### In `_handle_breach()` (after line ~477):
```python
if report.trade_setup and report.signal_quality in mt5.config.auto_execute_grades:
mt5 = _get_mt5_client()
if mt5:
result = await mt5.execute_trade(report.trade_setup, report.signal_quality)
# Send execution confirmation/error to Telegram
```
## New Telegram Commands
| Command | Description |
|---------|-------------|
| `/mt5status` | Bridge health, account balance/equity, open positions count |
| `/positions` | List all open MT5 positions with P/L |
## Implementation Order
1. `config.py` — Add `MT5Config`
2. `mt5_bridge/mt5_bridge.py` + `requirements.txt` — Windows bridge server
3. `mt5_client.py` — Linux HTTP client
4. `telegram_bot.py` — Wire execution + new commands
5. `requirements.txt` — Add `aiohttp`
## Verification
1. Start bridge on Windows: `python mt5_bridge.py` -> verify `curl /api/health` returns MT5 connected
2. Test order via curl: `POST /api/order` with demo credentials -> verify order appears in MT5
3. Start bot on Linux with `MT5_ENABLED=true` -> verify `/mt5status` command works
4. Wait for actionable signal (or manually trigger scan) -> verify order placed + Telegram confirmation
5. Verify duplicate prevention: same signal shouldn't place second order
6. Kill bridge -> verify bot continues in signal-only mode without crashing
## Windows Bridge Setup (one-time)
```bash
# On Windows machine:
pip install fastapi uvicorn MetaTrader5 pydantic
# Set environment variables:
set MT5_LOGIN=12345678
set MT5_PASSWORD=your-password
set MT5_SERVER=TIOMarkets-Demo
set MT5_PATH=C:\Program Files\MetaTrader 5\terminal64.exe
set BRIDGE_API_KEY=your-random-32-char-key
set BRIDGE_PORT=8080
# Run:
python mt5_bridge.py
```
## Linux Bot Config (env vars)
```bash
MT5_ENABLED=true
MT5_BRIDGE_URL=http://<windows-ip>:8080
MT5_BRIDGE_API_KEY=your-random-32-char-key
MT5_RISK_PCT=1.0
MT5_MAX_OPEN_POSITIONS=3
MT5_AUTO_EXECUTE_GRADES=A+,A,B
```
+3
View File
@@ -4,3 +4,6 @@ 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
+330 -67
View File
@@ -101,6 +101,42 @@ class SignalAggregator:
if symbol is None: if symbol is None:
symbol = self.config.symbol symbol = self.config.symbol
pair_info = self.data_provider.get_pair_info(symbol)
now_utc = datetime.now(timezone.utc)
if pair_info["display"] == "XAU/USD" and self.config.xau_strategy.enabled:
xau_cfg = self.config.xau_strategy
if xau_cfg.weekend_filter and now_utc.weekday() >= 5:
report = SignalReport(
symbol=symbol,
display_name=pair_info["display"],
timestamp=str(now_utc),
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity")
report.action_recommendation = "NO TRADE — Weekend (market closed)"
return report
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri"}
allowed = "/".join(day_names.get(d, str(d)) for d in xau_cfg.allowed_weekdays)
report = SignalReport(
symbol=symbol,
display_name=pair_info["display"],
timestamp=str(now_utc),
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append(
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
)
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
return report
timeframes = [self.config.primary_timeframe] + list( timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes self.config.confirmation_timeframes
) )
@@ -339,30 +375,45 @@ class SignalAggregator:
# ── 6b. Session filter (works for both live and backtest) ─ # ── 6b. Session filter (works for both live and backtest) ─
if self.config.backtest.session_filter: if self.config.backtest.session_filter:
try: try:
bar_hour = pd.Timestamp(report.timestamp).hour bar_ts = pd.Timestamp(report.timestamp)
if bar_hour in self.config.backtest.blocked_hours_utc: bar_hour = bar_ts.hour
bar_wd = bar_ts.weekday()
_is_xau_pair = report.display_name == "XAU/USD"
_blocked_hours = (self.config.xau_strategy.blocked_hours_utc
if _is_xau_pair else self.config.backtest.blocked_hours_utc)
if bar_hour in _blocked_hours:
report.final_signal = SignalStrength.NEUTRAL report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F" report.signal_quality = "F"
report.warnings.append(f"SESSION FILTER: {bar_hour:02}:00 UTC blocked") report.warnings.append(f"SESSION FILTER: {bar_hour:02}:00 UTC blocked")
_allowed_wd = (self.config.xau_strategy.allowed_weekdays
if _is_xau_pair else self.config.backtest.allowed_weekdays)
if _allowed_wd and bar_wd not in _allowed_wd:
day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu",
4: "Fri", 5: "Sat", 6: "Sun"}
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.warnings.append(
f"DAY FILTER: {day_names.get(bar_wd, '?')} not in allowed days"
)
except Exception: except Exception:
pass pass
# ── 6c. XAU/USD oversold bounce strategy ───────────────── # ── 6c. XAU/USD oversold bounce strategy (DISABLED — replaced by v2 confluence) ──
if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled: # if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled:
if self._check_xau_oversold_bounce(report, dfs): # if self._check_xau_oversold_bounce(report, dfs):
report.final_signal = SignalStrength.STRONG_BUY # report.final_signal = SignalStrength.STRONG_BUY
report.final_score = 50.0 # report.final_score = 50.0
report.final_confidence = 85.0 # report.final_confidence = 85.0
report.signal_quality = "A+" # report.signal_quality = "A+"
report.action_recommendation = ( # report.action_recommendation = (
"STRONG BUY — XAU oversold bounce " # "STRONG BUY — XAU oversold bounce "
"(RSI<30 + M15/H1/H4 all above EMA200)" # "(RSI<30 + M15/H1/H4 all above EMA200)"
) # )
report.bullish_reasons.insert( # report.bullish_reasons.insert(
0, # 0,
"XAU STRATEGY: M15 RSI oversold + triple-TF bullish trend confirmation", # "XAU STRATEGY: M15 RSI oversold + triple-TF bullish trend confirmation",
) # )
report.trade_setup = self._generate_xau_trade_setup(report, dfs) # report.trade_setup = self._generate_xau_trade_setup(report, dfs)
# ── 6d. EUR/USD pullback-buy strategy ───────────────────── # ── 6d. EUR/USD pullback-buy strategy ─────────────────────
if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled: if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled:
@@ -398,8 +449,118 @@ class SignalAggregator:
) )
report.trade_setup = self._generate_gbp_trade_setup(report, dfs) 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 = (
report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL)
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
)
if (report.display_name == "XAU/USD"
and self.config.xau_strategy.enabled
and xau_is_sell_dir
and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
report.warnings.append(
f"XAU SELL FILTER: quality {report.signal_quality} below minimum for SELL"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 7. Trade setup for actionable signals ────────────────── # ── 7. Trade setup for actionable signals ──────────────────
if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"): # Pair-specific generic-trade suppression: when a pair has a dedicated
# strategy and that strategy didn't fire, falling through to a generic
# swing-based setup tends to chase momentum at unfavorable spots.
# 90-day EUR M15 backtest: allow=True → +4.7R, allow=False → +9.5R.
suppress_generic = (
(report.display_name == "XAU/USD"
and not self.config.xau_strategy.allow_generic_trades)
or (report.display_name == "EUR/USD"
and self.config.eur_strategy.enabled
and not self.config.eur_strategy.allow_generic_trades)
or (report.display_name == "GBP/USD"
and self.config.gbp_strategy.enabled
and not self.config.gbp_strategy.allow_generic_trades)
)
if report.trade_setup is None and suppress_generic:
pass # generic trade setup suppressed for this pair
elif report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
is_sell_direction = ( is_sell_direction = (
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)
@@ -431,12 +592,10 @@ class SignalAggregator:
) -> bool: ) -> bool:
"""Return True when XAU/USD oversold-bounce conditions are met. """Return True when XAU/USD oversold-bounce conditions are met.
Conditions (all must be true): Conditions:
1. M15 RSI(14) < 30 1. M15 RSI(14) < rsi_entry_threshold
2. M15 price > M15 EMA(200) 2. Price > EMA(200) on at least min_ema200_tfs of {M15, H1, H4}
3. H1 price > H1 EMA(200) 3. Current bar hour not in blocked list
4. H4 price > H4 EMA(200)
5. Current bar hour not in blocked list
""" """
cfg = self.config.xau_strategy cfg = self.config.xau_strategy
@@ -457,31 +616,35 @@ class SignalAggregator:
h1_analysis = report.primary_analysis h1_analysis = report.primary_analysis
if not m15_analysis or not h1_analysis or not h4_analysis: if not m15_analysis or not h1_analysis:
return False return False
m15_rsi = m15_analysis.indicators.get("rsi") m15_rsi = m15_analysis.indicators.get("rsi")
if not m15_rsi or m15_rsi.value >= cfg.rsi_entry_threshold: if not m15_rsi or m15_rsi.value >= cfg.rsi_entry_threshold:
return False return False
ema200_count = 0
m15_ema200 = m15_analysis.key_levels.get("ema_trend") m15_ema200 = m15_analysis.key_levels.get("ema_trend")
m15_df = dfs.get(Timeframe.M15) m15_df = dfs.get(Timeframe.M15)
if m15_df is None or m15_ema200 is None: if m15_df is not None and m15_ema200 is not None:
return False
m15_price = float(m15_df["Close"].iloc[-1]) m15_price = float(m15_df["Close"].iloc[-1])
if m15_price <= m15_ema200: if m15_price > m15_ema200:
return False ema200_count += 1
h1_ema200 = h1_analysis.key_levels.get("ema_trend") h1_ema200 = h1_analysis.key_levels.get("ema_trend")
if h1_ema200 is None or report.current_price <= h1_ema200: if h1_ema200 is not None and report.current_price > h1_ema200:
return False ema200_count += 1
if h4_analysis:
h4_ema200 = h4_analysis.key_levels.get("ema_trend") h4_ema200 = h4_analysis.key_levels.get("ema_trend")
h4_df = dfs.get(Timeframe.H4) h4_df = dfs.get(Timeframe.H4)
if h4_df is None or h4_ema200 is None: if h4_df is not None and h4_ema200 is not None:
return False
h4_price = float(h4_df["Close"].iloc[-1]) h4_price = float(h4_df["Close"].iloc[-1])
if h4_price <= h4_ema200: if h4_price > h4_ema200:
ema200_count += 1
if ema200_count < cfg.min_ema200_tfs:
return False return False
return True return True
@@ -530,12 +693,57 @@ class SignalAggregator:
pip_value=pip_value, pip_value=pip_value,
) )
def _generate_xau_atr_trade_setup(
self, report: SignalReport, atr: float
) -> Optional[TradeSetup]:
"""XAU-aware exits for generic signals: wide SL, tight TP via ATR."""
cfg = self.config.xau_strategy
price = report.current_price
pip_value = 0.10
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
is_buy = True
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
is_buy = False
else:
is_buy = report.final_score >= 0
if is_buy:
sl = price - cfg.sl_atr_multiplier * atr
tp1 = price + cfg.tp_atr_multiplier * atr
tp2 = price + cfg.tp_atr_multiplier * 2.0 * atr
direction = "BUY"
else:
sl = price + cfg.sl_atr_multiplier * atr
tp1 = price - cfg.tp_atr_multiplier * atr
tp2 = price - cfg.tp_atr_multiplier * 2.0 * atr
direction = "SELL"
sl_distance = abs(price - sl)
tp_distance = abs(tp1 - price)
rr = tp_distance / sl_distance if sl_distance > 0 else 0
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction=direction,
entry_price=price,
stop_loss=round(sl, 2),
take_profit_1=round(tp1, 2),
take_profit_2=round(tp2, 2),
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_eur_pullback_buy( def _check_eur_pullback_buy(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame] self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> bool: ) -> bool:
"""Return True when EUR/USD pullback-buy conditions are met. """Return True when EUR/USD pullback-buy conditions are met.
Conditions (all must be true): Conditions (all must be true):
0. Bar's weekday is in the universal allowed_weekdays list
1. Current bar hour not in blocked list 1. Current bar hour not in blocked list
2. M15 RSI(14) in 30–55 (pullback, not overbought) 2. M15 RSI(14) in 30–55 (pullback, not overbought)
3. M15 BB position < 0.75 (not at resistance) 3. M15 BB position < 0.75 (not at resistance)
@@ -546,9 +754,16 @@ class SignalAggregator:
cfg = self.config.eur_strategy cfg = self.config.eur_strategy
try: try:
bar_hour = pd.Timestamp(report.timestamp).hour bar_ts = pd.Timestamp(report.timestamp)
bar_hour = bar_ts.hour
bar_wd = bar_ts.weekday()
if bar_hour in cfg.blocked_hours_utc: if bar_hour in cfg.blocked_hours_utc:
return False 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: except Exception:
return False return False
@@ -602,6 +817,20 @@ class SignalAggregator:
if dist > cfg.chase_max_atr_from_ema21: if dist > cfg.chase_max_atr_from_ema21:
return False 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 return True
def _generate_eur_trade_setup( def _generate_eur_trade_setup(
@@ -658,9 +887,14 @@ class SignalAggregator:
cfg = self.config.gbp_strategy cfg = self.config.gbp_strategy
try: try:
bar_hour = pd.Timestamp(report.timestamp).hour bar_ts = pd.Timestamp(report.timestamp)
bar_hour = bar_ts.hour
bar_wd = bar_ts.weekday()
if bar_hour in cfg.blocked_hours_utc: if bar_hour in cfg.blocked_hours_utc:
return False return False
allowed_wd = self.config.backtest.allowed_weekdays
if allowed_wd and bar_wd not in allowed_wd:
return False
except Exception: except Exception:
return False return False
@@ -714,6 +948,17 @@ class SignalAggregator:
if dist > cfg.chase_max_atr_from_ema21: if dist > cfg.chase_max_atr_from_ema21:
return False 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 return True
def _generate_gbp_trade_setup( def _generate_gbp_trade_setup(
@@ -824,6 +1069,8 @@ class SignalAggregator:
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, tight ranges")
is_xau = report.display_name == "XAU/USD"
primary = report.primary_analysis 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")
@@ -835,12 +1082,12 @@ class SignalAggregator:
report.warnings.append( report.warnings.append(
f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk" f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk"
) )
confidence -= 10 confidence -= 3 if is_xau else 10
elif ema_dist_pct > _hi: elif ema_dist_pct > (2.5 if is_xau else _hi):
report.warnings.append( report.warnings.append(
f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended" f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended"
) )
confidence -= 10 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:
@@ -850,29 +1097,33 @@ class SignalAggregator:
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_")
) )
if swing_lows and report.current_price - swing_lows[0] > 2.5 * atr: _exh_mult = 3.5 if is_xau else 2.5
confidence -= 15 if swing_lows and report.current_price - swing_lows[0] > _exh_mult * atr:
report.warnings.append("TREND EXHAUSTION — price extended >2.5 ATR past swing low") confidence -= 5 if is_xau else 15
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,
) )
if swing_highs and swing_highs[0] - report.current_price > 2.5 * atr: _exh_mult_s = 3.5 if is_xau else 2.5
confidence -= 15 if swing_highs and swing_highs[0] - report.current_price > _exh_mult_s * atr:
report.warnings.append("TREND EXHAUSTION — price extended >2.5 ATR past swing high") confidence -= 5 if is_xau else 15
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
if pullback_dist > 2.0: _pb_severe = 2.5 if is_xau else 2.0
confidence -= 20 _pb_mild = 2.0 if is_xau else 1.5
if pullback_dist > _pb_severe:
confidence -= 8 if is_xau else 20
report.warnings.append( report.warnings.append(
f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21, chasing risk" f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21, chasing risk"
) )
elif pullback_dist > 1.5: elif pullback_dist > _pb_mild:
confidence -= 10 confidence -= 5 if is_xau else 10
report.warnings.append( report.warnings.append(
f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21" f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21"
) )
@@ -883,9 +1134,10 @@ class SignalAggregator:
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: if report.market_regime in ("trending_up", "trending") and score > 0:
if rsi_val > 65: if rsi_val > 65:
confidence -= 15 confidence -= _rsi_pen
report.warnings.append( report.warnings.append(
f"Extended entry — RSI {rsi_val:.0f} in uptrend (prefer pullback to 40-55)" f"Extended entry — RSI {rsi_val:.0f} in uptrend (prefer pullback to 40-55)"
) )
@@ -893,7 +1145,7 @@ class SignalAggregator:
confidence += 5 confidence += 5
elif report.market_regime == "trending_down" and score < 0: elif report.market_regime == "trending_down" and score < 0:
if rsi_val < 35: if rsi_val < 35:
confidence -= 15 confidence -= _rsi_pen
report.warnings.append( report.warnings.append(
f"Extended entry — RSI {rsi_val:.0f} in downtrend (prefer bounce to 45-62)" f"Extended entry — RSI {rsi_val:.0f} in downtrend (prefer bounce to 45-62)"
) )
@@ -916,16 +1168,16 @@ class SignalAggregator:
confidence = max(0.0, min(95.0, confidence)) confidence = max(0.0, min(95.0, confidence))
# Final signal # Final signal
if score > 38: if not is_xau and score > 42:
confidence -= 20 confidence -= 10
report.warnings.append("EXHAUSTION: score > 38 — trend overextended") report.warnings.append("EXHAUSTION: score > 42 — trend overextended")
if score >= 40 and confidence >= 60: if score >= 35 and confidence >= 55:
report.final_signal = SignalStrength.STRONG_BUY report.final_signal = SignalStrength.STRONG_BUY
elif score >= 30 and confidence >= 55: elif score >= 25 and confidence >= 50:
report.final_signal = SignalStrength.BUY report.final_signal = SignalStrength.BUY
elif score <= -40 and confidence >= 60: elif score <= -35 and confidence >= 55:
report.final_signal = SignalStrength.STRONG_SELL report.final_signal = SignalStrength.STRONG_SELL
elif score <= -30 and confidence >= 55: elif score <= -25 and confidence >= 50:
report.final_signal = SignalStrength.SELL report.final_signal = SignalStrength.SELL
else: else:
report.final_signal = SignalStrength.NEUTRAL report.final_signal = SignalStrength.NEUTRAL
@@ -947,13 +1199,13 @@ class SignalAggregator:
abs_score = abs(score) abs_score = abs(score)
if sell_suppressed: if sell_suppressed:
report.signal_quality = "F" report.signal_quality = "F"
elif abs_score >= 45 and confidence >= 70 and report.mtf_agreement: elif abs_score >= 40 and confidence >= 65 and report.mtf_agreement:
report.signal_quality = "A+" report.signal_quality = "A+"
elif abs_score >= 38 and confidence >= 60 and report.mtf_agreement: elif abs_score >= 33 and confidence >= 55 and report.mtf_agreement:
report.signal_quality = "A" report.signal_quality = "A"
elif abs_score >= 33 and confidence >= 55: elif abs_score >= 28 and confidence >= 50:
report.signal_quality = "B" report.signal_quality = "B"
elif abs_score >= 30 and confidence >= 50: elif abs_score >= 25 and confidence >= 45:
report.signal_quality = "C" report.signal_quality = "C"
elif abs_score >= 15: elif abs_score >= 15:
report.signal_quality = "D" report.signal_quality = "D"
@@ -980,6 +1232,13 @@ class SignalAggregator:
if atr == 0: if atr == 0:
return None return None
# Fix 2: Minimum ATR threshold for XAU/USD
if report.display_name == "XAU/USD" and atr < self.config.xau_strategy.min_atr_threshold:
report.warnings.append(
f"LOW ATR FILTER: ATR ${atr:.2f} < ${self.config.xau_strategy.min_atr_threshold:.2f} minimum"
)
return None
min_sl_pips = 8.0 if pip_value < 0.01 else 80.0 min_sl_pips = 8.0 if pip_value < 0.01 else 80.0
min_sl_price = min_sl_pips * pip_value min_sl_price = min_sl_pips * pip_value
@@ -993,8 +1252,12 @@ 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 = 1.5 if mature_move else 2.0 tp_mult = 0.9 if mature_move else 1.2
tp_swing_cap = 2.5 if mature_move else 3.0 tp_swing_cap = 1.5 if mature_move else 2.0
# 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
if report.display_name == "XAU/USD" else 1.5)
if is_buy: if is_buy:
swing_lows = sorted( swing_lows = sorted(
@@ -1005,7 +1268,7 @@ class SignalAggregator:
sl_price = swing_lows[0] - atr * 1.0 sl_price = swing_lows[0] - atr * 1.0
else: else:
sl_price = price - atr * 2.0 sl_price = price - atr * 2.0
min_sl = max(atr * 1.5, min_sl_price) min_sl = max(atr * sl_atr_floor, min_sl_price)
if price - sl_price < min_sl: if price - sl_price < min_sl:
sl_price = price - min_sl sl_price = price - min_sl
@@ -1035,7 +1298,7 @@ class SignalAggregator:
sl_price = swing_highs[0] + atr * 1.0 sl_price = swing_highs[0] + atr * 1.0
else: else:
sl_price = price + atr * 2.0 sl_price = price + atr * 2.0
min_sl = max(atr * 1.5, min_sl_price) min_sl = max(atr * sl_atr_floor, min_sl_price)
if sl_price - price < min_sl: if sl_price - price < min_sl:
sl_price = price + min_sl sl_price = price + min_sl
+303
View File
@@ -0,0 +1,303 @@
#!/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())
+7 -2
View File
@@ -172,7 +172,7 @@ def _():
def _(): def _():
sc = SignalConfig() sc = SignalConfig()
assert sc.min_confidence == 55.0 assert sc.min_confidence == 55.0
assert sc.min_rr_ratio == 1.2 assert sc.min_rr_ratio == 0.5
assert sc.atr_sl_multiplier == 1.5 assert sc.atr_sl_multiplier == 1.5
assert sc.default_rr_ratio == 2.0 assert sc.default_rr_ratio == 2.0
assert sc.cooldown_bars == 5 assert sc.cooldown_bars == 5
@@ -198,7 +198,9 @@ def _():
def _(): def _():
xc = XauStrategyConfig() xc = XauStrategyConfig()
assert xc.enabled is True assert xc.enabled is True
assert xc.rsi_entry_threshold == 38.0 assert xc.rsi_entry_threshold == 45.0
assert xc.min_ema200_tfs == 2
assert xc.allow_generic_trades is True
assert xc.tp_atr_multiplier == 0.9 assert xc.tp_atr_multiplier == 0.9
assert xc.sl_atr_multiplier == 2.0 assert xc.sl_atr_multiplier == 2.0
assert isinstance(xc.blocked_hours_utc, list) assert isinstance(xc.blocked_hours_utc, list)
@@ -1123,6 +1125,9 @@ def _():
config = PlatformConfig() config = PlatformConfig()
config.backtest.session_filter = True config.backtest.session_filter = True
config.backtest.blocked_hours_utc = list(range(24)) # block all hours config.backtest.blocked_hours_utc = list(range(24)) # block all hours
config.eur_strategy.enabled = False
config.gbp_strategy.enabled = False
config.xau_strategy.enabled = False
agg = SignalAggregator(config) agg = SignalAggregator(config)
dfs = { dfs = {
Timeframe.H1: make_ohlcv(300, seed=42), Timeframe.H1: make_ohlcv(300, seed=42),
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
ForexBot2 — Web API
====================
Thin FastAPI process that reads signal data from Redis and serves it
via REST + WebSocket. Apache reverse-proxies to this.
Endpoints:
GET /api/signals — current snapshot from Redis
WS /ws — real-time updates via Redis pub/sub
No signal computation here — signal_web.py daemon handles that.
Usage:
uvicorn web.app:app --host 127.0.0.1 --port 8000
"""
import asyncio
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Optional, Set
import redis.asyncio as aioredis
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
datefmt="%H:%M:%S",
)
logger = logging.getLogger("web_api")
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
REDIS_KEY = "forexbot2:signals"
REDIS_CHANNEL = "forexbot2:updates"
_redis: Optional[aioredis.Redis] = None
_ws_clients: Set[WebSocket] = set()
_subscriber_task: Optional[asyncio.Task] = None
async def _redis_subscriber() -> None:
pubsub = _redis.pubsub()
await pubsub.subscribe(REDIS_CHANNEL)
try:
async for msg in pubsub.listen():
if msg["type"] != "message":
continue
payload = msg["data"]
dead = []
for ws in _ws_clients:
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
_ws_clients.discard(ws)
except asyncio.CancelledError:
pass
finally:
await pubsub.unsubscribe(REDIS_CHANNEL)
await pubsub.close()
@asynccontextmanager
async def lifespan(application: FastAPI):
global _redis, _subscriber_task
_redis = aioredis.from_url(REDIS_URL, decode_responses=True)
await _redis.ping()
logger.info(f"Redis connected: {REDIS_URL}")
_subscriber_task = asyncio.create_task(_redis_subscriber())
logger.info("Redis subscriber started")
yield
_subscriber_task.cancel()
await asyncio.gather(_subscriber_task, return_exceptions=True)
await _redis.close()
logger.info("Web API stopped")
app = FastAPI(title="ForexBot2 API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
@app.get("/api/signals")
async def api_signals():
raw = await _redis.get(REDIS_KEY)
if raw:
return json.loads(raw)
return {"type": "update", "timestamp": "", "data": {}}
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
_ws_clients.add(ws)
try:
raw = await _redis.get(REDIS_KEY)
if raw:
await ws.send_text(raw)
while True:
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
_ws_clients.discard(ws)
if __name__ == "__main__":
port = int(os.environ.get("WEB_PORT", "8000"))
logger.info(f"Starting ForexBot2 API on http://127.0.0.1:{port}")
uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
+482
View File
@@ -0,0 +1,482 @@
<!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>