Add pivot retest + engulfing strategy with dual take-profit

New strategy (pivot_retest_engulfing) that enters long/short trades at
pivot level retests confirmed by SMA 50 alignment and engulfing candle
patterns. Uses ATR-based stop loss with two take-profit levels — at TP1
half the position closes and SL moves to breakeven, at TP2 the rest closes.

- data_engine: add detect_engulfing() for bullish/bearish pattern detection
- backtester: add generate_signals_pivot_retest(), run_backtest_dual_tp(),
  update signal dispatcher and metrics for dual-TP trade format
- order_executor: support signal=-1 (SHORT), attach SL/TP levels
- config: switch to pivot_retest_engulfing with default params
- chart_trades: new mplfinance script to visualize entries on candlesticks
- README: rewrite with full setup guide, project structure, strategy docs
- requirements.txt: make portable (remove conda file:// paths), add mplfinance
- .env.example: add template for secrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brent Neale
2026-02-17 15:18:02 +10:00
parent d843e63e7b
commit b1f3a919bf
11 changed files with 1017 additions and 114 deletions
+10 -1
View File
@@ -8,7 +8,16 @@
"Bash(python:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
"Bash(git push:*)",
"Bash(git -C /c/Users/brent/Documents/Projects/fx-quant log --oneline -10)",
"Bash(where:*)",
"Bash(docker build:*)",
"Bash(docker-compose build:*)",
"Bash(docker-compose up:*)",
"Bash(docker-compose down:*)",
"Bash(docker-compose logs:*)",
"Bash(echo:*)",
"Bash(docker exec:*)"
]
}
}
+175 -54
View File
@@ -1,69 +1,190 @@
# fx-quant
Algorithmic FX trading system with backtesting, paper/live execution via OANDA, and a Flask web dashboard.
## Quick Start (New Machine Setup)
### 1. Clone & install dependencies
```bash
git clone <your-repo-url>
cd fx-quant
python -m pip install -r requirements.txt
```
### 2. Configure environment variables
Copy the template and fill in your credentials:
```bash
cp config/.env.example config/.env
```
Required variables in `config/.env`:
| Variable | Description |
|----------|-------------|
| `SUPABASE_URL` | Your Supabase project URL |
| `SUPABASE_KEY` | Supabase anon/service key |
| `OANDA_API_KEY` | OANDA v20 API token |
| `OANDA_ACCOUNT_ID` | OANDA account ID |
| `OANDA_ENV` | `practice` or `live` (default: practice) |
| `DASHBOARD_PASSWORD` | Password for web dashboard login |
### 3. Verify connectivity
```bash
python src/test_connection.py
```
### 4. Load historical data (if Supabase table is empty)
```bash
python src/historical_loader.py
```
### 5. Run the backtester
```bash
python src/backtester.py
```
Output goes to `logs/`:
- `backtest_trades_<INSTRUMENT>_<GRANULARITY>.csv` -- trade log
- `backtest_summary_<INSTRUMENT>_<GRANULARITY>.json` -- metrics + monthly P&L
### 6. Visualize trades on a chart
```bash
python src/chart_trades.py --start 2025-02-24 --end 2025-02-27
python src/chart_trades.py --granularity M15 --start 2025-04-01 --end 2025-04-15
```
Saves PNG charts to `logs/chart_trades_*.png`.
---
## Project Structure
```
fx-quant/
config/
system.yaml # Main configuration (strategy, features, execution)
.env # Secrets (not committed)
src/
config_loader.py # Loads system.yaml + .env
data_engine.py # Feature engineering (SMA, EMA, RSI, ATR, VWAP, pivots, engulfing)
backtester.py # Backtesting engine (SMA cross + pivot retest strategies)
order_executor.py # Paper/live order execution via OANDA
ai_wrapper.py # ML ensemble (logistic reg, RF, gradient boosting) signal validation
dashboard.py # Flask web dashboard
chart_trades.py # Matplotlib/mplfinance trade visualization
get_candles.py # Fetch candles from OANDA API
historical_loader.py # Bulk historical data loader
supabase_upload.py # Upload candle data to Supabase
param_sweep.py # Strategy parameter optimization
test_connection.py # OANDA + Supabase connectivity check
templates/ # Flask HTML templates (chart, backtest, config, logs)
logs/ # Output: trade CSVs, JSON summaries, chart PNGs
models/ # Saved ML models
sql/ # Database schemas
Dockerfile # Bot container
docker-compose.yml # Bot + dashboard services
requirements.txt # Python dependencies (portable)
requirements.docker.txt# Docker-specific deps
```
## Strategies
### 1. SMA Cross (original)
Long-only strategy. Goes long when short SMA > long SMA, flat otherwise.
```yaml
strategy:
rule: sma_cross
params:
short: 50
long: 100
```
### 2. Pivot Retest + Engulfing (current)
Long/short strategy with dual take-profit and ATR-based stop loss.
**Entry conditions (all must be true):**
- Price retests a pivot level (broke through, then returned within ATR tolerance)
- SMA 50 aligns with trade direction relative to the pivot level
- Engulfing candle pattern confirmed
- Strong close (in top/bottom 30% of candle range)
**Position management:**
- SL: 1.5x ATR from entry
- TP1: next pivot level in trade direction (close 50%, move SL to breakeven)
- TP2: pivot level after TP1 (close remaining 50%)
```yaml
strategy:
rule: pivot_retest_engulfing
params:
sma_period: 50
lookback_bars: 20
retest_tolerance_atr: 0.5
strong_close_pct: 0.30
sl_atr_multiplier: 1.5
```
To switch strategies, edit `config/system.yaml` and change `strategy.rule`.
## Configuration Reference
All settings live in `config/system.yaml`:
| Section | Key settings |
|---------|-------------|
| `brokers[0].instruments` | Currency pairs to trade (e.g. `EUR_USD`) |
| `data.candle_granularities` | Timeframes (`M5`, `M15`, `H1`, etc.) |
| `features.*` | Indicator windows (SMA, EMA, RSI, ATR, VWAP, volatility) |
| `strategy.*` | Active strategy rule + parameters |
| `ai.*` | ML ensemble config, confidence threshold, sanity checks |
| `execution.paper_mode` | `true` for paper trading, `false` for live |
| `execution.interval_seconds` | Bot loop interval |
| `execution.max_positions` | Max concurrent open trades |
## Web Dashboard
A Flask-based web UI for managing the trading bot remotely — edit config, monitor status, view logs, and toggle the kill switch from a browser instead of SSH + manual YAML editing.
Flask-based UI for remote management.
### Setup
```bash
# Docker
docker-compose build && docker-compose up -d
1. **Set your dashboard password** in `config/.env`:
```
DASHBOARD_PASSWORD=your-secure-password-here
```
# Or run directly
python src/dashboard.py
```
2. **Build and start** both services:
```bash
docker-compose build && docker-compose up -d
```
Access via SSH tunnel: `ssh -L 5000:localhost:5000 your-server`, then open http://localhost:5000.
3. **Access via SSH tunnel** (dashboard is not exposed publicly):
```bash
ssh -L 5000:localhost:5000 your-server
```
Then open http://localhost:5000 in your browser.
Pages: Status (`/`), Backtest (`/backtest`), Chart (`/chart`), Config (`/config`), Logs (`/logs`)
4. **Log in** with username `admin` and the password you set in step 1.
## Kill Switch
### Dashboard Pages
Create `STOP_ALL_TRADING` in the project root to halt all trading immediately. The bot checks for this file every loop iteration. In live mode, it also closes all open trades. Toggle via the dashboard or manually:
- **Status** (`/`) — Current mode (paper/live), strategy, instruments, loop interval, kill switch toggle, and last 10 orders
- **Config** (`/config`) — Form-based editor for all `system.yaml` sections: instruments, granularities, strategy params, feature windows, AI settings, execution settings. Saves with backup and signals the bot to reload.
- **Logs** (`/logs`) — Tabbed tables showing order history (`logs/order_log.csv`) and AI decisions (`logs/ai_decisions.csv`), newest first
```bash
touch STOP_ALL_TRADING # activate
rm STOP_ALL_TRADING # deactivate
```
### Config Reload Flow
## Running the Bot
When you save config changes through the dashboard:
```bash
# Single execution
python src/order_executor.py --once
1. Dashboard backs up `system.yaml` to `system.yaml.backup`
2. Dashboard writes the updated config
3. Dashboard creates a `RELOAD_CONFIG` signal file
4. Bot checks for this file at the top of each 60s loop iteration
5. Bot reloads config, deletes the signal file, and continues with new settings
# Continuous loop (default 60s interval)
python src/order_executor.py
### Kill Switch
The dashboard provides activate/deactivate buttons (with confirmation prompts) that create/remove the `STOP_ALL_TRADING` file — the same mechanism the bot already uses.
### Files Added/Changed
| File | What |
|------|------|
| `src/dashboard.py` | Flask app — routes, auth, config editor, status, logs |
| `templates/base.html` | Base layout (Bootstrap 5 via CDN) |
| `templates/index.html` | Status page |
| `templates/config.html` | Config editor form |
| `templates/logs.html` | Order log + AI decisions tables |
| `docker-compose.yml` | Added `dashboard` service on port 5000 |
| `requirements.docker.txt` | Added `flask`, `flask-httpauth` |
| `config/.env` | Added `DASHBOARD_PASSWORD` |
| `src/order_executor.py` | Added `RELOAD_CONFIG` signal check in main loop |
### Verification Checklist
- [ ] `docker-compose build && docker-compose up -d` — both containers start
- [ ] http://localhost:5000 shows login prompt (via SSH tunnel)
- [ ] Status page shows current config and kill switch state
- [ ] Edit a setting (e.g. add `GBP_USD` to instruments), save
- [ ] Bot logs show `CONFIG RELOAD REQUESTED` within 60s
- [ ] Logs page shows order history and AI decisions
- [ ] Kill switch toggle works with confirmation dialog
# Docker
docker-compose up -d
```
+6
View File
@@ -0,0 +1,6 @@
OANDA_API_KEY=your-oanda-api-key-here
OANDA_ACCOUNT_ID=your-account-id-here
OANDA_ENV=practice
DASHBOARD_PASSWORD=changeme
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-supabase-anon-key-here
+14 -3
View File
@@ -40,12 +40,23 @@ ai:
rsi_oversold: 20
volatility_multiplier: 3.0
strategy:
rule: sma_cross
rule: pivot_retest_engulfing
params:
short: 50
long: 100
sma_period: 50
lookback_bars: 20
retest_tolerance_atr: 0.5
strong_close_pct: 0.30
sl_atr_multiplier: 1.5
trade_size_pct_of_equity: 0.025
max_drawdown_pct: 0.05
# Previous strategy (uncomment to switch back):
# strategy:
# rule: sma_cross
# params:
# short: 50
# long: 100
# trade_size_pct_of_equity: 0.025
# max_drawdown_pct: 0.05
execution:
paper_mode: true
canary_size_pct: 0.01
+15 -34
View File
@@ -1,35 +1,16 @@
backports.zstd @ file:///D:/bld/bld/rattler-build_backports.zstd_1767045012/work
Brotli @ file:///D:/bld/brotli-split_1764017006836/work
certifi @ file:///home/conda/feedstock_root/build_artifacts/certifi_1767500808759/work/certifi
charset-normalizer @ file:///home/conda/feedstock_root/build_artifacts/charset-normalizer_1760437218288/work
contourpy==1.3.3
cycler==0.12.1
fonttools==4.61.1
h2 @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_h2_1756364871/work
hpack @ file:///home/conda/feedstock_root/build_artifacts/hpack_1737618293087/work
hyperframe @ file:///home/conda/feedstock_root/build_artifacts/hyperframe_1737618333194/work
idna @ file:///home/conda/feedstock_root/build_artifacts/idna_1760286409563/work
joblib==1.5.3
kiwisolver==1.4.9
lightgbm @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_lightgbm_1768022071/work
matplotlib==3.10.8
narwhals==2.16.0
numpy @ file:///D:/bld/bld/rattler-build_numpy_1770098404/work/dist/numpy-2.4.2-cp311-cp311-win_amd64.whl#sha256=1dc64abcea7678d915a259496c0283ff327afeed3b11d508be6117caf2623ad7
pandas>=2.0
numpy>=1.24
scipy>=1.10
scikit-learn>=1.3
lightgbm>=4.0
matplotlib>=3.7
mplfinance>=0.12.10b0
plotly>=5.0
pyarrow>=14.0
oandapyV20==0.7.2
packaging @ file:///C:/miniconda3/conda-bld/packaging_1761049096285/work
pandas @ file:///D:/bld/bld/rattler-build_pandas_1769076324/work
pillow==12.1.1
plotly==6.5.2
pyarrow==23.0.0
pyparsing==3.3.2
PySocks @ file:///D:/bld/pysocks_1733217287171/work
python-dateutil @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_python-dateutil_1751104122/work
python-dotenv @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_python-dotenv_1761503229/work
requests @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_requests_1766926974/work
scikit-learn==1.8.0
scipy @ file:///C:/bld/scipy-split_1768799500391/work/dist/scipy-1.17.0-cp311-cp311-win_amd64.whl#sha256=ab381c0683a4c5959a4eb8ad52025d587189c73b376c9d03fe6886a42f3fe699
six @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_six_1753199211/work
threadpoolctl==3.6.0
tzdata @ file:///home/conda/feedstock_root/build_artifacts/python-tzdata_1765719872007/work
urllib3 @ file:///home/conda/feedstock_root/build_artifacts/urllib3_1767817748113/work
win_inet_pton @ file:///D:/bld/win_inet_pton_1733130564612/work
requests>=2.28
supabase>=2.0
python-dotenv>=1.0
PyYAML>=6.0
flask>=3.0
flask-httpauth>=4.8
+459 -16
View File
@@ -15,6 +15,7 @@ import numpy as np
from supabase import create_client
from config_loader import load_config, get_project_root
from data_engine import detect_engulfing, add_pivot_points
# ---------------------------------------------------------------------------
@@ -83,7 +84,9 @@ def generate_signals(df, strategy_cfg, ai_cfg=None):
to block entries at extreme levels.
"""
rule = strategy_cfg["rule"]
if rule != "sma_cross":
if rule == "pivot_retest_engulfing":
return generate_signals_pivot_retest(df, strategy_cfg)
elif rule != "sma_cross":
raise ValueError(f"Unsupported strategy rule: {rule}")
short_w = strategy_cfg["params"]["short"]
@@ -129,7 +132,429 @@ def generate_signals(df, strategy_cfg, ai_cfg=None):
# ---------------------------------------------------------------------------
# Backtest engine
# Pivot retest + engulfing signal generation
# ---------------------------------------------------------------------------
# Ordered pivot levels from lowest to highest
PIVOT_LEVEL_ORDER = ["s3", "s2", "s1", "pivot", "r1", "r2", "r3"]
def generate_signals_pivot_retest(df, strategy_cfg):
"""
Generate LONG/SHORT signals based on pivot level retest confirmed by
SMA 50 alignment and an engulfing candle pattern.
Signal values: 1 = LONG, -1 = SHORT, 0 = FLAT.
Also populates per-row: entry_level, sl_price, tp1_price, tp2_price.
"""
params = strategy_cfg.get("params", {})
sma_period = params.get("sma_period", 50)
lookback = params.get("lookback_bars", 20)
retest_tol_atr = params.get("retest_tolerance_atr", 0.5)
strong_close_pct = params.get("strong_close_pct", 0.30)
sl_atr_mult = params.get("sl_atr_multiplier", 1.5)
sma_col = f"sma_{sma_period}"
atr_col = "atr_14"
# Ensure required columns exist
required = ["open", "high", "low", "close", sma_col, atr_col,
"pivot", "r1", "r2", "r3", "s1", "s2", "s3"]
for col in required:
if col not in df.columns:
raise KeyError(f"Missing required column for pivot_retest_engulfing: {col}")
# Add engulfing pattern detection
df = detect_engulfing(df)
# Drop warmup rows
df = df.dropna(subset=[sma_col, atr_col, "pivot"]).copy()
closes = df["close"].values
opens = df["open"].values
highs = df["high"].values
lows = df["low"].values
sma_vals = df[sma_col].values
atr_vals = df[atr_col].values
bull_eng = df["bullish_engulfing"].values
bear_eng = df["bearish_engulfing"].values
# Build a matrix of pivot level values per bar: shape (len(df), 7)
level_names = PIVOT_LEVEL_ORDER
level_matrix = np.column_stack([df[lv].values for lv in level_names])
# Use numpy arrays for output (avoids pandas CoW issues)
n = len(df)
out_signal = np.zeros(n, dtype=int)
out_entry_level = np.empty(n, dtype=object)
out_entry_level[:] = ""
out_sl = np.full(n, np.nan)
out_tp1 = np.full(n, np.nan)
out_tp2 = np.full(n, np.nan)
for i in range(lookback, n):
atr = atr_vals[i]
if atr <= 0 or np.isnan(atr):
continue
candle_range = highs[i] - lows[i]
if candle_range <= 0:
continue
tolerance = retest_tol_atr * atr
# Check each pivot level for a retest setup
for lv_idx, lv_name in enumerate(level_names):
level_val = level_matrix[i, lv_idx]
if np.isnan(level_val):
continue
# --- LONG check (support retest) ---
# Look for: price broke below level, then returned above it
broke_below = False
for j in range(i - lookback, i):
if closes[j] < level_val:
broke_below = True
break
if broke_below and closes[i] > level_val:
# Price is back above the level (retest from above)
near_level = abs(closes[i] - level_val) <= tolerance
sma_above = sma_vals[i] >= level_val
is_bull_eng = bool(bull_eng[i])
strong = (closes[i] - lows[i]) >= (1 - strong_close_pct) * candle_range
if near_level and sma_above and is_bull_eng and strong:
# Find TP levels: next levels above entry
tp1, tp2 = _find_tp_levels_long(level_matrix[i], lv_idx)
if not np.isnan(tp1):
out_signal[i] = 1
out_entry_level[i] = lv_name
out_sl[i] = closes[i] - sl_atr_mult * atr
out_tp1[i] = tp1
out_tp2[i] = tp2 if not np.isnan(tp2) else tp1
break # one signal per bar
# --- SHORT check (resistance retest) ---
broke_above = False
for j in range(i - lookback, i):
if closes[j] > level_val:
broke_above = True
break
if broke_above and closes[i] < level_val:
near_level = abs(closes[i] - level_val) <= tolerance
sma_below = sma_vals[i] <= level_val
is_bear_eng = bool(bear_eng[i])
strong = (highs[i] - closes[i]) >= (1 - strong_close_pct) * candle_range
if near_level and sma_below and is_bear_eng and strong:
tp1, tp2 = _find_tp_levels_short(level_matrix[i], lv_idx)
if not np.isnan(tp1):
out_signal[i] = -1
out_entry_level[i] = lv_name
out_sl[i] = closes[i] + sl_atr_mult * atr
out_tp1[i] = tp1
out_tp2[i] = tp2 if not np.isnan(tp2) else tp1
break
# Assign output arrays back to DataFrame
df["signal"] = out_signal
df["entry_level"] = out_entry_level
df["sl_price"] = out_sl
df["tp1_price"] = out_tp1
df["tp2_price"] = out_tp2
df["position"] = out_signal
return df
def _find_tp_levels_long(level_values, entry_lv_idx):
"""
For a LONG trade entered at level_values[entry_lv_idx],
find the next two pivot levels above (higher index = higher level).
Returns (tp1, tp2) as floats; NaN if not found.
"""
tp1 = np.nan
tp2 = np.nan
found = 0
for k in range(entry_lv_idx + 1, len(level_values)):
val = level_values[k]
if not np.isnan(val):
if found == 0:
tp1 = val
found += 1
elif found == 1:
tp2 = val
break
return tp1, tp2
def _find_tp_levels_short(level_values, entry_lv_idx):
"""
For a SHORT trade entered at level_values[entry_lv_idx],
find the next two pivot levels below (lower index = lower level).
Returns (tp1, tp2) as floats; NaN if not found.
"""
tp1 = np.nan
tp2 = np.nan
found = 0
for k in range(entry_lv_idx - 1, -1, -1):
val = level_values[k]
if not np.isnan(val):
if found == 0:
tp1 = val
found += 1
elif found == 1:
tp2 = val
break
return tp1, tp2
# ---------------------------------------------------------------------------
# Dual take-profit backtest engine
# ---------------------------------------------------------------------------
def run_backtest_dual_tp(df, strategy_cfg):
"""
Backtest engine supporting per-trade SL/TP with partial closes.
Position management:
- On entry: full position at entry price with SL, TP1, TP2.
- On TP1 hit: close 50%, move SL to breakeven (entry price).
- On TP2 hit: close remaining 50%.
- On SL hit: close full remaining position.
Returns dict with equity_curve, trades, metrics (same interface as run_backtest).
"""
trade_size_pct = strategy_cfg.get("trade_size_pct_of_equity", 0.01)
max_dd_pct = strategy_cfg.get("max_drawdown_pct", 0.05)
starting_equity = strategy_cfg.get("starting_equity", 100_000.0)
equity = starting_equity
peak_equity = equity
stopped = False
# Position state
in_position = False
direction = 0 # 1 = long, -1 = short
entry_price = 0.0
sl_price = 0.0
tp1_price = 0.0
tp2_price = 0.0
position_size = 0.0
half_closed = False
equity_curve = []
trades = []
times = df.index.tolist()
signals = df["signal"].values
closes = df["close"].values
highs = df["high"].values
lows = df["low"].values
sl_col = df["sl_price"].values
tp1_col = df["tp1_price"].values
tp2_col = df["tp2_price"].values
for i in range(len(df)):
bar_time = times[i]
bar_high = highs[i]
bar_low = lows[i]
bar_close = closes[i]
if stopped:
equity_curve.append(equity)
continue
# --- Check exits for active position ---
if in_position:
remaining_size = position_size * (0.5 if half_closed else 1.0)
if direction == 1: # LONG position
# Check SL hit (low touches SL)
if bar_low <= sl_price:
pnl = remaining_size * (sl_price - entry_price) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "SL_EXIT_LONG",
"price": sl_price,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl, 2),
})
in_position = False
half_closed = False
# Check TP1 hit
elif not half_closed and bar_high >= tp1_price:
half_size = position_size * 0.5
pnl = half_size * (tp1_price - entry_price) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "TP1_LONG",
"price": tp1_price,
"position_size": round(half_size, 2),
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl, 2),
})
half_closed = True
sl_price = entry_price # move SL to breakeven
# Check if TP2 also hit on same bar
if bar_high >= tp2_price:
pnl2 = half_size * (tp2_price - entry_price) / entry_price
equity += pnl2
trades.append({
"time": bar_time, "side": "TP2_LONG",
"price": tp2_price,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl2, 2),
})
in_position = False
half_closed = False
# Check TP2 hit (after TP1 already closed)
elif half_closed and bar_high >= tp2_price:
half_size = position_size * 0.5
pnl = half_size * (tp2_price - entry_price) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "TP2_LONG",
"price": tp2_price,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl, 2),
})
in_position = False
half_closed = False
elif direction == -1: # SHORT position
# Check SL hit (high touches SL)
if bar_high >= sl_price:
pnl = remaining_size * (entry_price - sl_price) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "SL_EXIT_SHORT",
"price": sl_price,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl, 2),
})
in_position = False
half_closed = False
# Check TP1 hit (low touches TP1)
elif not half_closed and bar_low <= tp1_price:
half_size = position_size * 0.5
pnl = half_size * (entry_price - tp1_price) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "TP1_SHORT",
"price": tp1_price,
"position_size": round(half_size, 2),
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl, 2),
})
half_closed = True
sl_price = entry_price # move SL to breakeven
# Check if TP2 also hit on same bar
if bar_low <= tp2_price:
pnl2 = half_size * (entry_price - tp2_price) / entry_price
equity += pnl2
trades.append({
"time": bar_time, "side": "TP2_SHORT",
"price": tp2_price,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl2, 2),
})
in_position = False
half_closed = False
# Check TP2 hit
elif half_closed and bar_low <= tp2_price:
half_size = position_size * 0.5
pnl = half_size * (entry_price - tp2_price) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "TP2_SHORT",
"price": tp2_price,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": round(pnl, 2),
})
in_position = False
half_closed = False
# --- Check for new entry signal (only when flat) ---
if not in_position and not stopped:
sig = signals[i]
if sig in (1, -1) and not np.isnan(sl_col[i]):
direction = sig
entry_price = bar_close
sl_price = sl_col[i]
tp1_price = tp1_col[i]
tp2_price = tp2_col[i]
position_size = equity * trade_size_pct
half_closed = False
in_position = True
side_label = "BUY" if sig == 1 else "SELL_SHORT"
trades.append({
"time": bar_time,
"side": side_label,
"price": bar_close,
"position_size": round(position_size, 2),
"equity": round(equity, 2),
"drawdown": 0.0,
"pnl": 0.0,
})
# Update peak and drawdown
if equity > peak_equity:
peak_equity = equity
drawdown = (peak_equity - equity) / peak_equity if peak_equity > 0 else 0.0
# Max drawdown breached — close position and stop
if drawdown >= max_dd_pct:
if in_position:
remaining_size = position_size * (0.5 if half_closed else 1.0)
if direction == 1:
pnl = remaining_size * (bar_close - entry_price) / entry_price
else:
pnl = remaining_size * (entry_price - bar_close) / entry_price
equity += pnl
trades.append({
"time": bar_time, "side": "DD_EXIT",
"price": bar_close,
"position_size": 0.0,
"equity": round(equity, 2),
"drawdown": round(drawdown, 6),
"pnl": round(pnl, 2),
})
in_position = False
half_closed = False
stopped = True
print(f" Max drawdown {max_dd_pct:.1%} breached at {bar_time}. Stopping.")
equity_curve.append(equity)
equity_series = pd.Series(equity_curve, index=df.index, name="equity")
metrics = compute_metrics(equity_series, trades, starting_equity)
return {
"equity_curve": equity_series,
"trades": trades,
"metrics": metrics,
}
# ---------------------------------------------------------------------------
# Backtest engine (SMA cross — original)
# ---------------------------------------------------------------------------
def run_backtest(df, strategy_cfg):
@@ -250,18 +675,25 @@ def compute_metrics(equity_curve, trades, starting_equity=100_000.0):
# Trade stats
num_trades = len(trades)
# Count winning round-trips (BUY followed by FLAT with higher equity)
wins = 0
buy_equity = None
for t in trades:
if t["side"] == "BUY":
buy_equity = t["equity"]
elif t["side"] == "FLAT" and buy_equity is not None:
if t["equity"] > buy_equity:
wins += 1
buy_equity = None
round_trips = sum(1 for t in trades if t["side"] == "FLAT")
# Count winning round-trips
# Supports both original (BUY→FLAT) and dual-TP (BUY→TP/SL exit) formats
entry_sides = {"BUY", "SELL_SHORT"}
exit_sides = {"FLAT", "SL_EXIT_LONG", "SL_EXIT_SHORT",
"TP1_LONG", "TP1_SHORT", "TP2_LONG", "TP2_SHORT", "DD_EXIT"}
wins = 0
entry_equity = None
round_trips = 0
for t in trades:
if t["side"] in entry_sides:
entry_equity = t["equity"]
elif t["side"] in exit_sides and entry_equity is not None:
# A round-trip completes when the full position is closed (size=0)
if t.get("position_size", 0) == 0:
round_trips += 1
if t["equity"] > entry_equity:
wins += 1
entry_equity = None
win_rate = (wins / round_trips * 100) if round_trips > 0 else 0.0
# Sharpe ratio (annualized, from per-bar returns of the equity curve)
@@ -328,8 +760,11 @@ def save_results(instrument, granularity, results, metrics):
trade_df = pd.DataFrame(trades)
trade_df["instrument"] = instrument
trade_df["granularity"] = granularity
cols = ["time", "instrument", "granularity", "side", "price",
"position_size", "equity", "drawdown"]
base_cols = ["time", "instrument", "granularity", "side", "price",
"position_size", "equity", "drawdown"]
if "pnl" in trade_df.columns:
base_cols.append("pnl")
cols = [c for c in base_cols if c in trade_df.columns]
trade_df = trade_df[cols]
csv_path = logs_dir / f"backtest_trades_{instrument}_{granularity}.csv"
trade_df.to_csv(csv_path, index=False)
@@ -414,12 +849,20 @@ def main():
print(" Skipping — no data.\n")
continue
# Add pivot points if needed for pivot_retest_engulfing strategy
if strategy_cfg["rule"] == "pivot_retest_engulfing":
from data_engine import add_pivot_points
df = add_pivot_points(df)
df = generate_signals(df, strategy_cfg, ai_cfg=ai_cfg)
if df.empty:
print(" Skipping — no valid rows after warmup.\n")
continue
results = run_backtest(df, strategy_cfg)
if strategy_cfg["rule"] == "pivot_retest_engulfing":
results = run_backtest_dual_tp(df, strategy_cfg)
else:
results = run_backtest(df, strategy_cfg)
save_results(instrument, granularity, results, results["metrics"])
print("Backtesting complete.")
+222
View File
@@ -0,0 +1,222 @@
# src/chart_trades.py
"""
Generate candlestick chart with trade entry arrows, SMA 50, and pivot levels.
Usage: python src/chart_trades.py [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--granularity M5|M15]
"""
import argparse
import os
from pathlib import Path
import pandas as pd
import numpy as np
import mplfinance as mpf
import matplotlib.pyplot as plt
from supabase import create_client
from config_loader import load_config, get_project_root
from backtester import fetch_candles_from_supabase
from data_engine import add_pivot_points, add_sma, add_atr, detect_engulfing
def load_trade_log(instrument, granularity):
"""Load the backtest trade CSV for a given instrument/granularity."""
root = get_project_root()
csv_path = root / "logs" / f"backtest_trades_{instrument}_{granularity}.csv"
if not csv_path.exists():
raise FileNotFoundError(f"Trade log not found: {csv_path}")
df = pd.read_csv(csv_path, parse_dates=["time"])
return df
def build_chart(instrument, granularity, start_date=None, end_date=None):
"""Build candlestick chart with trade arrows, SMA 50, and pivot levels."""
cfg = load_config()
# --- Fetch candle data from Supabase ---
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_KEY")
sb = create_client(supabase_url, supabase_key)
table = cfg.get("supabase", {}).get("table", "fx_candles")
df = fetch_candles_from_supabase(instrument, granularity, sb, table)
if df.empty:
print("No candle data found.")
return
# Add indicators
df = add_sma(df, period=50)
df = add_atr(df, period=14)
df = add_pivot_points(df)
# --- Filter date range ---
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")]
if df.empty:
print("No data in the specified date range.")
return
# --- Load trade log and filter to date range ---
trades_df = load_trade_log(instrument, granularity)
entries = trades_df[trades_df["side"].isin(["BUY", "SELL_SHORT"])].copy()
entries["time"] = pd.to_datetime(entries["time"], utc=True)
if start_date:
entries = entries[entries["time"] >= pd.Timestamp(start_date, tz="UTC")]
if end_date:
entries = entries[entries["time"] <= pd.Timestamp(end_date, tz="UTC")]
# Separate longs and shorts
buys = entries[entries["side"] == "BUY"]
sells = entries[entries["side"] == "SELL_SHORT"]
# --- Build mplfinance addplots ---
addplots = []
# SMA 50 line
if "sma_50" in df.columns:
addplots.append(mpf.make_addplot(df["sma_50"], color="cyan", width=1.5,
label="SMA 50"))
# Pivot levels
pivot_styles = {
"r3": ("red", 0.3, "dotted"),
"r2": ("red", 0.5, "dashed"),
"r1": ("red", 0.8, "dashed"),
"pivot": ("blue", 1.0, "solid"),
"s1": ("green", 0.8, "dashed"),
"s2": ("green", 0.5, "dashed"),
"s3": ("green", 0.3, "dotted"),
}
for level, (color, alpha, linestyle) in pivot_styles.items():
if level in df.columns:
addplots.append(mpf.make_addplot(
df[level], color=color, alpha=alpha, width=0.8,
linestyle=linestyle, label=level.upper(),
))
# Buy arrows (up triangles at entry price)
buy_markers = pd.Series(np.nan, index=df.index)
for _, row in buys.iterrows():
t = row["time"]
if t in df.index:
buy_markers.at[t] = row["price"]
if buy_markers.notna().any():
addplots.append(mpf.make_addplot(
buy_markers, type="scatter", marker="^", markersize=90,
color="lime", edgecolors="black", linewidths=0.5,
))
# Sell arrows (down triangles at entry price)
sell_markers = pd.Series(np.nan, index=df.index)
for _, row in sells.iterrows():
t = row["time"]
if t in df.index:
sell_markers.at[t] = row["price"]
if sell_markers.notna().any():
addplots.append(mpf.make_addplot(
sell_markers, type="scatter", marker="v", markersize=90,
color="red", edgecolors="black", linewidths=0.5,
))
# --- Style ---
mc = mpf.make_marketcolors(
up="green", down="red",
edge={"up": "green", "down": "red"},
wick={"up": "green", "down": "red"},
volume="in",
)
style = mpf.make_mpf_style(
marketcolors=mc,
gridstyle=":",
gridcolor="#2a2a2a",
facecolor="#1e1e1e",
figcolor="#1e1e1e",
rc={
"axes.labelcolor": "white",
"xtick.color": "white",
"ytick.color": "white",
},
)
# --- Plot ---
n_bars = len(df)
width_inches = max(16, n_bars * 0.02)
width_inches = min(width_inches, 48) # cap at 48"
fig, axes = mpf.plot(
df, type="candle", style=style,
addplot=addplots if addplots else None,
volume=False,
figsize=(width_inches, 10),
tight_layout=True,
returnfig=True,
)
ax = axes[0]
# Title and legend
n_buys = buys.shape[0]
n_sells = sells.shape[0]
title = (f"{instrument} {granularity} — Pivot Retest + Engulfing Strategy\n"
f"{df.index[0].strftime('%Y-%m-%d %H:%M')} to {df.index[-1].strftime('%Y-%m-%d %H:%M')} | "
f"Entries: {n_buys} LONG (▲), {n_sells} SHORT (▼)")
ax.set_title(title, color="white", fontsize=13, pad=15)
# Build manual legend
from matplotlib.lines import Line2D
legend_elements = [
Line2D([0], [0], color="cyan", lw=1.5, label="SMA 50"),
Line2D([0], [0], color="blue", lw=1, label="Pivot"),
Line2D([0], [0], color="red", lw=1, linestyle="dashed", label="R1/R2/R3"),
Line2D([0], [0], color="green", lw=1, linestyle="dashed", label="S1/S2/S3"),
Line2D([0], [0], marker="^", color="lime", lw=0, markersize=10,
markeredgecolor="black", label=f"BUY entry ({n_buys})"),
Line2D([0], [0], marker="v", color="red", lw=0, markersize=10,
markeredgecolor="black", label=f"SHORT entry ({n_sells})"),
]
ax.legend(handles=legend_elements, loc="upper left", fontsize=9,
facecolor="#2a2a2a", edgecolor="gray", labelcolor="white")
# Save
root = get_project_root()
out_dir = root / "logs"
out_dir.mkdir(exist_ok=True)
suffix = ""
if start_date:
suffix += f"_{start_date}"
if end_date:
suffix += f"_to_{end_date}"
out_path = out_dir / f"chart_trades_{instrument}_{granularity}{suffix}.png"
fig.savefig(out_path, dpi=150, bbox_inches="tight", facecolor="#1e1e1e")
print(f"Chart saved: {out_path}")
plt.close(fig)
return out_path
def main():
parser = argparse.ArgumentParser(description="Chart trade entries on candlesticks")
parser.add_argument("--instrument", default="EUR_USD")
parser.add_argument("--granularity", default="M5")
parser.add_argument("--start", default=None, help="Start date YYYY-MM-DD")
parser.add_argument("--end", default=None, help="End date YYYY-MM-DD")
args = parser.parse_args()
# Default to a 2-week window showing a mix of trades
if not args.start and not args.end:
args.start = "2025-02-24"
args.end = "2025-03-07"
build_chart(args.instrument, args.granularity, args.start, args.end)
if __name__ == "__main__":
main()
+13 -1
View File
@@ -305,6 +305,7 @@ def chart():
ohlc_json = "[]"
pivot_json = "{}"
sma_json = "{}"
error_msg = None
try:
@@ -325,7 +326,7 @@ def chart():
resp = (
sb.table(table)
.select("time,open,high,low,close")
.select("time,open,high,low,close,sma_3,sma_20,sma_21,sma_50,sma_100")
.eq("instrument", instrument)
.eq("granularity", granularity)
.order("time", desc=True)
@@ -355,6 +356,16 @@ def chart():
latest = df[pivot_cols].dropna().iloc[-1] if df[pivot_cols].dropna().shape[0] > 0 else None
if latest is not None:
pivot_json = latest.to_json()
# Prepare SMA data as {col_name: [values]} for overlay
sma_cols = [c for c in df.columns if c.startswith("sma_")]
if sma_cols:
sma_dict = {}
for c in sma_cols:
df[c] = pd.to_numeric(df[c], errors="coerce")
sma_dict[c] = df[c].where(df[c].notna(), None).tolist()
import json
sma_json = json.dumps(sma_dict)
else:
error_msg = "Supabase credentials not configured."
except Exception as e:
@@ -363,6 +374,7 @@ def chart():
return render_template("chart.html",
ohlc_json=ohlc_json,
pivot_json=pivot_json,
sma_json=sma_json,
instrument=instrument,
granularity=granularity,
instruments=VALID_INSTRUMENTS,
+29
View File
@@ -90,6 +90,35 @@ def add_vwap(df, period=20):
return df
def detect_engulfing(df):
"""
Detect bullish and bearish engulfing candle patterns.
Bullish engulfing: current close > prev open AND current open < prev close
AND current body > prev body (fully engulfs).
Bearish engulfing: current close < prev open AND current open > prev close
AND current body > prev body (fully engulfs).
Returns df with 'bullish_engulfing' and 'bearish_engulfing' boolean columns.
"""
prev_open = df["open"].shift(1)
prev_close = df["close"].shift(1)
prev_body = (prev_close - prev_open).abs()
curr_body = (df["close"] - df["open"]).abs()
df["bullish_engulfing"] = (
(df["close"] > prev_open)
& (df["open"] < prev_close)
& (curr_body > prev_body)
)
df["bearish_engulfing"] = (
(df["close"] < prev_open)
& (df["open"] > prev_close)
& (curr_body > prev_body)
)
return df
def add_pivot_points(df):
"""
Add Classic Pivot Point support/resistance levels.
+53 -5
View File
@@ -319,6 +319,7 @@ def execute_signals(signals_df, cfg, ai_models=None):
close_price = float(latest["close"])
# Determine current position for this instrument
# Track position state for paper mode using a module-level dict
current_position = 0
if not paper_mode:
try:
@@ -331,8 +332,27 @@ def execute_signals(signals_df, cfg, ai_models=None):
results = []
# Signal=1 means go long, signal=0 means go flat
if signal == 1 and current_position == 0:
# Extract per-bar SL/TP levels if available (pivot_retest_engulfing strategy)
sl_price = latest.get("sl_price") if hasattr(latest, "get") else getattr(latest, "sl_price", None)
tp1_price = latest.get("tp1_price") if hasattr(latest, "get") else getattr(latest, "tp1_price", None)
tp2_price = latest.get("tp2_price") if hasattr(latest, "get") else getattr(latest, "tp2_price", None)
# Coerce NaN to None
if sl_price is not None and pd.isna(sl_price):
sl_price = None
if tp1_price is not None and pd.isna(tp1_price):
tp1_price = None
if tp2_price is not None and pd.isna(tp2_price):
tp2_price = None
# Signal=1 means go long, signal=-1 means go short, signal=0 means go flat
if signal == 1 and current_position <= 0:
# Close any existing short first
if current_position == -1:
units = compute_units(balance, instrument, "BUY", cfg)
result = place_order(instrument, units, "BUY", cfg, price=close_price)
results.append(result)
# AI validation before placing BUY order
if ai_models:
ai_decision = validate_signal(instrument, signal, signals_df, cfg, models=ai_models)
@@ -344,12 +364,39 @@ def execute_signals(signals_df, cfg, ai_models=None):
units = compute_units(balance, instrument, "BUY", cfg)
result = place_order(instrument, units, "BUY", cfg, price=close_price)
results.append(result)
elif signal == 0 and current_position == 1:
if sl_price is not None:
print(f" SL={sl_price:.5f} TP1={tp1_price:.5f} TP2={tp2_price:.5f}")
elif signal == -1 and current_position >= 0:
# Close any existing long first
if current_position == 1:
units = compute_units(balance, instrument, "SELL", cfg)
result = place_order(instrument, units, "SELL", cfg, price=close_price)
results.append(result)
# AI validation before placing SHORT order
if ai_models:
ai_decision = validate_signal(instrument, signal, signals_df, cfg, models=ai_models)
log_ai_decision(ai_decision)
if not ai_decision["approved"]:
print(f" AI REJECTED: confidence={ai_decision['confidence']:.2f}, {ai_decision['rationale']}")
return results
units = compute_units(balance, instrument, "SELL", cfg)
result = place_order(instrument, units, "SELL", cfg, price=close_price)
results.append(result)
if sl_price is not None:
print(f" SL={sl_price:.5f} TP1={tp1_price:.5f} TP2={tp2_price:.5f}")
elif signal == 0 and current_position != 0:
side = "SELL" if current_position == 1 else "BUY"
units = compute_units(balance, instrument, side, cfg)
result = place_order(instrument, units, side, cfg, price=close_price)
results.append(result)
else:
action = "LONG" if signal == 1 else "FLAT"
action = {1: "LONG", -1: "SHORT", 0: "FLAT"}.get(signal, "FLAT")
print(f" {instrument}: signal={action}, position matches — no action.")
return results
@@ -429,7 +476,8 @@ def main():
if "instrument" not in df.columns:
df["instrument"] = instrument
latest_signal = "LONG" if df["signal"].iloc[-1] == 1 else "FLAT"
sig_val = int(df["signal"].iloc[-1])
latest_signal = {1: "LONG", -1: "SHORT", 0: "FLAT"}.get(sig_val, "FLAT")
print(f" Latest signal: {latest_signal} (close={df['close'].iloc[-1]:.5f})")
# Train AI ensemble for this instrument
+21
View File
@@ -37,6 +37,7 @@
(function() {
var ohlc = {{ ohlc_json | safe }};
var pivots = {{ pivot_json | safe }};
var smas = {{ sma_json | safe }};
if (!ohlc || ohlc.length === 0) {
document.getElementById('chart').innerHTML =
@@ -58,6 +59,26 @@
name: '{{ instrument }}'
}];
// SMA overlay lines
var smaColors = {
'sma_3': '#ff9800',
'sma_20': '#9c27b0',
'sma_21': '#673ab7',
'sma_50': '#00bcd4',
'sma_100': '#e91e63'
};
Object.keys(smas).forEach(function(key) {
traces.push({
x: times,
y: smas[key],
type: 'scatter',
mode: 'lines',
name: key.toUpperCase().replace('_', ' '),
line: {color: smaColors[key] || '#999', width: 1.5},
connectgaps: false
});
});
// Pivot level lines
var levelDefs = [
{key: 'r3', color: '#b71c1c', label: 'R3'},