diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..947c63a --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,1578 @@ +# aiomql — The Complete Guide to Building Algorithmic Trading Bots with Python & MetaTrader 5 + +> **An in-depth tutorial covering every feature of the aiomql framework — from your first async connection to production-grade, multi-instrument trading bots.** + +--- + +## Table of Contents + +1. [What is aiomql?](#what-is-aiomql) +2. [Installation & Requirements](#installation--requirements) +3. [Configuration](#configuration) +4. [Connecting to MetaTrader 5](#connecting-to-metatrader-5) +5. [Working with Symbols](#working-with-symbols) +6. [Market Data: Candles & Ticks](#market-data-candles--ticks) +7. [Technical Analysis](#technical-analysis) +8. [Placing Orders](#placing-orders) +9. [The Trader Abstraction](#the-trader-abstraction) +10. [Risk & Money Management (RAM)](#risk--money-management-ram) +11. [Building a Strategy](#building-a-strategy) +12. [Trading Sessions](#trading-sessions) +13. [The Bot Orchestrator](#the-bot-orchestrator) +14. [Position Tracking](#position-tracking) +15. [Trade Result Recording](#trade-result-recording) +16. [Trade History](#trade-history) +17. [Contributed Extensions](#contributed-extensions) +18. [Multi-Process Execution](#multi-process-execution) +19. [Synchronous API](#synchronous-api) +20. [Full Example: EMA Crossover Bot](#full-example-ema-crossover-bot) +21. [Architecture Overview](#architecture-overview) +22. [Summary](#summary) + +--- + +## What is aiomql? + +**aiomql** is a Python framework for building algorithmic trading bots on top of +[MetaTrader 5](https://www.metatrader5.com/). Rather than writing raw MT5 API calls +and managing connection boilerplate, aiomql gives you: + +- **An async-first MetaTrader 5 interface** — every MT5 function wrapped with + `asyncio.to_thread` and automatic reconnection on transient errors. +- **High-level abstractions** — Strategy, Bot, Trader, Order, Symbol, Sessions, RAM, + and more — so you can focus on trading logic. +- **A full synchronous API** — every async class has a sync counterpart for scripts, + notebooks, and quick prototyping. +- **Built-in technical analysis** — pandas-ta integration with optional TA-Lib support. +- **Trade recording** — persist results to CSV, JSON, or SQLite automatically. +- **Position tracking** — monitor open positions with trailing stops, extending + take-profits, hedging, and stacking. +- **Multi-process execution** — run independent bots in parallel with a single call. + +aiomql is designed for traders who want to build, test, and deploy algorithmic +strategies in Python without reinventing the wheel. + +--- + +## Installation & Requirements + +### Prerequisites + +| Requirement | Details | +|---|---| +| **Python** | ≥ 3.13 | +| **OS** | Windows (MetaTrader 5 terminal requirement) | +| **Account** | A MetaTrader 5 trading account (demo or live) | + +### Install via pip + +```bash +pip install aiomql +``` + +### Optional Extras + +```bash +# TA-Lib technical indicators +pip install aiomql[talib] + +# Optional accelerators (Cython, Numba, tqdm) +pip install aiomql[optional] + +# Everything +pip install aiomql[all] +``` + +--- + +## Configuration + +aiomql uses a **singleton `Config` class** that can load settings from a JSON file +or be configured programmatically. This single configuration object is shared across +all components. + +### Option 1: JSON Configuration File + +Create an `aiomql.json` file in your project root: + +```json +{ + "login": 12345678, + "password": "your_password", + "server": "YourBroker-Demo", + "trade_record_mode": "csv" +} +``` + +The `Config` class automatically searches for `aiomql.json` in your project directory +tree, starting from the current working directory and walking up to the root. + +### Option 2: Programmatic Configuration + +```python +from aiomql import Config + +config = Config( + login=12345678, + password="your_password", + server="YourBroker-Demo", + trade_record_mode="csv" +) +``` + +### Key Configuration Options + +| Setting | Type | Description | +|---|---|---| +| `login` | `int` | MetaTrader account number | +| `password` | `str` | Account password | +| `server` | `str` | Broker server name | +| `path` | `str` | Path to the MT5 terminal executable | +| `trade_record_mode` | `str` | Trade logging format: `"csv"`, `"json"`, or `"sql"` | +| `root` | `str` | Project root directory | + +Because `Config` is a **singleton**, any component in your application that creates +a `Config()` instance receives the same shared configuration. + +### State & Store + +The `Config` class also provides access to two persistent storage mechanisms: + +- **`config.state`** — A `State` object for persistent key-value storage (backed by SQLite). +- **`config.store`** — A `Store` object for in-memory shared state accessible across components. + +These are useful for sharing data between strategies, trackers, and other components at runtime. + +--- + +## Connecting to MetaTrader 5 + +The `MetaTrader` class wraps every MT5 API function with async execution and +automatic retry logic. It supports both async context manager usage and direct calls. + +### Basic Connection + +```python +import asyncio +from aiomql import MetaTrader + + +async def main(): + async with MetaTrader() as mt5: + # Get account information + account = await mt5.account_info() + print(f"Balance: {account.balance}") + print(f"Equity: {account.equity}") + + # Get terminal info + terminal = await mt5.terminal_info() + print(f"Terminal: {terminal.name}") + + # Get available symbols + symbols = await mt5.symbols_get() + print(f"{len(symbols)} symbols available") + + +asyncio.run(main()) +``` + +### How It Works + +Under the hood, `MetaTrader` runs every MT5 call through `asyncio.to_thread`, which +means your event loop stays responsive even during blocking I/O. If a connection +error occurs, the `_handler` method automatically retries initialization and login up +to 3 times. + +```python +# The MetaTrader class is also a singleton — every instance shares the same state +mt5_a = MetaTrader() +mt5_b = MetaTrader() +assert mt5_a is mt5_b # True +``` + +### Key Methods + +| Method | Description | +|---|---| +| `initialize()` | Connect to the MT5 terminal | +| `login()` | Log in with credentials | +| `shutdown()` | Close the connection | +| `account_info()` | Get account details | +| `terminal_info()` | Get terminal details | +| `symbols_get()` | List available symbols | +| `symbol_info()` | Get info for a specific symbol | +| `copy_rates_from()` | Fetch OHLC bar data | +| `copy_rates_from_pos()` | Fetch recent bars by count | +| `copy_ticks_from()` | Fetch tick data | +| `order_check()` | Check if an order can be executed | +| `order_send()` | Send a trade order | +| `positions_get()` | Get open positions | +| `history_deals_get()` | Get historical deals | +| `history_orders_get()` | Get historical orders | + +--- + +## Working with Symbols + +The `Symbol` class is a high-level wrapper around a financial instrument. It pulls +properties from MT5 (spread, lot sizes, contract size, etc.) and provides methods +to fetch market data. + +### Initializing a Symbol + +```python +from aiomql import Symbol + + +async def main(): + symbol = Symbol(name="EURUSD") + await symbol.initialize() + + # Now you can access all symbol properties + print(f"Spread: {symbol.spread}") + print(f"Point: {symbol.point}") + print(f"Min volume: {symbol.volume_min}") + print(f"Max volume: {symbol.volume_max}") + print(f"Volume step: {symbol.volume_step}") + print(f"Contract size: {symbol.trade_contract_size}") +``` + +### Getting Price Data + +```python +# Get the current tick +tick = await symbol.info_tick() +print(f"Bid: {tick.bid}, Ask: {tick.ask}") + +# Get the last 500 H1 candles +from aiomql import TimeFrame + +candles = await symbol.copy_rates_from_pos( + timeframe=TimeFrame.H1, count=500 +) +``` + +### Volume Utilities + +The `Symbol` class includes helpers for working with lot sizes: + +```python +# Check if a volume is valid +is_valid, adjusted = symbol.check_volume(volume=0.15) + +# Round volume to the nearest step +rounded = symbol.round_off_volume(volume=0.153, round_down=True) +``` + +### Currency Conversion + +```python +# Convert between currencies using live rates +amount_in_usd = await symbol.convert_currency( + amount=100.0, + from_currency="EUR", + to_currency="USD" +) +``` + +### ForexSymbol — Specialized for Forex + +The `ForexSymbol` class extends `Symbol` with forex-specific calculations: + +```python +from aiomql import ForexSymbol + +eurusd = ForexSymbol(name="EURUSD") +await eurusd.initialize() + +# Pip value (point * 10) +print(f"Pip: {eurusd.pip}") # 0.0001 for most pairs + +# Compute volume based on risk amount and stop loss distance +volume = await eurusd.compute_volume_sl( + amount=100.0, # risk $100 + price=1.1000, # entry price + sl=1.0950, # stop loss + round_down=True +) + +# Compute volume based on risk amount and points +volume = await eurusd.compute_volume_points( + amount=100.0, + points=500, + round_down=True +) + +# Compute points needed for a given profit at a given volume +points = eurusd.compute_points(amount=50.0, volume=0.1) +``` + +--- + +## Market Data: Candles & Ticks + +### Candles + +The `Candles` class wraps pandas `DataFrame` with trading-specific functionality. +When you fetch rates from a `Symbol`, you receive a `Candles` object: + +```python +from aiomql import TimeFrame + +candles = await symbol.copy_rates_from_pos( + timeframe=TimeFrame.H1, count=500 +) + +# It's a DataFrame under the hood +print(candles.data.head()) + +# Access individual candles by index +last_candle = candles[-1] +print(f"Open: {last_candle.open}, Close: {last_candle.close}") + +# Candle properties +print(f"Body: {last_candle.body}") +print(f"Range: {last_candle.range}") +print(f"Is Bullish: {last_candle.is_bullish()}") +print(f"Is Bearish: {last_candle.is_bearish()}") +print(f"Is Doji: {last_candle.is_doji()}") + +# DataFrame columns are accessible directly +print(candles.open) # Series of open prices +print(candles.close) # Series of close prices +print(candles.high) # Series of high prices +print(candles.low) # Series of low prices +``` + +### Renaming Columns + +```python +# Rename DataFrame columns (useful after adding indicators) +candles.rename( + EMA_34="fast_ema", + EMA_55="slow_ema", + inplace=True +) +``` + +### Ticks + +The `Tick` class represents a single price tick, and `Ticks` is a collection: + +```python +# Get recent ticks +ticks = await symbol.copy_ticks_from( + date_from=datetime.now() - timedelta(minutes=5), + count=1000 +) + +# Access tick data +for tick in ticks: + print(f"Time: {tick.time}, Bid: {tick.bid}, Ask: {tick.ask}") + +# The Ticks object also supports technical analysis +ticks.ta.sma(length=20, append=True) +``` + +--- + +## Technical Analysis + +aiomql integrates **pandas-ta** (classic) out of the box, available through the +`.ta` accessor on `Candles` and `Ticks` objects. Every pandas-ta indicator is available +directly. + +### Using pandas-ta Indicators + +```python +candles = await symbol.copy_rates_from_pos( + timeframe=TimeFrame.H1, count=500 +) + +# Moving averages +candles.ta.sma(length=20, append=True) +candles.ta.ema(length=50, append=True) + +# RSI +candles.ta.rsi(length=14, append=True) + +# MACD +candles.ta.macd(fast=12, slow=26, signal=9, append=True) + +# Bollinger Bands +candles.ta.bbands(length=20, std=2, append=True) + +# ATR +candles.ta.atr(length=14, append=True) + +# Stochastic +candles.ta.stoch(k=14, d=3, append=True) + +# Access the results as DataFrame columns +print(candles.data.columns.tolist()) +``` + +### The ta_lib Helper + +The `Candles` object also includes a `ta_lib` helper with convenience functions: + +```python +# Check if series A is above series B +above = candles.ta_lib.above(candles.fast_ema, candles.slow_ema) + +# Check if series A is below series B +below = candles.ta_lib.below(candles.fast_ema, candles.slow_ema) +``` + +### Optional: TA-Lib Integration + +If you install `aiomql[talib]`, you get access to the full C-based TA-Lib library +for high-performance indicator computation. + +--- + +## Placing Orders + +The `Order` class handles all trade order operations. It's a subclass of +`TradeRequest` and provides methods for checking, sending, and managing orders. + +### Creating and Sending a Market Order + +```python +from aiomql import Order, OrderType, TradeAction + +# Create a buy order +order = Order( + symbol="EURUSD", + type=OrderType.BUY, + volume=0.1, + price=1.1000, + sl=1.0950, + tp=1.1100 +) + +# Check if the order can be executed +check_result = await order.check() +print(f"Margin required: {check_result.margin}") + +# Send the order +result = await order.send() +print(f"Order ticket: {result.order}") +print(f"Return code: {result.retcode}") +``` + +### Modifying an Order + +```python +order.modify( + sl=1.0960, + tp=1.1120 +) +``` + +### Margin and Profit Calculations + +```python +# Calculate required margin +margin = await order.calc_margin() +print(f"Margin needed: {margin}") + +# Calculate expected profit (at take-profit level) +profit = await order.calc_profit() +print(f"Expected profit: {profit}") + +# Calculate expected loss (at stop-loss level) +loss = await order.calc_loss() +print(f"Expected loss: {loss}") +``` + +### Pending Order Management + +```python +# Get total pending orders +total = await Order.orders_total() + +# Get all pending orders +pending = await Order.get_pending_orders() + +# Get a specific pending order by ticket +order = await Order.get_pending_order(ticket=123456) + +# Cancel a pending order +await Order.cancel_order(order=123456, symbol="EURUSD") +``` + +### Connection Retry Logic + +The `send_order` method automatically retries on connection errors (retcode 10031) +with exponential backoff, up to 3 retries. + +--- + +## The Trader Abstraction + +While `Order` handles the low-level mechanics, the `Trader` class provides a +higher-level interface for placing trades with proper risk management and trade +recording. + +### Why Use Trader? + +`Trader` ties together the `Order`, `Symbol`, and `RAM` (risk manager) classes +into a cohesive workflow: + +1. **Calculate position size** based on your risk parameters +2. **Set stop-loss and take-profit** levels +3. **Check the order** before sending +4. **Send the order** and handle errors +5. **Record the trade** to your chosen storage format + +### Creating Orders with Stops + +```python +from aiomql import Trader, ForexSymbol, RAM, OrderType + +symbol = ForexSymbol(name="EURUSD") +await symbol.initialize() + +ram = RAM(risk=2, risk_to_reward=3) # 2% risk, 1:3 R:R +trader = Trader(symbol=symbol, ram=ram) + +# Create order with stop-loss and take-profit using absolute prices +await trader.create_order_with_stops( + order_type=OrderType.BUY, + sl=1.0950, + tp=1.1150 +) + +# Create order with just a stop-loss (TP calculated from R:R ratio) +await trader.create_order_with_sl( + order_type=OrderType.BUY, + sl=1.0950 +) + +# Create order using points distance +await trader.create_order_with_points( + order_type=OrderType.BUY, + points=500 +) + +# Create a simple order with no stops +await trader.create_order_no_stops( + order_type=OrderType.BUY, + volume=0.01 +) +``` + +### Checking and Sending + +```python +# Check if the order passes broker validation +can_trade = await trader.check_order() + +if can_trade: + result = await trader.send_order() +``` + +### Recording Trades + +After sending, trades are automatically recorded via the `record_trade` method: + +```python +await trader.record_trade( + result=result, + parameters={"strategy": "EMA Crossover"}, + name="EMAXOver", + expected_profit=25.50 +) +``` + +### Pre-Built Traders + +aiomql ships with two ready-to-use `Trader` subclasses in the `contrib` package: + +#### SimpleTrader + +Places trades with a specified stop-loss. Volume is calculated based on the RAM +risk amount and the distance to the stop-loss. + +```python +from aiomql import SimpleTrader, ForexSymbol, OrderType + +symbol = ForexSymbol(name="EURUSD") +trader = SimpleTrader(symbol=symbol) + +await trader.place_trade( + order_type=OrderType.BUY, + sl=1.0950, + parameters={"name": "MyStrategy"} +) +``` + +#### ScalpTrader + +Places trades using minimum volume and no stop-loss/take-profit levels. +Ideal for scalping strategies where positions are managed manually. + +```python +from aiomql import ScalpTrader, ForexSymbol, OrderType + +symbol = ForexSymbol(name="EURUSD") +trader = ScalpTrader(symbol=symbol) + +await trader.place_trade( + order_type=OrderType.BUY, + parameters={"name": "ScalpBot"} +) +``` + +--- + +## Risk & Money Management (RAM) + +The `RAM` (Risk Assessment & Money) class is at the heart of aiomql's risk +management. It calculates position sizes, enforces trade limits, and manages +your exposure. + +### Configuration + +```python +from aiomql import RAM + +ram = RAM( + risk=2, # Risk 2% of free margin per trade + risk_to_reward=3, # 1:3 risk-to-reward ratio + min_amount=5, # Minimum $5 risk per trade + max_amount=500, # Maximum $500 risk per trade + loss_limit=3, # Max 3 losing positions at once + open_limit=5, # Max 5 total open positions + fixed_amount=None # Set to override percentage-based calculation +) +``` + +### Calculating Risk Amount + +```python +# Get the amount to risk per trade (based on free margin) +amount = await ram.get_amount() +print(f"Amount to risk: ${amount:.2f}") + +# If fixed_amount is set, it always returns that +ram.modify_ram(fixed_amount=100.0) +amount = await ram.get_amount() # Always $100 +``` + +### Enforcing Position Limits + +```python +# Check if we can open a new position +can_open = await ram.check_open_positions() +if not can_open: + print("Too many open positions!") + +# Check if we've hit the loss limit +can_trade = await ram.check_losing_positions() +if not can_trade: + print("Too many losing positions!") +``` + +### How the Amount is Calculated + +The calculation flow is: + +1. If `fixed_amount` is set → return `fixed_amount` +2. Otherwise → `margin_free × (risk / 100)` +3. If `min_amount` and `max_amount` are set → clamp the result + +--- + +## Building a Strategy + +The `Strategy` class is the cornerstone of aiomql. You subclass it, define your +parameters, and implement the `trade()` method. The framework handles everything +else — initialization, the execution loop, session management, and error handling. + +### Anatomy of a Strategy + +```python +from aiomql import Strategy, ForexSymbol, TimeFrame, Tracker, OrderType, Sessions, ScalpTrader + + +class MyStrategy(Strategy): + # Type annotations for strategy-specific parameters + ttf: TimeFrame + fast_period: int + slow_period: int + tracker: Tracker + + # Default parameter values — these become instance attributes + parameters = { + "ttf": TimeFrame.H1, + "fast_period": 20, + "slow_period": 50, + } + + def __init__(self, *, symbol: ForexSymbol, params: dict = None, + sessions: Sessions = None, name: str = "MyStrategy"): + super().__init__(symbol=symbol, params=params, sessions=sessions, name=name) + self.tracker = Tracker(snooze=self.ttf.seconds) + self.trader = ScalpTrader(symbol=self.symbol) + + async def trade(self): + """This method is called repeatedly by the execution loop.""" + # 1. Fetch market data + candles = await self.symbol.copy_rates_from_pos( + timeframe=self.ttf, count=200 + ) + + # 2. Apply indicators + candles.ta.sma(length=self.fast_period, append=True) + candles.ta.sma(length=self.slow_period, append=True) + candles.rename( + **{f"SMA_{self.fast_period}": "fast", + f"SMA_{self.slow_period}": "slow"}, + inplace=True + ) + + # 3. Check for signals + if candles.ta_lib.above(candles.fast, candles.slow).iloc[-1]: + self.tracker.update(order_type=OrderType.BUY, snooze=3600) + elif candles.ta_lib.below(candles.fast, candles.slow).iloc[-1]: + self.tracker.update(order_type=OrderType.SELL, snooze=3600) + else: + self.tracker.update(order_type=None, snooze=self.ttf.seconds) + + # 4. Execute or sleep + if self.tracker.order_type is not None: + await self.trader.place_trade( + order_type=self.tracker.order_type, + parameters=self.parameters + ) + await self.delay(secs=self.tracker.snooze) + else: + await self.sleep(secs=self.tracker.snooze) +``` + +### Key Concepts + +#### Parameters Dictionary + +The `parameters` class attribute defines default values for your strategy. When you +instantiate the strategy, you can override any parameter via the `params` argument: + +```python +strategy = MyStrategy( + symbol=ForexSymbol(name="EURUSD"), + params={"fast_period": 10, "slow_period": 30} +) +``` + +Parameters are also accessible as attributes thanks to `__getattr__`: + +```python +print(strategy.fast_period) # 10 +``` + +#### The Tracker + +The `Tracker` (from `aiomql.contrib.utils`) is a lightweight state holder for +strategies. It stores the current `order_type`, a `snooze` duration, and any extra +state you want to track: + +```python +from aiomql import Tracker + +tracker = Tracker(snooze=300) # default snooze of 5 minutes + +# Update tracker state +tracker.update( + order_type=OrderType.BUY, + snooze=3600, + trend="bullish", + last_price=1.1050 +) + +# Access state +print(tracker.order_type) # OrderType.BUY +print(tracker.trend) # "bullish" +``` + +#### Sleep vs Delay + +- **`self.sleep(secs=...)`** — Computes the exact time until the next bar opens, + ensuring your strategy wakes up right at the start of a new candle. This is + critical for cooperative multitasking. +- **`self.delay(secs=...)`** — A simple `asyncio.sleep` for the given duration. + +#### Initialization + +Override `initialize()` to run one-time setup. By default, it initializes the +symbol: + +```python +async def initialize(self): + """Called once before the strategy starts trading.""" + result = await self.symbol.initialize() + # Load historical data, train models, etc. + return result +``` + +#### Stopping a Strategy + +Raise `StopTrading` from within `trade()` to gracefully stop the strategy: + +```python +from aiomql.core.exceptions import StopTrading + +async def trade(self): + if some_condition: + raise StopTrading("Reached daily profit target") +``` + +--- + +## Trading Sessions + +Sessions let you restrict when a strategy can trade. This is essential for forex +markets where different sessions (London, New York, Tokyo) have different +characteristics. + +### Defining Sessions + +```python +from datetime import time +from aiomql import Session, Sessions + +# Define individual sessions (times are in UTC) +london = Session( + name="London", + start=time(8, 0), + end=time(16, 0) +) + +new_york = Session( + name="New York", + start=time(13, 0), + end=time(21, 0) +) + +tokyo = Session( + name="Tokyo", + start=time(0, 0), + end=time(9, 0) +) +``` + +### Session Actions + +Sessions can automatically execute actions at their start and end: + +```python +# Close all positions when the London session ends +london = Session( + name="London", + start=time(8, 0), + end=time(16, 0), + on_end="close_all" +) + +# Close only losing positions at session end +new_york = Session( + name="New York", + start=time(13, 0), + end=time(21, 0), + on_end="close_loss" +) + +# Close only winning positions +tokyo = Session( + name="Tokyo", + start=time(0, 0), + end=time(9, 0), + on_end="close_win" +) + +# Custom actions +async def my_start_action(session): + print(f"{session.name} started!") + +async def my_end_action(session): + print(f"{session.name} ended!") + +custom = Session( + name="Custom", + start=time(10, 0), + end=time(18, 0), + on_start="custom_start", + on_end="custom_end", + custom_start=my_start_action, + custom_end=my_end_action +) +``` + +### Combining Sessions + +Group sessions together with the `Sessions` class. When a strategy uses +`Sessions`, the framework automatically: + +1. Checks if the current time falls within any session +2. Waits (sleeps) if outside all sessions +3. Handles transitions between sessions (closing the previous, opening the next) + +```python +sessions = Sessions(sessions=[london, new_york]) + +strategy = MyStrategy( + symbol=ForexSymbol(name="EURUSD"), + sessions=sessions +) +``` + +### Session Utilities + +```python +# Check if currently in session +print(london.in_session()) + +# Get session duration +duration = london.duration() +print(f"Duration: {duration.hours}h {duration.minutes}m") + +# Seconds until session starts +secs = london.until() +print(f"Session starts in {secs} seconds") +``` + +--- + +## The Bot Orchestrator + +The `Bot` class is the top-level orchestrator that brings everything together. +It manages the MT5 terminal connection, initializes strategies, and coordinates +their execution. + +### Basic Usage + +```python +from aiomql import Bot, ForexSymbol +from my_strategies import EMAXOver, RSIStrategy + +def main(): + # Create symbols + symbols = [ForexSymbol(name=s) for s in ["EURUSD", "GBPUSD", "USDJPY"]] + + # Create strategies + strategies = [EMAXOver(symbol=sym) for sym in symbols] + + # Create bot and add strategies + bot = Bot() + bot.add_strategies(strategies) + + # Run (blocks until shutdown) + bot.execute() + + +if __name__ == "__main__": + main() +``` + +### Adding Coroutines + +You can add background coroutines that run alongside your strategies: + +```python +async def log_account_status(): + """Periodically log account balances.""" + from aiomql import Account + account = Account() + while True: + await account.refresh() + print(f"Balance: {account.balance}, Equity: {account.equity}") + await asyncio.sleep(60) + + +bot = Bot() +bot.add_strategies(strategies) + +# Runs in the same event loop +bot.add_coroutine(coroutine=log_account_status) + +# Runs on a separate thread (for CPU-intensive or blocking tasks) +bot.add_coroutine( + coroutine=some_blocking_task, + on_separate_thread=True +) +``` + +### Adding Functions + +For synchronous functions that should run in a thread pool: + +```python +import time + +def heartbeat(interval=30): + while True: + print("Bot is alive...") + time.sleep(interval) + + +bot.add_function(function=heartbeat, interval=30) +``` + +### Async Entry Point + +If you're already inside an async context, use `start()` instead of `execute()`: + +```python +async def main(): + bot = Bot() + bot.add_strategies(strategies) + await bot.start() + +asyncio.run(main()) +``` + +### What Happens Inside + +When you call `bot.execute()`: + +1. **Terminal Start** — connects to the MT5 terminal and logs in +2. **Strategy Initialization** — each strategy's `initialize()` is called; + strategies that fail are silently skipped +3. **Executor Start** — the `Executor` runs all strategies, coroutines, and + functions concurrently using asyncio tasks and thread pools + +--- + +## Position Tracking + +aiomql provides a sophisticated position tracking system through the `contrib.trackers` +package. This allows you to monitor open positions and apply automated management rules. + +### OpenPosition + +The `OpenPosition` class wraps a trade position with tracking, hedging, and stacking +capabilities: + +```python +from aiomql import OpenPosition, PositionTracker + +# Create an OpenPosition from a trade result +pos = OpenPosition( + ticket=123456, + symbol="EURUSD", + # ... other position details +) + +# Modify stop-loss and take-profit +await pos.modify_stops(sl=1.0960, tp=1.1120) + +# Close the position +success, result = await pos.close_position() + +# Add custom trackers to the position +pos.add_tracker(tracker=my_tracker, name="trailing_stop") +``` + +### Position Tracking Functions + +aiomql includes pre-built tracking functions: + +- **`exit_at_profit`** — Close a position when profit reaches a target price +- **`extend_take_profit`** — Dynamically extend the take-profit as price moves favorably + +### OpenPositionsTracker + +The `OpenPositionsTracker` manages all open positions and runs their trackers +automatically: + +```python +from aiomql import Bot, OpenPositionsTracker + +bot = Bot() +bot.add_strategies(strategies) + +# Track all open positions on a separate thread +bot.add_coroutine( + coroutine=OpenPositionsTracker(autocommit=True).track, + on_separate_thread=True +) + +bot.execute() +``` + +This automatically: +- Discovers all open positions +- Runs any registered trackers on each position +- Handles position closure and cleanup +- Manages hedges, stacks, and pending orders + +--- + +## Trade Result Recording + +Every trade can be automatically recorded for analysis. The `Result` class supports +three storage formats: + +### CSV + +```python +# In your Config or aiomql.json +config = Config(trade_record_mode="csv") + +# Trades are saved to: /trade_records/.csv +``` + +### JSON + +```python +config = Config(trade_record_mode="json") + +# Trades are saved to: /trade_records/.json +``` + +### SQLite + +```python +config = Config(trade_record_mode="sql") + +# Trades are saved to a SQLite database in your project root +``` + +### What Gets Recorded? + +Each trade record includes: +- **Order result** — ticket, deal, volume, price, retcode +- **Strategy parameters** — all parameters from the strategy's `parameters` dict +- **Trade request** — the actual request sent to the broker +- **Extras** — timestamp, expected profit, strategy name + +### Manual Recording + +```python +from aiomql import Result +from aiomql.core.models import OrderSendResult + +result = Result( + result=order_send_result, + parameters={"strategy": "EMA", "timeframe": "H1"}, + name="EMAXOver", + expected_profit=25.0 +) + +await result.save(trade_record_mode="csv") +``` + +--- + +## Trade History + +The `History` class lets you retrieve completed deals and orders from your account +history: + +```python +from datetime import datetime, timedelta +from aiomql import History + +# Get the last 7 days of history +history = History( + date_from=datetime.now() - timedelta(days=7), + date_to=datetime.now() +) + +# Fetch deals and orders +await history.initialize() + +# Access deals +print(f"Total deals: {history.total_deals}") +for deal in history.deals: + print(f" {deal.symbol}: {deal.profit}") + +# Access orders +print(f"Total orders: {history.total_orders}") + +# Filter by ticket +deals = history.filter_deals_by_ticket(ticket=123456) + +# Filter by position ID +deals = history.filter_deals_by_position(position=789012) + +# Filter by symbol group (e.g., only USD pairs) +history = History( + date_from=datetime.now() - timedelta(days=30), + date_to=datetime.now(), + group="*USD*" +) +``` + +--- + +## Contributed Extensions + +The `contrib` package contains community extensions that build on the core framework. + +### Strategies + +#### Chaos + +A demo strategy that randomly buys or sells. Useful for testing your infrastructure: + +```python +from aiomql import Chaos, ForexSymbol + +strategy = Chaos(symbol=ForexSymbol(name="EURUSD")) +``` + +### Symbols + +#### ForexSymbol + +Specialized `Symbol` subclass with pip calculations and volume computation based on +stop-loss distance. [See the Symbols section above for details.](#forexsymbol--specialized-for-forex) + +### Traders + +#### SimpleTrader + +Trades with a stop-loss, calculating volume from risk. +[See above.](#simpletrader) + +#### ScalpTrader + +Trades with minimum volume and no stops. +[See above.](#scalptrader) + +### Trackers + +#### Tracker (Strategy Tracker) + +A lightweight state holder for tracking strategy signals and snooze timers. +[See the Strategy section.](#the-tracker) + +#### PositionTracker + +Wraps a tracking function to execute on an open position. + +#### OpenPositionsTracker + +Automatically discovers and manages all open positions. +[See the Position Tracking section.](#openpositionstracker) + +--- + +## Multi-Process Execution + +For running completely independent bots in parallel, use `Bot.process_pool()`: + +```python +from aiomql import Bot, ForexSymbol +from strategies import ForexStrategy, CryptoStrategy + + +def run_forex(): + bot = Bot() + symbols = [ForexSymbol(name=s) for s in ["EURUSD", "GBPUSD"]] + strategies = [ForexStrategy(symbol=s) for s in symbols] + bot.add_strategies(strategies) + bot.execute() + + +def run_crypto(): + bot = Bot() + symbols = [ForexSymbol(name=s) for s in ["BTCUSD", "ETHUSD"]] + strategies = [CryptoStrategy(symbol=s) for s in symbols] + bot.add_strategies(strategies) + bot.execute() + + +# Run both bots in separate processes +Bot.process_pool( + processes={ + run_forex: {}, + run_crypto: {} + }, + num_workers=2 +) +``` + +Each process gets its own event loop, MT5 connection, and strategy set. This is +useful when you want complete isolation between different trading systems. + +--- + +## Synchronous API + +Every async class in aiomql has a synchronous counterpart. This makes the library +usable in Jupyter notebooks, simple scripts, or anywhere you don't want to deal with +`asyncio`. + +### Synchronous MetaTrader + +```python +from aiomql.core.sync import MetaTrader as SyncMetaTrader + +mt5 = SyncMetaTrader() +mt5.initialize_sync() +mt5.login_sync(login=12345678, password="your_password", server="YourBroker-Demo") + +account = mt5._account_info() +print(f"Balance: {account.balance}") + +mt5.shutdown() +``` + +### Synchronous Strategy Initialization + +```python +# The Bot class handles sync initialization internally +bot = Bot() +bot.execute() # Uses sync initialization under the hood +``` + +### Synchronous RAM + +```python +ram = RAM(risk=2) +amount = ram.get_amount_sync() +can_trade = ram.check_open_positions_sync() +``` + +--- + +## Full Example: EMA Crossover Bot + +Here's a complete, production-ready example that puts everything together: + +### Project Structure + +``` +my_trading_bot/ +├── aiomql.json +├── strategies/ +│ ├── __init__.py +│ └── ema_crossover.py +└── bot.py +``` + +### aiomql.json + +```json +{ + "login": 12345678, + "password": "your_password", + "server": "YourBroker-Demo", + "trade_record_mode": "csv" +} +``` + +### strategies/ema_crossover.py + +```python +from aiomql import ( + Strategy, ForexSymbol, TimeFrame, Tracker, + OrderType, Sessions, ScalpTrader +) + + +class EMAXOver(Strategy): + """EMA Crossover strategy. + + Buys when the fast EMA crosses above the slow EMA. + Sells when the fast EMA crosses below the slow EMA. + """ + + ttf: TimeFrame + tcc: int + fast_ema: int + slow_ema: int + tracker: Tracker + interval: TimeFrame + timeout: int + + parameters = { + "ttf": TimeFrame.H1, + "tcc": 3000, + "fast_ema": 34, + "slow_ema": 55, + "interval": TimeFrame.M15, + "timeout": 3 * 60 * 60, # 3 hours cooldown after a trade + } + + def __init__(self, *, symbol: ForexSymbol, params: dict = None, + sessions: Sessions = None, name: str = "EMAXOver"): + super().__init__( + symbol=symbol, params=params, + sessions=sessions, name=name + ) + self.tracker = Tracker(snooze=self.interval.seconds) + self.trader = ScalpTrader(symbol=self.symbol) + + async def find_entry(self): + # Fetch candle data + candles = await self.symbol.copy_rates_from_pos( + timeframe=self.ttf, count=self.tcc + ) + + # Calculate EMAs + candles.ta.ema(length=self.fast_ema, append=True) + candles.ta.ema(length=self.slow_ema, append=True) + candles.rename( + **{f"EMA_{self.fast_ema}": "fast_ema", + f"EMA_{self.slow_ema}": "slow_ema"}, + inplace=True, + ) + + # Check for crossover signals + fast_above_slow = candles.ta_lib.above( + candles.fast_ema, candles.slow_ema + ) + fast_below_slow = candles.ta_lib.below( + candles.fast_ema, candles.slow_ema + ) + + if fast_above_slow.iloc[-1]: + self.tracker.update( + order_type=OrderType.BUY, + snooze=self.timeout + ) + elif fast_below_slow.iloc[-1]: + self.tracker.update( + order_type=OrderType.SELL, + snooze=self.timeout + ) + else: + self.tracker.update( + order_type=None, + snooze=self.interval.seconds + ) + + async def trade(self): + await self.find_entry() + + if self.tracker.order_type is None: + # No signal — sleep until next bar + await self.sleep(secs=self.tracker.snooze) + else: + # Signal found — place trade and cooldown + await self.trader.place_trade( + order_type=self.tracker.order_type, + parameters=self.parameters, + ) + await self.delay(secs=self.tracker.snooze) +``` + +### bot.py + +```python +import logging +from aiomql import Bot, ForexSymbol, OpenPositionsTracker +from strategies.ema_crossover import EMAXOver + +logging.basicConfig(level=logging.INFO) + + +def main(): + # Define symbols to trade + symbols = [ + ForexSymbol(name=s) + for s in ["EURUSD", "GBPUSD", "USDJPY"] + ] + + # Create a strategy instance for each symbol + strategies = [EMAXOver(symbol=sym) for sym in symbols] + + # Create the bot + bot = Bot() + bot.add_strategies(strategies) + + # Track open positions on a separate thread + bot.add_coroutine( + coroutine=OpenPositionsTracker(autocommit=True).track, + on_separate_thread=True + ) + + # Start trading + bot.execute() + + +if __name__ == "__main__": + main() +``` + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ Bot (Orchestrator) │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Executor │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐│ │ +│ │ │Strategy 1│ │Strategy 2│ │ Coroutines/ ││ │ +│ │ │(EURUSD) │ │(GBPUSD) │ │ Functions ││ │ +│ │ └────┬────┘ └────┬────┘ └────────┬────────┘│ │ +│ └───────┼───────────┼───────────────┼──────────┘ │ +│ │ │ │ │ +│ ┌───────▼───────────▼───────────────▼──────────┐ │ +│ │ Shared Services │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐│ │ +│ │ │ Config │ │MetaTrader│ │ RAM │ │ Result ││ │ +│ │ │(single)│ │(single) │ │ │ │ ││ │ +│ │ └────────┘ └────────┘ └────────┘ └────────┘│ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ MetaTrader 5 │ +│ Terminal │ +│ (asyncio.to_thread)│ +└─────────────────────┘ +``` + +### Design Principles + +1. **Singletons** — `Config` and `MetaTrader` in some cases are singletons, ensuring consistent + state across all components. +2. **Async-first** — Every MT5 call is wrapped with `asyncio.to_thread`, keeping + the event loop responsive. +3. **Composition** — Components are designed to be composed. A `Strategy` uses a + `Symbol`, `Trader`, `RAM`, and `Sessions`. +4. **Separation of concerns** — Market data (`Symbol`, `Candles`, `Ticks`), trade + execution (`Order`, `Trader`), risk management (`RAM`), and orchestration (`Bot`, + `Executor`) are all separate modules. + +--- + +## Summary + +| Feature | Module | Description | +|---|---|---| +| MT5 Connection | `MetaTrader` | Async MT5 interface with retry | +| Configuration | `Config` | Singleton JSON/programmatic config | +| Market Data | `Symbol`, `Candles`, `Ticks` | Symbols, OHLC bars, tick data | +| Technical Analysis | `ta_libs` | pandas-ta + optional TA-Lib | +| Orders | `Order` | Check, send, margin/profit calc | +| Trade Execution | `Trader`, `SimpleTrader`, `ScalpTrader` | High-level trade placement | +| Risk Management | `RAM` | Position sizing, trade limits | +| Strategy | `Strategy` | Base class for trading logic | +| Sessions | `Session`, `Sessions` | Time-window trading restrictions | +| Orchestration | `Bot`, `Executor` | Multi-strategy concurrent execution | +| Position Tracking | `OpenPositionsTracker`, `OpenPosition` | Trailing stops, hedging, stacking | +| Trade Recording | `Result` | CSV, JSON, SQLite persistence | +| History | `History` | Deal and order history queries | +| Forex Helpers | `ForexSymbol` | Pip calc, volume from SL | +| Positions | `Positions` | Open position management | +| Multi-Process | `Bot.process_pool()` | Run bots in parallel processes | +| Sync API | `core.sync`, `lib.sync` | Synchronous mirrors | + +aiomql takes care of the infrastructure, the connection handling, the execution +loops, and the bookkeeping — so you can focus on what matters: **your trading +strategy**. + +--- + +*For the full API reference, see the [documentation](docs/toc.md).* + +*aiomql is MIT-licensed and maintained by [Ichinga Samuel](https://github.com/Ichinga-Samuel/aiomql).* diff --git a/examples/sample_app_2/__init__.py b/examples/sample_app_2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/sample_app_2/bot.py b/examples/sample_app_2/bot.py new file mode 100644 index 0000000..7e72026 --- /dev/null +++ b/examples/sample_app_2/bot.py @@ -0,0 +1,16 @@ +import asyncio + +from aiomql import Bot, ForexSymbol + +from .strategies.ribbon_scalper import RibbonScalper + +async def rs_bot(): + bot = Bot() + syms = ["ETHUSD", "BTCUSD", "ADAUSD", "SOLUSD", "LTCUSD", "XRPUSD"] + strategies = [RibbonScalper(symbol=ForexSymbol(name=sym)) for sym in syms] + bot.add_strategies(strategies=strategies) + await bot.start() + + +if __name__ == "__main__": + asyncio.run(rs_bot()) diff --git a/examples/sample_app_2/strategies/__init__.py b/examples/sample_app_2/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/sample_app_2/strategies/ribbon_scalper.py b/examples/sample_app_2/strategies/ribbon_scalper.py new file mode 100644 index 0000000..f362d0b --- /dev/null +++ b/examples/sample_app_2/strategies/ribbon_scalper.py @@ -0,0 +1,59 @@ +from aiomql import Strategy, ForexSymbol, TimeFrame, OrderType, Tracker, Trader +from ..traders.rs_trader import RSTrader + +class RibbonScalper(Strategy): + """ + Entry Strategy (Long): When the 5 and 8 EMA cross above the 13 EMA and the EMAs start spreading out (indicating a trend). + Entry Strategy (Short): When the 5 and 8 EMA cross below the 13 EMA and spread out. + Exit Strategy (Take Profit): Set a small, fixed profit target (e.g., 5-10 pips) or exit when the EMA ribbon starts to flatten or twist. + Exit Strategy (Stop Loss): Tight stop-loss the previous candle high/low to limit risk. + """ + fast_ema: int # fast moving average + medium_ema: int # medium moving average + slow_ema: int # slow moving average + time_frame: TimeFrame # time frame + candles_count: int # lookback period + tracker: Tracker # a tracker class + pips_target: int # number of pips to target + interval: int # interval between successive runs in seconds + + parameters = {"fast_ema": 5, "medium_ema": 8, "slow_ema": 13, "time_frame": TimeFrame.M1, + "candles_count": 120, "pips_target": 10, "interval": 30} + + def __init__(self, *, symbol: ForexSymbol, params: dict = None, trader: Trader = None, name="RibbonScalper"): + super().__init__(symbol=symbol, params=params, name=name) + self.tracker = Tracker() + self.trader = trader or RSTrader(symbol=symbol) + self.interval = self.time_frame.seconds + + async def find_entry(self): + rates = await self.symbol.copy_rates_from_pos(count=self.candles_count, timeframe=self.time_frame) + rates.ta.ema(length=self.fast_ema, append=True) + rates.ta.ema(length=self.medium_ema, append=True) + rates.ta.ema(length=self.slow_ema, append=True) + rates.rename(**{f"EMA_{self.fast_ema}": "fast", f"EMA_{self.medium_ema}": "medium", f"EMA_{self.slow_ema}": "slow"}) + find_long_fast = rates.ta_lib.cross(rates.fast, rates.slow) + find_long_medium = rates.ta_lib.cross(rates.medium, rates.slow) + + if find_long_fast.iloc[-1] and find_long_medium.iloc[-1]: + self.tracker.update(order_type=OrderType.BUY, trend="bullish", sl=rates[-2].low, snooze=300) + return + + find_short_fast = rates.ta_lib.cross(rates.fast, rates.slow, above=False) + find_short_medium = rates.ta_lib.cross(rates.medium, rates.slow, above=False) + + if find_short_fast.iloc[-1] and find_short_medium.iloc[-1]: + self.tracker.update(order_type=OrderType.SELL, trend="bearish", sl=rates[-2].high, snooze=300) + return + + self.tracker.update(order_type=None, trend="ranging", snooze=self.interval) + + async def trade(self): + await self.find_entry() + if self.tracker.order_type is not None: + await self.trader.place_trade(parameters=self.parameters, order_type=self.tracker.order_type, + sl=self.tracker.sl, pips_target=self.pips_target) + await self.sleep(secs=self.tracker.snooze) + return + else: + await self.sleep(secs=self.tracker.snooze) \ No newline at end of file diff --git a/examples/sample_app_2/traders/__init__.py b/examples/sample_app_2/traders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/sample_app_2/traders/rs_trader.py b/examples/sample_app_2/traders/rs_trader.py new file mode 100644 index 0000000..ad19ec4 --- /dev/null +++ b/examples/sample_app_2/traders/rs_trader.py @@ -0,0 +1,30 @@ +from logging import getLogger + +from aiomql import Trader, OrderType + +logger = getLogger(__name__) + +class RSTrader(Trader): + async def create_order(self, sl: float, pips_target: int = 5): + try: + amount = await self.ram.get_amount() + tick = await self.symbol.info_tick() + price = tick.ask if self.order.type.is_long else tick.bid + volume = await self.symbol.compute_volume_sl(sl=sl, amount=amount, price=price) + tp = price + (self.symbol.point * 10 * pips_target) + self.order.set_attributes(sl=sl, tp=tp, volume=volume, price=price) + except Exception as exe: + logger.error("%s: unable to create order", exe) + + async def place_trade(self, sl: float, order_type: OrderType, pips_target: int = 10, parameters: dict = None): + try: + self.order.type = order_type + await self.create_order(sl=sl, pips_target=pips_target) + parameters = parameters or {} + self.order.set_attributes(comment=comment) if (comment := parameters.get("name")) else ... + res = await self.send_order() + if res and res.retcode == 10009: + await self.record_trade(result=res, parameters=parameters) + self.reset_order() + except Exception as exe: + logger.error("%s: unable to place trade", exe) diff --git a/pyproject.toml b/pyproject.toml index e355f13..7527a15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aiomql" -version = "4.1.1" +version = "4.1.2" readme = "README.md" requires-python = ">=3.13" classifiers = [ diff --git a/src/aiomql/lib/executor.py b/src/aiomql/lib/executor.py index 684f1bb..afd4189 100644 --- a/src/aiomql/lib/executor.py +++ b/src/aiomql/lib/executor.py @@ -17,7 +17,6 @@ import inspect import os import time from concurrent.futures import ThreadPoolExecutor -from signal import signal, SIGINT from typing import Coroutine, Callable from logging import getLogger @@ -61,7 +60,6 @@ class Executor: self.functions: dict[Callable:dict] = {} self.config = Config() self.timeout = None # Timeout for the executor. For testing purposes only - signal(SIGINT, self.sigint_handle) def add_function(self, *, function: Callable, kwargs: dict = None): """Registers a synchronous function to run in the executor. @@ -144,16 +142,6 @@ class Executor: """ function(**kwargs) - def sigint_handle(self, signum, frame): - """Handles SIGINT (Ctrl+C) by signaling a shutdown. - - Args: - signum: The signal number received. - frame: The current stack frame. - """ - print("shutting down") - self.config.shutdown = True - def exit(self): """Monitors for shutdown signals and gracefully shuts down the executor. @@ -193,11 +181,9 @@ class Executor: Notes: No matter the number specified, the executor will always use a minimum of 5 workers. """ - signal(SIGINT, self.sigint_handle) workers_ = len(self.strategy_runners) + len(self.functions) + len(self.coroutine_threads) + 3 workers = max(workers, workers_) with ThreadPoolExecutor(max_workers=workers) as executor: - signal(SIGINT, self.sigint_handle) self.executor = executor [self.executor.submit(self.run_strategy, strategy) for strategy in self.strategy_runners] [self.executor.submit(function, **kwargs) for function, kwargs in self.functions.items()]