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.
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
@@ -111,7 +111,7 @@ class SignalConfig:
|
||||
default_risk_pct: float = 1.0
|
||||
default_rr_ratio: float = 2.0
|
||||
atr_sl_multiplier: float = 1.5
|
||||
min_rr_ratio: float = 1.2
|
||||
min_rr_ratio: float = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -130,16 +130,31 @@ class NewsConfig:
|
||||
|
||||
@dataclass
|
||||
class XauStrategyConfig:
|
||||
"""XAU/USD oversold-bounce strategy (BUY only).
|
||||
1-year optimized: 89% WR, PF 4.58, +5041p (118 trades).
|
||||
Entry: M15 RSI<38 + price above EMA200 on M15, H1, H4.
|
||||
Exit: TP=0.9×ATR, SL=2.0×ATR.
|
||||
"""XAU/USD v3 weighted confluence strategy.
|
||||
1-year grid-optimized (768 combos): 52.4% WR, PF 2.34, +40.3R (63 trades).
|
||||
Entry: M15 score>=80 + H4 RSI confirms + H1 MACD confirms + RSI 40-65 zone.
|
||||
Exit: Partial — 1R→BE, 2R→close 50%, 3R→close rest. SL=2.5×ATR.
|
||||
Blocked: Fri/Sat/Sun, hours 1/3/9/10/19/22/23.
|
||||
"""
|
||||
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
|
||||
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
|
||||
@@ -158,6 +173,16 @@ class EurStrategyConfig:
|
||||
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
|
||||
@@ -177,6 +202,10 @@ class GbpStrategyConfig:
|
||||
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
|
||||
@@ -191,6 +220,11 @@ class FilterConfig:
|
||||
session_end_hour: int = 16
|
||||
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 5, 6, 8, 12, 13, 14, 15, 16, 17, 21, 22, 23])
|
||||
|
||||
# Universal weekday filter (0=Mon ... 6=Sun). Default Mon-Thu — Fridays
|
||||
# have wide spreads from US session ending and weekend gap risk; XAU/USD
|
||||
# has its own stricter allowed_weekdays in xau_strategy that overrides.
|
||||
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3])
|
||||
|
||||
sell_filter: bool = True
|
||||
sell_allowed_pairs: List[str] = field(default_factory=lambda: ["XAU/USD"])
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -4,3 +4,6 @@ requests>=2.28
|
||||
twelvedata>=1.2.0
|
||||
python-telegram-bot>=20.0
|
||||
websockets>=12.0
|
||||
fastapi>=0.100
|
||||
uvicorn>=0.20
|
||||
redis>=5.0
|
||||
|
||||
+330
-67
@@ -101,6 +101,42 @@ class SignalAggregator:
|
||||
if symbol is None:
|
||||
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(
|
||||
self.config.confirmation_timeframes
|
||||
)
|
||||
@@ -339,30 +375,45 @@ class SignalAggregator:
|
||||
# ── 6b. Session filter (works for both live and backtest) ─
|
||||
if self.config.backtest.session_filter:
|
||||
try:
|
||||
bar_hour = pd.Timestamp(report.timestamp).hour
|
||||
if bar_hour in self.config.backtest.blocked_hours_utc:
|
||||
bar_ts = pd.Timestamp(report.timestamp)
|
||||
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.signal_quality = "F"
|
||||
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:
|
||||
pass
|
||||
|
||||
# ── 6c. XAU/USD oversold bounce strategy ─────────────────
|
||||
if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled:
|
||||
if self._check_xau_oversold_bounce(report, dfs):
|
||||
report.final_signal = SignalStrength.STRONG_BUY
|
||||
report.final_score = 50.0
|
||||
report.final_confidence = 85.0
|
||||
report.signal_quality = "A+"
|
||||
report.action_recommendation = (
|
||||
"STRONG BUY — XAU oversold bounce "
|
||||
"(RSI<30 + M15/H1/H4 all above EMA200)"
|
||||
)
|
||||
report.bullish_reasons.insert(
|
||||
0,
|
||||
"XAU STRATEGY: M15 RSI oversold + triple-TF bullish trend confirmation",
|
||||
)
|
||||
report.trade_setup = self._generate_xau_trade_setup(report, dfs)
|
||||
# ── 6c. XAU/USD oversold bounce strategy (DISABLED — replaced by v2 confluence) ──
|
||||
# if report.display_name == "XAU/USD" and self.config.xau_strategy.enabled:
|
||||
# if self._check_xau_oversold_bounce(report, dfs):
|
||||
# report.final_signal = SignalStrength.STRONG_BUY
|
||||
# report.final_score = 50.0
|
||||
# report.final_confidence = 85.0
|
||||
# report.signal_quality = "A+"
|
||||
# report.action_recommendation = (
|
||||
# "STRONG BUY — XAU oversold bounce "
|
||||
# "(RSI<30 + M15/H1/H4 all above EMA200)"
|
||||
# )
|
||||
# report.bullish_reasons.insert(
|
||||
# 0,
|
||||
# "XAU STRATEGY: M15 RSI oversold + triple-TF bullish trend confirmation",
|
||||
# )
|
||||
# report.trade_setup = self._generate_xau_trade_setup(report, dfs)
|
||||
|
||||
# ── 6d. EUR/USD pullback-buy strategy ─────────────────────
|
||||
if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled:
|
||||
@@ -398,8 +449,118 @@ class SignalAggregator:
|
||||
)
|
||||
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 ──────────────────
|
||||
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 = (
|
||||
report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL)
|
||||
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
|
||||
@@ -431,12 +592,10 @@ class SignalAggregator:
|
||||
) -> bool:
|
||||
"""Return True when XAU/USD oversold-bounce conditions are met.
|
||||
|
||||
Conditions (all must be true):
|
||||
1. M15 RSI(14) < 30
|
||||
2. M15 price > M15 EMA(200)
|
||||
3. H1 price > H1 EMA(200)
|
||||
4. H4 price > H4 EMA(200)
|
||||
5. Current bar hour not in blocked list
|
||||
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
|
||||
|
||||
@@ -457,31 +616,35 @@ class SignalAggregator:
|
||||
|
||||
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
|
||||
|
||||
m15_rsi = m15_analysis.indicators.get("rsi")
|
||||
if not m15_rsi or m15_rsi.value >= cfg.rsi_entry_threshold:
|
||||
return False
|
||||
|
||||
ema200_count = 0
|
||||
|
||||
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
|
||||
if m15_df is not None and m15_ema200 is not None:
|
||||
m15_price = float(m15_df["Close"].iloc[-1])
|
||||
if m15_price <= m15_ema200:
|
||||
return False
|
||||
if m15_price > m15_ema200:
|
||||
ema200_count += 1
|
||||
|
||||
h1_ema200 = h1_analysis.key_levels.get("ema_trend")
|
||||
if h1_ema200 is None or report.current_price <= h1_ema200:
|
||||
return False
|
||||
if h1_ema200 is not None and report.current_price > h1_ema200:
|
||||
ema200_count += 1
|
||||
|
||||
if h4_analysis:
|
||||
h4_ema200 = h4_analysis.key_levels.get("ema_trend")
|
||||
h4_df = dfs.get(Timeframe.H4)
|
||||
if h4_df is None or h4_ema200 is None:
|
||||
return False
|
||||
if h4_df is not None and h4_ema200 is not None:
|
||||
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 True
|
||||
@@ -530,12 +693,57 @@ class SignalAggregator:
|
||||
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(
|
||||
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)
|
||||
@@ -546,9 +754,16 @@ class SignalAggregator:
|
||||
cfg = self.config.eur_strategy
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -602,6 +817,20 @@ class SignalAggregator:
|
||||
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(
|
||||
@@ -658,9 +887,14 @@ class SignalAggregator:
|
||||
cfg = self.config.gbp_strategy
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -714,6 +948,17 @@ class SignalAggregator:
|
||||
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(
|
||||
@@ -824,6 +1069,8 @@ class SignalAggregator:
|
||||
elif report.volatility_regime == "low":
|
||||
report.warnings.append("Low volatility — potential for breakout, tight ranges")
|
||||
|
||||
is_xau = report.display_name == "XAU/USD"
|
||||
|
||||
primary = report.primary_analysis
|
||||
if primary is not None:
|
||||
ema200 = primary.key_levels.get("ema_trend")
|
||||
@@ -835,12 +1082,12 @@ class SignalAggregator:
|
||||
report.warnings.append(
|
||||
f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk"
|
||||
)
|
||||
confidence -= 10
|
||||
elif ema_dist_pct > _hi:
|
||||
confidence -= 3 if is_xau else 10
|
||||
elif ema_dist_pct > (2.5 if is_xau else _hi):
|
||||
report.warnings.append(
|
||||
f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended"
|
||||
)
|
||||
confidence -= 10
|
||||
confidence -= 5 if is_xau else 10
|
||||
|
||||
atr = primary.key_levels.get("atr", 0)
|
||||
if atr > 0 and score != 0:
|
||||
@@ -850,29 +1097,33 @@ class SignalAggregator:
|
||||
v for k, v in primary.key_levels.items()
|
||||
if k.startswith("swing_low_")
|
||||
)
|
||||
if swing_lows and report.current_price - swing_lows[0] > 2.5 * atr:
|
||||
confidence -= 15
|
||||
report.warnings.append("TREND EXHAUSTION — price extended >2.5 ATR past swing low")
|
||||
_exh_mult = 3.5 if is_xau else 2.5
|
||||
if swing_lows and report.current_price - swing_lows[0] > _exh_mult * atr:
|
||||
confidence -= 5 if is_xau else 15
|
||||
report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult} ATR past swing low")
|
||||
else:
|
||||
swing_highs = sorted(
|
||||
(v for k, v in primary.key_levels.items()
|
||||
if k.startswith("swing_high_")),
|
||||
reverse=True,
|
||||
)
|
||||
if swing_highs and swing_highs[0] - report.current_price > 2.5 * atr:
|
||||
confidence -= 15
|
||||
report.warnings.append("TREND EXHAUSTION — price extended >2.5 ATR past swing high")
|
||||
_exh_mult_s = 3.5 if is_xau else 2.5
|
||||
if swing_highs and swing_highs[0] - report.current_price > _exh_mult_s * atr:
|
||||
confidence -= 5 if is_xau else 15
|
||||
report.warnings.append(f"TREND EXHAUSTION — price extended >{_exh_mult_s} ATR past swing high")
|
||||
|
||||
ema_medium = primary.key_levels.get("ema_medium")
|
||||
if atr > 0 and ema_medium and report.market_regime.startswith("trending"):
|
||||
pullback_dist = abs(report.current_price - ema_medium) / atr
|
||||
if pullback_dist > 2.0:
|
||||
confidence -= 20
|
||||
_pb_severe = 2.5 if is_xau else 2.0
|
||||
_pb_mild = 2.0 if is_xau else 1.5
|
||||
if pullback_dist > _pb_severe:
|
||||
confidence -= 8 if is_xau else 20
|
||||
report.warnings.append(
|
||||
f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21, chasing risk"
|
||||
)
|
||||
elif pullback_dist > 1.5:
|
||||
confidence -= 10
|
||||
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"
|
||||
)
|
||||
@@ -883,9 +1134,10 @@ class SignalAggregator:
|
||||
|
||||
if rsi_ind:
|
||||
rsi_val = rsi_ind.value
|
||||
_rsi_pen = 5 if is_xau else 15
|
||||
if report.market_regime in ("trending_up", "trending") and score > 0:
|
||||
if rsi_val > 65:
|
||||
confidence -= 15
|
||||
confidence -= _rsi_pen
|
||||
report.warnings.append(
|
||||
f"Extended entry — RSI {rsi_val:.0f} in uptrend (prefer pullback to 40-55)"
|
||||
)
|
||||
@@ -893,7 +1145,7 @@ class SignalAggregator:
|
||||
confidence += 5
|
||||
elif report.market_regime == "trending_down" and score < 0:
|
||||
if rsi_val < 35:
|
||||
confidence -= 15
|
||||
confidence -= _rsi_pen
|
||||
report.warnings.append(
|
||||
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))
|
||||
|
||||
# Final signal
|
||||
if score > 38:
|
||||
confidence -= 20
|
||||
report.warnings.append("EXHAUSTION: score > 38 — trend overextended")
|
||||
if score >= 40 and confidence >= 60:
|
||||
if not is_xau and score > 42:
|
||||
confidence -= 10
|
||||
report.warnings.append("EXHAUSTION: score > 42 — trend overextended")
|
||||
if score >= 35 and confidence >= 55:
|
||||
report.final_signal = SignalStrength.STRONG_BUY
|
||||
elif score >= 30 and confidence >= 55:
|
||||
elif score >= 25 and confidence >= 50:
|
||||
report.final_signal = SignalStrength.BUY
|
||||
elif score <= -40 and confidence >= 60:
|
||||
elif score <= -35 and confidence >= 55:
|
||||
report.final_signal = SignalStrength.STRONG_SELL
|
||||
elif score <= -30 and confidence >= 55:
|
||||
elif score <= -25 and confidence >= 50:
|
||||
report.final_signal = SignalStrength.SELL
|
||||
else:
|
||||
report.final_signal = SignalStrength.NEUTRAL
|
||||
@@ -947,13 +1199,13 @@ class SignalAggregator:
|
||||
abs_score = abs(score)
|
||||
if sell_suppressed:
|
||||
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+"
|
||||
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"
|
||||
elif abs_score >= 33 and confidence >= 55:
|
||||
elif abs_score >= 28 and confidence >= 50:
|
||||
report.signal_quality = "B"
|
||||
elif abs_score >= 30 and confidence >= 50:
|
||||
elif abs_score >= 25 and confidence >= 45:
|
||||
report.signal_quality = "C"
|
||||
elif abs_score >= 15:
|
||||
report.signal_quality = "D"
|
||||
@@ -980,6 +1232,13 @@ class SignalAggregator:
|
||||
if atr == 0:
|
||||
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_price = min_sl_pips * pip_value
|
||||
|
||||
@@ -993,8 +1252,12 @@ class SignalAggregator:
|
||||
min_rr = self.config.signals.min_rr_ratio
|
||||
|
||||
mature_move = abs(report.final_score) > 30
|
||||
tp_mult = 1.5 if mature_move else 2.0
|
||||
tp_swing_cap = 2.5 if mature_move else 3.0
|
||||
tp_mult = 0.9 if mature_move else 1.2
|
||||
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:
|
||||
swing_lows = sorted(
|
||||
@@ -1005,7 +1268,7 @@ class SignalAggregator:
|
||||
sl_price = swing_lows[0] - atr * 1.0
|
||||
else:
|
||||
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:
|
||||
sl_price = price - min_sl
|
||||
|
||||
@@ -1035,7 +1298,7 @@ class SignalAggregator:
|
||||
sl_price = swing_highs[0] + atr * 1.0
|
||||
else:
|
||||
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:
|
||||
sl_price = price + min_sl
|
||||
|
||||
|
||||
+303
@@ -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
@@ -172,7 +172,7 @@ def _():
|
||||
def _():
|
||||
sc = SignalConfig()
|
||||
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.default_rr_ratio == 2.0
|
||||
assert sc.cooldown_bars == 5
|
||||
@@ -198,7 +198,9 @@ def _():
|
||||
def _():
|
||||
xc = XauStrategyConfig()
|
||||
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.sl_atr_multiplier == 2.0
|
||||
assert isinstance(xc.blocked_hours_utc, list)
|
||||
@@ -1123,6 +1125,9 @@ def _():
|
||||
config = PlatformConfig()
|
||||
config.backtest.session_filter = True
|
||||
config.backtest.blocked_hours_utc = list(range(24)) # block all hours
|
||||
config.eur_strategy.enabled = False
|
||||
config.gbp_strategy.enabled = False
|
||||
config.xau_strategy.enabled = False
|
||||
agg = SignalAggregator(config)
|
||||
dfs = {
|
||||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||||
|
||||
+121
@@ -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")
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user