commit ad8dfa27d7958ca66176466562aa23361f2d68d7 Author: Jonathan Date: Tue Apr 28 21:09:12 2026 +0200 Initial commit: multi-symbol bot with backtest engine and RSI trend strategy Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1dd9f6a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0988811 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/backtest.py b/backtest.py new file mode 100644 index 0000000..aaad9b9 --- /dev/null +++ b/backtest.py @@ -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) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/alerts.py b/bot/alerts.py new file mode 100644 index 0000000..b45b3ee --- /dev/null +++ b/bot/alerts.py @@ -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"[{mode}] {arrow} {symbol}\n" + f"Entry : {entry:.5f}\n" + f"SL : {sl:.5f}\n" + f"TP : {tp:.5f}\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"{sign} CLOSED {symbol}\n" + f"Dir : {direction.upper()}\n" + f"Entry : {entry:.5f}{exit_price:.5f}\n" + f"P&L : {pnl_pips:+.1f} pips\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"🚨 ERROR: {message}") diff --git a/bot/broker.py b/bot/broker.py new file mode 100644 index 0000000..79662f1 --- /dev/null +++ b/bot/broker.py @@ -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, + ) diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..36a4ee6 --- /dev/null +++ b/bot/config.py @@ -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", "") diff --git a/bot/loop.py b/bot/loop.py new file mode 100644 index 0000000..584d1e8 --- /dev/null +++ b/bot/loop.py @@ -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 diff --git a/bot/mt5_client.py b/bot/mt5_client.py new file mode 100644 index 0000000..6b7a85b --- /dev/null +++ b/bot/mt5_client.py @@ -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() diff --git a/bot/paper_client.py b/bot/paper_client.py new file mode 100644 index 0000000..bdf9cfc --- /dev/null +++ b/bot/paper_client.py @@ -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} diff --git a/bot/risk.py b/bot/risk.py new file mode 100644 index 0000000..d6b5d34 --- /dev/null +++ b/bot/risk.py @@ -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 diff --git a/bot/signals.py b/bot/signals.py new file mode 100644 index 0000000..f2a8885 --- /dev/null +++ b/bot/signals.py @@ -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 diff --git a/config.json b/config.json new file mode 100644 index 0000000..af1d296 --- /dev/null +++ b/config.json @@ -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 +} diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/backtest.py b/engine/backtest.py new file mode 100644 index 0000000..7897ea6 --- /dev/null +++ b/engine/backtest.py @@ -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) diff --git a/engine/data.py b/engine/data.py new file mode 100644 index 0000000..f51cb0e --- /dev/null +++ b/engine/data.py @@ -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() diff --git a/engine/indicators.py b/engine/indicators.py new file mode 100644 index 0000000..84aef0a --- /dev/null +++ b/engine/indicators.py @@ -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() diff --git a/engine/metrics.py b/engine/metrics.py new file mode 100644 index 0000000..bef62b2 --- /dev/null +++ b/engine/metrics.py @@ -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]), + ) diff --git a/engine/optimizer.py b/engine/optimizer.py new file mode 100644 index 0000000..9b3a6c7 --- /dev/null +++ b/engine/optimizer.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..ae2012e --- /dev/null +++ b/main.py @@ -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()) diff --git a/reports/__init__.py b/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports/baseline.png b/reports/baseline.png new file mode 100644 index 0000000..b348652 Binary files /dev/null and b/reports/baseline.png differ diff --git a/reports/chart.py b/reports/chart.py new file mode 100644 index 0000000..65fb00e --- /dev/null +++ b/reports/chart.py @@ -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 diff --git a/reports/improved_default.png b/reports/improved_default.png new file mode 100644 index 0000000..67f0dcd Binary files /dev/null and b/reports/improved_default.png differ diff --git a/reports/optimised_full.png b/reports/optimised_full.png new file mode 100644 index 0000000..4b9d6da Binary files /dev/null and b/reports/optimised_full.png differ diff --git a/reports/walkforward_oos.png b/reports/walkforward_oos.png new file mode 100644 index 0000000..ac9ca51 Binary files /dev/null and b/reports/walkforward_oos.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..337be56 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pandas>=2.0.0 +numpy>=1.24.0 +matplotlib>=3.7.0 diff --git a/run_backtest.py b/run_backtest.py new file mode 100644 index 0000000..452376b --- /dev/null +++ b/run_backtest.py @@ -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() diff --git a/run_multi_symbol.py b/run_multi_symbol.py new file mode 100644 index 0000000..a6ca74c --- /dev/null +++ b/run_multi_symbol.py @@ -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() diff --git a/run_paper.py b/run_paper.py new file mode 100644 index 0000000..019d885 --- /dev/null +++ b/run_paper.py @@ -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()) diff --git a/strategies/__init__.py b/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/strategies/rsi_trend.py b/strategies/rsi_trend.py new file mode 100644 index 0000000..b4c41eb --- /dev/null +++ b/strategies/rsi_trend.py @@ -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)})" + )