Strip backtesting, rebalance indicator weights toward leading signals

Remove backtest.py, backtest_gpu.py, metrics.py, local CSV data,
Dukascopy loader, and all backtest CLI modes from main.py. Rebalance
engine weights: leading indicators (divergence, RSI, stochastic, BB)
now carry 66% of signal weight vs 34% for lagging (EMA, MACD). Add
ws_twelvedata.py for live tick streaming. Drop pyarrow and tqdm deps.
This commit is contained in:
Harwinder
2026-05-10 23:40:15 -04:00
parent 2e09237671
commit 7ccd8c4b04
16 changed files with 82 additions and 234029 deletions
-450
View File
@@ -1,450 +0,0 @@
# ForexBot2 — Engine Logic & Design Reference
> USD/JPY signal intelligence platform.
> Last updated: 2026-05-08
---
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Data Pipeline](#2-data-pipeline)
3. [Indicator Engine (`engine.py`)](#3-indicator-engine)
4. [Signal Aggregator (`signal_aggregator.py`)](#4-signal-aggregator)
5. [Composite Scoring Formula](#5-composite-scoring-formula)
6. [Indicator Weights — Optimised](#6-indicator-weights--optimised)
7. [Market Regime Classification](#7-market-regime-classification)
8. [EMA200 Distance Gate](#8-ema200-distance-gate)
9. [Multi-Timeframe Alignment](#9-multi-timeframe-alignment)
10. [Signal Grade System](#10-signal-grade-system)
11. [GPU Backtester (`backtest_gpu.py`)](#11-gpu-backtester)
12. [Backtesting Results Summary](#12-backtesting-results-summary)
13. [Filter Catalogue](#13-filter-catalogue)
14. [Configuration Reference](#14-configuration-reference)
---
## 1. Architecture Overview
```
market_data/ (local CSV)
│
▼
ForexDataProvider ← also pulls live via Twelve Data SDK
│
▼
TechnicalEngine ← computes all indicators bar-by-bar
│
▼
SignalAggregator ← MTF blending, EMA200 gate, grading
│
├──▶ Live output (main.py)
└──▶ GPUBacktester ← vectorised O(N) replay over full history
```
**Key design principle:** No single indicator is trusted alone. The composite score is a confidence-weighted, regime-adjusted blend of up to 7 indicators across 3 timeframes. A signal only fires when direction, strength, and confidence all agree.
---
## 2. Data Pipeline
### Live mode
`ForexDataProvider.fetch_ohlcv()` calls the Twelve Data SDK with in-memory caching (5-min TTL). Fetches primary TF + two confirmation TFs + optional regime TF.
### Backtest mode
`ForexDataProvider.load_local_csv()` reads MetaTrader tab-delimited exports from `market_data/`:
| File | TF | Rows | Date Range |
|------|----|------|------------|
| USDJPY_M15.csv | M15 | 100,000 | 2022-05-04 → 2026-05-08 |
| USDJPY_H1.csv | H1 | 100,000 | 2010-04-23 → 2026-05-08 |
| USDJPY_H4.csv | H4 | 25,855 | 2010-04-22 → 2026-05-08 |
| USDJPY_D1.csv | D1 | 5,020 | 2010-04-22 → 2026-05-08 |
CSV format: tab-delimited, columns `Time | Open | High | Low | Close | Volume | Spread`. Spread column is dropped on load. Timestamps are UTC.
**Data validation** (applied to both sources):
- Drop NaN OHLC rows; forward-fill up to 3 bars
- Drop future timestamps (>5 min ahead of UTC now)
- Fix OHLC inconsistencies (High < Low etc.)
- Remove outlier bars where `|return| > 10 × rolling_std`
---
## 3. Indicator Engine
`engine.py → TechnicalEngine.analyze(df, symbol, timeframe)`
All indicators are computed on a single OHLCV DataFrame. Each returns an `IndicatorSignal` with:
- `value` — raw numeric reading
- `signal` — `SignalStrength` enum: STRONG_BUY(+2) / BUY(+1) / NEUTRAL(0) / SELL(-1) / STRONG_SELL(-2)
- `confidence` — 0–100%
- `weight` — contribution to composite score (0 = excluded)
### Indicators computed
| Name | Key | Wilder smoothing? | Notes |
|------|-----|:-----------------:|-------|
| EMA Alignment | `ema_trend` | No | 4-EMA stack: 9/21/50/200; normalised to -1..+1 |
| RSI | `rsi` | Yes (`alpha=1/14`) | Overbought/oversold + momentum zones |
| MACD | `macd` | No | 12/26/9; crossover = STRONG signal |
| Bollinger Bands | `bollinger` | No | 20-period; position within band + squeeze detect |
| Stochastic | `stochastic` | No | 14/3; K+D both OB/OS required for signal |
| ADX | `adx` | Yes | DI+/DI- direction + ADX strength gate (≥25) |
| ATR | `atr` | Yes | Context only — sets SL/TP distance; weight=0 |
| Ichimoku | `ichimoku` | No | Price vs cloud (+2/-2) + TK cross (+1/-1) + cloud colour (+1/-1) |
| Divergence | `divergence` | — | RSI/MACD price vs indicator swing divergence; weight=1.5 |
| Candlestick | `candlestick` | — | **Disabled** (ablation: net -3.4%) |
| Pivot Points | `pivot` | — | **Disabled** (ablation: net -9.5%) |
| Volume | `volume` | — | Context filter; weight=0 |
**Disabled indicators** are controlled by `TechnicalConfig.disabled_indicators` (default: `["pivot", "candlestick"]`).
---
## 4. Signal Aggregator
`signal_aggregator.py → SignalAggregator.analyze()`
Orchestrates three timeframes (primary + 2 confirmation), blends their scores, applies gates, and emits a final graded signal.
### Flow
```
H1 (primary) ──┐
M15 (confirm) ──┼──▶ per-TF score ──▶ MTF blend ──▶ EMA200 gate ──▶ grade ──▶ output
H4 (confirm) ──┘
```
### MTF alignment (no lookahead)
Lower-frequency bars are aligned to the primary TF index by forward-filling at **close times**:
```
secondary close time = secondary bar open + secondary TF minutes
primary close time = primary bar open + primary TF minutes
For each primary bar T: use the most recent secondary close ≤ T_close
```
This ensures no future information leaks into the signal.
---
## 5. Composite Scoring Formula
Mirrors `engine._compute_composite` exactly:
```
composite_score = clip(
(Σ signal_i × weight_i × regime_mult_i × (confidence_i / 100))
────────────────────────────────────────────────────────────────── × 50,
(Σ weight_i × regime_mult_i)
-100, +100
)
avg_confidence = Σ(confidence_i × weight_i) / Σ weight_i
```
- `signal_i` ∈ {-2, -1, 0, +1, +2}
- `weight_i` — per-indicator base weight (see §6)
- `regime_mult_i` — boosted/dampened by market regime (see §7)
- Indicators with `weight == 0` are excluded entirely
---
## 6. Indicator Weights — Optimised
Optimised 2026-05-08 via 36-combo sweep (ablation + ×0.5/1.5/2.0/3.0 per indicator) on H4 data 2022–2026. Combined result: **+21.30% return / -8.52% max DD / Sharpe 0.47** vs baseline +7.12% / -9.05% DD / Sharpe 0.23.
| Indicator | Weight | Change from original | Reason |
|-----------|-------:|---------------------|--------|
| `ema_trend` | **3.6** | was 1.2 (×3) | Strongest directional signal on trending pair |
| `macd` | **3.3** | was 1.1 (×3) | Most impactful single indicator; ablation = worst result |
| `stochastic` | **1.6** | was 0.8 (×2) | Clean timing signal; 40% WR at ×2 |
| `adx` | **0.5** | was 1.0 (÷2) | Partially redundant with EMA stack |
| `ichimoku` | **0.55** | was 1.1 (÷2) | Lags too much at H4; hurts on ranging periods |
| `rsi` | **0.0** | was 0.3 (ablated) | Net drag across all test windows |
| `bollinger` | **0.0** | was 0.9 (ablated) | Counter-trend noise on trending pair |
| `divergence` | 1.5 | unchanged | Strong reversal signal; not in sweep scope |
| `atr` | 0.0 | unchanged | Context only; sets SL/TP |
> **Note:** RSI and Bollinger are still **computed and displayed** in live output for reference, but their weight=0 means they contribute nothing to the composite score.
### Regime weight multipliers
Applied on top of base weights:
| Indicator | Trending mult | Ranging mult |
|-----------|:-------------:|:------------:|
| ema_trend | ×1.5 | ×0.6 |
| adx | ×1.3 | ×0.5 |
| macd | ×1.2 | ×1.0 |
| ichimoku | ×1.3 | ×0.2 |
| rsi | ×0.7 | ×1.4 |
| bollinger | ×0.6 | ×1.4 |
| stochastic | ×1.0 | ×1.3 |
In trending regimes: EMA trend, MACD, and Ichimoku are boosted further.
In ranging regimes: RSI and Bollinger would be boosted — but since both are weight=0, ranging regime currently relies entirely on stochastic + reduced EMA/ADX.
---
## 7. Market Regime Classification
`engine._classify_regime()` — runs after all indicators are computed:
```
if ADX ≥ 25:
if EMA signal is BUY or STRONG_BUY → trending_up
if EMA signal is SELL or STRONG_SELL → trending_down
else → trending
else:
→ ranging
```
Regime feeds directly into the weight multipliers applied in `_compute_composite`.
---
## 8. EMA200 Distance Gate
**Strongest single filter discovered** — implemented in `signal_aggregator.py`.
```
ema_dist = |close - EMA200| / EMA200 × 100 (percent)
if ema_dist < 0.5% or ema_dist > 1.5%:
confidence *= 0.55 ← damped
else: ← sweet spot
no change
```
**Rationale:** Price within 0.5% of EMA200 is in a high-chop zone where the 200 EMA acts as contested support/resistance. Price more than 1.5% away has often already run far and is prone to reversion. The 0.5–1.5% band is where trending momentum is cleanest.
**Impact measured:** Lifted cumulative return +13.88% → +35.50% in the 2024-trend ablation window.
---
## 9. Multi-Timeframe Alignment
Primary TF: **H1** (default). Confirmation TFs: **M15** and **H4**.
### MTF blending (when 2 of 3 TFs agree)
```
mtf_agree = (bull_count ≥ 2) OR (bear_count ≥ 2)
if mtf_agree:
score = 0.6 × primary_score + 0.4 × mean(all_scores)
confidence = min(confidence + 15, 95)
else:
confidence = confidence × 0.7 ← penalised for disagreement
```
### MTF agreement bonus in grade assignment
`mtf_agree=True` is required for grades A and A+.
---
## 10. Signal Grade System
| Grade | Conditions |
|-------|-----------|
| **A+** | `|score| ≥ 50` AND `conf ≥ 75%` AND `mtf_agree` |
| **A** | `|score| ≥ 35` AND `conf ≥ 65%` AND `mtf_agree` |
| **B** | `|score| ≥ 25` AND `conf ≥ 55%` |
| **C** | `|score| ≥ 15` AND `conf ≥ 45%` |
| **D** | `|score| ≥ 10` |
| **F** | below all thresholds |
Default minimum grade for trade entry: **B** (`BacktestConfig.min_trade_grade`).
### Signal integer thresholds (for trade direction)
| Signal | Condition |
|--------|-----------|
| STRONG_BUY (+2) | score ≥ +40 AND conf ≥ 65% |
| BUY (+1) | score ≥ +15 AND conf ≥ 50% |
| STRONG_SELL (-2) | score ≤ -40 AND conf ≥ 65% |
| SELL (-1) | score ≤ -15 AND conf ≥ 50% |
| NEUTRAL | otherwise |
---
## 11. GPU Backtester
`backtest_gpu.py` — completely separate from `backtest.py`.
### Why O(N) instead of O(N²)
`backtest.py` slices the DataFrame and recomputes all indicators fresh each bar → N × O(N) = **O(N²)**.
`backtest_gpu.py` computes every indicator series end-to-end once, then reads bar T's value in O(1) → **O(N) total**.
On 100,000 H1 bars: ~4 seconds vs >10 minutes.
### Two-phase architecture
```
Phase 1 — prepare_arrays(dfs)
VectorIndicators.compute_all() ← pandas ewm/rolling, C-optimised
Align MTF signals to primary TF ← forward-fill at close times
Compute regime ADX series ← if regime filter enabled
Returns reusable bundle dict
Phase 2 — score_and_run(bundle, weights, disabled)
compute_bar_scores() ← CuPy GPU or NumPy CPU
compute_final_scores() ← MTF blend + EMA200 gate (vectorised)
compute_grades() ← vectorised grade assignment
run_state_machine() ← sequential position management (O(N))
```
`prepare_arrays()` is called once; `score_and_run()` can be called many times with different weights — this is what the `--optimize` sweep uses.
### Optimization sweep (`--optimize`)
36 combinations tested per run:
- **1** baseline (default weights)
- **7** ablation runs (each indicator disabled one at a time)
- **28** weight multiplier runs (each indicator at ×0.5, ×1.5, ×2.0, ×3.0)
Indicator arrays computed once → all 36 replays run in seconds.
### Trade execution model
```
Entry: next bar Open ± (spread/2 + slippage) [default: 0.5 pip slippage]
SL: entry ± ATR × atr_sl_multiplier [default: 1.5×]
TP: entry ± SL_dist × rr_ratio [default: 2.0× = 1:2 R:R]
Risk: 1% of current equity per trade
```
If SL and TP both touch within the same bar: SL assumed to hit first (`sl_first_on_tie=True`).
### CLI flags
```
python backtest_gpu.py --tf H4 --local # local CSV, H4
python backtest_gpu.py --tf H1 --local --start 2022-01-01
python backtest_gpu.py --tf H4 --local --optimize # 36-combo weight sweep
python backtest_gpu.py --regime-filter --regime-tf D1
python backtest_gpu.py --session-filter # UTC 07-16 only
python backtest_gpu.py --journal --journal-csv out.csv
```
---
## 12. Backtesting Results Summary
### H4 — local CSV 2022–2026 (optimised weights)
| Metric | Value |
|--------|-------|
| Trades | 160 |
| Win Rate | 38.1% |
| Profit Factor | 1.19 |
| Total Return | +21.30% |
| Max Drawdown | -8.52% |
| Sharpe | 0.47 |
| Sortino | 1.91 |
### D1 — Twelve Data 2007–2026 (optimised weights)
| Metric | Value |
|--------|-------|
| Trades | 62 |
| Win Rate | 40.3% |
| Profit Factor | 1.27 |
| Total Return | +10.51% |
| Max Drawdown | -6.84% |
| Sortino | 7.74 |
### Optimisation sweep delta (H4 2022–2026)
| | Baseline | Optimised |
|--|----------|-----------|
| Return | +7.12% | +21.30% |
| Max DD | -9.05% | -8.52% |
| Sharpe | 0.23 | 0.47 |
| Win Rate | 36.3% | 38.1% |
---
## 13. Filter Catalogue
### Regime filter (`--regime-filter`)
Gates trade entry on ADX of a **higher** timeframe than the primary. Default: H4 ADX gate when trading H1. Redundant if regime TF == primary TF.
```
regime TF ADX ≥ min_adx (default 25) → entry allowed
```
Sensible defaults: H1→H4, H4→D1, D1→D1.
### Session filter (`--session-filter`)
Only trade during UTC 07:00–16:00 (London + NY overlap).
2026 diagnostic: 07-15 UTC = 47% WR / +127 pips; off-hours = 0% WR / -571 pips.
### Auto-session toggle (`auto_session_filter`)
Reactive version of session filter. Activates automatically when mean P&L over last N closed trades drops below threshold:
```
if mean(pnl_pct[-10:]) < -0.3%:
session_filter = ON ← restrict to liquid hours
else:
session_filter = OFF ← all hours allowed
```
### EMA200 distance gate
Always active. See §8.
### Signal grade gate (`min_trade_grade`)
Default: **B**. Only trades with grade ≥ B are entered. Raise to A for fewer, higher-quality trades.
### Min absolute score (`min_abs_score`)
Default: **0** (no extra gate). Set to e.g. 15 to skip weak directional scores.
---
## 14. Configuration Reference
All knobs live in `config.py → PlatformConfig`.
```python
# Indicator parameters
TechnicalConfig.ema_fast = 9
TechnicalConfig.ema_medium = 21
TechnicalConfig.ema_slow = 50
TechnicalConfig.ema_trend = 200
TechnicalConfig.rsi_period = 14
TechnicalConfig.macd_fast / slow / signal = 12/26/9
TechnicalConfig.bb_period = 20
TechnicalConfig.atr_period = 14
TechnicalConfig.stoch_k / d = 14/3
TechnicalConfig.adx_period = 14
TechnicalConfig.adx_strong_trend = 25.0
TechnicalConfig.ichimoku_tenkan / kijun / senkou_b = 9/26/52
TechnicalConfig.disabled_indicators = ["pivot", "candlestick"]
# Signal scoring
SignalConfig.min_confidence = 55.0
SignalConfig.atr_sl_multiplier = 1.5
SignalConfig.default_rr_ratio = 2.0
SignalConfig.default_risk_pct = 1.0
# Backtest
BacktestConfig.initial_balance = 10_000
BacktestConfig.risk_per_trade_pct = 1.0
BacktestConfig.min_trade_grade = "B"
BacktestConfig.regime_filter = False
BacktestConfig.session_filter = False
BacktestConfig.auto_session_filter = False
BacktestConfig.cache_dir = ".cache"
```
-42
View File
@@ -1,42 +0,0 @@
# Running ForexBot2 on Linux
## Install dependencies
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## Telegram bot
```bash
# Start (runs in background, logs to file)
nohup .venv/bin/python telegram_bot.py >> bot.log 2>&1 &
# Follow logs
tail -f bot.log
# Check it is running
pgrep -a -f telegram_bot.py
# Stop
pkill -f telegram_bot.py
```
## Live signal (CLI)
```bash
.venv/bin/python main.py # H1 summary
.venv/bin/python main.py --detail # full indicator breakdown
.venv/bin/python main.py --json # JSON output
```
## Backtest
```bash
.venv/bin/python main.py --backtest
.venv/bin/python main.py --backtest --optimize
```
> No GPU required — GPU code is only used by `backtest_gpu.py`.
-667
View File
@@ -1,667 +0,0 @@
"""
ForexBot2 — Walk-Forward Backtester + Parameter Optimizer
==========================================================
Replays Twelve Data USD/JPY history bar-by-bar through the same
SignalAggregator that drives live mode. The same code path that
trades USD/JPY in production is what produces these metrics — so
the backtest validates the engine, not a parallel implementation.
"""
from __future__ import annotations
import copy
import itertools
import logging
import multiprocessing as mp
import os
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import replace
from typing import Deque, Dict, List, Optional, Tuple
import pandas as pd
from config import PlatformConfig, Timeframe, USDJPY, PIP_VALUE
from data_provider import ForexDataProvider
from metrics import BacktestResult, Trade, compute_metrics, trades_to_dataframe
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
logger = logging.getLogger(__name__)
# Period in minutes for each timeframe (used for close-time computation)
TF_MINUTES: Dict[Timeframe, int] = {
Timeframe.M1: 1,
Timeframe.M5: 5,
Timeframe.M15: 15,
Timeframe.M30: 30,
Timeframe.H1: 60,
Timeframe.H4: 240,
Timeframe.D1: 1440,
Timeframe.W1: 10080,
}
GRADE_ORDER = ["A+", "A", "B", "C", "D", "F"]
def _grade_passes(grade: str, min_grade: str) -> bool:
"""True if `grade` is at least as good as `min_grade`."""
if grade not in GRADE_ORDER or min_grade not in GRADE_ORDER:
return False
return GRADE_ORDER.index(grade) <= GRADE_ORDER.index(min_grade)
# ══════════════════════════════════════════════════════════════════
# WALK-FORWARD BACKTESTER
# ══════════════════════════════════════════════════════════════════
class WalkForwardBacktester:
"""Single-pair, single-position event-driven backtester."""
def __init__(self, config: PlatformConfig):
self.config = config
self.aggregator = SignalAggregator(config)
self.data_provider = self.aggregator.data_provider
self._regime_adx: Optional[pd.Series] = None
self._regime_tf: Optional[Timeframe] = None
# ── Public ─────────────────────────────────────────────────────
def load_data(self) -> Dict[Timeframe, pd.DataFrame]:
"""Pull (or read from disk cache) all needed timeframes."""
timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes
)
bt = self.config.backtest
if bt.regime_filter and bt.regime_filter_timeframe not in timeframes:
timeframes.append(bt.regime_filter_timeframe)
dfs: Dict[Timeframe, pd.DataFrame] = {}
for tf in timeframes:
df = self.data_provider.fetch_full_history(
symbol=self.config.symbol,
timeframe=tf,
max_bars=bt.max_bars,
start_date=bt.start_date,
end_date=bt.end_date,
)
df = self._apply_window(df)
dfs[tf] = df
return dfs
def _precompute_all_signals(
self,
dfs: Dict[Timeframe, pd.DataFrame],
primary_df: pd.DataFrame,
warmup: int,
progress: bool = True,
) -> Dict[int, "SignalReport"]:
"""Pre-compute signals for every bar in parallel using all CPU threads.
TechnicalEngine.analyze() is stateless (pure function of the df slice),
so concurrent calls are safe. NumPy/pandas C extensions release the GIL,
giving real parallelism across threads without pickling overhead.
Returns bar-index → SignalReport so the sequential state machine can
do O(1) lookups instead of recomputing indicators per bar.
"""
primary_tf = self.config.primary_timeframe
# Use all logical threads; cap at 2× physical cores to avoid over-subscription
n_workers = min(os.cpu_count() or 4, 16)
bar_indices = list(range(warmup, len(primary_df) - 1))
def _compute_bar(t: int) -> Tuple[int, "SignalReport"]:
decision_time = self._bar_close_time(primary_df.index[t], primary_tf)
sliced = self._slice_dfs(dfs, decision_time)
report = self.aggregator.analyze_from_dataframes(
symbol=self.config.symbol,
dfs=sliced,
use_news=False,
)
return t, report
precomputed: Dict[int, "SignalReport"] = {}
pbar = None
if progress:
try:
from tqdm import tqdm # type: ignore
pbar = tqdm(
total=len(bar_indices),
desc=f"Signals ({n_workers} threads)",
unit="bar",
leave=False,
)
except ImportError:
pass
with ThreadPoolExecutor(max_workers=n_workers) as executor:
futures = {executor.submit(_compute_bar, t): t for t in bar_indices}
for future in as_completed(futures):
t, report = future.result()
precomputed[t] = report
if pbar:
pbar.update(1)
if pbar:
pbar.close()
return precomputed
def run(
self,
dfs: Optional[Dict[Timeframe, pd.DataFrame]] = None,
progress: bool = True,
) -> BacktestResult:
if dfs is None:
dfs = self.load_data()
self._precompute_regime_filter(dfs)
primary_tf = self.config.primary_timeframe
primary_df = dfs.get(primary_tf)
if primary_df is None or primary_df.empty:
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
# Warmup: enough bars for ema_trend & ichimoku_senkou_b on the primary TF
warmup = max(
self.config.technical.ema_trend,
self.config.technical.ichimoku_senkou_b,
200,
) + 30
if len(primary_df) <= warmup + 1:
logger.warning(
f"Insufficient data: {len(primary_df)} primary bars, "
f"need >{warmup}."
)
return BacktestResult(initial_balance=self.config.backtest.initial_balance)
# Pre-compute all bar signals in parallel before the sequential replay
precomputed = self._precompute_all_signals(
dfs, primary_df, warmup, progress=progress
)
bt = self.config.backtest
balance = bt.initial_balance
equity_points: List[Tuple[pd.Timestamp, float]] = [
(primary_df.index[warmup], balance)
]
trades: List[Trade] = []
# Auto-session feedback: rolling pnl_pct over last K closed trades.
# When mean falls below threshold, the session filter activates;
# once expectancy recovers, it releases.
self._recent_pnl_pcts: Deque[float] = deque(
maxlen=max(1, bt.auto_session_lookback_trades)
)
open_position: Optional[Dict] = None
# Optional progress bar for the state-machine replay pass
iterator = range(warmup, len(primary_df) - 1)
if progress:
try:
from tqdm import tqdm # type: ignore
iterator = tqdm(iterator, desc="Replaying bars", unit="bar", leave=False)
except ImportError:
pass
for t in iterator:
decision_time = self._bar_close_time(primary_df.index[t], primary_tf)
# ── Step 1: manage any open position ───────────────────
if open_position is not None:
next_bar = primary_df.iloc[t]
exit_info = self._check_exit(open_position, next_bar)
if exit_info is not None:
closed = self._close_trade(
open_position, next_bar.name, exit_info, balance
)
trades.append(closed)
balance += closed.pnl_currency
equity_points.append((next_bar.name, balance))
self._recent_pnl_pcts.append(float(closed.pnl_pct))
open_position = None
# ── Step 2: maybe open a new position ──────────────────
if open_position is None or not bt.one_position_only:
report = precomputed[t]
if (
_grade_passes(report.signal_quality, bt.min_trade_grade)
and abs(float(report.final_score)) >= bt.min_abs_score
and report.trade_setup is not None
and t + 1 < len(primary_df)
and self._regime_passes(decision_time)
and self._session_passes(decision_time)
):
next_bar = primary_df.iloc[t + 1]
open_position = self._open_trade(report, next_bar, balance)
# ── Force-close any still-open position at end of data ────
if open_position is not None:
last_bar = primary_df.iloc[-1]
closed = self._close_trade(
open_position,
last_bar.name,
{"price": float(last_bar["Close"]), "reason": "end_of_data"},
balance,
)
trades.append(closed)
balance += closed.pnl_currency
equity_points.append((last_bar.name, balance))
# Build equity curve
eq_index = [p[0] for p in equity_points]
eq_values = [p[1] for p in equity_points]
equity_curve = pd.Series(eq_values, index=pd.DatetimeIndex(eq_index))
elapsed_days: Optional[float] = None
if len(equity_curve) >= 2:
elapsed_days = (equity_curve.index[-1] - equity_curve.index[0]).total_seconds() / 86400
metrics = compute_metrics(
trades, equity_curve, bt.initial_balance, elapsed_days=elapsed_days
)
return BacktestResult(
trades=trades,
equity_curve=equity_curve,
initial_balance=bt.initial_balance,
final_balance=balance,
metrics=metrics,
)
# ── Private: regime gate ───────────────────────────────────────
def _precompute_regime_filter(self, dfs: Dict[Timeframe, pd.DataFrame]) -> None:
"""Pre-compute ADX on the regime-filter TF; cached for the whole run."""
bt = self.config.backtest
if not bt.regime_filter:
self._regime_adx = None
self._regime_tf = None
return
tf = bt.regime_filter_timeframe
df = dfs.get(tf)
if df is None or df.empty:
logger.warning(
f"Regime filter requested ({tf.value}) but no data available; "
f"all signals will be skipped."
)
self._regime_adx = pd.Series(dtype=float)
self._regime_tf = tf
return
self._regime_tf = tf
self._regime_adx = self._compute_adx_series(df, self.config.technical.adx_period)
def _regime_passes(self, decision_time: pd.Timestamp) -> bool:
bt = self.config.backtest
if not bt.regime_filter:
return True
if self._regime_adx is None or self._regime_adx.empty:
return False
period_min = TF_MINUTES[self._regime_tf]
close_times = self._regime_adx.index + pd.Timedelta(minutes=period_min)
valid = self._regime_adx[close_times <= decision_time]
if valid.empty:
return False
last = valid.iloc[-1]
if pd.isna(last):
return False
return float(last) >= bt.regime_filter_min_adx
def _session_passes(self, decision_time: pd.Timestamp) -> bool:
"""UTC hour gate — decision_time = entry bar's open time.
Filter is active when EITHER manual `session_filter` is on, OR
`auto_session_filter` is on and the rolling mean pnl_pct over the
last N closed trades is below threshold.
"""
bt = self.config.backtest
active = bt.session_filter
if not active and bt.auto_session_filter:
recent = getattr(self, "_recent_pnl_pcts", None)
if recent is not None and len(recent) >= bt.auto_session_lookback_trades:
mean_pnl = sum(recent) / len(recent)
active = mean_pnl < bt.auto_session_threshold_pct
if not active:
return True
h = int(decision_time.hour)
return bt.session_start_hour <= h < bt.session_end_hour
@staticmethod
def _compute_adx_series(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Wilder's ADX series — same algorithm as engine._compute_adx."""
import numpy as np
high, low, close = df["High"], df["Low"], df["Close"]
tr = pd.concat([
high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs(),
], axis=1).max(axis=1)
plus_dm = high.diff()
minus_dm = -low.diff()
plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
atr_s = tr.ewm(alpha=1/period, min_periods=period).mean()
plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s)
minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr_s)
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
return dx.ewm(alpha=1/period, min_periods=period).mean()
# ── Private: data slicing ──────────────────────────────────────
def _apply_window(self, df: pd.DataFrame) -> pd.DataFrame:
bt = self.config.backtest
if bt.start_date:
start = pd.Timestamp(bt.start_date, tz="UTC")
df = df[df.index >= start]
if bt.end_date:
end = pd.Timestamp(bt.end_date, tz="UTC")
df = df[df.index <= end]
return df
@staticmethod
def _bar_close_time(open_time: pd.Timestamp, tf: Timeframe) -> pd.Timestamp:
return open_time + pd.Timedelta(minutes=TF_MINUTES[tf])
def _slice_dfs(
self, dfs: Dict[Timeframe, pd.DataFrame], decision_time: pd.Timestamp
) -> Dict[Timeframe, pd.DataFrame]:
"""Return only bars whose close time ≤ decision_time (no lookahead)."""
sliced: Dict[Timeframe, pd.DataFrame] = {}
for tf, df in dfs.items():
close_times = df.index + pd.Timedelta(minutes=TF_MINUTES[tf])
sliced[tf] = df[close_times <= decision_time]
return sliced
# ── Private: trade execution ───────────────────────────────────
def _open_trade(
self, report: SignalReport, next_bar: pd.Series, balance: float
) -> Dict:
"""Open at next bar's open, applying spread + slippage."""
ts: TradeSetup = report.trade_setup # type: ignore[assignment]
bt = self.config.backtest
raw_entry = float(next_bar["Open"])
# Sanity-test inversion: flip direction and mirror SL/TP around entry
direction = ts.direction
sl = ts.stop_loss
tp = ts.take_profit_1
if bt.invert_signal:
direction = "SELL" if direction == "BUY" else "BUY"
sl, tp = (2 * raw_entry - sl), (2 * raw_entry - tp)
# Cost in price units = (half-spread + slippage) × pip_value
half_spread_pips = 0.5 if bt.use_spread_cost else 0.0
cost_pips = half_spread_pips + bt.slippage_pips
cost_price = cost_pips * PIP_VALUE
if direction == "BUY":
entry_price = raw_entry + cost_price # buy higher (worse)
else:
entry_price = raw_entry - cost_price # sell lower (worse)
# Position sizing — risk a fixed % of current balance
risk_pct = bt.risk_per_trade_pct / 100.0
risk_amount = balance * risk_pct
risk_pips = max(ts.risk_pips, 1e-9)
pip_currency_value = risk_amount / risk_pips # $ per pip
# ── Diagnostic snapshot at entry ─────────────────────────
analysis = report.primary_analysis
adx_val = 0.0
atr_pct = 0.0
rsi_val = 50.0
ema_dist = 0.0
if analysis is not None:
adx_sig = analysis.indicators.get("adx")
if adx_sig is not None:
adx_val = float(adx_sig.value)
atr_pct = float(analysis.key_levels.get("atr_pct", 0.0))
rsi_sig = analysis.indicators.get("rsi")
if rsi_sig is not None:
rsi_val = float(rsi_sig.value)
ema_trend = analysis.key_levels.get("ema_trend")
if ema_trend and ema_trend > 0:
ema_dist = (raw_entry - ema_trend) / ema_trend * 100.0
# Per-timeframe scores from MTF map (keys are tf.value strings)
m15_s = float(report.mtf_scores.get("15min", 0.0))
h1_s = float(report.mtf_scores.get("1h", 0.0))
h4_s = float(report.mtf_scores.get("4h", 0.0))
entry_ts = next_bar.name
entry_hour = int(entry_ts.hour) if hasattr(entry_ts, "hour") else -1
entry_dow = int(entry_ts.dayofweek) if hasattr(entry_ts, "dayofweek") else -1
return {
"direction": direction,
"entry_time": next_bar.name,
"entry_price": entry_price,
"stop_loss": sl,
"take_profit": tp,
"risk_pips": risk_pips,
"pip_currency_value": pip_currency_value,
"balance_at_open": balance,
"grade": report.signal_quality,
"score_at_entry": float(report.final_score),
"confidence_at_entry": float(report.final_confidence),
"bar_index_open": next_bar.name,
# Diagnostics
"entry_hour": entry_hour,
"entry_dow": entry_dow,
"adx_at_entry": adx_val,
"atr_pct_at_entry": atr_pct,
"market_regime": report.market_regime or "",
"volatility_regime": report.volatility_regime or "",
"mtf_agreement": bool(report.mtf_agreement),
"m15_score": m15_s,
"h1_score": h1_s,
"h4_score": h4_s,
"rsi_at_entry": rsi_val,
"ema_distance_pct": ema_dist,
}
def _check_exit(
self, position: Dict, bar: pd.Series
) -> Optional[Dict]:
"""Return {price, reason} if SL or TP hit, else None.
On the entry bar (bar with same timestamp as position's entry_time),
we already filled at the open — only check subsequent bars' OHLC.
"""
if bar.name == position["bar_index_open"]:
return None # entry bar — no exit check
bt = self.config.backtest
high = float(bar["High"])
low = float(bar["Low"])
sl = position["stop_loss"]
tp = position["take_profit"]
slip = bt.slippage_pips * PIP_VALUE
half_spread = (0.5 if bt.use_spread_cost else 0.0) * PIP_VALUE
if position["direction"] == "BUY":
sl_hit = low <= sl
tp_hit = high >= tp
if sl_hit and tp_hit:
# Conservative: SL first
return {"price": sl - slip - half_spread, "reason": "sl"} if bt.sl_first_on_tie else \
{"price": tp - slip - half_spread, "reason": "tp"}
if sl_hit:
return {"price": sl - slip - half_spread, "reason": "sl"}
if tp_hit:
return {"price": tp - slip - half_spread, "reason": "tp"}
else: # SELL
sl_hit = high >= sl
tp_hit = low <= tp
if sl_hit and tp_hit:
return {"price": sl + slip + half_spread, "reason": "sl"} if bt.sl_first_on_tie else \
{"price": tp + slip + half_spread, "reason": "tp"}
if sl_hit:
return {"price": sl + slip + half_spread, "reason": "sl"}
if tp_hit:
return {"price": tp + slip + half_spread, "reason": "tp"}
return None
def _close_trade(
self, position: Dict, exit_time: pd.Timestamp, exit_info: Dict, balance: float
) -> Trade:
exit_price = exit_info["price"]
reason = exit_info["reason"]
if position["direction"] == "BUY":
pnl_pips = (exit_price - position["entry_price"]) / PIP_VALUE
else:
pnl_pips = (position["entry_price"] - exit_price) / PIP_VALUE
pnl_currency = pnl_pips * position["pip_currency_value"]
pnl_pct = pnl_currency / position["balance_at_open"] * 100
# Duration in primary-TF bars (timestamp diff / period)
period_min = TF_MINUTES[self.config.primary_timeframe]
duration_minutes = (exit_time - position["entry_time"]).total_seconds() / 60
duration_bars = int(round(duration_minutes / period_min))
return Trade(
entry_time=position["entry_time"],
exit_time=exit_time,
direction=position["direction"],
entry_price=position["entry_price"],
exit_price=exit_price,
stop_loss=position["stop_loss"],
take_profit=position["take_profit"],
exit_reason=reason,
risk_pips=position["risk_pips"],
pnl_pips=pnl_pips,
pnl_pct=pnl_pct,
pnl_currency=pnl_currency,
duration_bars=duration_bars,
grade=position["grade"],
score_at_entry=position["score_at_entry"],
confidence_at_entry=position["confidence_at_entry"],
entry_hour=position.get("entry_hour", -1),
entry_dow=position.get("entry_dow", -1),
adx_at_entry=position.get("adx_at_entry", 0.0),
atr_pct_at_entry=position.get("atr_pct_at_entry", 0.0),
market_regime=position.get("market_regime", ""),
volatility_regime=position.get("volatility_regime", ""),
mtf_agreement=position.get("mtf_agreement", False),
m15_score=position.get("m15_score", 0.0),
h1_score=position.get("h1_score", 0.0),
h4_score=position.get("h4_score", 0.0),
rsi_at_entry=position.get("rsi_at_entry", 50.0),
ema_distance_pct=position.get("ema_distance_pct", 0.0),
)
# ══════════════════════════════════════════════════════════════════
# PARAMETER OPTIMIZER (PARALLEL)
# ══════════════════════════════════════════════════════════════════
def _apply_overrides(config: PlatformConfig, overrides: Dict[str, float]) -> PlatformConfig:
"""Return a deep-copied config with parameter overrides applied."""
new_config = copy.deepcopy(config)
if "atr_sl_multiplier" in overrides:
new_config.signals.atr_sl_multiplier = float(overrides["atr_sl_multiplier"])
if "default_rr_ratio" in overrides:
new_config.signals.default_rr_ratio = float(overrides["default_rr_ratio"])
if "min_trade_grade" in overrides:
new_config.backtest.min_trade_grade = str(overrides["min_trade_grade"])
if "min_abs_score" in overrides:
new_config.backtest.min_abs_score = float(overrides["min_abs_score"])
if "invert_signal" in overrides:
new_config.backtest.invert_signal = bool(overrides["invert_signal"])
if "regime_filter" in overrides:
new_config.backtest.regime_filter = bool(overrides["regime_filter"])
if "regime_filter_min_adx" in overrides:
new_config.backtest.regime_filter_min_adx = float(overrides["regime_filter_min_adx"])
if "session_filter" in overrides:
new_config.backtest.session_filter = bool(overrides["session_filter"])
if "session_start_hour" in overrides:
new_config.backtest.session_start_hour = int(overrides["session_start_hour"])
if "session_end_hour" in overrides:
new_config.backtest.session_end_hour = int(overrides["session_end_hour"])
if "auto_session_filter" in overrides:
new_config.backtest.auto_session_filter = bool(overrides["auto_session_filter"])
if "auto_session_lookback_trades" in overrides:
new_config.backtest.auto_session_lookback_trades = int(overrides["auto_session_lookback_trades"])
if "auto_session_threshold_pct" in overrides:
new_config.backtest.auto_session_threshold_pct = float(overrides["auto_session_threshold_pct"])
if "risk_per_trade_pct" in overrides:
new_config.backtest.risk_per_trade_pct = float(overrides["risk_per_trade_pct"])
if "disable_indicator" in overrides:
v = overrides["disable_indicator"]
new_config.technical.disabled_indicators = [str(v)] if v else []
return new_config
def _run_single_combo(args: Tuple[PlatformConfig, Dict[str, float]]) -> BacktestResult:
"""Worker function — must be top-level for multiprocessing.Pool."""
base_config, overrides = args
config = _apply_overrides(base_config, overrides)
backtester = WalkForwardBacktester(config)
# Workers reuse the disk parquet cache, so no Twelve Data fetch needed
result = backtester.run(progress=False)
result.params = overrides
return result
class Optimizer:
"""Embarrassingly parallel grid search over BacktestConfig.param_grid."""
def __init__(self, config: PlatformConfig):
self.config = config
def run_grid(
self,
param_grid: Optional[Dict[str, List[float]]] = None,
prewarm_cache: bool = True,
) -> List[BacktestResult]:
if param_grid is None:
param_grid = self.config.backtest.param_grid
# Prewarm the disk cache once before fanning out — avoids each worker
# re-fetching from Twelve Data.
if prewarm_cache:
logger.info("Prewarming disk cache for backtest data...")
warmer = WalkForwardBacktester(self.config)
warmer.load_data()
keys = list(param_grid.keys())
value_lists = [param_grid[k] for k in keys]
combos = [
dict(zip(keys, vals))
for vals in itertools.product(*value_lists)
]
n_workers = min(
self.config.backtest.parallel_workers,
len(combos),
os.cpu_count() or 1,
)
logger.info(
f"Optimizer: {len(combos)} combos × {n_workers} workers"
)
tasks = [(self.config, c) for c in combos]
if n_workers <= 1:
results = [_run_single_combo(t) for t in tasks]
else:
with mp.Pool(processes=n_workers) as pool:
results = pool.map(_run_single_combo, tasks)
# Sort by total return descending
results.sort(
key=lambda r: r.metrics.get("total_return_pct", -float("inf")),
reverse=True,
)
return results
-1323
View File
File diff suppressed because it is too large Load Diff
+9 -83
View File
@@ -1,15 +1,11 @@
""" """
ForexBot2 — Configuration (USD/JPY focus) ForexBot2 — Configuration (USD/JPY focus)
========================================== ==========================================
Single-pair signal & backtest configuration.
All knobs that govern indicator behavior, signal scoring, news handling,
and the walk-forward backtester live here.
""" """
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Dict, List, Optional from typing import Dict, List
class Timeframe(Enum): class Timeframe(Enum):
@@ -85,23 +81,7 @@ class TechnicalConfig:
pivot_lookback: int = 1 pivot_lookback: int = 1
divergence_lookback: int = 14 divergence_lookback: int = 14
# Ablation control — names listed here are skipped during analyze(). disabled_indicators: List[str] = field(default_factory=list)
# Use lowercase keys: ema_trend, rsi, macd, bollinger, stochastic,
# adx, ichimoku, divergence, candlestick, pivot, atr, volume.
#
# Defaults disable indicators that empirically hurt P&L on USD/JPY across
# 2024-trend / 2023-chop / 2026-recent ablation sweeps (see project memory):
# - pivot: net +9.5% to disable across 3 windows
# - candlestick: net +3.4% to disable
disabled_indicators: List[str] = field(
default_factory=lambda: ["pivot", "candlestick"]
)
ALL_INDICATORS: List[str] = [
"ema_trend", "rsi", "macd", "bollinger", "stochastic",
"adx", "ichimoku", "divergence", "candlestick", "pivot",
]
@dataclass @dataclass
@@ -141,67 +121,15 @@ class NewsConfig:
@dataclass @dataclass
class BacktestConfig: class FilterConfig:
"""Walk-forward backtest configuration.""" """Regime and session filters for live signals."""
# Data window — None means "all available history"
start_date: Optional[str] = None # "YYYY-MM-DD"
end_date: Optional[str] = None # "YYYY-MM-DD"
max_bars: int = 5000 # Twelve Data ceiling per request
# Account & sizing
initial_balance: float = 10_000.0
risk_per_trade_pct: float = 1.0 # % of equity risked per trade
# Execution realism
use_spread_cost: bool = True
slippage_pips: float = 0.5 # added to spread on entry & exit
one_position_only: bool = True
sl_first_on_tie: bool = True # if SL & TP both inside bar, SL hits first
# Signal gates
min_trade_grade: str = "B" # "A+", "A", "B", "C", "D", "F"
min_abs_score: float = 0.0 # |final_score| must exceed this to enter
# Sanity-test knob — flip BUY↔SELL to see if the engine has reversed edge
invert_signal: bool = False
# Regime gate — only trade when a higher-TF trend is confirmed
regime_filter: bool = False regime_filter: bool = False
regime_filter_timeframe: Timeframe = Timeframe.H4 regime_filter_timeframe: Timeframe = Timeframe.H4
regime_filter_min_adx: float = 25.0 regime_filter_min_adx: float = 25.0
# Session gate — only trade during a specified UTC hour window.
# 2026 diagnostic backtest showed 07-15 UTC (London + NY) had
# 47% WR / +127 pips while off-hours were 0% WR / -571 pips.
session_filter: bool = False session_filter: bool = False
session_start_hour: int = 7 # UTC, inclusive session_start_hour: int = 7
session_end_hour: int = 16 # UTC, exclusive — so trades fire on 07..15 session_end_hour: int = 16
# Auto-toggle: enable session filter ONLY when recent realised trades
# show adverse expectancy (mean pnl_pct over the last N closed trades
# below threshold). Releases automatically once expectancy recovers.
# Designed so trending regimes keep all hours, while adverse/chop
# regimes auto-restrict to liquid sessions.
auto_session_filter: bool = False
auto_session_lookback_trades: int = 10
auto_session_threshold_pct: float = -0.3
# Parallelism (used by Optimizer; single-run backtest is sequential)
parallel_workers: int = field(default_factory=lambda: os.cpu_count() or 4)
# Optimizer
optimize: bool = False
param_grid: Dict[str, list] = field(default_factory=lambda: {
# 2 × 2 × 3 × 2 × 2 = 48 combos
"atr_sl_multiplier": [1.5, 2.5],
"default_rr_ratio": [1.5, 2.5],
"min_abs_score": [15.0, 35.0, 60.0],
"min_trade_grade": ["B", "A"],
"invert_signal": [False, True],
})
# Cache directory for fetched OHLCV (parquet)
cache_dir: str = ".cache"
@dataclass @dataclass
@@ -210,7 +138,7 @@ class PlatformConfig:
technical: TechnicalConfig = field(default_factory=TechnicalConfig) technical: TechnicalConfig = field(default_factory=TechnicalConfig)
signals: SignalConfig = field(default_factory=SignalConfig) signals: SignalConfig = field(default_factory=SignalConfig)
news: NewsConfig = field(default_factory=NewsConfig) news: NewsConfig = field(default_factory=NewsConfig)
backtest: BacktestConfig = field(default_factory=BacktestConfig) backtest: FilterConfig = field(default_factory=FilterConfig)
symbol: str = USDJPY symbol: str = USDJPY
display_name: str = DISPLAY display_name: str = DISPLAY
@@ -221,12 +149,10 @@ class PlatformConfig:
default_factory=lambda: [Timeframe.M15, Timeframe.H4] default_factory=lambda: [Timeframe.M15, Timeframe.H4]
) )
data_lookback_days: int = 60 # enough for 200 EMA on H1 data_lookback_days: int = 60
twelve_data_api_key: str = "f563b22ffdfd4924955decbe1a8c8701" twelve_data_api_key: str = "f563b22ffdfd4924955decbe1a8c8701"
refresh_interval: int = 300 # 5 min cache TTL refresh_interval: int = 300
log_level: str = "INFO" log_level: str = "INFO"
log_signals_to_file: bool = True
signal_log_path: str = "signals.log"
-133
View File
@@ -2,9 +2,6 @@
ForexBot2 — Data Provider (Twelve Data SDK, USD/JPY only) ForexBot2 — Data Provider (Twelve Data SDK, USD/JPY only)
========================================================== ==========================================================
Fetches and validates OHLCV via the official `twelvedata` Python SDK. Fetches and validates OHLCV via the official `twelvedata` Python SDK.
No synthetic fallback — if the API fails the caller learns about it.
Adds a parquet on-disk cache so the backtest worker pool reuses one
download instead of hammering Twelve Data during a parameter sweep.
""" """
import logging import logging
@@ -29,14 +26,6 @@ class DataIntegrityError(Exception):
class ForexDataProvider: class ForexDataProvider:
"""Twelve Data SDK client + on-disk parquet cache.""" """Twelve Data SDK client + on-disk parquet cache."""
# Local MetaTrader CSV exports — tab-delimited, 7 columns (includes Spread)
LOCAL_CSV_MAP = {
Timeframe.M15: "USDJPY_M15.csv",
Timeframe.H1: "USDJPY_H1.csv",
Timeframe.H4: "USDJPY_H4.csv",
Timeframe.D1: "USDJPY_D1.csv",
}
TD_INTERVALS = { TD_INTERVALS = {
Timeframe.M1: "1min", Timeframe.M1: "1min",
Timeframe.M5: "5min", Timeframe.M5: "5min",
@@ -48,18 +37,6 @@ class ForexDataProvider:
Timeframe.W1: "1week", Timeframe.W1: "1week",
} }
BARS_PER_DAY = {
Timeframe.M1: 1440,
Timeframe.M5: 288,
Timeframe.M15: 96,
Timeframe.M30: 48,
Timeframe.H1: 24,
Timeframe.H4: 6,
Timeframe.D1: 1,
Timeframe.W1: 0.143,
}
# Free plan: 8 credits / minute
RATE_LIMIT = 8 RATE_LIMIT = 8
RATE_WINDOW = 60.0 RATE_WINDOW = 60.0
@@ -116,100 +93,6 @@ class ForexDataProvider:
self._cache_timestamps[cache_key] = now self._cache_timestamps[cache_key] = now
return df return df
def fetch_full_history(
self,
symbol: str = USDJPY,
timeframe: Timeframe = Timeframe.H1,
max_bars: int = 5000,
use_disk_cache: bool = True,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> pd.DataFrame:
"""
Pull the largest window Twelve Data allows in one call.
On-disk parquet cache prevents re-download across worker subprocesses
during an optimizer sweep.
"""
cache_path = self._disk_cache_path(symbol, timeframe, start_date, end_date)
if use_disk_cache and os.path.exists(cache_path):
try:
df = pd.read_parquet(cache_path)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
logger.info(
f"Loaded {len(df)} bars from disk cache: {cache_path}"
)
return df
except Exception as e:
logger.warning(f"Disk cache read failed ({e}); refetching.")
outputsize = min(max_bars, 5000)
df = self._fetch_twelve_data(
symbol, timeframe, outputsize,
start_date=start_date, end_date=end_date,
)
df = self._validate_and_clean(df, symbol, timeframe)
if use_disk_cache:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
try:
df.to_parquet(cache_path)
logger.info(f"Cached {len(df)} bars → {cache_path}")
except Exception as e:
logger.warning(f"Disk cache write failed: {e}")
return df
def load_local_csv(
self,
timeframe: Timeframe,
data_dir: str = "market_data",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> Optional[pd.DataFrame]:
"""
Load OHLCV from a local MetaTrader CSV export.
Format: tab-separated, columns Time/Open/High/Low/Close/Volume/Spread.
Returns None silently if the file doesn't exist for this timeframe.
"""
fname = self.LOCAL_CSV_MAP.get(timeframe)
if fname is None:
logger.info(f"No local CSV mapping for {timeframe.value} — skipping")
return None
path = os.path.join(data_dir, fname)
if not os.path.exists(path):
logger.warning(f"Local CSV not found: {path}")
return None
df = pd.read_csv(
path, sep="\t",
names=["Time", "Open", "High", "Low", "Close", "Volume", "Spread"],
header=0,
parse_dates=["Time"],
)
df = df.set_index("Time")
df = df.drop(columns=["Spread"], errors="ignore")
for col in ("Open", "High", "Low", "Close", "Volume"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
if "Volume" not in df.columns:
df["Volume"] = 0.0
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
df.index.name = "Datetime"
if start_date:
df = df[df.index >= pd.Timestamp(start_date, tz="UTC")]
if end_date:
df = df[df.index <= pd.Timestamp(end_date, tz="UTC")]
df = self._validate_and_clean(df, USDJPY, timeframe)
logger.info(f"Loaded {len(df):,} bars from local CSV: {path}")
return df
def get_latest_price(self, symbol: str = USDJPY) -> Optional[float]: def get_latest_price(self, symbol: str = USDJPY) -> Optional[float]:
try: try:
df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1) df = self.fetch_ohlcv(symbol, Timeframe.M5, lookback_days=1)
@@ -240,22 +123,6 @@ class ForexDataProvider:
# ── Private ──────────────────────────────────────────────────── # ── Private ────────────────────────────────────────────────────
def _disk_cache_path(
self,
symbol: str,
timeframe: Timeframe,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> str:
clean = symbol.replace("=X", "").lower()
suffix = ""
if start_date or end_date:
suffix = f"_{start_date or 'min'}_{end_date or 'max'}"
return os.path.join(
self.config.backtest.cache_dir,
f"{clean}_{timeframe.value}{suffix}.parquet",
)
def _throttle(self, credits: int = 1) -> None: def _throttle(self, credits: int = 1) -> None:
"""Free tier: 8 credits / minute. Block until budget allows.""" """Free tier: 8 credits / minute. Block until budget allows."""
while True: while True:
+12 -14
View File
@@ -171,7 +171,7 @@ class TechnicalEngine:
result.indicators["ema_trend"] = IndicatorSignal( result.indicators["ema_trend"] = IndicatorSignal(
name="EMA Trend", value=normalized, name="EMA Trend", value=normalized,
signal=signal, confidence=conf, signal=signal, confidence=conf,
description=desc, weight=1.8, description=desc, weight=1.0,
) )
if t is not None: if t is not None:
result.key_levels["ema_trend"] = float(t) result.key_levels["ema_trend"] = float(t)
@@ -222,7 +222,7 @@ class TechnicalEngine:
result.indicators["rsi"] = IndicatorSignal( result.indicators["rsi"] = IndicatorSignal(
name="RSI", value=rsi_val, signal=signal, name="RSI", value=rsi_val, signal=signal,
confidence=conf, description=desc, weight=0.0, confidence=conf, description=desc, weight=1.8,
) )
# Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix) # Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix)
@@ -291,7 +291,7 @@ class TechnicalEngine:
result.indicators["macd"] = IndicatorSignal( result.indicators["macd"] = IndicatorSignal(
name="MACD", value=hist_val, signal=signal, name="MACD", value=hist_val, signal=signal,
confidence=conf, description=desc, weight=3.3, confidence=conf, description=desc, weight=1.2,
) )
# Attach histogram for divergence detection # Attach histogram for divergence detection
@@ -358,7 +358,7 @@ class TechnicalEngine:
result.indicators["bollinger"] = IndicatorSignal( result.indicators["bollinger"] = IndicatorSignal(
name="Bollinger Bands", value=bb_position, signal=signal, name="Bollinger Bands", value=bb_position, signal=signal,
confidence=conf, description=desc, weight=0.0, confidence=conf, description=desc, weight=1.5,
) )
result.key_levels["bb_upper"] = upper_val result.key_levels["bb_upper"] = upper_val
@@ -423,7 +423,7 @@ class TechnicalEngine:
result.indicators["stochastic"] = IndicatorSignal( result.indicators["stochastic"] = IndicatorSignal(
name="Stochastic", value=k_val, signal=signal, name="Stochastic", value=k_val, signal=signal,
confidence=conf, description=desc, weight=2.4, confidence=conf, description=desc, weight=2.5,
) )
def _compute_adx(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_adx(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -609,7 +609,7 @@ class TechnicalEngine:
name="Ichimoku", value=float(score), signal=signal, name="Ichimoku", value=float(score), signal=signal,
confidence=conf, confidence=conf,
description=f"Ichimoku [{score}/4]: {' | '.join(signals)}", description=f"Ichimoku [{score}/4]: {' | '.join(signals)}",
weight=0.55, weight=0.3,
) )
result.key_levels["ichimoku_cloud_top"] = cloud_top result.key_levels["ichimoku_cloud_top"] = cloud_top
@@ -672,7 +672,7 @@ class TechnicalEngine:
name="Divergence", value=len(divergences), name="Divergence", value=len(divergences),
signal=signal, confidence=70, signal=signal, confidence=70,
description=" | ".join(divergences), description=" | ".join(divergences),
weight=1.5, # divergences are strong reversal signals weight=3.5,
) )
# Note: divergences are NOT added to pattern_signals to avoid # Note: divergences are NOT added to pattern_signals to avoid
# duplicate reason collection (the indicator entry covers them). # duplicate reason collection (the indicator entry covers them).
@@ -742,7 +742,7 @@ class TechnicalEngine:
name="Candlestick", value=float(bullish - bearish), name="Candlestick", value=float(bullish - bearish),
signal=signal, confidence=50, signal=signal, confidence=50,
description=" | ".join(patterns), description=" | ".join(patterns),
weight=0.7, weight=1.2,
) )
result.pattern_signals.extend(patterns) result.pattern_signals.extend(patterns)
@@ -784,7 +784,7 @@ class TechnicalEngine:
result.indicators["pivot"] = IndicatorSignal( result.indicators["pivot"] = IndicatorSignal(
name="Pivot Points", value=pivot, name="Pivot Points", value=pivot,
signal=SignalStrength.BUY if latest > pivot else SignalStrength.SELL, signal=SignalStrength.BUY if latest > pivot else SignalStrength.SELL,
confidence=45, description=desc, weight=0.6, confidence=45, description=desc, weight=0.8,
) )
def _compute_volume_analysis(self, df: pd.DataFrame, result: TechnicalAnalysis): def _compute_volume_analysis(self, df: pd.DataFrame, result: TechnicalAnalysis):
@@ -854,12 +854,10 @@ class TechnicalEngine:
total_weight = 0.0 total_weight = 0.0
total_confidence = 0.0 total_confidence = 0.0
# Regime-gate ichimoku: trend hero (-23% to disable in 2024 trend) but
# chop villain (+18% to disable in 2023 chop & 2026 recent).
regime_weights = { regime_weights = {
"trending_up": {"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "ichimoku": 1.3, "rsi": 0.7, "bollinger": 0.6}, "trending_up": {"divergence": 1.5, "rsi": 1.2, "stochastic": 1.1, "candlestick": 1.2, "ema_trend": 1.0, "macd": 1.0, "adx": 1.0, "ichimoku": 0.8, "bollinger": 0.7, "pivot": 0.8},
"trending_down": {"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "ichimoku": 1.3, "rsi": 0.7, "bollinger": 0.6}, "trending_down": {"divergence": 1.5, "rsi": 1.2, "stochastic": 1.1, "candlestick": 1.2, "ema_trend": 1.0, "macd": 1.0, "adx": 1.0, "ichimoku": 0.8, "bollinger": 0.7, "pivot": 0.8},
"ranging": {"rsi": 1.4, "stochastic": 1.3, "bollinger": 1.4, "ema_trend": 0.6, "adx": 0.5, "ichimoku": 0.2}, "ranging": {"divergence": 1.3, "rsi": 1.4, "stochastic": 1.4, "bollinger": 1.5, "candlestick": 1.3, "pivot": 1.3, "ema_trend": 0.5, "macd": 0.6, "adx": 0.4, "ichimoku": 0.2},
} }
active_regime_weights = regime_weights.get(result.market_regime, {}) active_regime_weights = regime_weights.get(result.market_regime, {})
+3 -260
View File
@@ -2,14 +2,10 @@
""" """
ForexBot2 — USD/JPY Signal Intelligence (single-pair) ForexBot2 — USD/JPY Signal Intelligence (single-pair)
======================================================= =======================================================
Entry point for both live signal analysis and walk-forward backtesting.
Usage: Usage:
python main.py # Live signal on H1 python main.py # Live signal on H1
python main.py --timeframe H4 --detail # Detailed H4 signal python main.py --timeframe H4 --detail # Detailed H4 signal
python main.py --json # JSON output python main.py --json # JSON output
python main.py --backtest # Walk-forward backtest
python main.py --backtest --optimize # Parallel grid search
""" """
import argparse import argparse
@@ -233,54 +229,6 @@ def report_to_dict(report: SignalReport) -> dict:
return d return d
# ── Backtest renderer ──────────────────────────────────────────────
def print_backtest_report(result, params: dict = None, verbose_journal: bool = False):
m = result.metrics
print(f" {Colors.BOLD}{Colors.WHITE}Backtest Summary — USD/JPY{Colors.RESET}")
if params:
params_str = ", ".join(f"{k}={v}" for k, v in params.items())
print(f" {Colors.GRAY}Params: {params_str}{Colors.RESET}")
print(f" {Colors.GRAY}{'─' * 76}{Colors.RESET}")
initial = result.initial_balance
final = result.final_balance
ret_pct = m.get("total_return_pct", 0)
ret_color = Colors.GREEN if ret_pct >= 0 else Colors.RED
print(f" Trades: {Colors.BOLD}{m.get('total_trades', 0)}{Colors.RESET}"
f" ({m.get('wins', 0)}W / {m.get('losses', 0)}L)")
print(f" Win Rate: {m.get('win_rate', 0):.1f}%")
print(f" Profit Factor: {m.get('profit_factor', 0):.2f}")
pf_inf = m.get('profit_factor', 0) == float('inf')
if pf_inf:
print(f" (no losing trades — infinite PF)")
print(f" Expectancy: {m.get('expectancy_pips', 0):+.2f} pips/trade"
f" ({m.get('expectancy_per_trade', 0):+.2f} per trade)")
print(f" Initial Balance: ${initial:,.2f}")
print(f" Final Balance: {ret_color}${final:,.2f}{Colors.RESET}")
print(f" Total Return: {ret_color}{ret_pct:+.2f}%{Colors.RESET}")
print(f" CAGR: {m.get('cagr', 0):+.2f}%")
print(f" Max Drawdown: {Colors.RED}{m.get('max_drawdown_pct', 0):.2f}%{Colors.RESET}")
print(f" Sharpe Ratio: {m.get('sharpe', 0):.2f}")
print(f" Sortino Ratio: {m.get('sortino', 0):.2f}")
print(f" Avg Duration: {m.get('avg_trade_duration_bars', 0):.1f} bars")
print(f" Win Streak: {m.get('longest_winning_streak', 0)} | "
f"Loss Streak: {m.get('longest_losing_streak', 0)}")
print(f" {Colors.GRAY}{'─' * 76}{Colors.RESET}")
if verbose_journal and result.trades:
print(f"\n {Colors.BOLD}Trade Journal (last 10):{Colors.RESET}")
print(f" {'#':<4} {'Entry Time':<22} {'Dir':<5} {'Entry':<9} "
f"{'Exit':<9} {'Reason':<6} {'Pips':>8} {'P&L%':>7}")
for i, t in enumerate(result.trades[-10:], start=max(1, len(result.trades) - 9)):
color = Colors.GREEN if t.pnl_currency > 0 else Colors.RED
entry_str = str(t.entry_time)[:19]
print(f" {i:<4} {entry_str:<22} {t.direction:<5} {t.entry_price:<9.3f} "
f"{t.exit_price:<9.3f} {t.exit_reason:<6} "
f"{color}{t.pnl_pips:>+8.1f}{Colors.RESET} "
f"{color}{t.pnl_pct:>+6.2f}%{Colors.RESET}")
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════
# CLI # CLI
@@ -288,7 +236,7 @@ def print_backtest_report(result, params: dict = None, verbose_journal: bool = F
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="ForexBot2 — USD/JPY Signal Intelligence + Backtester", description="ForexBot2 — USD/JPY Signal Intelligence",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Examples:
@@ -296,58 +244,18 @@ Examples:
python main.py --detail Live signal with full breakdown python main.py --detail Live signal with full breakdown
python main.py --timeframe H4 --detail H4 timeframe python main.py --timeframe H4 --detail H4 timeframe
python main.py --json JSON output python main.py --json JSON output
python main.py --backtest Walk-forward backtest (full history)
python main.py --backtest --bars 2000 Backtest on last 2000 bars
python main.py --backtest --optimize Parallel grid search across CPU cores
""", """,
) )
parser.add_argument("--timeframe", "-tf", default="H1", parser.add_argument("--timeframe", "-tf", default="H1",
choices=["M5", "M15", "M30", "H1", "H4", "D1"], choices=["M5", "M15", "M30", "H1", "H4", "D1"],
help="Primary analysis timeframe (default: H1)") help="Primary analysis timeframe (default: H1)")
parser.add_argument("--detail", action="store_true", parser.add_argument("--detail", action="store_true",
help="Show full indicator breakdown (live mode)") help="Show full indicator breakdown")
parser.add_argument("--json", action="store_true", parser.add_argument("--json", action="store_true",
help="Output as JSON") help="Output as JSON")
parser.add_argument("--verbose", "-v", action="store_true", parser.add_argument("--verbose", "-v", action="store_true",
help="Debug logging") help="Debug logging")
# Backtest options
parser.add_argument("--backtest", action="store_true",
help="Run walk-forward backtest instead of live signal")
parser.add_argument("--optimize", action="store_true",
help="Run parallel parameter grid search (implies --backtest)")
parser.add_argument("--bars", type=int, default=5000,
help="Max bars of history (default 5000 — TD ceiling)")
parser.add_argument("--start", default=None,
help="Backtest window start (YYYY-MM-DD)")
parser.add_argument("--end", default=None,
help="Backtest window end (YYYY-MM-DD)")
parser.add_argument("--min-grade", default="B",
choices=["A+", "A", "B", "C", "D", "F"],
help="Minimum grade to open backtest trade (default B)")
parser.add_argument("--journal", action="store_true",
help="Print last 10 trades from backtest journal")
parser.add_argument("--journal-csv", default=None,
help="Write full trade journal to CSV path")
parser.add_argument("--regime-filter", action="store_true",
help="Gate signals on higher-TF ADX trend confirmation")
parser.add_argument("--regime-min-adx", type=float, default=30.0,
help="Min ADX for regime filter (default 30, requires --regime-filter)")
parser.add_argument("--session-filter", action="store_true",
help="Only trade during a UTC hour window (default 07-16, London + NY)")
parser.add_argument("--session-start", type=int, default=7,
help="Session start hour UTC, inclusive (default 7)")
parser.add_argument("--session-end", type=int, default=16,
help="Session end hour UTC, exclusive (default 16)")
parser.add_argument("--auto-session-filter", action="store_true",
help="Auto-toggle session filter when recent trades show adverse mean pnl%%")
parser.add_argument("--auto-session-lookback", type=int, default=10,
help="Lookback in closed trades for auto-session detection (default 10)")
parser.add_argument("--auto-session-threshold-pct", type=float, default=-0.3,
help="Mean pnl%% threshold below which auto-session activates (default -0.3)")
parser.add_argument("--ablate", action="store_true",
help="Run leave-one-out indicator ablation sweep (parallel)")
return parser.parse_args() return parser.parse_args()
@@ -372,21 +280,6 @@ def build_config(args) -> PlatformConfig:
args.timeframe, [Timeframe.M15, Timeframe.H4] args.timeframe, [Timeframe.M15, Timeframe.H4]
) )
# Backtest config
config.backtest.start_date = args.start
config.backtest.end_date = args.end
config.backtest.max_bars = args.bars
config.backtest.min_trade_grade = args.min_grade
config.backtest.optimize = args.optimize
config.backtest.regime_filter = args.regime_filter
config.backtest.regime_filter_min_adx = args.regime_min_adx
config.backtest.session_filter = args.session_filter
config.backtest.session_start_hour = args.session_start
config.backtest.session_end_hour = args.session_end
config.backtest.auto_session_filter = args.auto_session_filter
config.backtest.auto_session_lookback_trades = args.auto_session_lookback
config.backtest.auto_session_threshold_pct = args.auto_session_threshold_pct
return config return config
@@ -406,148 +299,6 @@ def run_live(args, config: PlatformConfig):
print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n") print(f"{Colors.CYAN}{'═' * 80}{Colors.RESET}\n")
def run_backtest(args, config: PlatformConfig):
from backtest import WalkForwardBacktester
from metrics import trades_to_dataframe
print_header("USD/JPY WALK-FORWARD BACKTEST")
print(f"{Colors.GRAY} Loading data and replaying bars...{Colors.RESET}\n")
backtester = WalkForwardBacktester(config)
result = backtester.run()
if args.json:
out = {
"metrics": result.metrics,
"initial_balance": result.initial_balance,
"final_balance": result.final_balance,
"trades": [
{
"entry_time": str(t.entry_time),
"exit_time": str(t.exit_time),
"direction": t.direction,
"entry_price": t.entry_price,
"exit_price": t.exit_price,
"exit_reason": t.exit_reason,
"pnl_pips": t.pnl_pips,
"pnl_pct": t.pnl_pct,
"grade": t.grade,
}
for t in result.trades
],
}
print(json.dumps(out, indent=2))
else:
print_backtest_report(result, verbose_journal=args.journal)
if args.journal_csv and result.trades:
df = trades_to_dataframe(result.trades)
df.to_csv(args.journal_csv, index=False)
print(f"\n {Colors.GREEN}✓ Trade journal written to {args.journal_csv}{Colors.RESET}")
def run_optimize(args, config: PlatformConfig):
from backtest import Optimizer
print_header("USD/JPY BACKTEST PARAMETER OPTIMIZER")
grid = config.backtest.param_grid
n_combos = 1
for v in grid.values():
n_combos *= len(v)
print(f"{Colors.GRAY} Grid: {n_combos} combos × {config.backtest.parallel_workers} workers{Colors.RESET}\n")
optimizer = Optimizer(config)
results = optimizer.run_grid(grid)
print(f"\n {Colors.BOLD}Top 10 Parameter Combinations (by total return):{Colors.RESET}\n")
print(f" {'#':<3} {'Return%':>8} {'PF':>6} {'WinR':>6} {'Trades':>7} "
f"{'MaxDD%':>7} {'Sharpe':>7} Params")
print(f" {Colors.GRAY}{'─' * 90}{Colors.RESET}")
for i, r in enumerate(results[:10], start=1):
m = r.metrics
ret = m.get("total_return_pct", 0)
ret_color = Colors.GREEN if ret >= 0 else Colors.RED
params_str = ", ".join(f"{k}={v}" for k, v in r.params.items())
pf = m.get("profit_factor", 0)
pf_str = "inf" if pf == float("inf") else f"{pf:.2f}"
print(f" {i:<3} {ret_color}{ret:>+7.2f}%{Colors.RESET} {pf_str:>6} "
f"{m.get('win_rate', 0):>5.1f}% {m.get('total_trades', 0):>7} "
f"{m.get('max_drawdown_pct', 0):>+6.2f}% {m.get('sharpe', 0):>7.2f}"
f" {Colors.GRAY}{params_str}{Colors.RESET}")
print(f"\n {Colors.BOLD}Best combination details:{Colors.RESET}\n")
print_backtest_report(results[0], params=results[0].params, verbose_journal=False)
def run_ablation(args, config: PlatformConfig):
"""Leave-one-out ablation: baseline + each indicator disabled, in parallel."""
from backtest import Optimizer
from config import ALL_INDICATORS
print_header("USD/JPY INDICATOR ABLATION SWEEP")
grid = {"disable_indicator": [None] + list(ALL_INDICATORS)}
n_combos = len(grid["disable_indicator"])
print(f"{Colors.GRAY} Grid: {n_combos} runs (1 baseline + {n_combos-1} ablations) "
f"× {config.backtest.parallel_workers} workers{Colors.RESET}\n")
optimizer = Optimizer(config)
results = optimizer.run_grid(grid)
# Find baseline (disable_indicator=None) for delta computation
baseline = next(
(r for r in results if not r.params.get("disable_indicator")),
None,
)
base_ret = baseline.metrics.get("total_return_pct", 0.0) if baseline else 0.0
base_pf = baseline.metrics.get("profit_factor", 0.0) if baseline else 0.0
base_sharpe = baseline.metrics.get("sharpe", 0.0) if baseline else 0.0
# Sort: baseline first, then ablations by impact (largest drop = most useful indicator)
ablations = [r for r in results if r is not baseline]
ablations.sort(
key=lambda r: r.metrics.get("total_return_pct", 0.0) - base_ret
)
print(f" {Colors.BOLD}Indicator Ablation Report{Colors.RESET}")
print(f" {Colors.GRAY}{'─' * 88}{Colors.RESET}")
print(f" {'Disabled':<14} {'Return%':>9} {'ΔRet%':>8} {'PF':>6} {'ΔPF':>7} "
f"{'Sharpe':>7} {'ΔSh':>7} {'Trades':>7}")
print(f" {Colors.GRAY}{'─' * 88}{Colors.RESET}")
def fmt_row(label, m, deltas=True):
ret = m.get("total_return_pct", 0.0)
pf = m.get("profit_factor", 0.0)
sh = m.get("sharpe", 0.0)
tr = m.get("total_trades", 0)
ret_color = Colors.GREEN if ret >= 0 else Colors.RED
pf_str = "inf" if pf == float("inf") else f"{pf:.2f}"
if deltas:
d_ret = ret - base_ret
d_pf = pf - base_pf if pf != float("inf") else 0.0
d_sh = sh - base_sharpe
d_ret_color = Colors.RED if d_ret < 0 else Colors.GREEN
print(f" {label:<14} {ret_color}{ret:>+8.2f}%{Colors.RESET} "
f"{d_ret_color}{d_ret:>+7.2f}%{Colors.RESET} "
f"{pf_str:>6} {d_pf:>+7.2f} "
f"{sh:>7.2f} {d_sh:>+7.2f} {tr:>7}")
else:
print(f" {Colors.BOLD}{label:<14}{Colors.RESET} "
f"{ret_color}{ret:>+8.2f}%{Colors.RESET} {'':>8} "
f"{pf_str:>6} {'':>7} {sh:>7.2f} {'':>7} {tr:>7}")
if baseline:
fmt_row("baseline", baseline.metrics, deltas=False)
print(f" {Colors.GRAY}{'─' * 88}{Colors.RESET}")
for r in ablations:
fmt_row(str(r.params.get("disable_indicator")), r.metrics)
print(f"\n {Colors.GRAY}Indicators ranked by importance — most negative ΔRet means "
f"removing it hurt most.{Colors.RESET}")
print(f" {Colors.GRAY}Positive ΔRet means the indicator is *hurting* the system.{Colors.RESET}\n")
def main(): def main():
args = parse_args() args = parse_args()
@@ -559,15 +310,7 @@ def main():
) )
config = build_config(args) config = build_config(args)
run_live(args, config)
if args.ablate:
run_ablation(args, config)
elif args.optimize:
run_optimize(args, config)
elif args.backtest:
run_backtest(args, config)
else:
run_live(args, config)
if __name__ == "__main__": if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-169
View File
@@ -1,169 +0,0 @@
"""
ForexBot2 — Backtest Metrics
==============================
Pure functions over Trade records & equity curves.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
@dataclass
class Trade:
"""A single closed trade."""
entry_time: pd.Timestamp
exit_time: pd.Timestamp
direction: str # "BUY" / "SELL"
entry_price: float
exit_price: float
stop_loss: float
take_profit: float
exit_reason: str # "tp" / "sl" / "end_of_data"
risk_pips: float
pnl_pips: float
pnl_pct: float # P&L as % of equity at trade open
pnl_currency: float # P&L in account currency (units = balance)
duration_bars: int
grade: str # signal grade at entry
score_at_entry: float
confidence_at_entry: float
# Diagnostic fields captured at entry — used to mine for loss-pattern
# filters when a window underperforms.
entry_hour: int = -1 # UTC hour 0-23
entry_dow: int = -1 # 0=Mon, 6=Sun
adx_at_entry: float = 0.0
atr_pct_at_entry: float = 0.0
market_regime: str = "" # trending_up / trending_down / ranging
volatility_regime: str = "" # low / medium / high
mtf_agreement: bool = False
m15_score: float = 0.0
h1_score: float = 0.0
h4_score: float = 0.0
rsi_at_entry: float = 50.0
ema_distance_pct: float = 0.0 # (price - ema_trend) / ema_trend * 100
@dataclass
class BacktestResult:
"""Complete backtest output."""
trades: List[Trade] = field(default_factory=list)
equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
initial_balance: float = 0.0
final_balance: float = 0.0
metrics: Dict[str, float] = field(default_factory=dict)
params: Dict[str, float] = field(default_factory=dict)
def compute_metrics(
trades: List[Trade],
equity_curve: pd.Series,
initial_balance: float,
elapsed_days: Optional[float] = None,
) -> Dict[str, float]:
"""All summary metrics from a list of closed trades + equity curve."""
if not trades:
return {
"total_trades": 0,
"win_rate": 0.0,
"profit_factor": 0.0,
"expectancy_per_trade": 0.0,
"expectancy_pips": 0.0,
"total_return_pct": 0.0,
"cagr": 0.0,
"max_drawdown_pct": 0.0,
"sharpe": 0.0,
"sortino": 0.0,
"avg_trade_duration_bars": 0.0,
"longest_winning_streak": 0,
"longest_losing_streak": 0,
"final_balance": initial_balance,
}
pnl_currency = np.array([t.pnl_currency for t in trades])
pnl_pips = np.array([t.pnl_pips for t in trades])
wins = pnl_currency > 0
losses = pnl_currency < 0
total_wins = pnl_currency[wins].sum() if wins.any() else 0.0
total_losses = -pnl_currency[losses].sum() if losses.any() else 0.0
win_rate = wins.sum() / len(trades) * 100
profit_factor = (total_wins / total_losses) if total_losses > 0 else float("inf")
expectancy_per_trade = pnl_currency.mean()
expectancy_pips = pnl_pips.mean()
final_balance = float(equity_curve.iloc[-1]) if len(equity_curve) else initial_balance
total_return_pct = (final_balance / initial_balance - 1) * 100
# CAGR
if elapsed_days and elapsed_days > 0 and final_balance > 0:
years = elapsed_days / 365.25
cagr = ((final_balance / initial_balance) ** (1 / years) - 1) * 100 if years > 0 else 0.0
else:
cagr = 0.0
# Max drawdown
if len(equity_curve):
running_max = equity_curve.cummax()
drawdown = (equity_curve - running_max) / running_max
max_dd = float(drawdown.min()) * 100
else:
max_dd = 0.0
# Sharpe / Sortino — daily returns from equity curve
sharpe, sortino = 0.0, 0.0
if len(equity_curve) > 2:
daily = equity_curve.resample("1D").last().ffill().pct_change().dropna()
if len(daily) > 1 and daily.std() > 0:
sharpe = float(daily.mean() / daily.std() * np.sqrt(252))
downside = daily[daily < 0]
if len(downside) > 1 and downside.std() > 0:
sortino = float(daily.mean() / downside.std() * np.sqrt(252))
# Streaks
win_loss_seq = [1 if t.pnl_currency > 0 else (-1 if t.pnl_currency < 0 else 0) for t in trades]
longest_win = _longest_streak(win_loss_seq, 1)
longest_loss = _longest_streak(win_loss_seq, -1)
avg_duration = float(np.mean([t.duration_bars for t in trades]))
return {
"total_trades": len(trades),
"wins": int(wins.sum()),
"losses": int(losses.sum()),
"win_rate": float(win_rate),
"profit_factor": float(profit_factor),
"expectancy_per_trade": float(expectancy_per_trade),
"expectancy_pips": float(expectancy_pips),
"total_return_pct": float(total_return_pct),
"cagr": float(cagr),
"max_drawdown_pct": float(max_dd),
"sharpe": float(sharpe),
"sortino": float(sortino),
"avg_trade_duration_bars": avg_duration,
"longest_winning_streak": longest_win,
"longest_losing_streak": longest_loss,
"final_balance": final_balance,
}
def _longest_streak(seq: List[int], target: int) -> int:
best = current = 0
for x in seq:
if x == target:
current += 1
best = max(best, current)
else:
current = 0
return best
def trades_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
"""Convert trade journal to a DataFrame for CSV/JSON export."""
if not trades:
return pd.DataFrame()
return pd.DataFrame([asdict(t) for t in trades])
+1 -2
View File
@@ -1,7 +1,6 @@
numpy>=1.24 numpy>=1.24
pandas>=2.0 pandas>=2.0
requests>=2.28 requests>=2.28
pyarrow>=14.0
tqdm>=4.66
twelvedata>=1.2.0 twelvedata>=1.2.0
python-telegram-bot>=20.0 python-telegram-bot>=20.0
websockets>=12.0
+2 -7
View File
@@ -2,9 +2,7 @@
ForexBot2 — Signal Aggregator ForexBot2 — Signal Aggregator
============================== ==============================
Combines multi-timeframe technical analysis with optional news context Combines multi-timeframe technical analysis with optional news context
into a graded actionable signal. Same code path is used for live mode into a graded actionable signal.
and walk-forward backtesting; the only difference is whether the input
OHLCV is fetched live or sliced from a pre-loaded historical buffer.
""" """
import logging import logging
@@ -79,10 +77,7 @@ class SignalReport:
class SignalAggregator: class SignalAggregator:
""" """Master aggregator for live signal analysis."""
Master aggregator. Designed so the backtester can drive it bar-by-bar
via `analyze_from_dataframes` without re-fetching from the API.
"""
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
self.config = config self.config = config
+55
View File
@@ -0,0 +1,55 @@
"""Live tick stream from Twelve Data WebSocket API."""
import json
import asyncio
import websockets
from datetime import datetime, timezone
API_KEY = "f563b22ffdfd4924955decbe1a8c8701"
WS_URL = f"wss://ws.twelvedata.com/v1/quotes/price?apikey={API_KEY}"
SYMBOLS = ["EUR/USD", "GBP/USD", "USD/JPY"]
async def stream_ticks():
async with websockets.connect(WS_URL) as ws:
subscribe = {
"action": "subscribe",
"params": {
"symbols": ",".join(SYMBOLS),
},
}
await ws.send(json.dumps(subscribe))
print(f"Subscribed to: {', '.join(SYMBOLS)}")
print("-" * 60)
async for raw in ws:
msg = json.loads(raw)
if msg.get("event") == "subscribe-status":
print(f"Status: {msg.get('status')} — {msg.get('message', '')}")
continue
if msg.get("event") == "heartbeat":
continue
if msg.get("event") == "price":
ts = datetime.fromtimestamp(msg["timestamp"], tz=timezone.utc)
symbol = msg["symbol"]
price = msg["price"]
print(f"{ts:%H:%M:%S} | {symbol:<8} | {price}")
async def main():
print(f"Connecting to Twelve Data WebSocket...")
print(f"API key: ...{API_KEY[-6:]}")
try:
await stream_ticks()
except KeyboardInterrupt:
print("\nStopped.")
except websockets.exceptions.ConnectionClosed as e:
print(f"\nConnection closed: {e}")
if __name__ == "__main__":
asyncio.run(main())