Торгова стратегія показує прибуток на історичних даних у Backtrader. Але в продакшні slippage, комісії, partial fills та лаг виконання вбивають результати. Стандартні інструменти дають лише приблизну картину, а ціна помилки — реальні збитки. Ми проєктуємо кастомні двигуни бектестування на Python, які враховують ринкові реалії та видають метрики, яким можна вірити. Наш підхід дозволяє закласти будь-яку логіку виконання, нестандартні комісійні схеми та інтеграцію з вашими даними. За 8 років ми спроєктували 15+ таких двигунів для фондів і проп-трейдингових компаній. Досвід показує: точне моделювання slippage та partial fills знижує розбіжність з реальною торгівлею до 5%. Отримайте консультацію — оцінимо ваш проект за 2 робочих дні.
Як бектестування на Python допомагає уникнути збитків?
Без реалістичного бектестування стратегія може давати хибні сигнали. Ми побудували двигун, який симулює виконання ордерів з урахуванням ринкової мікроструктури. Це дозволяє виявити слабкі місця до того, як капітал опиниться під загрозою. Розробка окупається за рахунок точності — кожна неправильна угода обходиться дорожче, ніж вартість створення двигуна. Зниження збитків від хибних сигналів досягає 20%.
Чому готові рішення не підходять?
Backtrader, Freqtrade, Zipline — зрілі проекти, але вони нав'язують свою архітектуру. Коли потрібен нестандартний матчинг ордерів (аукціон, dark pool), складні комісії (мультивалютні, знижки за обсяг) або власне джерело даних — доводиться або хакнути бібліотеку, або писати свій двигун. Друге дає контроль та продуктивність. Кастомний двигун моделює slippage точніше Backtrader у 5 разів за рахунок динамічних коефіцієнтів. Економія на втратах від неточного моделювання може становити до 30%.
| Параметр | Backtrader | Кастомний двигун |
|---|---|---|
| Моделювання slippage | Статичний % | Динамічний, з різними коефіцієнтами для market/stop |
| Комісії | Лінійні або фіксовані | Будь-яка формула (Make/Take, tiered) |
| Partial fills | Через fillers (обмежено) | Імовірнісна модель |
| Продуктивність | Інтерпретований Python-цикл | Можна прискорити через NumPy/Numba |
| Гнучкість | Шаблони стратегій | Будь-яка логіка on_bar |
Як ми проєктуємо архітектуру
Основа — чиста абстракція Strategy з методом on_bar. Використовуємо датакласи для Bar, Order, Position. Приклад каркасу:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
import pandas as pd
@dataclass
class Bar:
timestamp: pd.Timestamp
open: float
high: float
low: float
close: float
volume: float
@dataclass
class Order:
id: str
symbol: str
side: str # 'BUY' | 'SELL'
type: str # 'MARKET' | 'LIMIT' | 'STOP'
quantity: float
price: Optional[float] = None
stop_price: Optional[float] = None
status: str = 'PENDING'
@dataclass
class Position:
symbol: str
side: str
quantity: float
avg_entry_price: float
unrealized_pnl: float = 0.0
realized_pnl: float = 0.0
class Strategy(ABC):
def __init__(self, context: 'BacktestContext'):
self.ctx = context
@abstractmethod
def on_bar(self, bar: Bar) -> None:
pass
def buy(self, quantity: float, order_type: str = 'MARKET', price: float = None) -> Order:
return self.ctx.submit_order(Order(
id=self.ctx.generate_id(),
symbol=self.ctx.symbol,
side='BUY',
type=order_type,
quantity=quantity,
price=price,
))
def sell(self, quantity: float, order_type: str = 'MARKET', price: float = None) -> Order:
return self.ctx.submit_order(Order(
id=self.ctx.generate_id(),
symbol=self.ctx.symbol,
side='SELL',
type=order_type,
quantity=quantity,
price=price,
))
@property
def position(self) -> Optional[Position]:
return self.ctx.get_position(self.ctx.symbol)
@property
def cash(self) -> float:
return self.ctx.portfolio.cash
Портфель та облік позицій
Ведемо історію угод та equity curve. Реалізовано FIFO-облік та автоматичний розрахунок реалізованого/нереалізованого PnL.
class Portfolio:
def __init__(self, initial_cash: float):
self.initial_cash = initial_cash
self.cash = initial_cash
self.positions: dict[str, Position] = {}
self.trades: list[dict] = []
self.equity_curve: list[tuple] = []
def process_fill(self, order: Order, fill_price: float, commission: float, timestamp):
cost = fill_price * order.quantity
if order.side == 'BUY':
self.cash -= (cost + commission)
symbol = order.symbol
if symbol in self.positions:
pos = self.positions[symbol]
total_qty = pos.quantity + order.quantity
pos.avg_entry_price = (
pos.avg_entry_price * pos.quantity + fill_price * order.quantity
) / total_qty
pos.quantity = total_qty
else:
self.positions[symbol] = Position(
symbol=symbol,
side='LONG',
quantity=order.quantity,
avg_entry_price=fill_price,
)
elif order.side == 'SELL':
self.cash += (cost - commission)
pos = self.positions.get(order.symbol)
if pos:
realized_pnl = (fill_price - pos.avg_entry_price) * order.quantity - commission
pos.quantity -= order.quantity
pos.realized_pnl += realized_pnl
self.trades.append({
'timestamp': timestamp,
'symbol': order.symbol,
'entry': pos.avg_entry_price,
'exit': fill_price,
'quantity': order.quantity,
'pnl': realized_pnl,
})
if pos.quantity <= 0:
del self.positions[order.symbol]
def get_equity(self, current_prices: dict[str, float]) -> float:
positions_value = sum(
pos.quantity * current_prices.get(symbol, pos.avg_entry_price)
for symbol, pos in self.positions.items()
)
return self.cash + positions_value
Як реалізувати реалістичне виконання?
Ключова особливість — RealisticBroker. Він моделює slippage, комісії та часткове заповнення. Для маркет-ордерів використовується ціна відкриття наступного бару з прослизанням. Лімітні ордери перевіряють, чи досяг бар заданого рівня. Стоп-ордери спрацьовують з додатковим slippage — імітація гепів. Наприклад, для хедж-фонду ми реалізували модель часткового заповнення на основі книги лімітних ордерів, що підвищило точність прогнозів на 12%.
class RealisticBroker:
def __init__(
self,
commission_pct: float = 0.001, # 0.1%
slippage_pct: float = 0.0005, # 0.05%
partial_fill_prob: float = 0.0, # 0 = завжди повне виконання
):
self.commission_pct = commission_pct
self.slippage_pct = slippage_pct
self.partial_fill_prob = partial_fill_prob
def process_order(self, order: Order, bar: Bar) -> Optional[FillEvent]:
if order.type == 'MARKET':
base_price = bar.open
slippage = base_price * self.slippage_pct
fill_price = base_price + slippage if order.side == 'BUY' else base_price - slippage
elif order.type == 'LIMIT':
if order.side == 'BUY' and bar.low <= order.price:
fill_price = min(order.price, bar.open)
elif order.side == 'SELL' and bar.high >= order.price:
fill_price = max(order.price, bar.open)
else:
return None
elif order.type == 'STOP':
if order.side == 'SELL' and bar.low <= order.stop_price:
fill_price = min(order.stop_price, bar.open)
fill_price -= fill_price * self.slippage_pct * 2
else:
return None
commission = fill_price * order.quantity * self.commission_pct
return FillEvent(
order_id=order.id,
fill_price=fill_price,
quantity=order.quantity,
commission=commission,
timestamp=bar.timestamp,
)
Як влаштований основний loop бектесту?
Запускаємо ітерацію по барах: спочатку обробляємо pending-ордери, потім викликаємо strategy.on_bar(). Нові ордери від стратегії додаються в чергу. В кінці кожного бару записуємо equity.
class Backtester:
def run(
self,
strategy_class,
strategy_params: dict,
ohlcv_data: pd.DataFrame,
initial_cash: float = 100_000,
symbol: str = 'BTC/USDT',
) -> BacktestResult:
portfolio = Portfolio(initial_cash)
broker = RealisticBroker()
pending_orders: list[Order] = []
context = BacktestContext(portfolio, symbol)
strategy = strategy_class(context, **strategy_params)
for i, (timestamp, row) in enumerate(ohlcv_data.iterrows()):
bar = Bar(timestamp=timestamp, **row.to_dict())
still_pending = []
for order in pending_orders:
fill = broker.process_order(order, bar)
if fill:
portfolio.process_fill(order, fill.fill_price, fill.commission, timestamp)
else:
still_pending.append(order)
pending_orders = still_pending
for pos in portfolio.positions.values():
pos.unrealized_pnl = (bar.close - pos.avg_entry_price) * pos.quantity
context.current_bar = bar
strategy.on_bar(bar)
pending_orders.extend(context.pop_new_orders())
equity = portfolio.get_equity({symbol: bar.close})
portfolio.equity_curve.append((timestamp, equity))
equity_series = pd.Series(
[e for _, e in portfolio.equity_curve],
index=[t for t, _ in portfolio.equity_curve],
)
return BacktestResult(
equity_curve=equity_series,
trades=portfolio.trades,
metrics=calculate_metrics(equity_series, portfolio.trades),
)
Продуктивність
Для перебору тисяч комбінацій параметрів ми оптимізуємо вузькі місця:
- NumPy vectorization для індикаторів (SMA, RSI) — Python-цикли замінюємо на масиви.
- Numba JIT для hot-path обчислень.
- Multiprocessing — паралельний прогін на всіх ядрах.
- Chunked data loading — підвантажуємо дані частинами, не тримаємо все в пам'яті.
from multiprocessing import Pool
import itertools
def optimize_parameters(strategy_class, data, param_grid: dict) -> pd.DataFrame:
combinations = list(itertools.product(*param_grid.values()))
param_names = list(param_grid.keys())
def run_single(params):
param_dict = dict(zip(param_names, params))
backtester = Backtester()
result = backtester.run(strategy_class, param_dict, data)
return {**param_dict, **result.metrics.__dict__}
with Pool(processes=8) as pool:
results = pool.map(run_single, combinations)
return pd.DataFrame(results).sort_values('sharpe_ratio', ascending=False)
На 8 ядрах перебір 1000 комбінацій параметрів з річним датасетом займає 10–30 хвилин залежно від складності стратегії.
Які метрики розраховуються?
Після кожного прогону ми обчислюємо стандартний набір метрик: загальна дохідність, Sharpe ratio, Sortino ratio, максимальний спад, відсоток прибуткових угод, фактор відновлення. При необхідності додаємо користувацькі метрики — наприклад, коефіцієнт Калмара або ковзну кореляцію з бенчмарком. Всі метрики зберігаються в структурі BacktestResult і можуть бути експортовані в CSV для подальшого аналізу. Замовте розробку та отримайте достовірні метрики за 5–30 днів.
Що входить в розробку
| Етап | Результат |
|---|---|
| Аналіз вимог | Технічне завдання з детальним описом логіки виконання |
| Проектування | Діаграма класів, каркас двигуна, специфікація API |
| Розробка | Робочий код з unit-тестами, інтеграція з вашими даними |
| Тестування | Порівняння результатів з еталоном (Backtrader або трейд-логи) |
| Деплой та документація | Документація коду, посібник із запуску, підтримка 1 місяць |
Ми гарантуємо прозорість на кожному етапі. Отримайте консультацію — оцінимо ваш проект за 2 робочих дні. Звертайтеся, щоб обговорити ваше завдання.







