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:
2026-04-28 21:09:12 +02:00
commit ad8dfa27d7
32 changed files with 2644 additions and 0 deletions
+10
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
+321
View File
@@ -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:0020: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)
View File
+77
View File
@@ -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}")
+22
View File
@@ -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,
)
+51
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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()
+341
View File
@@ -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
View File
@@ -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
+91
View File
@@ -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
View File
@@ -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
}
View File
+169
View File
@@ -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)
+40
View File
@@ -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()
+40
View File
@@ -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()
+109
View File
@@ -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]),
)
+62
View File
@@ -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
+52
View File
@@ -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())
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

+97
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
pandas>=2.0.0
numpy>=1.24.0
matplotlib>=3.7.0
+224
View File
@@ -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()
+163
View File
@@ -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
View File
@@ -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())
View File
+138
View File
@@ -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)})"
)