mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-05 22:57:45 +00:00
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:
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'},
|
||||
|
||||
Reference in New Issue
Block a user