Розробка високопродуктивного двигуна бектестування на Python

Торгова стратегія показує прибуток на історичних даних у Backtrader. Але в продакшні slippage, комісії, partial fills та лаг виконання вбивають результати. Стандартні інструменти дають лише приблизну картину, а ціна помилки — реальні збитки. Ми проєктуємо кастомні двигуни бектестування на Python, як

Напрямки блокчейн-розробки

Часті запитання

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1451
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1309
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    1005
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1270
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    719
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    1011

Торгова стратегія показує прибуток на історичних даних у 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 робочих дні. Звертайтеся, щоб обговорити ваше завдання.