Initial commit: multi-symbol bot with backtest engine and RSI trend strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# MetaTrader 5 credentials (required for live trading; optional in paper mode)
|
||||
MT5_LOGIN=12345678
|
||||
MT5_PASSWORD=your_mt5_password
|
||||
MT5_SERVER=YourBroker-Server
|
||||
|
||||
# Telegram bot (optional — omit to disable notifications)
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RSI Mean Reversion Strategy — Backtest
|
||||
======================================
|
||||
Symbol : EURJPY M15
|
||||
Period : 2024-04-22 → 2026-04-21 (~2 years, 10 277 candles)
|
||||
|
||||
Strategy
|
||||
--------
|
||||
Signal : RSI(14) exits oversold (<30) or overbought (>70) zone
|
||||
Entry : Next bar open after signal candle
|
||||
Stop : 1.5 × ATR(14) against position
|
||||
Target : 3.0 × ATR(14) in favour → R:R = 1 : 2
|
||||
Session : 07:00–20:00 UTC only (avoid thin Asian/weekend hours)
|
||||
Max pos : 1 trade at a time
|
||||
|
||||
Rationale
|
||||
---------
|
||||
RSI reversals from extreme territory have decades of documented edge on
|
||||
liquid FX pairs. Entering on the *exit* from the extreme (rather than at
|
||||
the extreme itself) gives one-bar of confirmation that the bounce has
|
||||
started. ATR-based stops adapt to current volatility, keeping the R:R
|
||||
ratio constant in volatility-adjusted terms.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import sys
|
||||
|
||||
# ── numerical libs ──────────────────────────────────────────────────────────
|
||||
try:
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
sys.exit("Run with the BellCurve venv: "
|
||||
"/home/jonathan/Projects/ForexBots/BellCurve/.venv/bin/python3 backtest.py")
|
||||
|
||||
# ── constants ────────────────────────────────────────────────────────────────
|
||||
DB_PATH = Path("/home/jonathan/Projects/ForexBots/data/candles.db")
|
||||
SYMBOL = "EURJPY"
|
||||
TIMEFRAME = "M15"
|
||||
RSI_PERIOD = 14
|
||||
ATR_PERIOD = 14
|
||||
RSI_OB = 70 # overbought threshold
|
||||
RSI_OS = 30 # oversold threshold
|
||||
SL_ATR = 1.5 # stop-loss multiplier
|
||||
TP_ATR = 3.0 # take-profit multiplier
|
||||
SESSION_START = 7 # UTC hour
|
||||
SESSION_END = 20 # UTC hour
|
||||
PIP = 0.01 # 1 pip for JPY pairs (e.g. EURJPY)
|
||||
INITIAL_BALANCE = 10_000.0 # USD
|
||||
RISK_PER_TRADE = 0.01 # 1 % of balance per trade
|
||||
|
||||
|
||||
# ── data classes ─────────────────────────────────────────────────────────────
|
||||
@dataclass
|
||||
class Trade:
|
||||
direction: str # 'long' | 'short'
|
||||
entry_time: datetime
|
||||
entry_price: float
|
||||
sl: float
|
||||
tp: float
|
||||
exit_time: Optional[datetime] = None
|
||||
exit_price: Optional[float] = None
|
||||
exit_reason: str = ""
|
||||
pnl_pips: float = 0.0
|
||||
pnl_pct: float = 0.0 # % of balance risked
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self.exit_time is not None
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
def calc_rsi(close: pd.Series, period: int = 14) -> pd.Series:
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = (-delta).clip(lower=0)
|
||||
avg_g = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
avg_l = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
rs = avg_g / avg_l.replace(0, float("inf"))
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def calc_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
high, low, prev_close = df["high"], df["low"], df["close"].shift(1)
|
||||
tr = pd.concat([
|
||||
high - low,
|
||||
(high - prev_close).abs(),
|
||||
(low - prev_close).abs(),
|
||||
], axis=1).max(axis=1)
|
||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
|
||||
|
||||
def pip_diff(a: float, b: float) -> float:
|
||||
"""Signed pip difference a − b (JPY pair)."""
|
||||
return (a - b) / PIP
|
||||
|
||||
|
||||
# ── load data ─────────────────────────────────────────────────────────────────
|
||||
def load_candles() -> pd.DataFrame:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
df = pd.read_sql_query(
|
||||
"SELECT time, open, high, low, close, tick_volume AS volume "
|
||||
"FROM candles WHERE symbol=? AND timeframe=? ORDER BY time",
|
||||
conn, params=(SYMBOL, TIMEFRAME),
|
||||
)
|
||||
conn.close()
|
||||
df["time"] = pd.to_datetime(df["time"])
|
||||
df.set_index("time", inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
# ── core backtest ─────────────────────────────────────────────────────────────
|
||||
def run_backtest(df: pd.DataFrame) -> list[Trade]:
|
||||
df = df.copy()
|
||||
df["rsi"] = calc_rsi(df["close"], RSI_PERIOD)
|
||||
df["atr"] = calc_atr(df, ATR_PERIOD)
|
||||
|
||||
trades: list[Trade] = []
|
||||
position: Optional[Trade] = None
|
||||
|
||||
for i in range(1, len(df)):
|
||||
row = df.iloc[i]
|
||||
prev_row = df.iloc[i - 1]
|
||||
|
||||
hour = row.name.hour
|
||||
|
||||
# ── manage open position ─────────────────────────────────────────────
|
||||
if position is not None and not position.closed:
|
||||
hi, lo = row["high"], row["low"]
|
||||
sl, tp = position.sl, position.tp
|
||||
|
||||
hit_sl = (position.direction == "long" and lo <= sl) or \
|
||||
(position.direction == "short" and hi >= sl)
|
||||
hit_tp = (position.direction == "long" and hi >= tp) or \
|
||||
(position.direction == "short" and lo <= tp)
|
||||
|
||||
# if both hit on same bar, assume SL got hit first (conservative)
|
||||
if hit_sl or hit_tp:
|
||||
exit_price = sl if hit_sl else tp
|
||||
exit_reason = "SL" if hit_sl else "TP"
|
||||
pips = pip_diff(exit_price, position.entry_price) if position.direction == "long" \
|
||||
else pip_diff(position.entry_price, exit_price)
|
||||
position.exit_time = row.name
|
||||
position.exit_price = exit_price
|
||||
position.exit_reason = exit_reason
|
||||
position.pnl_pips = pips
|
||||
# pnl_pct: risk was SL distance; TP gives 2× risk, SL gives −1× risk
|
||||
sl_distance_pips = abs(pip_diff(position.entry_price, sl))
|
||||
position.pnl_pct = (pips / sl_distance_pips) * RISK_PER_TRADE * 100
|
||||
position = None
|
||||
continue # don't open new trade on same bar we just closed
|
||||
|
||||
# ── check for new signal (no open position) ──────────────────────────
|
||||
if position is not None:
|
||||
continue
|
||||
|
||||
# session filter
|
||||
if not (SESSION_START <= hour < SESSION_END):
|
||||
continue
|
||||
|
||||
# need valid indicators
|
||||
if pd.isna(prev_row["rsi"]) or pd.isna(row["rsi"]) or pd.isna(row["atr"]):
|
||||
continue
|
||||
|
||||
rsi_prev, rsi_now = prev_row["rsi"], row["rsi"]
|
||||
atr = row["atr"]
|
||||
|
||||
# signal: RSI exiting oversold (cross back above 30) → long
|
||||
long_signal = (rsi_prev < RSI_OS) and (rsi_now >= RSI_OS)
|
||||
# signal: RSI exiting overbought (cross back below 70) → short
|
||||
short_signal = (rsi_prev > RSI_OB) and (rsi_now <= RSI_OB)
|
||||
|
||||
if not (long_signal or short_signal):
|
||||
continue
|
||||
|
||||
# entry on NEXT bar open — peek at i+1 if available
|
||||
if i + 1 >= len(df):
|
||||
continue
|
||||
next_bar = df.iloc[i + 1]
|
||||
entry_price = next_bar["open"]
|
||||
atr_at_entry = row["atr"] # use signal-bar ATR for SL/TP
|
||||
|
||||
if long_signal:
|
||||
sl = entry_price - SL_ATR * atr_at_entry
|
||||
tp = entry_price + TP_ATR * atr_at_entry
|
||||
direction = "long"
|
||||
else:
|
||||
sl = entry_price + SL_ATR * atr_at_entry
|
||||
tp = entry_price - TP_ATR * atr_at_entry
|
||||
direction = "short"
|
||||
|
||||
position = Trade(
|
||||
direction = direction,
|
||||
entry_time = next_bar.name,
|
||||
entry_price = entry_price,
|
||||
sl = sl,
|
||||
tp = tp,
|
||||
)
|
||||
trades.append(position)
|
||||
|
||||
return trades
|
||||
|
||||
|
||||
# ── metrics ───────────────────────────────────────────────────────────────────
|
||||
def print_metrics(trades: list[Trade], df: pd.DataFrame) -> None:
|
||||
closed = [t for t in trades if t.closed]
|
||||
if not closed:
|
||||
print("No closed trades.")
|
||||
return
|
||||
|
||||
pips = [t.pnl_pips for t in closed]
|
||||
wins = [p for p in pips if p > 0]
|
||||
losses = [p for p in pips if p <= 0]
|
||||
|
||||
gross_profit = sum(wins) if wins else 0
|
||||
gross_loss = abs(sum(losses)) if losses else 0
|
||||
profit_factor = gross_profit / gross_loss if gross_loss else float("inf")
|
||||
|
||||
# equity curve (pnl_pct accumulates on balance)
|
||||
balance = INITIAL_BALANCE
|
||||
equity = [balance]
|
||||
for t in closed:
|
||||
balance += balance * (t.pnl_pct / 100)
|
||||
equity.append(balance)
|
||||
equity = np.array(equity)
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (equity - peak) / peak * 100
|
||||
max_dd = dd.min()
|
||||
|
||||
# annualised Sharpe (daily pnl_pct, then annualise √252)
|
||||
# group trades by calendar day, sum pnl_pct per day
|
||||
by_day: dict[str, float] = {}
|
||||
for t in closed:
|
||||
day = t.exit_time.date().isoformat() # type: ignore[union-attr]
|
||||
by_day[day] = by_day.get(day, 0) + t.pnl_pct
|
||||
daily = np.array(list(by_day.values()))
|
||||
sharpe = (daily.mean() / daily.std() * np.sqrt(252)) if daily.std() > 0 else 0
|
||||
|
||||
open_trades = [t for t in trades if not t.closed]
|
||||
|
||||
total_pips = sum(pips)
|
||||
total_return = (equity[-1] / INITIAL_BALANCE - 1) * 100
|
||||
avg_win = np.mean(wins) if wins else 0
|
||||
avg_loss = np.mean(losses) if losses else 0
|
||||
win_rate = len(wins) / len(closed) * 100
|
||||
|
||||
# trade duration
|
||||
durations = [(t.exit_time - t.entry_time).total_seconds() / 3600
|
||||
for t in closed]
|
||||
|
||||
longs = [t for t in closed if t.direction == "long"]
|
||||
shorts = [t for t in closed if t.direction == "short"]
|
||||
|
||||
print("=" * 60)
|
||||
print(" RSI Mean Reversion — EURJPY M15 Backtest Results")
|
||||
print("=" * 60)
|
||||
data_start = df.index[0].strftime("%Y-%m-%d")
|
||||
data_end = df.index[-1].strftime("%Y-%m-%d")
|
||||
print(f" Period : {data_start} → {data_end}")
|
||||
print(f" Candles : {len(df):,}")
|
||||
print(f" Initial balance : ${INITIAL_BALANCE:,.0f}")
|
||||
print()
|
||||
print("── Trade Summary ─────────────────────────────────────────")
|
||||
print(f" Total trades : {len(closed)}")
|
||||
print(f" Open (unresolved): {len(open_trades)}")
|
||||
print(f" Longs : {len(longs)}")
|
||||
print(f" Shorts : {len(shorts)}")
|
||||
print(f" Wins : {len(wins)}")
|
||||
print(f" Losses : {len(losses)}")
|
||||
print(f" Win rate : {win_rate:.1f}%")
|
||||
print()
|
||||
print("── P&L ───────────────────────────────────────────────────")
|
||||
print(f" Total pips : {total_pips:+.1f}")
|
||||
print(f" Total return : {total_return:+.2f}%")
|
||||
print(f" Final balance : ${equity[-1]:,.2f}")
|
||||
print(f" Avg win (pips) : {avg_win:+.1f}")
|
||||
print(f" Avg loss (pips) : {avg_loss:+.1f}")
|
||||
print(f" Reward/Risk : {abs(avg_win/avg_loss):.2f}" if avg_loss else " Reward/Risk : ∞")
|
||||
print(f" Profit factor : {profit_factor:.2f}")
|
||||
print()
|
||||
print("── Risk ──────────────────────────────────────────────────")
|
||||
print(f" Max drawdown : {max_dd:.2f}%")
|
||||
print(f" Sharpe ratio : {sharpe:.2f}")
|
||||
print(f" Risk/trade : {RISK_PER_TRADE*100:.1f}% of balance")
|
||||
print()
|
||||
print("── Timing ────────────────────────────────────────────────")
|
||||
print(f" Avg duration : {np.mean(durations):.1f} hours")
|
||||
print(f" Median duration : {np.median(durations):.1f} hours")
|
||||
print()
|
||||
print("── SL/TP Breakdown ───────────────────────────────────────")
|
||||
by_exit: dict[str, int] = {}
|
||||
for t in closed:
|
||||
by_exit[t.exit_reason] = by_exit.get(t.exit_reason, 0) + 1
|
||||
for reason, count in sorted(by_exit.items()):
|
||||
print(f" {reason:5s} : {count} trades ({count/len(closed)*100:.1f}%)")
|
||||
print("=" * 60)
|
||||
|
||||
# sample trades
|
||||
print("\n── Last 10 closed trades ─────────────────────────────────")
|
||||
print(f" {'Entry':19s} {'Exit':19s} {'Dir':5s} {'Pips':>8s} {'Reason':6s}")
|
||||
for t in closed[-10:]:
|
||||
print(f" {str(t.entry_time):19s} {str(t.exit_time):19s} "
|
||||
f"{t.direction:5s} {t.pnl_pips:+8.1f} {t.exit_reason}")
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
print(f"Loading {SYMBOL} {TIMEFRAME} candles...")
|
||||
df = load_candles()
|
||||
print(f" {len(df):,} candles "
|
||||
f"({df.index[0].date()} → {df.index[-1].date()})")
|
||||
|
||||
print("Running backtest...")
|
||||
trades = run_backtest(df)
|
||||
|
||||
print_metrics(trades, df)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Telegram alert system. All sends are fire-and-forget — a send failure
|
||||
never crashes the main loop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_bot = None
|
||||
_chat_id = None
|
||||
|
||||
|
||||
def _get_bot():
|
||||
global _bot, _chat_id
|
||||
if _bot is None and config.TELEGRAM_BOT_TOKEN and config.TELEGRAM_CHAT_ID:
|
||||
try:
|
||||
from telegram import Bot
|
||||
_bot = Bot(token=config.TELEGRAM_BOT_TOKEN)
|
||||
_chat_id = config.TELEGRAM_CHAT_ID
|
||||
except ImportError:
|
||||
logger.warning("python-telegram-bot not installed — Telegram disabled")
|
||||
return _bot
|
||||
|
||||
|
||||
async def send(text: str) -> None:
|
||||
bot = _get_bot()
|
||||
if bot is None:
|
||||
return
|
||||
try:
|
||||
await bot.send_message(chat_id=_chat_id, text=text, parse_mode="HTML")
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram send failed: %s", exc)
|
||||
|
||||
|
||||
async def notify_open(symbol: str, direction: str, entry: float,
|
||||
sl: float, tp: float, lots: float) -> None:
|
||||
arrow = "🟢 BUY" if direction == "long" else "🔴 SELL"
|
||||
mode = "PAPER" if config.PAPER_TRADING else "LIVE"
|
||||
text = (
|
||||
f"<b>[{mode}] {arrow} {symbol}</b>\n"
|
||||
f"Entry : <code>{entry:.5f}</code>\n"
|
||||
f"SL : <code>{sl:.5f}</code>\n"
|
||||
f"TP : <code>{tp:.5f}</code>\n"
|
||||
f"Size : {lots:.2f} lots\n"
|
||||
f"R:R : 1 : {config.TP_ATR / config.SL_ATR:.1f}"
|
||||
)
|
||||
logger.info("OPEN %s %s @ %.5f SL=%.5f TP=%.5f %.2f lots",
|
||||
symbol, direction.upper(), entry, sl, tp, lots)
|
||||
await send(text)
|
||||
|
||||
|
||||
async def notify_close(symbol: str, direction: str, entry: float,
|
||||
exit_price: float, pnl_pips: float, reason: str) -> None:
|
||||
sign = "✅" if pnl_pips > 0 else "❌"
|
||||
text = (
|
||||
f"<b>{sign} CLOSED {symbol}</b>\n"
|
||||
f"Dir : {direction.upper()}\n"
|
||||
f"Entry : <code>{entry:.5f}</code> → <code>{exit_price:.5f}</code>\n"
|
||||
f"P&L : <b>{pnl_pips:+.1f} pips</b>\n"
|
||||
f"Reason: {reason}"
|
||||
)
|
||||
logger.info("CLOSE %s %s %+.1f pips (%s)", symbol, direction.upper(),
|
||||
pnl_pips, reason)
|
||||
await send(text)
|
||||
|
||||
|
||||
async def notify_status(message: str) -> None:
|
||||
logger.info(message)
|
||||
await send(f"ℹ️ {message}")
|
||||
|
||||
|
||||
async def notify_error(message: str) -> None:
|
||||
logger.error(message)
|
||||
await send(f"🚨 <b>ERROR</b>: {message}")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Broker facade — routes all calls to either mt5_client or paper_client
|
||||
depending on config.PAPER_TRADING. Import this module everywhere; never
|
||||
import the underlying clients directly.
|
||||
"""
|
||||
|
||||
from . import config
|
||||
|
||||
if config.PAPER_TRADING:
|
||||
from .paper_client import ( # noqa: F401
|
||||
connect, disconnect,
|
||||
get_candles, get_tick, get_account_info,
|
||||
get_open_positions, get_symbol_info, place_order,
|
||||
ORDER_TYPE_BUY, ORDER_TYPE_SELL,
|
||||
)
|
||||
else:
|
||||
from .mt5_client import ( # noqa: F401
|
||||
connect, disconnect,
|
||||
get_candles, get_tick, get_account_info,
|
||||
get_open_positions, get_symbol_info, place_order,
|
||||
ORDER_TYPE_BUY, ORDER_TYPE_SELL,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Configuration — loaded once at import time.
|
||||
Values come from config.json (strategy params) and .env (secrets).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_root = Path(__file__).parent.parent
|
||||
with open(_root / "config.json") as f:
|
||||
_cfg = json.load(f)
|
||||
|
||||
# ── strategy ──────────────────────────────────────────────────────────────────
|
||||
SYMBOLS: list[str] = _cfg["symbols"]
|
||||
TIMEFRAME: str = _cfg["timeframe"] # "M15"
|
||||
LOOKBACK: int = _cfg["lookback_candles"] # bars for indicator warmup
|
||||
RSI_PERIOD: int = _cfg["rsi_period"]
|
||||
ATR_PERIOD: int = _cfg["atr_period"]
|
||||
OS_LEVEL: float = _cfg["os_level"]
|
||||
OB_LEVEL: float = _cfg["ob_level"]
|
||||
SL_ATR: float = _cfg["sl_atr"]
|
||||
TP_ATR: float = _cfg["tp_atr"]
|
||||
TREND_FAST: int = _cfg["trend_fast"]
|
||||
TREND_SLOW: int = _cfg["trend_slow"]
|
||||
SESSION_START: int = _cfg["session_start"]
|
||||
SESSION_END: int = _cfg["session_end"]
|
||||
|
||||
# ── risk ──────────────────────────────────────────────────────────────────────
|
||||
RISK_PER_TRADE: float = _cfg["risk_per_trade"] # fraction, e.g. 0.01
|
||||
MAX_POSITIONS: int = _cfg["max_positions"]
|
||||
MIN_SL_PIPS: float = _cfg["min_sl_pips"]
|
||||
MAX_DAILY_LOSS: float = _cfg["max_daily_loss"] # fraction of balance
|
||||
|
||||
# ── mode ──────────────────────────────────────────────────────────────────────
|
||||
PAPER_TRADING: bool = _cfg.get("paper_trading", True)
|
||||
PAPER_INITIAL_BALANCE: float = _cfg.get("paper_initial_balance", 10_000.0)
|
||||
REFRESH_INTERVAL: int = _cfg.get("refresh_interval_seconds", 15)
|
||||
|
||||
# ── broker secrets (from .env) ────────────────────────────────────────────────
|
||||
MT5_LOGIN: int = int(os.environ.get("MT5_LOGIN") or "0")
|
||||
MT5_PASSWORD: str = os.environ.get("MT5_PASSWORD", "")
|
||||
MT5_SERVER: str = os.environ.get("MT5_SERVER", "")
|
||||
|
||||
# ── telegram secrets ──────────────────────────────────────────────────────────
|
||||
TELEGRAM_BOT_TOKEN: str = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID: str = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Main trading loop.
|
||||
|
||||
Wakes on each M15 bar boundary, scans all configured symbols for signals,
|
||||
executes trades through the broker facade, and handles daily bookkeeping.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from . import broker, config, risk, alerts
|
||||
from .signals import check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── timing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _next_bar_time(now: datetime) -> datetime:
|
||||
"""Return the UTC datetime of the next M15 bar close."""
|
||||
minutes = (now.minute // 15 + 1) * 15
|
||||
base = now.replace(minute=0, second=0, microsecond=0)
|
||||
next_bar = base + timedelta(minutes=minutes)
|
||||
return next_bar + timedelta(seconds=5) # 5-s buffer after bar close
|
||||
|
||||
|
||||
async def _sleep_until(dt: datetime) -> None:
|
||||
delay = (dt - datetime.now(timezone.utc)).total_seconds()
|
||||
if delay > 0:
|
||||
logger.debug("Sleeping %.1f s until %s", delay, dt.strftime("%H:%M:%S"))
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
# ── daily reset ──────────────────────────────────────────────────────────────
|
||||
|
||||
_last_day: str = ""
|
||||
|
||||
|
||||
def _daily_reset():
|
||||
global _last_day
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
if today != _last_day:
|
||||
_last_day = today
|
||||
logger.info("── New trading day: %s ──", today)
|
||||
|
||||
|
||||
# ── position tracker (for can_trade check across symbols) ────────────────────
|
||||
|
||||
async def _get_state() -> dict:
|
||||
"""Read paper state for daily P&L. Returns empty dict for live MT5 mode."""
|
||||
if config.PAPER_TRADING:
|
||||
from .paper_client import _state
|
||||
return _state
|
||||
return {}
|
||||
|
||||
|
||||
# ── trade execution ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _execute(signal, account_info: dict) -> bool:
|
||||
symbol_info = await broker.get_symbol_info(signal.symbol)
|
||||
if symbol_info is None:
|
||||
logger.warning("No symbol info for %s", signal.symbol)
|
||||
return False
|
||||
|
||||
tick = await broker.get_tick(signal.symbol)
|
||||
if tick is None:
|
||||
logger.warning("No tick for %s", signal.symbol)
|
||||
return False
|
||||
|
||||
fill_price = tick["ask"] if signal.direction == "long" else tick["bid"]
|
||||
|
||||
volume = risk.calculate_lot_size(
|
||||
symbol_info,
|
||||
account_info["balance"],
|
||||
fill_price,
|
||||
signal.sl,
|
||||
)
|
||||
if volume == 0.0:
|
||||
return False
|
||||
|
||||
order_type = broker.ORDER_TYPE_BUY if signal.direction == "long" else broker.ORDER_TYPE_SELL
|
||||
result = await broker.place_order(
|
||||
symbol = signal.symbol,
|
||||
order_type = order_type,
|
||||
volume = volume,
|
||||
price = fill_price,
|
||||
sl = signal.sl,
|
||||
tp = signal.tp,
|
||||
comment = "RSITrend",
|
||||
)
|
||||
if result is None:
|
||||
return False
|
||||
|
||||
await alerts.notify_open(signal.symbol, signal.direction, fill_price,
|
||||
signal.sl, signal.tp, volume)
|
||||
return True
|
||||
|
||||
|
||||
# ── symbol already has open position? ────────────────────────────────────────
|
||||
|
||||
def _has_open(symbol: str, open_positions: list[dict]) -> bool:
|
||||
return any(
|
||||
p.get("symbol") == symbol or p.get("s") == symbol
|
||||
for p in open_positions
|
||||
)
|
||||
|
||||
|
||||
# ── main loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run() -> None:
|
||||
mode = "PAPER" if config.PAPER_TRADING else "LIVE"
|
||||
logger.info("Bot started in %s mode — symbols: %s", mode, config.SYMBOLS)
|
||||
await alerts.notify_status(
|
||||
f"NewBot started ({mode}) — watching {', '.join(config.SYMBOLS)}"
|
||||
)
|
||||
|
||||
while True:
|
||||
now = datetime.now(timezone.utc)
|
||||
next_bar = _next_bar_time(now)
|
||||
await _sleep_until(next_bar)
|
||||
|
||||
_daily_reset()
|
||||
|
||||
try:
|
||||
await _scan()
|
||||
except Exception as exc:
|
||||
logger.exception("Scan error: %s", exc)
|
||||
await alerts.notify_error(str(exc))
|
||||
|
||||
|
||||
async def _scan() -> None:
|
||||
account = await broker.get_account_info()
|
||||
if account is None:
|
||||
logger.error("Cannot fetch account info")
|
||||
return
|
||||
|
||||
open_positions = await broker.get_open_positions()
|
||||
state = await _get_state()
|
||||
|
||||
if not risk.can_trade(account, open_positions, state):
|
||||
return
|
||||
|
||||
now_str = datetime.now(timezone.utc).strftime("%H:%M UTC")
|
||||
logger.info("[%s] Scanning %d symbols open=%d balance=$%.2f",
|
||||
now_str, len(config.SYMBOLS), len(open_positions), account["balance"])
|
||||
|
||||
for symbol in config.SYMBOLS:
|
||||
# don't add a second position on the same symbol
|
||||
if _has_open(symbol, open_positions):
|
||||
continue
|
||||
|
||||
df = await broker.get_candles(symbol, config.TIMEFRAME, config.LOOKBACK)
|
||||
if df is None or len(df) < config.LOOKBACK:
|
||||
logger.debug("%s: not enough candles", symbol)
|
||||
continue
|
||||
|
||||
signal = check(df, symbol)
|
||||
if signal is None:
|
||||
continue
|
||||
|
||||
logger.info("Signal: %s", signal)
|
||||
success = await _execute(signal, account)
|
||||
if success:
|
||||
# re-check position count after each trade
|
||||
open_positions = await broker.get_open_positions()
|
||||
if not risk.can_trade(account, open_positions, state):
|
||||
break
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
MT5 broker client. Thin async wrapper around the synchronous MetaTrader5 API.
|
||||
All blocking calls run in the default executor so the event loop stays free.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
_MT5_AVAILABLE = True
|
||||
except ImportError:
|
||||
_MT5_AVAILABLE = False
|
||||
logger.warning("MetaTrader5 package not installed — MT5 client unavailable")
|
||||
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
|
||||
_TF_MAP = {
|
||||
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
||||
"H1": 16385, "H4": 16388, "D1": 16408,
|
||||
}
|
||||
|
||||
|
||||
def _run(fn):
|
||||
return asyncio.get_event_loop().run_in_executor(None, fn)
|
||||
|
||||
|
||||
def connect() -> bool:
|
||||
if not _MT5_AVAILABLE:
|
||||
logger.error("MetaTrader5 not installed")
|
||||
return False
|
||||
if not mt5.initialize(
|
||||
login=config.MT5_LOGIN,
|
||||
password=config.MT5_PASSWORD,
|
||||
server=config.MT5_SERVER,
|
||||
):
|
||||
logger.error("MT5 init failed: %s", mt5.last_error())
|
||||
return False
|
||||
info = mt5.terminal_info()
|
||||
logger.info("MT5 connected: %s", info.name if info else "unknown")
|
||||
return True
|
||||
|
||||
|
||||
def disconnect():
|
||||
if _MT5_AVAILABLE:
|
||||
mt5.shutdown()
|
||||
|
||||
|
||||
async def get_candles(symbol: str, timeframe: str, count: int) -> Optional[pd.DataFrame]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
tf = _TF_MAP[timeframe]
|
||||
rates = await _run(lambda: mt5.copy_rates_from_pos(symbol, tf, 0, count))
|
||||
if rates is None or len(rates) == 0:
|
||||
return None
|
||||
df = pd.DataFrame(rates)
|
||||
df["time"] = pd.to_datetime(df["time"], unit="s")
|
||||
df = df.rename(columns={"tick_volume": "tick_volume"})
|
||||
return df.set_index("time")
|
||||
|
||||
|
||||
async def get_tick(symbol: str) -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
tick = await _run(lambda: mt5.symbol_info_tick(symbol))
|
||||
return tick._asdict() if tick else None
|
||||
|
||||
|
||||
async def get_account_info() -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
info = await _run(mt5.account_info)
|
||||
return info._asdict() if info else None
|
||||
|
||||
|
||||
async def get_open_positions(magic: int = 234001) -> list[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return []
|
||||
positions = await _run(lambda: mt5.positions_get())
|
||||
if positions is None:
|
||||
return []
|
||||
return [p._asdict() for p in positions if p.magic == magic]
|
||||
|
||||
|
||||
async def get_symbol_info(symbol: str) -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
info = await _run(lambda: mt5.symbol_info(symbol))
|
||||
return info._asdict() if info else None
|
||||
|
||||
|
||||
async def place_order(
|
||||
symbol: str,
|
||||
order_type: int,
|
||||
volume: float,
|
||||
price: float,
|
||||
sl: float,
|
||||
tp: float,
|
||||
comment: str = "",
|
||||
magic: int = 234001,
|
||||
) -> Optional[dict]:
|
||||
if not _MT5_AVAILABLE:
|
||||
return None
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": round(volume, 2),
|
||||
"type": order_type,
|
||||
"price": price,
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
"deviation": 20,
|
||||
"magic": magic,
|
||||
"comment": comment,
|
||||
"type_time": mt5.ORDER_TIME_GTC,
|
||||
"type_filling": mt5.ORDER_FILLING_IOC,
|
||||
}
|
||||
result = await _run(lambda: mt5.order_send(request))
|
||||
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
||||
logger.error("Order failed %s: %s", symbol, result)
|
||||
return None
|
||||
return result._asdict()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Paper trading client — drop-in replacement for mt5_client.
|
||||
Simulates fills using real MT5 tick prices (if MT5 available) or midpoints
|
||||
from the local candles DB. State persists in paper_state.json.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
|
||||
STATE_PATH = Path(__file__).parent.parent / "paper_state.json"
|
||||
|
||||
_state: dict = {}
|
||||
|
||||
# Try to use MT5 for live prices even in paper mode
|
||||
try:
|
||||
from . import mt5_client as _mt5
|
||||
_MT5_FOR_PRICES = True
|
||||
except Exception:
|
||||
_MT5_FOR_PRICES = False
|
||||
|
||||
# yfinance as live-price fallback when MT5 unavailable
|
||||
try:
|
||||
import yfinance as _yf
|
||||
import warnings as _warnings
|
||||
_warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
_YF_AVAILABLE = True
|
||||
except ImportError:
|
||||
_YF_AVAILABLE = False
|
||||
|
||||
# candle cache: key → (fetched_at, DataFrame)
|
||||
_candle_cache: dict[str, tuple[float, pd.DataFrame]] = {}
|
||||
_CACHE_TTL = 60.0 # seconds
|
||||
|
||||
_SPREADS_PIPS: dict[str, float] = {
|
||||
"EURUSD": 0.8, "GBPUSD": 0.9, "USDJPY": 0.7, "USDCHF": 1.0,
|
||||
"USDCAD": 1.0, "AUDUSD": 0.9, "NZDUSD": 1.2, "EURJPY": 1.0,
|
||||
"GBPJPY": 1.5, "EURGBP": 1.0, "EURAUD": 1.5, "EURCAD": 1.5,
|
||||
"AUDCAD": 1.5, "CADJPY": 1.5, "CHFJPY": 1.5, "EURCHF": 1.0,
|
||||
"AUDCHF": 1.5, "CADCHF": 1.5, "AUDJPY": 1.2, "GBPCAD": 2.0,
|
||||
"GBPAUD": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def _pip_size(symbol: str) -> float:
|
||||
return 0.01 if symbol.endswith("JPY") else 0.0001
|
||||
|
||||
|
||||
def _half_spread(symbol: str) -> float:
|
||||
pips = _SPREADS_PIPS.get(symbol, 1.5)
|
||||
return pips * _pip_size(symbol) / 2
|
||||
|
||||
|
||||
# ── state ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_state() -> dict:
|
||||
if STATE_PATH.exists():
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"balance": config.PAPER_INITIAL_BALANCE,
|
||||
"positions": [],
|
||||
"history": [],
|
||||
"daily_pnl": {},
|
||||
}
|
||||
|
||||
|
||||
def _save_state():
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(_state, f, indent=2, default=str)
|
||||
|
||||
|
||||
def connect() -> bool:
|
||||
global _state
|
||||
_state = _load_state()
|
||||
logger.info("Paper trading — balance $%.2f open positions: %d",
|
||||
_state["balance"], len(_state["positions"]))
|
||||
return True
|
||||
|
||||
|
||||
def disconnect():
|
||||
_save_state()
|
||||
logger.info("Paper state saved → %s", STATE_PATH)
|
||||
|
||||
|
||||
# ── price helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _yf_ticker(symbol: str) -> str:
|
||||
return f"{symbol}=X"
|
||||
|
||||
|
||||
def _fetch_yf_candles(symbol: str, timeframe: str) -> Optional[pd.DataFrame]:
|
||||
"""Blocking yfinance fetch — run in executor."""
|
||||
if not _YF_AVAILABLE:
|
||||
return None
|
||||
interval_map = {"M15": "15m", "H1": "1h", "H4": "4h", "D1": "1d"}
|
||||
interval = interval_map.get(timeframe, "15m")
|
||||
# Request max available to give EMA(200) on H1 enough warmup
|
||||
period = "60d" if interval in ("15m", "30m") else "365d"
|
||||
try:
|
||||
df = _yf.download(
|
||||
_yf_ticker(symbol), interval=interval,
|
||||
period=period, auto_adjust=True, progress=False,
|
||||
)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
if isinstance(df.columns, pd.MultiIndex):
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
df = df.rename(columns=str.lower)
|
||||
df.index = pd.to_datetime(df.index, utc=True).tz_localize(None)
|
||||
df.index.name = "time"
|
||||
df["tick_volume"] = df.get("volume", 0).fillna(0).astype(int)
|
||||
return df[["open", "high", "low", "close", "tick_volume"]].dropna()
|
||||
except Exception as exc:
|
||||
logger.debug("yfinance fetch failed for %s: %s", symbol, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_yf_cached(symbol: str, timeframe: str) -> Optional[pd.DataFrame]:
|
||||
import time
|
||||
key = f"{symbol}_{timeframe}"
|
||||
now = time.monotonic()
|
||||
if key in _candle_cache:
|
||||
fetched_at, df = _candle_cache[key]
|
||||
if now - fetched_at < _CACHE_TTL:
|
||||
return df
|
||||
loop = asyncio.get_event_loop()
|
||||
df = await loop.run_in_executor(None, _fetch_yf_candles, symbol, timeframe)
|
||||
if df is not None and not df.empty:
|
||||
_candle_cache[key] = (now, df)
|
||||
return df
|
||||
|
||||
|
||||
async def get_tick(symbol: str) -> Optional[dict]:
|
||||
# 1. prefer live MT5 tick
|
||||
if _MT5_FOR_PRICES:
|
||||
tick = await _mt5.get_tick(symbol)
|
||||
if tick:
|
||||
return tick
|
||||
# 2. yfinance last bar
|
||||
df = await _get_yf_cached(symbol, "M15")
|
||||
if df is not None and not df.empty:
|
||||
mid = float(df["close"].iloc[-1])
|
||||
hs = _half_spread(symbol)
|
||||
return {"bid": mid - hs, "ask": mid + hs}
|
||||
# 3. DB last close
|
||||
try:
|
||||
import sqlite3
|
||||
from engine.data import DB_PATH
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT close FROM candles WHERE symbol=? AND timeframe='M15' "
|
||||
"ORDER BY time DESC LIMIT 1", (symbol,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
mid = row[0]
|
||||
hs = _half_spread(symbol)
|
||||
return {"bid": mid - hs, "ask": mid + hs}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def get_candles(symbol: str, timeframe: str, count: int) -> Optional[pd.DataFrame]:
|
||||
# 1. MT5
|
||||
if _MT5_FOR_PRICES:
|
||||
df = await _mt5.get_candles(symbol, timeframe, count)
|
||||
if df is not None:
|
||||
return df
|
||||
# 2. yfinance (returns all 60d; truncate to count)
|
||||
df = await _get_yf_cached(symbol, timeframe)
|
||||
if df is not None and not df.empty:
|
||||
return df.tail(count)
|
||||
# 3. DB fallback
|
||||
try:
|
||||
import sqlite3
|
||||
from engine.data import DB_PATH
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
rows = conn.execute(
|
||||
"SELECT time, open, high, low, close, tick_volume "
|
||||
"FROM candles WHERE symbol=? AND timeframe=? "
|
||||
"ORDER BY time DESC LIMIT ?",
|
||||
(symbol, timeframe, count)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
if not rows:
|
||||
return None
|
||||
df = pd.DataFrame(rows[::-1],
|
||||
columns=["time","open","high","low","close","tick_volume"])
|
||||
df["time"] = pd.to_datetime(df["time"])
|
||||
return df.set_index("time")
|
||||
except Exception as exc:
|
||||
logger.warning("DB candle fallback failed for %s: %s", symbol, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def get_account_info() -> Optional[dict]:
|
||||
return {"balance": _state["balance"], "equity": _state["balance"], "margin_level": 0.0}
|
||||
|
||||
|
||||
async def get_open_positions(**_) -> list[dict]:
|
||||
"""Settle any TP/SL hits, return surviving positions."""
|
||||
still_open = []
|
||||
|
||||
for pos in _state["positions"]:
|
||||
tick = await get_tick(pos["symbol"])
|
||||
if tick is None:
|
||||
still_open.append(pos)
|
||||
continue
|
||||
|
||||
mid = (tick["bid"] + tick["ask"]) / 2
|
||||
ps = _pip_size(pos["symbol"])
|
||||
pv = pos["pip_value_per_lot"] * pos["volume"]
|
||||
closed, pnl, reason, exit_px = False, 0.0, "", mid
|
||||
|
||||
if pos["direction"] == ORDER_TYPE_BUY:
|
||||
if mid <= pos["sl"]:
|
||||
pnl, reason, exit_px = (pos["sl"] - pos["entry"]) / ps * pv, "SL", pos["sl"]
|
||||
elif mid >= pos["tp"]:
|
||||
pnl, reason, exit_px = (pos["tp"] - pos["entry"]) / ps * pv, "TP", pos["tp"]
|
||||
else:
|
||||
if mid >= pos["sl"]:
|
||||
pnl, reason, exit_px = (pos["entry"] - pos["sl"]) / ps * pv, "SL", pos["sl"]
|
||||
elif mid <= pos["tp"]:
|
||||
pnl, reason, exit_px = (pos["entry"] - pos["tp"]) / ps * pv, "TP", pos["tp"]
|
||||
|
||||
if reason:
|
||||
_state["balance"] += pnl
|
||||
today = date.today().isoformat()
|
||||
_state.setdefault("daily_pnl", {})[today] = \
|
||||
_state["daily_pnl"].get(today, 0) + pnl
|
||||
pnl_pips = (exit_px - pos["entry"]) / ps * (1 if pos["direction"] == ORDER_TYPE_BUY else -1)
|
||||
_state["history"].append({
|
||||
**pos, "exit": exit_px, "pnl": round(pnl, 2),
|
||||
"pnl_pips": round(pnl_pips, 1),
|
||||
"reason": reason, "closed_at": datetime.now().isoformat(),
|
||||
})
|
||||
logger.info("%s %-8s %s pnl=$%+.2f (%.1f pips) balance=$%.2f",
|
||||
reason, pos["symbol"],
|
||||
"BUY" if pos["direction"] == ORDER_TYPE_BUY else "SELL",
|
||||
pnl, pnl_pips, _state["balance"])
|
||||
else:
|
||||
still_open.append(pos)
|
||||
|
||||
if len(still_open) != len(_state["positions"]):
|
||||
_state["positions"] = still_open
|
||||
_save_state()
|
||||
|
||||
return list(_state["positions"])
|
||||
|
||||
|
||||
async def get_symbol_info(symbol: str) -> Optional[dict]:
|
||||
"""Return pip-value per standard lot in USD."""
|
||||
ps = _pip_size(symbol)
|
||||
base, quote = symbol[:3], symbol[3:]
|
||||
|
||||
tick = await get_tick(symbol)
|
||||
if tick is None:
|
||||
return None
|
||||
mid = (tick["bid"] + tick["ask"]) / 2
|
||||
|
||||
async def cross_mid(pair: str) -> float:
|
||||
t = await get_tick(pair)
|
||||
return (t["bid"] + t["ask"]) / 2 if t else 1.0
|
||||
|
||||
if quote == "USD":
|
||||
pip_value = 100_000 * ps
|
||||
elif base == "USD":
|
||||
pip_value = 100_000 * ps / mid
|
||||
elif quote == "JPY":
|
||||
rate = await cross_mid("USDJPY")
|
||||
pip_value = 100_000 * ps / rate
|
||||
elif quote in ("CAD", "CHF"):
|
||||
rate = await cross_mid(f"USD{quote}")
|
||||
pip_value = 100_000 * ps / rate
|
||||
elif quote in ("AUD", "NZD", "GBP", "EUR"):
|
||||
rate = await cross_mid(f"{quote}USD")
|
||||
pip_value = 100_000 * ps * rate
|
||||
else:
|
||||
pip_value = 10.0
|
||||
|
||||
tick_size = ps / 10
|
||||
return {
|
||||
"name": symbol,
|
||||
"trade_tick_size": tick_size,
|
||||
"trade_tick_value": pip_value / 10,
|
||||
"volume_step": 0.01,
|
||||
"_pip_value_per_lot": pip_value,
|
||||
}
|
||||
|
||||
|
||||
async def place_order(
|
||||
symbol: str,
|
||||
order_type: int,
|
||||
volume: float,
|
||||
price: float,
|
||||
sl: float,
|
||||
tp: float,
|
||||
comment: str = "",
|
||||
**_,
|
||||
) -> Optional[dict]:
|
||||
tick = await get_tick(symbol)
|
||||
if tick is None:
|
||||
return None
|
||||
|
||||
fill = tick["ask"] if order_type == ORDER_TYPE_BUY else tick["bid"]
|
||||
info = await get_symbol_info(symbol)
|
||||
pv = info["_pip_value_per_lot"] if info else 10.0
|
||||
|
||||
trade_id = f"{symbol}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
_state["positions"].append({
|
||||
"id": trade_id,
|
||||
"symbol": symbol,
|
||||
"direction": order_type,
|
||||
"entry": fill,
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
"volume": volume,
|
||||
"pip_value_per_lot": pv,
|
||||
"comment": comment,
|
||||
"opened_at": datetime.now().isoformat(),
|
||||
})
|
||||
_save_state()
|
||||
logger.info("PAPER %s %-8s %.2f lots @ %.5f SL=%.5f TP=%.5f",
|
||||
"BUY " if order_type == ORDER_TYPE_BUY else "SELL",
|
||||
symbol, volume, fill, sl, tp)
|
||||
return {"order": trade_id}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Position sizing and trade-permission checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import date
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_LOT = 0.01
|
||||
MAX_LOT = 100.0
|
||||
|
||||
|
||||
def calculate_lot_size(
|
||||
symbol_info: dict,
|
||||
balance: float,
|
||||
entry_price: float,
|
||||
sl_price: float,
|
||||
) -> float:
|
||||
"""Risk `config.RISK_PER_TRADE` fraction of balance on a single trade."""
|
||||
risk_amount = balance * config.RISK_PER_TRADE
|
||||
|
||||
tick_value = symbol_info["trade_tick_value"]
|
||||
tick_size = symbol_info["trade_tick_size"]
|
||||
if tick_value == 0 or tick_size == 0:
|
||||
return MIN_LOT
|
||||
|
||||
pip_size = tick_size * 10
|
||||
pip_value = tick_value * (pip_size / tick_size)
|
||||
dist_pips = abs(entry_price - sl_price) / pip_size
|
||||
|
||||
if dist_pips < config.MIN_SL_PIPS:
|
||||
logger.warning("SL too tight for %s: %.1f pips — skipping",
|
||||
symbol_info.get("name"), dist_pips)
|
||||
return 0.0
|
||||
|
||||
lot = risk_amount / (pip_value * dist_pips)
|
||||
lot = max(MIN_LOT, min(MAX_LOT, lot))
|
||||
step = symbol_info.get("volume_step", 0.01)
|
||||
lot = math.floor(lot / step) * step
|
||||
return round(lot, 2)
|
||||
|
||||
|
||||
def can_trade(account_info: dict, open_positions: list[dict], state: dict) -> bool:
|
||||
if len(open_positions) >= config.MAX_POSITIONS:
|
||||
logger.debug("Max positions reached (%d)", config.MAX_POSITIONS)
|
||||
return False
|
||||
|
||||
today = date.today().isoformat()
|
||||
daily_pnl = state.get("daily_pnl", {}).get(today, 0.0)
|
||||
balance = account_info.get("balance", 1.0)
|
||||
if daily_pnl / balance < -config.MAX_DAILY_LOSS:
|
||||
logger.warning("Daily loss limit hit: %.1f%% of balance",
|
||||
-daily_pnl / balance * 100)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Live signal generation — applies the RSI Trend strategy to a freshly fetched
|
||||
candle window and returns a trade signal (or None).
|
||||
|
||||
The logic mirrors backtest.strategies.rsi_trend exactly so live and backtest
|
||||
behaviour stay in sync.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import config
|
||||
from engine import indicators as ind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── signal result ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Signal:
|
||||
__slots__ = ("symbol", "direction", "sl", "tp", "atr", "rsi")
|
||||
|
||||
def __init__(self, symbol: str, direction: str, sl: float, tp: float,
|
||||
atr: float, rsi: float):
|
||||
self.symbol = symbol
|
||||
self.direction = direction # 'long' | 'short'
|
||||
self.sl = sl
|
||||
self.tp = tp
|
||||
self.atr = atr
|
||||
self.rsi = rsi
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"Signal({self.symbol} {self.direction.upper()} "
|
||||
f"sl={self.sl:.5f} tp={self.tp:.5f} rsi={self.rsi:.1f})")
|
||||
|
||||
|
||||
# ── signal check ─────────────────────────────────────────────────────────────
|
||||
|
||||
def check(df: pd.DataFrame, symbol: str) -> Optional[Signal]:
|
||||
"""
|
||||
Expects a DataFrame with columns [open, high, low, close] indexed by time,
|
||||
with at least (LOOKBACK) rows. Returns a Signal or None.
|
||||
|
||||
We check bar [-2] (the last completed bar) relative to bar [-3] (prev).
|
||||
Bar [-1] is the currently forming bar — we never use it for signals.
|
||||
"""
|
||||
if len(df) < config.LOOKBACK:
|
||||
logger.debug("%s: insufficient bars (%d < %d)", symbol, len(df), config.LOOKBACK)
|
||||
return None
|
||||
|
||||
# M15 indicators on full window
|
||||
close = df["close"]
|
||||
rsi_s = ind.rsi(close, config.RSI_PERIOD)
|
||||
atr_s = ind.atr(df, config.ATR_PERIOD)
|
||||
|
||||
# H1 trend from resampled M15
|
||||
h1 = close.resample("1h", label="left", closed="left").last().dropna()
|
||||
ema_f = ind.ema(h1, config.TREND_FAST)
|
||||
ema_s = ind.ema(h1, config.TREND_SLOW)
|
||||
trend = (ema_f - ema_s).reindex(df.index, method="ffill")
|
||||
|
||||
# Use second-to-last bar as signal bar (last fully closed bar)
|
||||
bar = df.iloc[-2]
|
||||
prev = df.iloc[-3]
|
||||
rsi_now = rsi_s.iloc[-2]
|
||||
rsi_prev = rsi_s.iloc[-3]
|
||||
atr_val = atr_s.iloc[-2]
|
||||
trend_val = trend.iloc[-2]
|
||||
|
||||
if any(pd.isna(v) for v in [rsi_now, rsi_prev, atr_val, trend_val]):
|
||||
return None
|
||||
|
||||
# session filter on signal bar
|
||||
hour = bar.name.hour
|
||||
if not (config.SESSION_START <= hour < config.SESSION_END):
|
||||
return None
|
||||
|
||||
ref = bar["close"]
|
||||
|
||||
if trend_val > 0 and rsi_prev < config.OS_LEVEL and rsi_now >= config.OS_LEVEL:
|
||||
sl = ref - config.SL_ATR * atr_val
|
||||
tp = ref + config.TP_ATR * atr_val
|
||||
return Signal(symbol, "long", sl, tp, atr_val, rsi_now)
|
||||
|
||||
if trend_val < 0 and rsi_prev > config.OB_LEVEL and rsi_now <= config.OB_LEVEL:
|
||||
sl = ref + config.SL_ATR * atr_val
|
||||
tp = ref - config.TP_ATR * atr_val
|
||||
return Signal(symbol, "short", sl, tp, atr_val, rsi_now)
|
||||
|
||||
return None
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"symbols": [
|
||||
"EURJPY",
|
||||
"EURCHF",
|
||||
"GBPJPY",
|
||||
"CADJPY",
|
||||
"EURAUD",
|
||||
"EURUSD"
|
||||
],
|
||||
|
||||
"timeframe": "M15",
|
||||
"lookback_candles": 1200,
|
||||
|
||||
"rsi_period": 14,
|
||||
"atr_period": 14,
|
||||
"os_level": 30.0,
|
||||
"ob_level": 75.0,
|
||||
"sl_atr": 2.0,
|
||||
"tp_atr": 3.0,
|
||||
"trend_fast": 50,
|
||||
"trend_slow": 200,
|
||||
"session_start": 7,
|
||||
"session_end": 20,
|
||||
|
||||
"risk_per_trade": 0.01,
|
||||
"max_positions": 3,
|
||||
"min_sl_pips": 5.0,
|
||||
"max_daily_loss": 0.03,
|
||||
|
||||
"paper_trading": true,
|
||||
"paper_initial_balance": 10000.0,
|
||||
"refresh_interval_seconds": 15
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Backtest engine. Strategy-agnostic — the Strategy object owns all signal
|
||||
and position-sizing logic; this engine handles order simulation and P&L
|
||||
accounting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Protocol
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
PIP_SIZE: dict[str, float] = {
|
||||
"JPY": 0.01, # e.g. EURJPY
|
||||
"default": 0.0001,
|
||||
}
|
||||
|
||||
|
||||
def pip_size(symbol: str) -> float:
|
||||
return PIP_SIZE["JPY"] if symbol.endswith("JPY") else PIP_SIZE["default"]
|
||||
|
||||
|
||||
# ── trade record ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
direction: str # 'long' | 'short'
|
||||
entry_time: datetime
|
||||
entry_price: float
|
||||
sl: float
|
||||
tp: float
|
||||
risk_pct: float # fraction of balance risked (e.g. 0.01)
|
||||
exit_time: Optional[datetime] = None
|
||||
exit_price: Optional[float] = None
|
||||
exit_reason: str = ""
|
||||
pnl_pips: float = 0.0
|
||||
pnl_r: float = 0.0 # P&L in units of risk (R)
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self.exit_time is not None
|
||||
|
||||
@property
|
||||
def duration_hours(self) -> float:
|
||||
if not self.closed:
|
||||
return 0.0
|
||||
return (self.exit_time - self.entry_time).total_seconds() / 3600 # type: ignore[operator]
|
||||
|
||||
|
||||
# ── strategy protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
class Strategy(Protocol):
|
||||
"""Minimal interface every strategy must satisfy."""
|
||||
|
||||
def prepare(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Compute and attach all indicator columns needed by `signal()`."""
|
||||
...
|
||||
|
||||
def signal(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
i: int,
|
||||
) -> Optional[tuple[str, float, float]]:
|
||||
"""
|
||||
Called on each bar when no position is open.
|
||||
Returns (direction, sl_price, tp_price) or None.
|
||||
direction is 'long' or 'short'.
|
||||
Prices are for the bar at index i+1 (next open).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ── engine ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class BacktestEngine:
|
||||
def __init__(
|
||||
self,
|
||||
symbol: str,
|
||||
initial_balance: float = 10_000.0,
|
||||
risk_per_trade: float = 0.01, # 1 % per trade
|
||||
spread_pips: float = 0.5, # half-spread each side
|
||||
):
|
||||
self.symbol = symbol
|
||||
self.initial_balance = initial_balance
|
||||
self.risk_per_trade = risk_per_trade
|
||||
self.spread = spread_pips * pip_size(symbol)
|
||||
|
||||
def run(self, df: pd.DataFrame, strategy: Strategy) -> tuple[list[Trade], np.ndarray]:
|
||||
"""
|
||||
Run the strategy over `df`.
|
||||
Returns (trades, equity_curve) where equity_curve has one value per bar.
|
||||
"""
|
||||
df = strategy.prepare(df.copy())
|
||||
|
||||
trades: list[Trade] = []
|
||||
equity: list[float] = [self.initial_balance]
|
||||
balance = self.initial_balance
|
||||
position: Optional[Trade] = None
|
||||
|
||||
for i in range(len(df) - 1):
|
||||
row = df.iloc[i]
|
||||
next_bar = df.iloc[i + 1]
|
||||
|
||||
# ── manage open position ─────────────────────────────────────────
|
||||
if position is not None:
|
||||
hi, lo = row["high"], row["low"]
|
||||
sl, tp = position.sl, position.tp
|
||||
ps = pip_size(self.symbol)
|
||||
|
||||
hit_sl = (position.direction == "long" and lo <= sl) or \
|
||||
(position.direction == "short" and hi >= sl)
|
||||
hit_tp = (position.direction == "long" and hi >= tp) or \
|
||||
(position.direction == "short" and lo <= tp)
|
||||
|
||||
if hit_sl or hit_tp:
|
||||
exit_px = sl if hit_sl else tp
|
||||
reason = "SL" if hit_sl else "TP"
|
||||
raw = exit_px - position.entry_price
|
||||
pips = (raw if position.direction == "long" else -raw) / ps
|
||||
sl_dist = abs(position.entry_price - sl) / ps
|
||||
r_mult = pips / sl_dist if sl_dist > 0 else 0
|
||||
pnl_pct = r_mult * position.risk_pct
|
||||
|
||||
position.exit_time = row.name
|
||||
position.exit_price = exit_px
|
||||
position.exit_reason = reason
|
||||
position.pnl_pips = pips
|
||||
position.pnl_r = r_mult
|
||||
balance *= (1 + pnl_pct)
|
||||
position = None
|
||||
|
||||
equity.append(balance)
|
||||
continue
|
||||
|
||||
# ── check for entry ──────────────────────────────────────────────
|
||||
result = strategy.signal(df, i)
|
||||
if result is None:
|
||||
equity.append(balance)
|
||||
continue
|
||||
|
||||
direction, sl_px, tp_px = result
|
||||
entry_px = next_bar["open"]
|
||||
# apply spread (widen SL, narrow TP)
|
||||
if direction == "long":
|
||||
entry_px += self.spread
|
||||
sl_px -= self.spread
|
||||
tp_px -= self.spread
|
||||
else:
|
||||
entry_px -= self.spread
|
||||
sl_px += self.spread
|
||||
tp_px += self.spread
|
||||
|
||||
trade = Trade(
|
||||
direction = direction,
|
||||
entry_time = next_bar.name,
|
||||
entry_price = entry_px,
|
||||
sl = sl_px,
|
||||
tp = tp_px,
|
||||
risk_pct = self.risk_per_trade,
|
||||
)
|
||||
trades.append(trade)
|
||||
position = trade
|
||||
equity.append(balance)
|
||||
|
||||
return trades, np.array(equity)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Data loading and resampling utilities.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
DB_PATH = Path("/home/jonathan/Projects/ForexBots/data/candles.db")
|
||||
|
||||
_RESAMPLE_MAP = {
|
||||
"open": "first",
|
||||
"high": "max",
|
||||
"low": "min",
|
||||
"close": "last",
|
||||
"tick_volume": "sum",
|
||||
}
|
||||
|
||||
|
||||
def load_candles(symbol: str, timeframe: str, db_path: Path = DB_PATH) -> pd.DataFrame:
|
||||
conn = sqlite3.connect(db_path)
|
||||
df = pd.read_sql_query(
|
||||
"SELECT time, open, high, low, close, tick_volume "
|
||||
"FROM candles WHERE symbol=? AND timeframe=? ORDER BY time",
|
||||
conn, params=(symbol, timeframe),
|
||||
)
|
||||
conn.close()
|
||||
df["time"] = pd.to_datetime(df["time"])
|
||||
df.set_index("time", inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
def resample(df: pd.DataFrame, rule: str) -> pd.DataFrame:
|
||||
"""Resample an OHLCV dataframe to a coarser timeframe.
|
||||
|
||||
rule follows pandas offset alias (e.g. '1h', '4h', '1D').
|
||||
"""
|
||||
agg = {c: _RESAMPLE_MAP[c] for c in df.columns if c in _RESAMPLE_MAP}
|
||||
return df.resample(rule, label="left", closed="left").agg(agg).dropna()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Pure-function technical indicators. All operate on pandas Series/DataFrame
|
||||
and return pandas objects so NaN propagation is handled automatically.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def ema(series: pd.Series, period: int) -> pd.Series:
|
||||
return series.ewm(span=period, min_periods=period, adjust=False).mean()
|
||||
|
||||
|
||||
def rsi(close: pd.Series, period: int = 14) -> pd.Series:
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = (-delta).clip(lower=0)
|
||||
avg_g = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
avg_l = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
rs = avg_g / avg_l.replace(0, np.inf)
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
hi, lo, pc = df["high"], df["low"], df["close"].shift(1)
|
||||
tr = pd.concat([(hi - lo), (hi - pc).abs(), (lo - pc).abs()], axis=1).max(axis=1)
|
||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
|
||||
|
||||
def adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
"""Average Directional Index."""
|
||||
hi, lo, pc = df["high"], df["low"], df["close"].shift(1)
|
||||
tr = pd.concat([(hi - lo), (hi - pc).abs(), (lo - pc).abs()], axis=1).max(axis=1)
|
||||
dm_pos = (hi - hi.shift(1)).clip(lower=0).where((hi - hi.shift(1)) > (lo.shift(1) - lo), 0)
|
||||
dm_neg = (lo.shift(1) - lo).clip(lower=0).where((lo.shift(1) - lo) > (hi - hi.shift(1)), 0)
|
||||
atr_s = tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
di_pos = 100 * dm_pos.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() / atr_s
|
||||
di_neg = 100 * dm_neg.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() / atr_s
|
||||
dx = (100 * (di_pos - di_neg).abs() / (di_pos + di_neg).replace(0, np.nan))
|
||||
return dx.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Performance metrics computed from a completed backtest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .backtest import Trade
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metrics:
|
||||
n_trades: int
|
||||
n_wins: int
|
||||
n_losses: int
|
||||
win_rate: float # 0-1
|
||||
avg_win_r: float # average win in R
|
||||
avg_loss_r: float # average loss in R (negative)
|
||||
profit_factor: float
|
||||
expectancy_r: float # expected R per trade
|
||||
total_pips: float
|
||||
total_return: float # fractional (e.g. 0.12 = 12%)
|
||||
max_drawdown: float # fractional (negative)
|
||||
sharpe: float # annualised daily Sharpe
|
||||
avg_duration: float # hours
|
||||
n_longs: int
|
||||
n_shorts: int
|
||||
final_balance: float
|
||||
|
||||
def __str__(self) -> str:
|
||||
lines = [
|
||||
f" Trades : {self.n_trades} ({self.n_longs}L / {self.n_shorts}S)",
|
||||
f" Win rate : {self.win_rate*100:.1f}%",
|
||||
f" Avg win (R) : {self.avg_win_r:+.2f}R",
|
||||
f" Avg loss (R) : {self.avg_loss_r:+.2f}R",
|
||||
f" Expectancy : {self.expectancy_r:+.3f}R / trade",
|
||||
f" Profit factor : {self.profit_factor:.2f}",
|
||||
f" Total pips : {self.total_pips:+.1f}",
|
||||
f" Total return : {self.total_return*100:+.2f}%",
|
||||
f" Final balance : ${self.final_balance:,.2f}",
|
||||
f" Max drawdown : {self.max_drawdown*100:.2f}%",
|
||||
f" Sharpe : {self.sharpe:.2f}",
|
||||
f" Avg duration : {self.avg_duration:.1f} h",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def score(self) -> float:
|
||||
"""Composite optimisation score (higher = better)."""
|
||||
if self.n_trades < 20:
|
||||
return -999.0
|
||||
return self.sharpe * (1 + self.expectancy_r) * (1 + self.total_return)
|
||||
|
||||
|
||||
def compute(
|
||||
trades: list[Trade],
|
||||
equity: np.ndarray,
|
||||
initial_balance: float = 10_000.0,
|
||||
) -> Metrics:
|
||||
closed = [t for t in trades if t.closed]
|
||||
if not closed:
|
||||
return Metrics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, initial_balance)
|
||||
|
||||
pips = np.array([t.pnl_pips for t in closed])
|
||||
r_vals = np.array([t.pnl_r for t in closed])
|
||||
|
||||
wins = r_vals[r_vals > 0]
|
||||
losses = r_vals[r_vals <= 0]
|
||||
|
||||
gross_profit = wins.sum() if len(wins) else 0.0
|
||||
gross_loss = abs(losses.sum()) if len(losses) else 0.0
|
||||
pf = gross_profit / gross_loss if gross_loss else float("inf")
|
||||
|
||||
# equity metrics
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (equity - peak) / peak
|
||||
max_dd = dd.min()
|
||||
|
||||
# annualised Sharpe via daily returns
|
||||
by_day: dict[str, float] = {}
|
||||
for t in closed:
|
||||
day = t.exit_time.date().isoformat() # type: ignore[union-attr]
|
||||
by_day[day] = by_day.get(day, 0) + t.pnl_r * t.risk_pct
|
||||
daily = np.array(list(by_day.values()))
|
||||
sharpe = (daily.mean() / daily.std() * np.sqrt(252)) if (len(daily) > 1 and daily.std() > 0) else 0.0
|
||||
|
||||
durations = [t.duration_hours for t in closed]
|
||||
|
||||
return Metrics(
|
||||
n_trades = len(closed),
|
||||
n_wins = int((r_vals > 0).sum()),
|
||||
n_losses = int((r_vals <= 0).sum()),
|
||||
win_rate = float((r_vals > 0).mean()),
|
||||
avg_win_r = float(wins.mean()) if len(wins) else 0.0,
|
||||
avg_loss_r = float(losses.mean()) if len(losses) else 0.0,
|
||||
profit_factor = pf,
|
||||
expectancy_r = float(r_vals.mean()),
|
||||
total_pips = float(pips.sum()),
|
||||
total_return = float(equity[-1] / initial_balance - 1),
|
||||
max_drawdown = float(max_dd),
|
||||
sharpe = float(sharpe),
|
||||
avg_duration = float(np.mean(durations)),
|
||||
n_longs = sum(1 for t in closed if t.direction == "long"),
|
||||
n_shorts = sum(1 for t in closed if t.direction == "short"),
|
||||
final_balance = float(equity[-1]),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Grid-search optimizer.
|
||||
|
||||
Iterates over a parameter grid, re-instantiates the strategy for each
|
||||
combination, runs the backtest on the in-sample slice, and ranks by a
|
||||
composite score (Sharpe × expectancy × return).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Type
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .backtest import BacktestEngine
|
||||
from .metrics import Metrics, compute
|
||||
|
||||
|
||||
@dataclass
|
||||
class OptResult:
|
||||
params: dict[str, Any]
|
||||
metrics: Metrics
|
||||
score: float
|
||||
|
||||
|
||||
def grid_search(
|
||||
StrategyClass: Type,
|
||||
param_grid: dict[str, list],
|
||||
df: pd.DataFrame,
|
||||
engine: BacktestEngine,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
) -> list[OptResult]:
|
||||
"""
|
||||
Run every combination of parameters in `param_grid`.
|
||||
Returns results sorted best-first by composite score.
|
||||
"""
|
||||
keys = list(param_grid.keys())
|
||||
values = list(param_grid.values())
|
||||
combos = list(itertools.product(*values))
|
||||
|
||||
results: list[OptResult] = []
|
||||
|
||||
for idx, combo in enumerate(combos):
|
||||
params = dict(zip(keys, combo))
|
||||
strategy = StrategyClass(**params)
|
||||
|
||||
trades, equity = engine.run(df, strategy)
|
||||
m = compute(trades, equity, engine.initial_balance)
|
||||
s = m.score()
|
||||
|
||||
results.append(OptResult(params=params, metrics=m, score=s))
|
||||
|
||||
if verbose and (idx + 1) % 50 == 0:
|
||||
print(f" [{idx+1}/{len(combos)}] best so far: "
|
||||
f"{max(results, key=lambda r: r.score).score:.4f}")
|
||||
|
||||
results.sort(key=lambda r: r.score, reverse=True)
|
||||
return results
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NewBot — RSI Trend Pullback forex bot entry point.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python main.py # paper trading (default)
|
||||
PAPER_TRADING=false python main.py # live MT5
|
||||
|
||||
Keyboard interrupt stops the bot cleanly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(name)-24s %(levelname)-8s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(Path(__file__).parent / "bot.log"),
|
||||
],
|
||||
)
|
||||
|
||||
# suppress noisy third-party loggers
|
||||
for _noisy in ("httpx", "telegram", "asyncio"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
from bot import broker, loop, alerts, config
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
mode = "PAPER" if config.PAPER_TRADING else "LIVE (MT5)"
|
||||
logging.getLogger(__name__).info("Starting NewBot in %s mode", mode)
|
||||
|
||||
if not broker.connect():
|
||||
await alerts.notify_error("Broker connection failed at startup")
|
||||
return
|
||||
|
||||
try:
|
||||
await loop.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
broker.disconnect()
|
||||
logging.getLogger(__name__).info("Bot stopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Equity curve and trade distribution charts.
|
||||
Saved to reports/ as PNG files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # non-interactive backend
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.gridspec as gridspec
|
||||
|
||||
from engine.backtest import Trade
|
||||
from engine.metrics import Metrics
|
||||
|
||||
REPORTS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def save_equity_chart(
|
||||
equity: np.ndarray,
|
||||
trades: list[Trade],
|
||||
metrics: Metrics,
|
||||
label: str,
|
||||
filename: str = "equity.png",
|
||||
) -> Path:
|
||||
closed = [t for t in trades if t.closed]
|
||||
|
||||
# drawdown
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (equity - peak) / peak * 100
|
||||
|
||||
# daily pnl histogram
|
||||
by_day: dict[str, float] = {}
|
||||
for t in closed:
|
||||
day = t.exit_time.date().isoformat() # type: ignore[union-attr]
|
||||
by_day[day] = by_day.get(day, 0) + t.pnl_r
|
||||
daily_r = np.array(list(by_day.values()))
|
||||
|
||||
fig = plt.figure(figsize=(14, 10))
|
||||
fig.suptitle(label, fontsize=13, fontweight="bold")
|
||||
gs = gridspec.GridSpec(3, 2, figure=fig, hspace=0.45, wspace=0.3)
|
||||
|
||||
# ── equity curve ──────────────────────────────────────────────────────────
|
||||
ax1 = fig.add_subplot(gs[0, :])
|
||||
ax1.plot(equity, color="#2196F3", linewidth=1.2, label="Equity")
|
||||
ax1.axhline(equity[0], color="grey", linewidth=0.6, linestyle="--")
|
||||
ax1.set_ylabel("Balance ($)")
|
||||
ax1.set_title("Equity Curve")
|
||||
ax1.legend(fontsize=8)
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# ── drawdown ──────────────────────────────────────────────────────────────
|
||||
ax2 = fig.add_subplot(gs[1, :])
|
||||
ax2.fill_between(range(len(dd)), dd, 0, color="#F44336", alpha=0.6)
|
||||
ax2.set_ylabel("Drawdown (%)")
|
||||
ax2.set_title(f"Drawdown (max {metrics.max_drawdown*100:.1f}%)")
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
# ── R distribution ────────────────────────────────────────────────────────
|
||||
ax3 = fig.add_subplot(gs[2, 0])
|
||||
r_vals = [t.pnl_r for t in closed]
|
||||
colors = ["#4CAF50" if r > 0 else "#F44336" for r in r_vals]
|
||||
ax3.bar(range(len(r_vals)), r_vals, color=colors, width=0.8, alpha=0.8)
|
||||
ax3.axhline(0, color="black", linewidth=0.6)
|
||||
ax3.set_xlabel("Trade #")
|
||||
ax3.set_ylabel("R")
|
||||
ax3.set_title("R per Trade")
|
||||
ax3.grid(True, alpha=0.3, axis="y")
|
||||
|
||||
# ── daily R histogram ─────────────────────────────────────────────────────
|
||||
ax4 = fig.add_subplot(gs[2, 1])
|
||||
if len(daily_r) > 0:
|
||||
ax4.hist(daily_r, bins=30, color="#9C27B0", alpha=0.75, edgecolor="white")
|
||||
ax4.axvline(0, color="black", linewidth=0.8)
|
||||
mu, sigma = daily_r.mean(), daily_r.std()
|
||||
ax4.set_title(f"Daily R μ={mu:.3f} σ={sigma:.3f}")
|
||||
ax4.set_xlabel("Daily R")
|
||||
ax4.set_ylabel("Days")
|
||||
ax4.grid(True, alpha=0.3)
|
||||
|
||||
# stats annotation
|
||||
stats = (
|
||||
f"Trades: {metrics.n_trades} WR: {metrics.win_rate*100:.1f}%\n"
|
||||
f"PF: {metrics.profit_factor:.2f} Sharpe: {metrics.sharpe:.2f}\n"
|
||||
f"Return: {metrics.total_return*100:+.1f}% MDD: {metrics.max_drawdown*100:.1f}%"
|
||||
)
|
||||
fig.text(0.5, 0.01, stats, ha="center", fontsize=9,
|
||||
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.4))
|
||||
|
||||
out = REPORTS_DIR / filename
|
||||
plt.savefig(out, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return out
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,3 @@
|
||||
pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
matplotlib>=3.7.0
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Main backtest runner.
|
||||
|
||||
Steps
|
||||
-----
|
||||
1. Load EURJPY M15 data (2 years).
|
||||
2. Run baseline RSI Mean Reversion (original strategy, no trend filter).
|
||||
3. Run improved RSI Trend strategy with default params.
|
||||
4. Grid-search optimise on in-sample (first 70%).
|
||||
5. Walk-forward: run best params on out-of-sample (last 30%).
|
||||
6. Print full reports and save equity-curve charts.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import numpy as np
|
||||
|
||||
from engine.backtest import BacktestEngine
|
||||
from engine.data import load_candles
|
||||
from engine.metrics import compute, Metrics
|
||||
from engine.optimizer import grid_search
|
||||
from strategies.rsi_trend import RSITrendStrategy
|
||||
|
||||
try:
|
||||
from reports.chart import save_equity_chart
|
||||
CHARTS = True
|
||||
except ImportError:
|
||||
CHARTS = False
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
SYMBOL = "EURJPY"
|
||||
TIMEFRAME = "M15"
|
||||
INITIAL_BALANCE = 10_000.0
|
||||
RISK_PER_TRADE = 0.01
|
||||
SPREAD_PIPS = 0.5
|
||||
SPLIT_RATIO = 0.70 # in-sample fraction
|
||||
|
||||
# ── parameter grid for optimisation ──────────────────────────────────────────
|
||||
|
||||
PARAM_GRID = {
|
||||
"os_level": [25, 30, 35],
|
||||
"ob_level": [65, 70, 75],
|
||||
"sl_atr": [1.0, 1.5, 2.0],
|
||||
"tp_atr": [2.0, 3.0, 4.0],
|
||||
"trend_fast": [50],
|
||||
"trend_slow": [200],
|
||||
"session_start": [7],
|
||||
"session_end": [20],
|
||||
}
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def divider(title: str = "") -> None:
|
||||
if title:
|
||||
pad = (60 - len(title) - 2) // 2
|
||||
print(f"\n{'─'*pad} {title} {'─'*pad}")
|
||||
else:
|
||||
print("─" * 62)
|
||||
|
||||
|
||||
def print_report(label: str, metrics: Metrics, period: str = "") -> None:
|
||||
divider(label)
|
||||
if period:
|
||||
print(f" Period : {period}")
|
||||
print(metrics)
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 62)
|
||||
print(" NewBot — RSI Trend Pullback | EURJPY M15")
|
||||
print("=" * 62)
|
||||
|
||||
# 1. Load data
|
||||
print(f"\nLoading {SYMBOL} {TIMEFRAME}…")
|
||||
df = load_candles(SYMBOL, TIMEFRAME)
|
||||
print(f" {len(df):,} candles ({df.index[0].date()} → {df.index[-1].date()})")
|
||||
|
||||
split_idx = int(len(df) * SPLIT_RATIO)
|
||||
df_in = df.iloc[:split_idx]
|
||||
df_out = df.iloc[split_idx:]
|
||||
print(f" In-sample : {df_in.index[0].date()} → {df_in.index[-1].date()} ({len(df_in):,} bars)")
|
||||
print(f" Out-sample : {df_out.index[0].date()} → {df_out.index[-1].date()} ({len(df_out):,} bars)")
|
||||
|
||||
engine = BacktestEngine(
|
||||
SYMBOL,
|
||||
initial_balance = INITIAL_BALANCE,
|
||||
risk_per_trade = RISK_PER_TRADE,
|
||||
spread_pips = SPREAD_PIPS,
|
||||
)
|
||||
|
||||
# 2. Baseline: original RSI mean-reversion (no trend filter)
|
||||
# Simulated by using trend_fast=1, trend_slow=1 so trend is always 0 —
|
||||
# that short-circuits the trend guard. Instead we reuse the same class
|
||||
# but force trend filter OFF by setting trend EMAs equal (trend ≈ 0 always).
|
||||
# Simpler: use a tiny fast period so the two EMAs are always close → trend~0
|
||||
# Actually the cleanest baseline: subclass and override signal to ignore trend.
|
||||
|
||||
class BaselineRSI(RSITrendStrategy):
|
||||
"""RSI mean reversion without any trend filter — take both longs & shorts."""
|
||||
def signal(self, df, i):
|
||||
if i < 1:
|
||||
return None
|
||||
row = df.iloc[i]
|
||||
prev = df.iloc[i - 1]
|
||||
if any(df.iloc[i][c] != df.iloc[i][c] for c in ("rsi", "atr")):
|
||||
return None
|
||||
hour = row.name.hour
|
||||
if not (self.session_start <= hour < self.session_end):
|
||||
return None
|
||||
rsi_now, rsi_prev = row["rsi"], prev["rsi"]
|
||||
if __import__("pandas").isna(rsi_prev):
|
||||
return None
|
||||
atr = row["atr"]
|
||||
ref = row["close"]
|
||||
if rsi_prev < self.os_level and rsi_now >= self.os_level:
|
||||
return "long", ref - self.sl_atr * atr, ref + self.tp_atr * atr
|
||||
if rsi_prev > self.ob_level and rsi_now <= self.ob_level:
|
||||
return "short", ref + self.sl_atr * atr, ref - self.tp_atr * atr
|
||||
return None
|
||||
|
||||
baseline = BaselineRSI(os_level=30, ob_level=70, sl_atr=1.5, tp_atr=3.0)
|
||||
bl_trades, bl_equity = engine.run(df, baseline)
|
||||
bl_metrics = compute(bl_trades, bl_equity, INITIAL_BALANCE)
|
||||
print_report("Baseline RSI (no trend filter) — full dataset",
|
||||
bl_metrics,
|
||||
f"{df.index[0].date()} → {df.index[-1].date()}")
|
||||
|
||||
# 3. Improved strategy — default params, full dataset
|
||||
improved = RSITrendStrategy(os_level=30, ob_level=70, sl_atr=1.5, tp_atr=3.0)
|
||||
im_trades, im_equity = engine.run(df, improved)
|
||||
im_metrics = compute(im_trades, im_equity, INITIAL_BALANCE)
|
||||
print_report("Improved RSI Trend (default params) — full dataset",
|
||||
im_metrics,
|
||||
f"{df.index[0].date()} → {df.index[-1].date()}")
|
||||
|
||||
# 4. Optimise on in-sample
|
||||
divider("Grid Search (in-sample optimisation)")
|
||||
n_combos = 1
|
||||
for v in PARAM_GRID.values():
|
||||
n_combos *= len(v)
|
||||
print(f" Testing {n_combos} parameter combinations on in-sample data…")
|
||||
|
||||
results = grid_search(
|
||||
RSITrendStrategy, PARAM_GRID, df_in, engine, verbose=True
|
||||
)
|
||||
best = results[0]
|
||||
print(f"\n Best score : {best.score:.4f}")
|
||||
print(f" Params : {best.params}")
|
||||
print(f"\n In-sample metrics:")
|
||||
print(best.metrics)
|
||||
|
||||
print("\n Top-5 combinations:")
|
||||
print(f" {'Score':>8} {'WR%':>5} {'PF':>5} {'Sharpe':>6} {'Trades':>6} Params")
|
||||
for r in results[:5]:
|
||||
print(f" {r.score:8.4f} {r.metrics.win_rate*100:5.1f} "
|
||||
f"{r.metrics.profit_factor:5.2f} {r.metrics.sharpe:6.2f} "
|
||||
f"{r.metrics.n_trades:6d} {r.params}")
|
||||
|
||||
# 5. Walk-forward: run best params on out-of-sample
|
||||
opt_strategy = RSITrendStrategy(**best.params)
|
||||
oos_trades, oos_equity = engine.run(df_out, opt_strategy)
|
||||
oos_metrics = compute(oos_trades, oos_equity, INITIAL_BALANCE)
|
||||
print_report("Walk-Forward — out-of-sample (best params)",
|
||||
oos_metrics,
|
||||
f"{df_out.index[0].date()} → {df_out.index[-1].date()}")
|
||||
|
||||
# 6. Also run best params on full dataset for the final picture
|
||||
full_strategy = RSITrendStrategy(**best.params)
|
||||
full_trades, full_equity = engine.run(df, full_strategy)
|
||||
full_metrics = compute(full_trades, full_equity, INITIAL_BALANCE)
|
||||
print_report("Optimised Strategy — full dataset",
|
||||
full_metrics,
|
||||
f"{df.index[0].date()} → {df.index[-1].date()}")
|
||||
|
||||
# 7. Summary comparison table
|
||||
divider("Strategy Comparison Summary")
|
||||
print(f" {'Strategy':<35} {'Trades':>6} {'WR%':>5} {'PF':>5} {'Sharpe':>6} {'Return%':>7} {'MDD%':>6}")
|
||||
rows = [
|
||||
("Baseline RSI (no filter)", bl_metrics),
|
||||
("RSI Trend (default params)", im_metrics),
|
||||
(f"RSI Trend (optimised, in-sample)", best.metrics),
|
||||
(f"RSI Trend (optimised, out-sample)", oos_metrics),
|
||||
(f"RSI Trend (optimised, full data)", full_metrics),
|
||||
]
|
||||
for name, m in rows:
|
||||
print(f" {name:<35} {m.n_trades:>6} {m.win_rate*100:>5.1f} "
|
||||
f"{m.profit_factor:>5.2f} {m.sharpe:>6.2f} "
|
||||
f"{m.total_return*100:>+7.2f} {m.max_drawdown*100:>6.2f}")
|
||||
|
||||
# 8. Charts
|
||||
if CHARTS:
|
||||
divider("Saving charts")
|
||||
charts_dir = Path(__file__).parent / "reports"
|
||||
charts_dir.mkdir(exist_ok=True)
|
||||
|
||||
paths = []
|
||||
pairs = [
|
||||
(bl_equity, bl_trades, bl_metrics, "Baseline RSI (no filter)", "baseline.png"),
|
||||
(im_equity, im_trades, im_metrics, "RSI Trend — default params", "improved_default.png"),
|
||||
(full_equity, full_trades, full_metrics, "RSI Trend — optimised (full data)","optimised_full.png"),
|
||||
(oos_equity, oos_trades, oos_metrics, "RSI Trend — out-of-sample (WF)", "walkforward_oos.png"),
|
||||
]
|
||||
for eq, tr, me, lbl, fname in pairs:
|
||||
p = save_equity_chart(eq, tr, me, lbl, fname)
|
||||
paths.append(p)
|
||||
print(f" Saved: {p}")
|
||||
else:
|
||||
print("\n (matplotlib not available — skipping charts)")
|
||||
|
||||
print("\n" + "=" * 62)
|
||||
print(" Done.")
|
||||
print("=" * 62)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-symbol backtest.
|
||||
|
||||
Tests the optimised RSI Trend strategy across every M15 pair in the database
|
||||
with at least MIN_CANDLES bars. Ranks pairs by Sharpe ratio and prints an
|
||||
aggregate portfolio equity curve.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
|
||||
from engine.backtest import BacktestEngine
|
||||
from engine.data import load_candles, DB_PATH
|
||||
from engine.metrics import compute
|
||||
from strategies.rsi_trend import RSITrendStrategy
|
||||
|
||||
try:
|
||||
from reports.chart import save_equity_chart
|
||||
CHARTS = True
|
||||
except ImportError:
|
||||
CHARTS = False
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
MIN_CANDLES = 1_000 # require ≥ 1 000 M15 bars (≈ 6 months)
|
||||
EXCLUDE = {"USD_JPY", "EUR_USD", "GBP_USD", "AUD_USD", # OANDA-format dupes
|
||||
"EUR_JPY", "GBP_JPY", "EUR_JPY"}
|
||||
|
||||
# Optimised params from single-symbol grid search
|
||||
BEST_PARAMS = dict(
|
||||
os_level=30, ob_level=75, sl_atr=2.0, tp_atr=3.0,
|
||||
trend_fast=50, trend_slow=200,
|
||||
session_start=7, session_end=20,
|
||||
)
|
||||
|
||||
INITIAL_BALANCE = 10_000.0
|
||||
RISK_PER_TRADE = 0.01
|
||||
SPREAD_PIPS = 0.5
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def available_symbols() -> list[tuple[str, int]]:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, COUNT(*) FROM candles WHERE timeframe='M15' "
|
||||
"GROUP BY symbol ORDER BY COUNT(*) DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [(sym, cnt) for sym, cnt in rows
|
||||
if cnt >= MIN_CANDLES and sym not in EXCLUDE and "_" not in sym]
|
||||
|
||||
|
||||
def bar(val: float, max_val: float, width: int = 20) -> str:
|
||||
filled = int(round(abs(val) / max(abs(max_val), 1e-9) * width))
|
||||
char = "█" if val >= 0 else "▒"
|
||||
return char * filled
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
symbols = available_symbols()
|
||||
print("=" * 72)
|
||||
print(" Multi-Symbol Backtest — RSI Trend Pullback (optimised params)")
|
||||
print("=" * 72)
|
||||
print(f" Params : {BEST_PARAMS}")
|
||||
print(f" Symbols: {len(symbols)} pairs (min {MIN_CANDLES} M15 candles)")
|
||||
print()
|
||||
|
||||
engine = BacktestEngine(
|
||||
"MULTI",
|
||||
initial_balance=INITIAL_BALANCE,
|
||||
risk_per_trade=RISK_PER_TRADE,
|
||||
spread_pips=SPREAD_PIPS,
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
for symbol, n_candles in symbols:
|
||||
# pip size is embedded in the engine per symbol — re-create for each
|
||||
sym_engine = BacktestEngine(
|
||||
symbol,
|
||||
initial_balance=INITIAL_BALANCE,
|
||||
risk_per_trade=RISK_PER_TRADE,
|
||||
spread_pips=SPREAD_PIPS,
|
||||
)
|
||||
df = load_candles(symbol, "M15")
|
||||
strategy = RSITrendStrategy(**BEST_PARAMS)
|
||||
trades, equity = sym_engine.run(df, strategy)
|
||||
m = compute(trades, equity, INITIAL_BALANCE)
|
||||
results.append((symbol, n_candles, m, equity))
|
||||
status = "+" if m.total_return > 0 else "-"
|
||||
print(f" [{status}] {symbol:<10} {n_candles:>6} bars "
|
||||
f"T={m.n_trades:>3} WR={m.win_rate*100:>5.1f}% "
|
||||
f"PF={m.profit_factor:>5.2f} Sharpe={m.sharpe:>5.2f} "
|
||||
f"Ret={m.total_return*100:>+7.2f}%")
|
||||
|
||||
# ── ranked table ──────────────────────────────────────────────────────────
|
||||
results.sort(key=lambda x: x[2].sharpe, reverse=True)
|
||||
|
||||
profitable = [r for r in results if r[2].total_return > 0]
|
||||
losing = [r for r in results if r[2].total_return <= 0]
|
||||
|
||||
max_ret = max(abs(r[2].total_return) for r in results) if results else 1
|
||||
|
||||
print()
|
||||
print("─" * 72)
|
||||
print(f" {'Symbol':<10} {'Bars':>6} {'Trades':>6} {'WR%':>5} {'PF':>5} "
|
||||
f"{'Sharpe':>6} {'Return%':>8} {'MDD%':>6} Return bar")
|
||||
print("─" * 72)
|
||||
for symbol, n_candles, m, _ in results:
|
||||
if m.n_trades == 0:
|
||||
continue
|
||||
b = bar(m.total_return, max_ret)
|
||||
print(f" {symbol:<10} {n_candles:>6} {m.n_trades:>6} {m.win_rate*100:>5.1f} "
|
||||
f"{m.profit_factor:>5.2f} {m.sharpe:>6.2f} {m.total_return*100:>+8.2f} "
|
||||
f"{m.max_drawdown*100:>6.2f} {b}")
|
||||
print("─" * 72)
|
||||
print(f" Profitable: {len(profitable)}/{len(results)} pairs "
|
||||
f"({len(profitable)/len(results)*100:.0f}%)")
|
||||
|
||||
# ── portfolio equity (equal-weight, 1 trade at a time per symbol) ─────────
|
||||
# Normalise each equity curve to return-fraction, then average
|
||||
if results:
|
||||
min_len = min(len(eq) for _, _, _, eq in results)
|
||||
normed = [eq[:min_len] / eq[0] for _, _, _, eq in results]
|
||||
port_equity = np.mean(normed, axis=0) * INITIAL_BALANCE
|
||||
|
||||
total_ret = port_equity[-1] / port_equity[0] - 1
|
||||
peak = np.maximum.accumulate(port_equity)
|
||||
port_mdd = ((port_equity - peak) / peak).min()
|
||||
|
||||
print()
|
||||
print("── Portfolio (equal-weight average) ─────────────────────────────")
|
||||
print(f" Return : {total_ret*100:+.2f}%")
|
||||
print(f" Max DD : {port_mdd*100:.2f}%")
|
||||
|
||||
# ── recommended live symbols ───────────────────────────────────────────────
|
||||
live_candidates = [r for r in results
|
||||
if r[2].sharpe > 1.5 and r[2].n_trades >= 15
|
||||
and r[2].profit_factor > 1.2 and r[2].total_return > 0]
|
||||
|
||||
print()
|
||||
print("── Recommended symbols for live trading ─────────────────────────")
|
||||
if live_candidates:
|
||||
for symbol, _, m, _ in live_candidates[:6]:
|
||||
print(f" ✓ {symbol:<10} Sharpe={m.sharpe:.2f} "
|
||||
f"PF={m.profit_factor:.2f} Return={m.total_return*100:+.1f}%")
|
||||
else:
|
||||
print(" (None met Sharpe>1.5 + PF>1.2 + ≥15 trades)")
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Paper trading runner with live dashboard.
|
||||
|
||||
- Immediately runs a full signal scan (no waiting for bar boundary)
|
||||
- Opens paper positions where signals are found
|
||||
- Refreshes the dashboard every 60 seconds showing live P&L
|
||||
- Press Ctrl+C to stop
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# ── imports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
from bot import config, broker, risk
|
||||
from bot.signals import check
|
||||
from bot.loop import _execute, _has_open
|
||||
from bot.paper_client import _load_state, STATE_PATH
|
||||
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
for noisy in ("httpx", "telegram", "asyncio", "yfinance", "peewee"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger("paper")
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pip_size(symbol: str) -> float:
|
||||
return 0.01 if symbol.endswith("JPY") or symbol.endswith("CHF") and "JPY" in symbol else 0.0001
|
||||
|
||||
|
||||
async def _unrealised_pnl(pos: dict) -> float:
|
||||
"""Current unrealised P&L in pips for an open paper position."""
|
||||
try:
|
||||
from bot.paper_client import get_tick
|
||||
tick = await get_tick(pos["symbol"])
|
||||
if tick is None:
|
||||
return 0.0
|
||||
mid = (tick["bid"] + tick["ask"]) / 2
|
||||
ps = _pip_size(pos["symbol"])
|
||||
direction = pos["direction"] # 0=BUY, 1=SELL
|
||||
raw = mid - pos["entry"] if direction == 0 else pos["entry"] - mid
|
||||
return raw / ps
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _cls():
|
||||
os.system("clear" if os.name != "nt" else "cls")
|
||||
|
||||
|
||||
def _bar(pct: float, width: int = 20) -> str:
|
||||
filled = int(abs(pct) / max(abs(pct), 1e-9) * width)
|
||||
filled = min(filled, width)
|
||||
return ("█" * filled).ljust(width)
|
||||
|
||||
|
||||
# ── dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _dashboard():
|
||||
state = _load_state()
|
||||
positions = state.get("positions", [])
|
||||
history = state.get("history", [])
|
||||
balance = state.get("balance", config.PAPER_INITIAL_BALANCE)
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
initial = config.PAPER_INITIAL_BALANCE
|
||||
total_ret = (balance / initial - 1) * 100
|
||||
|
||||
_cls()
|
||||
print("╔══════════════════════════════════════════════════════════════╗")
|
||||
print(f"║ NewBot — Paper Trading Dashboard {now} ║")
|
||||
print("╚══════════════════════════════════════════════════════════════╝")
|
||||
print()
|
||||
print(f" Balance : ${balance:>10,.2f} ({total_ret:+.2f}% since start)")
|
||||
print(f" Strategy : RSI Trend Pullback | {', '.join(config.SYMBOLS)}")
|
||||
print()
|
||||
|
||||
# open positions
|
||||
print("── Open Positions ─────────────────────────────────────────────")
|
||||
if not positions:
|
||||
print(" (none)")
|
||||
else:
|
||||
print(f" {'Symbol':<10} {'Dir':<6} {'Entry':>10} {'Current':>10} "
|
||||
f"{'SL':>10} {'TP':>10} {'Pips':>8}")
|
||||
for pos in positions:
|
||||
upnl = await _unrealised_pnl(pos)
|
||||
try:
|
||||
from bot.paper_client import get_tick
|
||||
tick = await get_tick(pos["symbol"])
|
||||
current = (tick["bid"] + tick["ask"]) / 2 if tick else pos["entry"]
|
||||
except Exception:
|
||||
current = pos["entry"]
|
||||
d = "LONG" if pos["direction"] == 0 else "SHORT"
|
||||
pip_str = f"{upnl:+.1f}"
|
||||
sign = "🟢" if upnl >= 0 else "🔴"
|
||||
print(f" {pos['symbol']:<10} {d:<6} {pos['entry']:>10.5f} "
|
||||
f"{current:>10.5f} {pos['sl']:>10.5f} {pos['tp']:>10.5f} "
|
||||
f"{sign}{pip_str:>7}")
|
||||
|
||||
print()
|
||||
|
||||
# recent closed trades
|
||||
print("── Recent Closed Trades ───────────────────────────────────────")
|
||||
recent = sorted(history, key=lambda x: x.get("closed_at",""), reverse=True)[:10]
|
||||
if not recent:
|
||||
print(" (none yet)")
|
||||
else:
|
||||
print(f" {'Symbol':<10} {'Dir':<6} {'Result':<6} {'Pips':>8} When")
|
||||
for t in recent:
|
||||
d = "LONG" if t["direction"] == 0 else "SHORT"
|
||||
pips = t.get("pnl_pips", 0)
|
||||
sign = "✅" if pips > 0 else "❌"
|
||||
closed = t.get("closed_at","")[:16]
|
||||
print(f" {t['symbol']:<10} {d:<6} {t.get('reason','?'):<6} "
|
||||
f"{sign}{pips:>+7.1f} {closed}")
|
||||
|
||||
print()
|
||||
|
||||
# stats
|
||||
if history:
|
||||
pips_list = [t.get("pnl_pips", 0) for t in history]
|
||||
wins = [p for p in pips_list if p > 0]
|
||||
total = len(pips_list)
|
||||
wr = len(wins) / total * 100
|
||||
total_pips = sum(pips_list)
|
||||
avg_w = sum(wins) / len(wins) if wins else 0
|
||||
losses = [p for p in pips_list if p <= 0]
|
||||
avg_l = sum(losses) / len(losses) if losses else 0
|
||||
pf = abs(sum(wins) / sum(losses)) if losses and sum(losses) != 0 else float("inf")
|
||||
print("── Stats ──────────────────────────────────────────────────────")
|
||||
print(f" Trades : {total} | Wins: {len(wins)} Losses: {len(losses)}")
|
||||
print(f" Win % : {wr:.1f}% | PF: {pf:.2f}")
|
||||
print(f" Avg W : {avg_w:+.1f} pips Avg L : {avg_l:+.1f} pips")
|
||||
print(f" Total : {total_pips:+.1f} pips")
|
||||
print()
|
||||
|
||||
print(" [Ctrl+C to stop] Refreshing every 60s…")
|
||||
|
||||
|
||||
# ── main scan loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async def scan_once(label: str = "manual") -> None:
|
||||
logger.info("── Scanning symbols for signals (%s) ──", label)
|
||||
account = await broker.get_account_info()
|
||||
if account is None:
|
||||
logger.error("Broker not connected")
|
||||
return
|
||||
|
||||
state = _load_state()
|
||||
open_positions = await broker.get_open_positions()
|
||||
|
||||
for symbol in config.SYMBOLS:
|
||||
if _has_open(symbol, open_positions):
|
||||
logger.info("%-10s already has open position — skip", symbol)
|
||||
continue
|
||||
|
||||
logger.info("Fetching candles for %s…", symbol)
|
||||
df = await broker.get_candles(symbol, config.TIMEFRAME, config.LOOKBACK)
|
||||
if df is None or len(df) < 300:
|
||||
logger.warning("%-10s insufficient data (%s bars)", symbol,
|
||||
len(df) if df is not None else 0)
|
||||
continue
|
||||
|
||||
signal = check(df, symbol)
|
||||
if signal is None:
|
||||
logger.info("%-10s no signal (RSI=%.1f)", symbol,
|
||||
_last_rsi(df))
|
||||
continue
|
||||
|
||||
logger.info("SIGNAL: %s", signal)
|
||||
if not risk.can_trade(account, open_positions, state):
|
||||
logger.info("Trade limit reached")
|
||||
break
|
||||
|
||||
success = await _execute(signal, account)
|
||||
if success:
|
||||
open_positions = await broker.get_open_positions()
|
||||
state = _load_state()
|
||||
|
||||
|
||||
def _last_rsi(df) -> float:
|
||||
try:
|
||||
from engine.indicators import rsi
|
||||
return float(rsi(df["close"], 14).iloc[-2])
|
||||
except Exception:
|
||||
return float("nan")
|
||||
|
||||
|
||||
async def main():
|
||||
if not broker.connect():
|
||||
logger.error("Broker connect failed")
|
||||
return
|
||||
|
||||
# immediate scan on startup
|
||||
await scan_once("startup")
|
||||
await _dashboard()
|
||||
|
||||
# then loop every 60s: refresh dashboard + rescan on each new bar
|
||||
last_scan_minute = -1
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = datetime.now(timezone.utc)
|
||||
await _dashboard()
|
||||
|
||||
# rescan at each new M15 bar (minute % 15 == 0..2 window)
|
||||
if now.minute % 15 < 2 and now.minute != last_scan_minute:
|
||||
last_scan_minute = now.minute
|
||||
await scan_once(f"bar@{now.strftime('%H:%M')}")
|
||||
await _dashboard()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
broker.disconnect()
|
||||
print("\nBot stopped. Final state:")
|
||||
await _dashboard()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Trend-Filtered RSI Pullback Strategy
|
||||
=====================================
|
||||
|
||||
Core idea
|
||||
---------
|
||||
Use the H1 EMA(50)/EMA(200) crossover to establish the macro trend, then
|
||||
enter M15 trades only in the trend direction when RSI pulls back to an
|
||||
extreme and *exits* that extreme. This turns RSI from a counter-trend tool
|
||||
into a high-probability trend-entry timer.
|
||||
|
||||
Entry logic
|
||||
-----------
|
||||
LONG : H1 EMA(50) > EMA(200) [bullish bias] AND M15 RSI crosses back
|
||||
above `os_level` from below (RSI was oversold, now recovering).
|
||||
SHORT : H1 EMA(50) < EMA(200) [bearish bias] AND M15 RSI crosses back
|
||||
below `ob_level` from above (RSI was overbought, now rolling over).
|
||||
|
||||
Exits
|
||||
-----
|
||||
TP : entry ± tp_atr × ATR(14)
|
||||
SL : entry ∓ sl_atr × ATR(14)
|
||||
|
||||
Session filter
|
||||
--------------
|
||||
Only trades during [session_start, session_end) UTC to avoid thin
|
||||
Asian-overnight and weekend conditions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from engine import indicators as ind
|
||||
|
||||
|
||||
class RSITrendStrategy:
|
||||
def __init__(
|
||||
self,
|
||||
rsi_period: int = 14,
|
||||
atr_period: int = 14,
|
||||
os_level: float = 30.0, # oversold threshold
|
||||
ob_level: float = 70.0, # overbought threshold
|
||||
sl_atr: float = 1.5,
|
||||
tp_atr: float = 3.0,
|
||||
trend_fast: int = 50, # H1 EMA periods
|
||||
trend_slow: int = 200,
|
||||
session_start: int = 7, # UTC hour (inclusive)
|
||||
session_end: int = 20, # UTC hour (exclusive)
|
||||
):
|
||||
self.rsi_period = rsi_period
|
||||
self.atr_period = atr_period
|
||||
self.os_level = os_level
|
||||
self.ob_level = ob_level
|
||||
self.sl_atr = sl_atr
|
||||
self.tp_atr = tp_atr
|
||||
self.trend_fast = trend_fast
|
||||
self.trend_slow = trend_slow
|
||||
self.session_start = session_start
|
||||
self.session_end = session_end
|
||||
|
||||
# ── Strategy protocol ─────────────────────────────────────────────────────
|
||||
|
||||
def prepare(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
# M15 indicators
|
||||
df["rsi"] = ind.rsi(df["close"], self.rsi_period)
|
||||
df["atr"] = ind.atr(df, self.atr_period)
|
||||
|
||||
# Build H1 trend from M15 data (resample, compute EMAs, forward-fill)
|
||||
h1 = df["close"].resample("1h", label="left", closed="left").last().dropna()
|
||||
h1_ema_fast = ind.ema(h1, self.trend_fast)
|
||||
h1_ema_slow = ind.ema(h1, self.trend_slow)
|
||||
|
||||
df["h1_ema_fast"] = h1_ema_fast.reindex(df.index, method="ffill")
|
||||
df["h1_ema_slow"] = h1_ema_slow.reindex(df.index, method="ffill")
|
||||
|
||||
# EMA spread: positive = bullish, negative = bearish
|
||||
df["trend"] = df["h1_ema_fast"] - df["h1_ema_slow"]
|
||||
|
||||
return df
|
||||
|
||||
def signal(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
i: int,
|
||||
) -> Optional[tuple[str, float, float]]:
|
||||
if i < 1:
|
||||
return None
|
||||
|
||||
row = df.iloc[i]
|
||||
prev = df.iloc[i - 1]
|
||||
|
||||
# guard: need valid indicators
|
||||
if pd.isna(row["rsi"]) or pd.isna(row["atr"]) or pd.isna(row["trend"]):
|
||||
return None
|
||||
if pd.isna(prev["rsi"]):
|
||||
return None
|
||||
|
||||
# session filter
|
||||
hour = row.name.hour
|
||||
if not (self.session_start <= hour < self.session_end):
|
||||
return None
|
||||
|
||||
rsi_now = row["rsi"]
|
||||
rsi_prev = prev["rsi"]
|
||||
trend = row["trend"]
|
||||
atr = row["atr"]
|
||||
|
||||
# entry is on next bar's open — approximate SL/TP from current close
|
||||
ref = row["close"]
|
||||
|
||||
# LONG: bullish trend AND RSI exits oversold
|
||||
if trend > 0 and rsi_prev < self.os_level and rsi_now >= self.os_level:
|
||||
sl = ref - self.sl_atr * atr
|
||||
tp = ref + self.tp_atr * atr
|
||||
return "long", sl, tp
|
||||
|
||||
# SHORT: bearish trend AND RSI exits overbought
|
||||
if trend < 0 and rsi_prev > self.ob_level and rsi_now <= self.ob_level:
|
||||
sl = ref + self.sl_atr * atr
|
||||
tp = ref - self.tp_atr * atr
|
||||
return "short", sl, tp
|
||||
|
||||
return None
|
||||
|
||||
def describe(self, params: Optional[dict] = None) -> str:
|
||||
p = params or {}
|
||||
return (
|
||||
f"RSITrend("
|
||||
f"os={p.get('os_level', self.os_level)}, "
|
||||
f"ob={p.get('ob_level', self.ob_level)}, "
|
||||
f"sl={p.get('sl_atr', self.sl_atr)}, "
|
||||
f"tp={p.get('tp_atr', self.tp_atr)}, "
|
||||
f"fast={p.get('trend_fast', self.trend_fast)}, "
|
||||
f"slow={p.get('trend_slow', self.trend_slow)})"
|
||||
)
|
||||
Reference in New Issue
Block a user