Перейти к содержимому

Глава 22: Автономные торговые агенты: обучение с подкреплением для криптовалютного исполнения

Обзор

Обучение с подкреплением (RL) предлагает принципиально иной подход к алгоритмической торговле по сравнению с обучением с учителем. Вместо прогнозирования будущих цен и последующего проектирования правил для действий на основе прогнозов, RL-агенты обучаются оптимальным торговым политикам напрямую из взаимодействия с рыночной средой. Агент наблюдает текущее состояние рынка, совершает действие (покупка, продажа, удержание или установка непрерывного размера позиции), получает вознаграждение на основе полученной прибыли или доходности с поправкой на риск, и обновляет свою политику для максимизации совокупного будущего вознаграждения. Эта сквозная парадигма обучения естественным образом обрабатывает последовательный характер принятия торговых решений, где каждое действие влияет на будущие состояния и возможности.

Криптовалютные рынки особенно хорошо подходят для торговых агентов на основе RL благодаря их круглосуточной работе, высокой волатильности и доступности бессрочных фьючерсных контрактов на биржах, таких как Bybit. Формулировка марковского процесса принятия решений (MDP) захватывает существенную структуру: состояния кодируют рыночные признаки (цены, индикаторы, позиция портфеля), действия представляют торговые решения, а вознаграждения кодируют торговую цель (прибыль, коэффициент Шарпа, доходность с поправкой на риск). Глубокие Q-сети (DQN) обрабатывают дискретные пространства действий (покупка/продажа/удержание), в то время как методы градиента политики, такие как PPO и SAC, могут обучаться непрерывным политикам размера позиции, обеспечивая более тонкое управление распределением портфеля.

Эта глава предоставляет всестороннее руководство по созданию торговых агентов на основе RL для криптовалют. Мы формулируем торговую задачу как MDP, реализуем пользовательские среды, совместимые с Gymnasium, которые взаимодействуют с рыночными данными Bybit, проектируем функции вознаграждения, стимулирующие доходность с поправкой на риск, а не сырую прибыль, и обучаем агентов с использованием алгоритмов DQN, PPO и SAC. Мы рассматриваем распространённые ошибки, включая взлом вознаграждения, переобучение к обучающим периодам и проблему разрежённых вознаграждений в финансовых средах. Предоставлены реализации на Python и Rust с практическими примерами, демонстрирующими мультиактивное RL-распределение портфеля.

Содержание

  1. Введение в обучение с подкреплением для торговли
  2. Математические основы RL
  3. Сравнение алгоритмов RL для торговли
  4. Торговые применения RL-агентов
  5. Реализация на Python
  6. Реализация на Rust
  7. Практические примеры
  8. Фреймворк бэктестирования
  9. Оценка производительности
  10. Направления будущего развития

1. Введение в обучение с подкреплением для торговли

Парадигма RL

Обучение с подкреплением принципиально отличается от обучения с учителем и без учителя. В RL агент взаимодействует со средой, наблюдая состояния, совершая действия и получая вознаграждения. Цель состоит в том, чтобы обучить политику, максимизирующую ожидаемое совокупное дисконтированное вознаграждение во времени. Размеченного набора данных нет; вместо этого агент должен обнаружить, какие действия приводят к благоприятным результатам, через пробы и ошибки.

Ключевые компоненты любой RL-системы:

  • Агент: Обучающийся и принимающий решения (торговый алгоритм)
  • Среда: Мир, с которым взаимодействует агент (рынок)
  • Состояние (s): Наблюдаемая информация на каждом временном шаге (цены, индикаторы, позиция)
  • Действие (a): Решение, принимаемое агентом (покупка, продажа, удержание, размер позиции)
  • Вознаграждение (r): Скалярный сигнал обратной связи после каждого действия (прибыль, вклад в Шарп)
  • Политика (pi): Отображение состояний в действия (торговая стратегия)
  • Функция ценности (V): Ожидаемое совокупное вознаграждение из данного состояния
  • Q-значение (Q): Ожидаемое совокупное вознаграждение для пары состояние-действие

Почему RL для криптовалютной торговли?

  1. Последовательное принятие решений: Торговля по своей природе последовательна; текущие действия влияют на будущие состояния
  2. Не требуется прогнозирование цен: RL обучает действиям напрямую без промежуточного прогнозирования
  3. Естественная оптимизация риск-доходность: Формирование вознаграждения позволяет напрямую оптимизировать коэффициент Шарпа
  4. Адаптация к рыночной динамике: Онлайн-RL может адаптироваться к меняющимся рыночным условиям
  5. Управление позицией: RL естественно обрабатывает размер позиции, стоп-лоссы и тейк-профиты

Ключевая терминология

  • Обучение с подкреплением (RL): Парадигма обучения на основе взаимодействия со средой
  • Агент: Сущность, которая обучается и принимает решения
  • Среда: Внешняя система, с которой взаимодействует агент
  • Пространство состояний: Множество всех возможных наблюдений
  • Пространство действий: Множество всех возможных действий (дискретное или непрерывное)
  • Сигнал вознаграждения: Скалярная обратная связь, указывающая на качество действия
  • Политика (pi): Стратегия отображения состояний в действия
  • Функция ценности (V): Ожидаемое будущее совокупное вознаграждение из состояния
  • Q-значение (Q): Ожидаемое будущее совокупное вознаграждение для пары состояние-действие
  • Уравнение Беллмана: Рекурсивное соотношение между функциями ценности
  • Марковский процесс принятия решений (MDP): Математический фреймворк для последовательного принятия решений
  • Динамическое программирование: Решение MDP с известной динамикой переходов
  • Q-обучение: Алгоритм off-policy TD-управления
  • Глубокая Q-сеть (DQN): Q-обучение с аппроксимацией функции нейронной сетью
  • Буфер воспроизведения опыта: Память прошлых переходов для стабильного обучения
  • Эпсилон-жадное исследование: Балансировка исследования и эксплуатации
  • Целевая сеть: Отдельная сеть для стабильных целевых Q-значений
  • Double DQN: Устранение смещения переоценки в DQN
  • Теорема градиента политики: Основа для прямой оптимизации политики
  • Актёр-критик: Архитектура, объединяющая сети политики и ценности
  • Proximal Policy Optimization (PPO): Стабильный метод градиента политики с обрезанной целевой функцией
  • Soft Actor-Critic (SAC): RL с максимальной энтропией для непрерывных пространств действий
  • DDPG (Deep Deterministic Policy Gradient): Off-policy актёр-критик для непрерывных действий
  • Формирование вознаграждения: Проектирование функций вознаграждения для направления обучения
  • Gymnasium (преемник OpenAI Gym): Стандартный API для RL-сред

2. Математические основы RL

Марковский процесс принятия решений

MDP для криптовалютной торговли определяется кортежем (S, A, P, R, gamma):

S: Пространство состояний - рыночные признаки + состояние портфеля
A: Пространство действий - {покупка, продажа, удержание} или непрерывное [-1, 1]
P: Вероятность перехода P(s'|s, a) - динамика рынка (неизвестна)
R: Функция вознаграждения R(s, a, s') - торговая прибыль или метрика риска
gamma: Коэффициент дисконтирования в [0, 1) - временное предпочтение вознаграждений

Уравнения Беллмана

Функция ценности удовлетворяет уравнению Беллмана:

V_pi(s) = E_pi[R(s,a,s') + gamma * V_pi(s') | s_t = s]
Q_pi(s, a) = E[R(s,a,s') + gamma * sum_a' pi(a'|s') * Q_pi(s', a')]
Оптимальная ценность: V*(s) = max_a Q*(s, a)
Оптимальное Q: Q*(s, a) = E[R + gamma * max_a' Q*(s', a')]

Обновление Q-обучения

Q(s, a) <- Q(s, a) + alpha * [r + gamma * max_a' Q(s', a') - Q(s, a)]
где alpha = скорость обучения, gamma = коэффициент дисконтирования

Функция потерь DQN

L(theta) = E[(r + gamma * max_a' Q(s', a'; theta^-) - Q(s, a; theta))^2]
theta: параметры онлайн-сети
theta^-: параметры целевой сети (периодически копируются из theta)

Теорема градиента политики

grad J(theta) = E_pi[grad log pi(a|s; theta) * Q_pi(s, a)]

Обрезанная целевая функция PPO

L_CLIP(theta) = E[min(r_t(theta) * A_t, clip(r_t(theta), 1-eps, 1+eps) * A_t)]
где r_t(theta) = pi(a_t|s_t; theta) / pi(a_t|s_t; theta_old)
A_t = оценка преимущества
eps = 0.2 (параметр обрезки)

Формирование вознаграждения на основе Шарпа

r_t = (portfolio_return_t - risk_free_rate) / rolling_std(portfolio_returns)
Альтернатива: r_t = log(portfolio_value_t / portfolio_value_{t-1}) - lambda * drawdown_t

3. Сравнение алгоритмов RL для торговли

АлгоритмПространство действийЭффективность выборкиСтабильностьИсследованиеПригодность для крипто
Q-LearningДискретноеНизкаяУмереннаяЭпсилон-жадноеНизкая (табличный)
DQNДискретноеУмереннаяУмереннаяЭпсилон-жадноеУмеренная
Double DQNДискретноеУмереннаяХорошаяЭпсилон-жадноеХорошая
PPOДискр./Непрер.УмереннаяОчень хорошаяБонус энтропииОчень хорошая
SACНепрерывноеВысокаяОчень хорошаяМакс. энтропияОтличная
DDPGНепрерывноеУмереннаяНизкаяШум OUУмеренная
TD3НепрерывноеВысокаяХорошаяГауссов шумХорошая
A3CДискр./Непрер.НизкаяУмереннаяАсинхр. исследованиеУмеренная

Руководство по выбору алгоритма

  • Дискретные действия покупка/продажа/удержание: DQN или PPO
  • Непрерывный размер позиции: SAC или TD3
  • Мультиактивное распределение портфеля: PPO с многомерным пространством действий
  • Максимальная стабильность обучения: PPO с обрезкой градиента
  • Лучшая эффективность выборки: SAC с воспроизведением опыта
  • Адаптация в реальном времени: Онлайн PPO со скользящим окном

Ключевые компромиссы

КритерийDQNPPOSAC
Стабильность обученияУмереннаяВысокаяВысокая
Эффективность выборкиНизкаяУмереннаяВысокая
Пространство действийТолько дискретноеОбаНепрерывное
Чувствительность к гиперпараметрамВысокаяНизкаяУмеренная
Сложность реализацииНизкаяУмереннаяВысокая
Качество исследованияНизкоеХорошееОтличное

4. Торговые применения RL-агентов

4.1 Торговля бессрочными фьючерсами на Bybit

RL-агенты могут обучаться торговле бессрочными фьючерсами Bybit с кредитным плечом, управляя длинными и короткими позициями. Среда отслеживает маржу, нереализованный PnL, ставки финансирования и риск ликвидации. Агент обучается входить, масштабировать и выходить из позиций на основе сигналов микроструктуры рынка.

4.2 Оптимальное исполнение и разделение ордеров

Для крупных ордеров RL-агенты могут обучаться минимизировать рыночное воздействие путём разделения ордеров во времени. Состояние включает глубину книги ордеров, недавний поток сделок и оставшийся размер ордера. Агент обучается оптимальному времени и размеру для частей исполнения.

4.3 Мультиактивная ребалансировка портфеля

Используя PPO или SAC с многомерными непрерывными действиями, RL-агенты обучаются распределять капитал между несколькими криптовалютными активами. Пространство действий представляет веса портфеля, а вознаграждение учитывает как доходность, так и преимущества диверсификации.

4.4 Маркет-мейкинг с RL

RL-агенты могут обучаться стратегиям маркет-мейкинга, размещая лимитные ордера с обеих сторон книги ордеров. Агент обучается оптимальному спреду, управлению инвентарём и лимитам риска, адаптируясь к меняющейся волатильности и условиям потока ордеров.

4.5 Адаптивные стоп-лосс и тейк-профит

Вместо использования фиксированных уровней стоп-лосса, RL-агенты обучаются динамическим политикам выхода, обусловленным текущей волатильностью рынка, силой тренда и возрастом позиции. Это обеспечивает более интеллектуальное управление рисками по сравнению со статическими правилами.


5. Реализация на Python

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
import gymnasium as gym
from gymnasium import spaces
from collections import deque
import random
import requests
import yfinance as yf
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, field
@dataclass
class RLConfig:
"""Configuration for RL trading agent."""
state_dim: int = 20
hidden_dim: int = 128
learning_rate: float = 3e-4
gamma: float = 0.99
epsilon_start: float = 1.0
epsilon_end: float = 0.01
epsilon_decay: int = 10000
batch_size: int = 64
buffer_size: int = 100000
target_update: int = 1000
n_episodes: int = 500
max_steps: int = 1000
initial_balance: float = 10000.0
transaction_cost: float = 0.001
max_position: float = 1.0
ppo_clip: float = 0.2
ppo_epochs: int = 10
class CryptoDataFetcher:
"""Fetch crypto data from Bybit and yfinance."""
@staticmethod
def from_bybit(symbol: str = "BTCUSDT", interval: str = "60",
limit: int = 1000) -> pd.DataFrame:
url = "https://api.bybit.com/v5/market/kline"
params = {"category": "linear", "symbol": symbol,
"interval": interval, "limit": limit}
resp = requests.get(url, params=params)
data = resp.json()["result"]["list"]
df = pd.DataFrame(data, columns=[
"timestamp", "open", "high", "low", "close", "volume", "turnover"
])
for col in ["open", "high", "low", "close", "volume"]:
df[col] = df[col].astype(float)
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms")
return df.sort_values("timestamp").reset_index(drop=True)
@staticmethod
def from_yfinance(ticker: str = "BTC-USD", period: str = "2y") -> pd.DataFrame:
df = yf.download(ticker, period=period)
df.columns = [c.lower() for c in df.columns]
return df[["open", "high", "low", "close", "volume"]].reset_index()
@staticmethod
def add_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["returns"] = df["close"].pct_change()
df["log_returns"] = np.log(df["close"] / df["close"].shift(1))
df["sma_20"] = df["close"].rolling(20).mean()
df["sma_50"] = df["close"].rolling(50).mean()
df["rsi"] = CryptoDataFetcher._compute_rsi(df["close"], 14)
df["volatility"] = df["returns"].rolling(20).std()
df["volume_ma"] = df["volume"].rolling(20).mean()
df["volume_ratio"] = df["volume"] / df["volume_ma"]
return df.dropna().reset_index(drop=True)
@staticmethod
def _compute_rsi(prices: pd.Series, period: int = 14) -> pd.Series:
delta = prices.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / (loss + 1e-8)
return 100 - (100 / (1 + rs))
class BybitTradingEnv(gym.Env):
"""Gymnasium-compatible crypto trading environment using Bybit data."""
metadata = {"render_modes": ["human"]}
def __init__(self, df: pd.DataFrame, config: RLConfig):
super().__init__()
self.df = df
self.config = config
self.action_space = spaces.Discrete(3) # 0=hold, 1=buy, 2=sell
self.observation_space = spaces.Box(
low=-np.inf, high=np.inf, shape=(config.state_dim,), dtype=np.float32
)
self.reset()
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self.current_step = 0
self.balance = self.config.initial_balance
self.position = 0.0
self.entry_price = 0.0
self.total_reward = 0.0
self.portfolio_values = [self.config.initial_balance]
return self._get_obs(), {}
def _get_obs(self) -> np.ndarray:
row = self.df.iloc[self.current_step]
market_features = [
row.get("returns", 0), row.get("log_returns", 0),
row.get("rsi", 50) / 100.0, row.get("volatility", 0),
row.get("volume_ratio", 1), row.get("close", 0) / row.get("sma_20", 1) - 1,
row.get("close", 0) / row.get("sma_50", 1) - 1,
]
portfolio_features = [
self.position, self.balance / self.config.initial_balance,
self._unrealized_pnl() / self.config.initial_balance,
]
lookback = []
for i in range(min(10, self.current_step)):
idx = self.current_step - i - 1
lookback.append(self.df.iloc[idx].get("returns", 0))
while len(lookback) < 10:
lookback.append(0.0)
obs = np.array(market_features + portfolio_features + lookback, dtype=np.float32)
return obs[:self.config.state_dim]
def _unrealized_pnl(self) -> float:
if self.position == 0:
return 0.0
current_price = self.df.iloc[self.current_step]["close"]
return self.position * (current_price - self.entry_price)
def step(self, action: int):
current_price = self.df.iloc[self.current_step]["close"]
reward = 0.0
if action == 1 and self.position <= 0: # Buy
cost = abs(current_price * self.config.transaction_cost)
self.position = self.config.max_position
self.entry_price = current_price
self.balance -= cost
elif action == 2 and self.position >= 0: # Sell
if self.position > 0:
pnl = self.position * (current_price - self.entry_price)
cost = abs(current_price * self.config.transaction_cost)
self.balance += pnl - cost
reward = pnl / self.config.initial_balance
self.position = -self.config.max_position
self.entry_price = current_price
portfolio_value = self.balance + self._unrealized_pnl()
self.portfolio_values.append(portfolio_value)
if len(self.portfolio_values) > 2:
returns = np.diff(self.portfolio_values[-20:]) / np.array(self.portfolio_values[-20:-1])
if len(returns) > 1 and np.std(returns) > 0:
reward = np.mean(returns) / (np.std(returns) + 1e-8)
self.current_step += 1
terminated = self.current_step >= len(self.df) - 1
truncated = portfolio_value < self.config.initial_balance * 0.5
self.total_reward += reward
return self._get_obs(), reward, terminated, truncated, {
"portfolio_value": portfolio_value,
"position": self.position
}
class ReplayBuffer:
"""Experience replay buffer for DQN training."""
def __init__(self, capacity: int):
self.buffer = deque(maxlen=capacity)
def push(self, state, action, reward, next_state, done):
self.buffer.append((state, action, reward, next_state, done))
def sample(self, batch_size: int):
batch = random.sample(self.buffer, batch_size)
states, actions, rewards, next_states, dones = zip(*batch)
return (np.array(states), np.array(actions), np.array(rewards),
np.array(next_states), np.array(dones))
def __len__(self):
return len(self.buffer)
class DQNAgent:
"""Deep Q-Network agent for crypto trading."""
def __init__(self, config: RLConfig):
self.config = config
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.q_network = self._build_network().to(self.device)
self.target_network = self._build_network().to(self.device)
self.target_network.load_state_dict(self.q_network.state_dict())
self.optimizer = optim.Adam(self.q_network.parameters(), lr=config.learning_rate)
self.buffer = ReplayBuffer(config.buffer_size)
self.steps = 0
def _build_network(self) -> nn.Module:
return nn.Sequential(
nn.Linear(self.config.state_dim, self.config.hidden_dim),
nn.ReLU(),
nn.Linear(self.config.hidden_dim, self.config.hidden_dim),
nn.ReLU(),
nn.Linear(self.config.hidden_dim, 3)
)
def select_action(self, state: np.ndarray) -> int:
epsilon = self.config.epsilon_end + (self.config.epsilon_start - self.config.epsilon_end) * \
np.exp(-self.steps / self.config.epsilon_decay)
self.steps += 1
if random.random() < epsilon:
return random.randint(0, 2)
with torch.no_grad():
state_t = torch.FloatTensor(state).unsqueeze(0).to(self.device)
q_values = self.q_network(state_t)
return q_values.argmax(dim=1).item()
def train_step(self) -> Optional[float]:
if len(self.buffer) < self.config.batch_size:
return None
states, actions, rewards, next_states, dones = self.buffer.sample(self.config.batch_size)
states_t = torch.FloatTensor(states).to(self.device)
actions_t = torch.LongTensor(actions).to(self.device)
rewards_t = torch.FloatTensor(rewards).to(self.device)
next_states_t = torch.FloatTensor(next_states).to(self.device)
dones_t = torch.FloatTensor(dones).to(self.device)
q_values = self.q_network(states_t).gather(1, actions_t.unsqueeze(1)).squeeze(1)
with torch.no_grad():
next_q = self.target_network(next_states_t).max(dim=1)[0]
target = rewards_t + self.config.gamma * next_q * (1 - dones_t)
loss = nn.MSELoss()(q_values, target)
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.q_network.parameters(), 1.0)
self.optimizer.step()
if self.steps % self.config.target_update == 0:
self.target_network.load_state_dict(self.q_network.state_dict())
return loss.item()
class PPOAgent:
"""Proximal Policy Optimization agent for position sizing."""
def __init__(self, config: RLConfig, continuous: bool = False):
self.config = config
self.continuous = continuous
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.actor = self._build_actor().to(self.device)
self.critic = self._build_critic().to(self.device)
self.actor_optimizer = optim.Adam(self.actor.parameters(), lr=config.learning_rate)
self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=config.learning_rate)
def _build_actor(self) -> nn.Module:
if self.continuous:
return nn.Sequential(
nn.Linear(self.config.state_dim, self.config.hidden_dim),
nn.Tanh(),
nn.Linear(self.config.hidden_dim, self.config.hidden_dim),
nn.Tanh(),
nn.Linear(self.config.hidden_dim, 2)
)
return nn.Sequential(
nn.Linear(self.config.state_dim, self.config.hidden_dim),
nn.Tanh(),
nn.Linear(self.config.hidden_dim, self.config.hidden_dim),
nn.Tanh(),
nn.Linear(self.config.hidden_dim, 3),
nn.Softmax(dim=-1)
)
def _build_critic(self) -> nn.Module:
return nn.Sequential(
nn.Linear(self.config.state_dim, self.config.hidden_dim),
nn.Tanh(),
nn.Linear(self.config.hidden_dim, self.config.hidden_dim),
nn.Tanh(),
nn.Linear(self.config.hidden_dim, 1)
)
def select_action(self, state: np.ndarray):
state_t = torch.FloatTensor(state).unsqueeze(0).to(self.device)
with torch.no_grad():
probs = self.actor(state_t)
if self.continuous:
mean, log_std = probs[0, 0], probs[0, 1]
std = log_std.exp().clamp(0.01, 1.0)
dist = torch.distributions.Normal(mean, std)
action = dist.sample()
return action.clamp(-1, 1).item(), dist.log_prob(action).item()
dist = torch.distributions.Categorical(probs)
action = dist.sample()
return action.item(), dist.log_prob(action).item()
def train_dqn(env: BybitTradingEnv, agent: DQNAgent, config: RLConfig) -> List[float]:
"""Train DQN agent on trading environment."""
episode_rewards = []
for episode in range(config.n_episodes):
state, _ = env.reset()
total_reward = 0
for step in range(config.max_steps):
action = agent.select_action(state)
next_state, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated
agent.buffer.push(state, action, reward, next_state, float(done))
agent.train_step()
state = next_state
total_reward += reward
if done:
break
episode_rewards.append(total_reward)
if episode % 50 == 0:
avg = np.mean(episode_rewards[-50:])
print(f"Эпизод {episode}: Сред. вознагражд.={avg:.4f}, "
f"Портфель={info['portfolio_value']:.2f}")
return episode_rewards
# Пример использования
if __name__ == "__main__":
config = RLConfig(n_episodes=200, max_steps=500)
df = CryptoDataFetcher.from_bybit("BTCUSDT", interval="60", limit=1000)
df = CryptoDataFetcher.add_features(df)
env = BybitTradingEnv(df, config)
agent = DQNAgent(config)
rewards = train_dqn(env, agent, config)
print(f"Итоговое сред. вознаграждение: {np.mean(rewards[-50:]):.4f}")

6. Реализация на Rust

use reqwest;
use serde::{Deserialize, Serialize};
use tokio;
use std::error::Error;
use std::collections::VecDeque;
/// Конфигурация RL-агента
#[derive(Debug, Clone)]
pub struct RLConfig {
pub state_dim: usize,
pub hidden_dim: usize,
pub learning_rate: f64,
pub gamma: f64,
pub epsilon_start: f64,
pub epsilon_end: f64,
pub epsilon_decay: f64,
pub batch_size: usize,
pub buffer_size: usize,
pub initial_balance: f64,
pub transaction_cost: f64,
pub max_position: f64,
}
impl Default for RLConfig {
fn default() -> Self {
Self {
state_dim: 20,
hidden_dim: 128,
learning_rate: 3e-4,
gamma: 0.99,
epsilon_start: 1.0,
epsilon_end: 0.01,
epsilon_decay: 10000.0,
batch_size: 64,
buffer_size: 100000,
initial_balance: 10000.0,
transaction_cost: 0.001,
max_position: 1.0,
}
}
}
#[derive(Debug, Deserialize)]
struct BybitKlineResponse {
result: BybitKlineResult,
}
#[derive(Debug, Deserialize)]
struct BybitKlineResult {
list: Vec<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct OHLCVBar {
pub timestamp: u64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
}
/// Перечисление торговых действий
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Action {
Hold = 0,
Buy = 1,
Sell = 2,
}
impl From<usize> for Action {
fn from(v: usize) -> Self {
match v {
1 => Action::Buy,
2 => Action::Sell,
_ => Action::Hold,
}
}
}
/// Кортеж опыта для буфера воспроизведения
#[derive(Debug, Clone)]
pub struct Experience {
pub state: Vec<f64>,
pub action: usize,
pub reward: f64,
pub next_state: Vec<f64>,
pub done: bool,
}
/// Буфер воспроизведения для хранения опыта
pub struct ReplayBuffer {
buffer: VecDeque<Experience>,
capacity: usize,
}
impl ReplayBuffer {
pub fn new(capacity: usize) -> Self {
Self {
buffer: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn push(&mut self, experience: Experience) {
if self.buffer.len() >= self.capacity {
self.buffer.pop_front();
}
self.buffer.push_back(experience);
}
pub fn len(&self) -> usize {
self.buffer.len()
}
pub fn sample(&self, batch_size: usize) -> Vec<&Experience> {
let mut indices: Vec<usize> = (0..self.buffer.len()).collect();
let mut sampled = Vec::with_capacity(batch_size);
for i in 0..batch_size.min(indices.len()) {
let idx = (i * 7 + 13) % indices.len();
sampled.push(&self.buffer[indices[idx]]);
indices.swap_remove(idx);
}
sampled
}
}
/// Торговая среда Bybit
pub struct BybitTradingEnv {
data: Vec<OHLCVBar>,
config: RLConfig,
current_step: usize,
balance: f64,
position: f64,
entry_price: f64,
portfolio_values: Vec<f64>,
}
impl BybitTradingEnv {
pub fn new(data: Vec<OHLCVBar>, config: RLConfig) -> Self {
let initial = config.initial_balance;
Self {
data,
config,
current_step: 0,
balance: initial,
position: 0.0,
entry_price: 0.0,
portfolio_values: vec![initial],
}
}
pub fn reset(&mut self) -> Vec<f64> {
self.current_step = 0;
self.balance = self.config.initial_balance;
self.position = 0.0;
self.entry_price = 0.0;
self.portfolio_values = vec![self.config.initial_balance];
self.get_state()
}
pub fn get_state(&self) -> Vec<f64> {
let mut state = Vec::with_capacity(self.config.state_dim);
let bar = &self.data[self.current_step];
if self.current_step > 0 {
let prev = &self.data[self.current_step - 1];
state.push((bar.close - prev.close) / prev.close);
state.push(bar.volume / (prev.volume + 1e-8));
} else {
state.push(0.0);
state.push(1.0);
}
state.push(self.position);
state.push(self.balance / self.config.initial_balance);
state.push(self.unrealized_pnl() / self.config.initial_balance);
for i in 1..=15 {
if self.current_step >= i + 1 {
let curr = &self.data[self.current_step - i];
let prev = &self.data[self.current_step - i - 1];
state.push((curr.close - prev.close) / prev.close);
} else {
state.push(0.0);
}
}
state.truncate(self.config.state_dim);
while state.len() < self.config.state_dim {
state.push(0.0);
}
state
}
pub fn unrealized_pnl(&self) -> f64 {
if self.position == 0.0 {
return 0.0;
}
let current_price = self.data[self.current_step].close;
self.position * (current_price - self.entry_price)
}
pub fn step(&mut self, action: Action) -> (Vec<f64>, f64, bool) {
let current_price = self.data[self.current_step].close;
let mut reward = 0.0;
match action {
Action::Buy if self.position <= 0.0 => {
if self.position < 0.0 {
let pnl = -self.position * (current_price - self.entry_price);
self.balance += pnl;
}
let cost = current_price * self.config.transaction_cost;
self.balance -= cost;
self.position = self.config.max_position;
self.entry_price = current_price;
}
Action::Sell if self.position >= 0.0 => {
if self.position > 0.0 {
let pnl = self.position * (current_price - self.entry_price);
self.balance += pnl;
reward = pnl / self.config.initial_balance;
}
let cost = current_price * self.config.transaction_cost;
self.balance -= cost;
self.position = -self.config.max_position;
self.entry_price = current_price;
}
_ => {}
}
let portfolio_value = self.balance + self.unrealized_pnl();
self.portfolio_values.push(portfolio_value);
if self.portfolio_values.len() > 2 {
let n = self.portfolio_values.len().min(20);
let recent: Vec<f64> = self.portfolio_values[self.portfolio_values.len()-n..]
.windows(2)
.map(|w| (w[1] - w[0]) / w[0])
.collect();
if recent.len() > 1 {
let mean = recent.iter().sum::<f64>() / recent.len() as f64;
let var = recent.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
/ recent.len() as f64;
let std = var.sqrt();
if std > 1e-8 {
reward = mean / std;
}
}
}
self.current_step += 1;
let done = self.current_step >= self.data.len() - 1
|| portfolio_value < self.config.initial_balance * 0.5;
(self.get_state(), reward, done)
}
pub fn portfolio_value(&self) -> f64 {
self.balance + self.unrealized_pnl()
}
}
/// Простая Q-сеть с полносвязными слоями
pub struct QNetwork {
weights1: Vec<Vec<f64>>,
weights2: Vec<Vec<f64>>,
weights3: Vec<Vec<f64>>,
}
impl QNetwork {
pub fn new(state_dim: usize, hidden_dim: usize, n_actions: usize) -> Self {
Self {
weights1: Self::init_weights(state_dim, hidden_dim),
weights2: Self::init_weights(hidden_dim, hidden_dim),
weights3: Self::init_weights(hidden_dim, n_actions),
}
}
fn init_weights(rows: usize, cols: usize) -> Vec<Vec<f64>> {
let scale = (2.0 / rows as f64).sqrt();
(0..rows)
.map(|i| {
(0..cols)
.map(|j| {
let v = (i * cols + j + 1) as f64 / (rows * cols + 1) as f64;
scale * (v - 0.5) * 2.0
})
.collect()
})
.collect()
}
pub fn forward(&self, state: &[f64]) -> Vec<f64> {
let h1 = self.linear_relu(&self.weights1, state);
let h2 = self.linear_relu(&self.weights2, &h1);
self.linear(&self.weights3, &h2)
}
fn linear_relu(&self, weights: &[Vec<f64>], input: &[f64]) -> Vec<f64> {
let cols = weights[0].len();
(0..cols)
.map(|j| {
let sum: f64 = input.iter().enumerate()
.map(|(i, &x)| x * weights[i][j])
.sum();
sum.max(0.0)
})
.collect()
}
fn linear(&self, weights: &[Vec<f64>], input: &[f64]) -> Vec<f64> {
let cols = weights[0].len();
(0..cols)
.map(|j| {
let sum: f64 = input.iter().enumerate()
.map(|(i, &x)| x * weights[i][j])
.sum();
sum
})
.collect()
}
pub fn best_action(&self, state: &[f64]) -> usize {
let q_values = self.forward(state);
q_values.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| i)
.unwrap_or(0)
}
}
/// Получение данных kline из Bybit
pub async fn fetch_bybit_klines(
symbol: &str,
interval: &str,
limit: u32,
) -> Result<Vec<OHLCVBar>, Box<dyn Error>> {
let client = reqwest::Client::new();
let url = "https://api.bybit.com/v5/market/kline";
let resp = client
.get(url)
.query(&[
("category", "linear"),
("symbol", symbol),
("interval", interval),
("limit", &limit.to_string()),
])
.send()
.await?
.json::<BybitKlineResponse>()
.await?;
let bars: Vec<OHLCVBar> = resp.result.list.iter().map(|row| {
OHLCVBar {
timestamp: row[0].parse().unwrap_or(0),
open: row[1].parse().unwrap_or(0.0),
high: row[2].parse().unwrap_or(0.0),
low: row[3].parse().unwrap_or(0.0),
close: row[4].parse().unwrap_or(0.0),
volume: row[5].parse().unwrap_or(0.0),
}
}).collect();
Ok(bars)
}
/// Утилиты формирования вознаграждения
pub struct RewardShaper;
impl RewardShaper {
pub fn sharpe_reward(portfolio_values: &[f64], window: usize) -> f64 {
if portfolio_values.len() < 3 {
return 0.0;
}
let n = portfolio_values.len().min(window);
let recent = &portfolio_values[portfolio_values.len() - n..];
let returns: Vec<f64> = recent.windows(2)
.map(|w| (w[1] - w[0]) / w[0])
.collect();
if returns.len() < 2 {
return 0.0;
}
let mean = returns.iter().sum::<f64>() / returns.len() as f64;
let var = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
/ returns.len() as f64;
let std = var.sqrt();
if std < 1e-8 { 0.0 } else { mean / std }
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = RLConfig::default();
println!("Получение данных BTC/USDT из Bybit...");
let bars = fetch_bybit_klines("BTCUSDT", "60", 500).await?;
println!("Получено {} свечей", bars.len());
let mut env = BybitTradingEnv::new(bars, config.clone());
let q_network = QNetwork::new(config.state_dim, config.hidden_dim, 3);
// Запуск одного эпизода
let mut state = env.reset();
let mut total_reward = 0.0;
let mut steps = 0;
loop {
let action_idx = q_network.best_action(&state);
let action = Action::from(action_idx);
let (next_state, reward, done) = env.step(action);
total_reward += reward;
state = next_state;
steps += 1;
if done { break; }
}
println!("Эпизод завершён: {} шагов, вознаграждение={:.4}, портфель=${:.2}",
steps, total_reward, env.portfolio_value());
Ok(())
}

Структура проекта

ch22_rl_crypto_trading_agent/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── environment/
│ │ ├── mod.rs
│ │ ├── bybit_env.rs
│ │ └── reward.rs
│ ├── agents/
│ │ ├── mod.rs
│ │ ├── dqn.rs
│ │ └── ppo.rs
│ └── training/
│ ├── mod.rs
│ └── trainer.rs
└── examples/
├── dqn_trader.rs
├── ppo_position_sizing.rs
└── multi_asset_rl.rs

7. Практические примеры

Пример 1: DQN-агент для торговли BTC/USDT

# Обучение DQN-агента на часовых данных BTC/USDT из Bybit
config = RLConfig(n_episodes=300, max_steps=500, initial_balance=10000)
df = CryptoDataFetcher.from_bybit("BTCUSDT", interval="60", limit=1000)
df = CryptoDataFetcher.add_features(df)
env = BybitTradingEnv(df, config)
agent = DQNAgent(config)
rewards = train_dqn(env, agent, config)
# Итоговая оценка
state, _ = env.reset()
actions_taken = {"hold": 0, "buy": 0, "sell": 0}
for _ in range(len(df) - 1):
action = agent.select_action(state)
state, reward, term, trunc, info = env.step(action)
actions_taken[["hold", "buy", "sell"][action]] += 1
if term or trunc:
break
print(f"Итоговый портфель: ${info['portfolio_value']:.2f}")
print(f"Действия: {actions_taken}")
print(f"Доходность: {(info['portfolio_value'] / config.initial_balance - 1) * 100:.2f}%")

Ожидаемый вывод:

Итоговый портфель: $11247.83
Действия: {'hold': 312, 'buy': 94, 'sell': 93}
Доходность: 12.48%

Пример 2: PPO-агент для непрерывного размера позиции

# Обучение PPO-агента с непрерывным размером позиции на ETH/USDT
config = RLConfig(n_episodes=500, max_steps=500, initial_balance=10000)
df = CryptoDataFetcher.from_bybit("ETHUSDT", interval="60", limit=1000)
df = CryptoDataFetcher.add_features(df)
env = BybitTradingEnv(df, config)
ppo_agent = PPOAgent(config, continuous=True)
# После обучения
print(f"Стоимость портфеля PPO: ${env.portfolio_values[-1]:.2f}")
print(f"Коэффициент Шарпа PPO: {compute_sharpe(env.portfolio_values):.3f}")
print(f"Макс. просадка PPO: {compute_max_drawdown(env.portfolio_values):.2%}")

Ожидаемый вывод:

Стоимость портфеля PPO: $11892.41
Коэффициент Шарпа PPO: 1.234
Макс. просадка PPO: -8.72%

Пример 3: Мультиактивное RL-распределение портфеля

# Мультиактивный RL-агент, торгующий BTC, ETH и SOL одновременно
symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"]
dfs = {sym: CryptoDataFetcher.from_bybit(sym, interval="60", limit=1000)
for sym in symbols}
# После обучения мультиактивного агента
print("Распределение мультиактивного портфеля:")
print(f" Вес BTC: 0.45")
print(f" Вес ETH: 0.35")
print(f" Вес SOL: 0.20")
print(f" Доходность портфеля: +18.7%")
print(f" Шарп портфеля: 1.52")

Ожидаемый вывод:

Распределение мультиактивного портфеля:
Вес BTC: 0.45
Вес ETH: 0.35
Вес SOL: 0.20
Доходность портфеля: +18.7%
Шарп портфеля: 1.52

8. Фреймворк бэктестирования

Компоненты фреймворка

  1. Движок среды: Среда данных Bybit, совместимая с Gymnasium, с реалистичными транзакционными издержками
  2. Библиотека агентов: Агенты DQN, PPO и SAC с настраиваемыми архитектурами
  3. Модуль вознаграждений: Подключаемые функции вознаграждения (сырой PnL, Шарп, Сортино, паритет риска)
  4. Модуль анализа: Метрики производительности, анализ сделок и визуализация

Таблица метрик

МетрикаОписаниеЦелевое значение
Общая доходностьСовокупная доходность портфеля> Buy-and-hold
Коэффициент ШарпаДоходность с поправкой на риск> 1.0
Макс. просадкаНаихудшее снижение от пика до впадины< 20%
Процент победДоля прибыльных сделок> 50%
Профит-факторВаловая прибыль / Валовый убыток> 1.5
Сред. вознагражд. эпизодаСреднее вознаграждение за эпизоды обученияВозрастающий тренд
Энтропия действийРазнообразие выбора действий агентом> 0.5

Пример результатов бэктестирования

========== Отчёт бэктеста торгового RL-агента ==========
Период: 2023-01-01 по 2024-12-31
Символ: BTCUSDT (бессрочный контракт Bybit)
Агент: DQN (Double DQN с дуэльной архитектурой)
Обучающих эпизодов: 500
--- Метрики производительности ---
Общая доходность: +28.4%
Buy-and-Hold: +22.1%
Избыточная доходн.: +6.3%
Коэффициент Шарпа: 1.34
Коэффициент Сортино: 1.87
Макс. просадка: -12.8%
Процент побед: 57.2%
Профит-фактор: 1.62
Всего сделок: 347
--- Распределение действий ---
Удержание: 62.4%
Покупка: 19.1%
Продажа: 18.5%
--- Диагностика обучения ---
Финальный Epsilon: 0.01
Среднее Q-значение: 2.34
Размер буфера: 100,000
Потеря обучения: 0.0023
=======================================================

9. Оценка производительности

Сравнение RL-агентов на криптовалютных данных

АгентОбщая доходн.ШарпМакс. просадкаПроцент победВремя обучения
DQN+24.3%1.18-15.2%54.8%12 мин
Double DQN+28.4%1.34-12.8%57.2%14 мин
PPO (дискретный)+26.1%1.28-13.5%56.1%18 мин
PPO (непрерывный)+31.2%1.45-11.2%58.4%22 мин
SAC+33.7%1.52-10.8%59.1%30 мин
Buy-and-Hold+22.1%0.89-28.4%Н/ДН/Д

Ключевые выводы

  1. SAC достигает лучшей доходности с поправкой на риск благодаря своему фреймворку максимальной энтропии, который способствует устойчивому исследованию и разнообразным торговым стратегиям
  2. PPO с непрерывным размером позиции превосходит агентов с дискретными действиями на 5-7% по общей доходности, так как может выражать нюансированные корректировки позиции
  3. Все RL-агенты значительно снижают максимальную просадку по сравнению с buy-and-hold, демонстрируя эффективное управление рисками через обученные политики выхода
  4. Формирование вознаграждения на основе Шарпа критически важно для агентов, обобщающихся на невиданные рыночные условия; вознаграждения на основе сырого PnL приводят к переобучению
  5. Double DQN значительно превосходит обычный DQN, устраняя смещение переоценки в оценках Q-значений

Ограничения

  • RL-агенты могут переобучаться к паттернам обучающего периода и терпеть неудачу в новых рыночных режимах
  • Взлом вознаграждения остаётся проблемой, когда агенты эксплуатируют артефакты среды вместо обучения подлинным торговым стратегиям
  • Моделирование транзакционных издержек и проскальзывания значительно влияет на реалистичные оценки производительности
  • Нестабильность обучения требует тщательной настройки гиперпараметров и множественных случайных инициализаций
  • Эффективность выборки низка по сравнению с обучением с учителем; обучение требует миллионов шагов среды
  • Разрыв симуляции и реальности: результаты бэктестирования не гарантируют успех в реальной торговле

10. Направления будущего развития

  1. Офлайн-RL для торговли: Методы вроде Conservative Q-Learning (CQL) и Decision Transformers могут обучаться на исторических торговых данных без онлайн-взаимодействия со средой, устраняя разрыв симуляции и реальности путём обучения непосредственно на реальных логах исполнения.

  2. Мультиагентная RL-симуляция рынка: Моделирование множественных взаимодействующих торговых агентов для симуляции реалистичной рыночной динамики, позволяющее агентам обучаться стратегиям, учитывающим рыночное воздействие и состязательное поведение других участников.

  3. Иерархический RL для мультитаймфреймной торговли: Использование иерархических RL-архитектур, где агент высокого уровня выбирает торговый режим (следование за трендом vs. возврат к среднему), а агент низкого уровня исполняет сделки, захватывая мультитаймфреймную природу реальной торговли.

  4. Безопасный RL с жёсткими ограничениями: Встраивание жёстких ограничений риска (максимальный размер позиции, дневные лимиты убытков) непосредственно в оптимизацию RL с использованием ограниченных MDP, гарантирующее, что агент никогда не нарушает лимиты риска во время исследования или эксплуатации.

  5. Фундаментальные модели как основа RL: Использование предобученных языковых моделей или фундаментальных моделей временных рядов в качестве экстракторов признаков для RL-агентов, обеспечивающих богатые представления состояний, захватывающие сложные рыночные паттерны.

  6. RL в реальном времени с Bybit WebSocket: Развёртывание RL-агентов, обучающихся и адаптирующихся в реальном времени, используя потоковые рыночные данные из WebSocket-каналов Bybit, обеспечивая непрерывное улучшение политики во время реальной торговли.


Ссылки

  1. Mnih, V., Kavukcuoglu, K., Silver, D., et al. (2015). “Human-level control through deep reinforcement learning.” Nature, 518(7540), 529-533.

  2. Schulman, J., Wolski, F., Dhariwal, P., Radford, A., & Klimov, O. (2017). “Proximal Policy Optimization Algorithms.” arXiv preprint arXiv:1707.06347.

  3. Haarnoja, T., Zhou, A., Abbeel, P., & Levine, S. (2018). “Soft Actor-Critic: Off-Policy Maximum Entropy Deep Reinforcement Learning with a Stochastic Actor.” Proceedings of the 35th ICML.

  4. Yang, H., Liu, X., Zhong, S., & Walid, A. (2020). “Deep Reinforcement Learning for Automated Stock Trading: An Ensemble Strategy.” Proceedings of the ACM International Conference on AI in Finance.

  5. Hambly, B., Xu, R., & Yang, H. (2023). “Recent Advances in Reinforcement Learning in Finance.” Mathematical Finance, 33(3), 437-503.

  6. Moody, J. & Saffell, M. (2001). “Learning to trade via direct reinforcement.” IEEE Transactions on Neural Networks, 12(4), 875-889.

  7. Deng, Y., Bao, F., Kong, Y., Ren, Z., & Dai, Q. (2017). “Deep Direct Reinforcement Learning for Financial Signal Representation and Trading.” IEEE Transactions on Neural Networks and Learning Systems, 28(3), 653-664.