Skip to content

2024 · Solo design, Python · experiment

Python Algo

XAUUSD algorithmic trading system with institutional-grade backtesting. Dual MA crossover, grid-search optimisation across 5 timeframes, honest about what the backtester does not model.

2.5 : 1 reward-to-risk ratio, 29% win-rate threshold for profitability

  • Python 3.8+
  • MetaTrader 5
  • Pandas
  • NumPy
  • Matplotlib
  • Telegram Bot API

A dual moving-average crossover is the oldest trick in technical analysis. It also quietly loses money on most instruments across most parameter combinations. What makes it useful as a system is not the crossover rule. It is the discipline around risk, the grid-search honesty about where profitability lives, and the willingness to name what the backtester does not model.

Context

What Python Algo is

Python Algo is an algorithmic trading system built for XAUUSD, the spot gold pair on MetaTrader 5. It runs two distinct modes: a backtester that replays historical data across a grid of parameter combinations, and a live trading module that connects to a running MT5 terminal, executes orders, and dispatches Telegram notifications on every position event. Both modes share the same strategy, risk, and position management logic so that what the backtester evaluates is what the live module actually runs.

The system targets five timeframes simultaneously: M15, M30, H1, H4, and D1. For each timeframe the backtester sweeps fast and slow moving-average windows across a defined search space and records profit factor, drawdown, and trade count for every combination. The results are not cherry-picked. Most combinations lose money. The grid is surfaced in full so the parameter selection has a documented basis rather than a chosen result.

This is an experiment, not a product. Python Algo was built to understand what it actually takes to go from a strategy idea to a system that can connect to real broker infrastructure, enforce risk rules mechanically, and log its own reasoning. It is not an endorsement of algorithmic trading as a reliable income strategy, and no performance claims are made beyond what the backtester measured on historical XAUUSD data. Past backtest results do not predict live trading outcomes, especially when the backtester cannot model slippage, liquidity gaps, or broker spread variance at execution time.

Architecture

Abstract base classes, swappable components

The system is structured around Python's abc module. Three abstract base classes define the contracts: BaseStrategy in the strategies package, BaseTrader in the traders package, and BaseConnector in the connectors package. Concrete implementations fill those contracts. mt5_connector.py implements BaseConnector for the MetaTrader 5 API. live_trader.py implements BaseTrader for live execution. The dual-MA crossover logic lives in a concrete BaseStrategy subclass.

This separation was not academic. The Factory pattern in strategies/factory.py uses it directly: to run a grid search, the factory spins up strategy instances with different fast and slow window parameters and passes each one the same historical data. Because every strategy instance satisfies the same interface, the backtesting loop does not need to know what kind of strategy it is running. It calls the same methods, compares the same return shape, and stores the results. Adding a new strategy means subclassing BaseStrategy, not modifying the backtesting loop.

The chart below is an illustrative rendering. It is shaped like a plausible backtest output but is not a real equity curve from a specific run.

The Telegram integration follows an Observer pattern. telegram_notifier.py subscribes to position events rather than being called inline from the trading loop. This matters because Telegram's HTTP API is blocking I/O. If the notifier call sat inside the trade execution path, a slow or failed Telegram response would hold up the next tick evaluation. Decoupling it means a notification failure does not stall the trading loop. The data pipeline is straightforward: the MT5 connector pulls OHLCV data as a Pandas DataFrame, the strategy computes indicator columns on that DataFrame, and the trader reads signal columns to decide whether to open, close, or adjust positions.

01 · ABC pattern for swappable components

The three abstract base classes define what each component must do without specifying how. BaseStrategy declares methods for computing signals on a price DataFrame. BaseTrader declares methods for opening and closing positions. BaseConnector declares methods for fetching market data and submitting orders. Nothing in the backtesting loop imports a concrete class directly.

base classes (simplified)
from abc import ABC, abstractmethod
import pandas as pd

class BaseStrategy(ABC):
  def __init__(self, fast: int, slow: int):
      self.fast = fast
      self.slow = slow

  @abstractmethod
  def generate_signals(self, df: pd.DataFrame) -> pd.DataFrame:
      """Return df with a 'signal' column: 1 buy, -1 sell, 0 flat."""
      ...

class BaseConnector(ABC):
  @abstractmethod
  def get_ohlcv(self, symbol: str, timeframe: str, count: int) -> pd.DataFrame: ...

  @abstractmethod
  def place_order(self, symbol: str, order_type: str, volume: float, **kwargs) -> dict: ...

The Factory pattern in strategies/factory.py constructs strategy instances from a parameter dictionary. During grid search, the outer loop iterates over all (fast, slow) pairs, calls the factory for each, passes the resulting strategy instance to the backtesting runner, and stores the metrics. The loop is generic. The factory is the only place where a concrete class name appears.

02 · Grid search honesty and look-ahead prevention

The grid search sweeps fast windows from 5 to 19, slow windows from 20 to 49, across five timeframes and nine months of historical XAUUSD data. That is roughly 450 strategy configurations per timeframe. Most lose money. The search is brute-force and the full results are retained, not filtered to show only the profitable configurations.

Look-ahead bias is the most common silent error in backtesting. The naive implementation computes a fast moving average on the current bar and compares it to a slow moving average on the current bar, then decides whether to trade that bar based on the result. The problem is that in live trading, you cannot know the current bar's closing price until the bar closes. The backtester has it, which means it can use future information to make a past decision. To prevent this, the signal comparison uses .shift(1) on both moving average columns before evaluating the crossover condition.

look-ahead prevention
# Without .shift(1): look-ahead bias present
df['signal'] = np.where(df['fast_ma'] > df['slow_ma'], 1, -1)

# With .shift(1): uses prior bar values, matching live behaviour
df['signal'] = np.where(
  df['fast_ma'].shift(1) > df['slow_ma'].shift(1), 1, -1
)

The .shift(1) fix is a one-line change that meaningfully changes the result distribution. Without it, the backtester silently inflates performance on fast crossover strategies because it peeks at the bar that triggered the signal before that bar has closed. With it, the backtester matches the information available to the live module at decision time. The best performing configuration in the corrected results sits near fast=19, slow=20 on H1, which is a near-adjacent pair. That narrow edge is not surprising for a mean-reverting instrument like gold.

03 · Risk model and reward-to-risk math

Position sizing uses a fixed-dollar risk model. Each trade risks a fixed amount regardless of account balance or recent performance. Position size is calculated as:

position sizing
# Fixed risk per trade
risk_amount = 200  # USD

# Stop distance in price units, converted to pips
sl_distance_pips = max(sl_distance / pip_value, 10)  # 10 pip minimum floor

# Lot size
position_size = risk_amount / (sl_distance_pips * pip_value_per_lot)

Stop-loss placement follows ICT swing high/low logic: the stop sits beyond the most recent structural high or low, with a 2x spread buffer added to reduce the chance of a noise-driven stop-out. The minimum floor is 10 pips regardless of the swing distance, which prevents micro-stop positions in low-volatility conditions.

The reward-to-risk target is 2.5:1. At that ratio, the system needs a win rate above approximately 29% to be profitable in expectancy terms. The breakeven calculation is 1 / (1 + R:R), which at 2.5:1 gives roughly 28.6%. The system also applies a partial close at 1:1 reward: 50% of the position is closed at the first target, and the stop is moved to entry plus a 3-pip buffer. This locks in a partial profit on half the position while leaving the other half to run toward the 2.5:1 target. The net effect on the expectancy calculation is that the system needs a slightly lower raw win rate to break even, because the partial close at 1:1 means a stopped-out second half still contributed some profit on the first half.

  • 2.5 : 1

    reward-to-risk ratio per trade

  • 29%

    minimum win rate for profitability at 2.5:1 R:R

  • $200

    fixed-dollar risk per trade

  • 5

    timeframes tested (M15, M30, H1, H4, D1)

  • 19 / 20

    best MA crossover pair on H1 (grid search result)

Learnings

  1. Profit factor and maximum drawdown duration tell you more about a strategy than Sharpe ratio. Sharpe ratio rewards consistency but penalises positive outliers. A strategy with a profit factor above 1.5 and a drawdown that recovers within two weeks of the sample period is more useful than one with a high Sharpe and a single catastrophic drawdown that wipes the cumulative gain. The grid search surfaced several configurations that looked attractive on Sharpe alone and would have been a poor choice.
  2. The .shift(1) look-ahead error is easy to miss and expensive to ignore. It does not produce an obvious wrong answer. The backtester runs without errors. The results look plausible. The inflated performance only becomes visible when you compare against a corrected backtest on the same data. The fix is one line; the diagnostic cost of discovering you need it is much higher.
  3. The Observer pattern for Telegram notifications was worth the extra structure. The first implementation called the Telegram API inline after every position event. During testing, a slow Telegram response stalled the next price evaluation by several seconds. In a fast-moving market that delay has consequences. Moving the notification to an observer that runs outside the main execution path eliminated the stall without changing the notification behaviour.
  4. MetaTrader 5's Python API being Windows-only is a real infrastructure constraint, not a minor detail. A cloud deployment would require a Windows VM with a running MT5 terminal and an active broker connection. The practical consequence is that the live trading module runs on a laptop. Any strategy that depends on 24-hour uptime needs a different deployment approach, and that decision belongs in the system design phase, not after the code is written.

FAQ

Does it trade live now?
No. The live trading module works and connects to MT5 successfully, but it is not running in production. The project is an experiment in building and validating the system, not an active trading operation. Running it live would require continuous Windows uptime, ongoing monitoring, and a risk tolerance for real capital loss that goes beyond what this project was designed to test.
Why XAUUSD and not forex majors?
Gold has distinct volatility characteristics and tends to trend more clearly than EUR/USD or GBP/USD on certain timeframes. The ICT swing high/low stop-loss logic also tends to work more cleanly on gold because the swing structure is more pronounced. The parameter search was not run on other instruments, so there is no basis for claiming the results transfer.
Is this profitable?
The backtester found configurations with positive expectancy on historical XAUUSD data. Whether those configurations would remain profitable in live trading is a separate question. The backtester does not model slippage, gaps, or liquidity, which means the live result will differ from the simulation. No capital was traded in production, so there is no live performance record.
Why MT5 and not a backtest-only framework like Backtrader or Zipline?
The goal was not just to backtest a strategy but to build a complete system that could connect to real broker infrastructure. MT5 provides the data feed, the order execution API, and the account management layer in one place. Backtrader is a better pure backtesting tool, but it does not produce a system that can go live without a significant rewrite. Building against MT5 from the start meant the backtesting and live modules share the same connector interface.