跳至正文
返回文库全部文档

在市场冲击环境中执行加密货币永续期货交易

代码 《交易机器学习》

总结

该模块定义了一个时点模拟环境,用于执行加密货币永续期货的大额卖单。观测值结合了剩余持仓和时间,以及市场波动率、溢价指数、相对成交量、时段和距资金费结算的时间。行动会在参考计划附近调整执行速度,同时受计划和参与率上限约束。该环境使用平方根加线性市场冲击估算执行偏差,并可针对持仓风险和计划偏离增加惩罚。

每个模拟回合都会抽取一段连续市场数据窗口,跟踪执行成本,并返回按订单名义金额缩放的奖励。最后一步会计入任何强制平仓,并根据合并参与率收取冲击成本,避免同一柱线内拆分交易带来虚假的成本优势。该框架可用于在给定假设下比较执行策略,但其现实性取决于输入数据以及所选的冲击、流动性和惩罚参数。它模拟的是卖出计划,本身并不能证明实盘执行表现。

核心观点

  • 状态包括持仓、剩余时间、波动率、相对成交量、溢价和资金费结算时点。
  • 行动会在计划和流动性限制内控制执行速度。
  • 执行偏差基于柱线参与率,通过平方根和线性市场冲击建模。
  • 持仓风险和计划偏离可能给奖励增加额外成本。
  • 强制终端平仓和合并参与率假设会影响模拟执行成本。

标签

全文
# crypto_execution_env.py


```py
"""Point-in-time crypto execution environment for Chapter 21."""

from __future__ import annotations

from dataclasses import dataclass

import gymnasium as gym
import numpy as np
import polars as pl
from gymnasium import spaces


@dataclass
class CryptoMarketState:
    """Current crypto market microstructure state."""

    timestamp: np.datetime64
    price: float
    volume: float
    avg_volume: float
    volatility: float
    premium_index: float
    hour: int
    hours_to_funding: int


class CryptoExecutionEnv(gym.Env):
    """
    Optimal execution environment for crypto perpetual futures.

    Uses real market data to simulate execution of a large order,
    modeling market impact based on actual volume patterns.

    State: [inventory_ratio, time_ratio, volatility, premium_index,
            volume_ratio, hour_of_day, hours_to_funding]
    Action: Box([0, 1]) -> pace multiplier around a reference schedule
    Reward: Negative cost-risk objective in basis points
    """

    metadata = {"render_modes": ["human"]}

    def __init__(
        self,
        market_data: pl.DataFrame,
        symbol: str = "BTCUSDT",
        total_shares: float = 100.0,
        horizon: int = 24,
        impact_coefficient: float = 0.001,
        risk_aversion: float = 0.0,
        schedule_penalty: float = 0.0,
        pace_min_multiplier: float = 0.5,
        pace_max_multiplier: float = 1.5,
        max_participation_rate: float = 0.10,
        seed: int | None = None,
    ):
        super().__init__()

        self.symbol = symbol
        self.total_shares = total_shares
        self.horizon = horizon
        self.impact_coefficient = impact_coefficient
        self.risk_aversion = risk_aversion
        self.schedule_penalty = schedule_penalty
        self.pace_min_multiplier = pace_min_multiplier
        self.pace_max_multiplier = pace_max_multiplier
        self.max_participation_rate = max_participation_rate

        # Extract data for this symbol
        symbol_data = market_data.filter(pl.col("symbol") == symbol).sort("timestamp")
        self.timestamps = symbol_data["timestamp"].to_numpy()
        self.prices = symbol_data["open"].to_numpy()
        self.volumes = symbol_data["observed_volume"].to_numpy()
        self.avg_volumes = symbol_data["avg_volume_24h"].to_numpy()
        self.volatilities = symbol_data["volatility_24h"].to_numpy()
        self.premium_indices = symbol_data["premium_index_close"].to_numpy()
        self.hours = symbol_data["hour"].to_numpy()
        self.hours_to_funding = symbol_data["hours_to_funding"].to_numpy()

        self.n_samples = len(self.prices)
        self.rng = np.random.default_rng(seed)

        # State: 7 features
        self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(7,), dtype=np.float32)

        self.action_space = spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32)

        self.reset()

    def market_state(self) -> CryptoMarketState:
        """The bar the next call to :meth:`step` will execute against."""
        return self._get_market_state(self.start_idx + self.step_idx)

    def _get_market_state(self, idx: int) -> CryptoMarketState:
        """Get market state at given index."""
        return CryptoMarketState(
            timestamp=self.timestamps[idx],
            price=self.prices[idx],
            volume=self.volumes[idx],
            avg_volume=self.avg_volumes[idx],
            volatility=self.volatilities[idx],
            premium_index=self.premium_indices[idx],
            hour=self.hours[idx],
            hours_to_funding=self.hours_to_funding[idx],
        )

    def reset(self, seed: int | None = None, options: dict | None = None):
        super().reset(seed=seed)

        if seed is not None:
            self.rng = np.random.default_rng(seed)

        # Start at a random point with enough data for the full horizon. The
        # upper bound is exclusive, so n_samples - horizon + 1 keeps the last
        # two otherwise-valid windows in play.
        if self.n_samples < self.horizon:
            raise ValueError(
                f"{self.symbol}: {self.n_samples} rows is fewer than the "
                f"{self.horizon}-step execution horizon"
            )
        self.start_idx = self.rng.integers(0, self.n_samples - self.horizon + 1)
        self.step_idx = 0

        self.remaining_shares = self.total_shares
        self.arrival_price = self.prices[self.start_idx]
        self.total_cost = 0.0
        self.execution_history = []

        return self._get_obs(), {}

    def _get_obs(self) -> np.ndarray:
        """Construct observation vector."""
        market = self.market_state()

        inventory_ratio = self.remaining_shares / self.total_shares
        time_ratio = (self.horizon - self.step_idx) / self.horizon

        # Volume ratio (current vs average)
        volume_ratio = market.volume / (market.avg_volume + 1e-8)

        # Normalize premium index (typically in range -0.01 to 0.01)
        premium_normalized = market.premium_index * 100

        # Volatility (typically 0.01-0.05)
        vol_normalized = market.volatility * 100 if not np.isnan(market.volatility) else 2.0

        return np.array(
            [
                inventory_ratio,
                time_ratio,
                vol_normalized,
                premium_normalized,
                min(volume_ratio, 5.0),  # Cap outliers
                market.hour / 24.0,  # Normalize hour
                market.hours_to_funding / 8.0,  # Normalize to funding window
            ],
            dtype=np.float32,
        )

    def _coerce_action_fraction(self, action: np.ndarray | float) -> float:
        action_array = np.asarray(action, dtype=np.float32).reshape(-1)
        raw_action = float(action_array[0]) if action_array.size else 0.0
        return float(np.clip(raw_action, 0.0, 1.0))

    def _remaining_steps(self) -> int:
        return max(self.horizon - self.step_idx, 1)

    def reference_trade_size(self) -> float:
        if self.step_idx >= self.horizon - 1:
            return float(self.remaining_shares)
        return float(self.remaining_shares / self._remaining_steps())

    def max_trade_size(self, market: CryptoMarketState) -> float:
        """Largest executable size, capped by both schedule and liquidity.

        The liquidity cap binds on the final step as well as every other one, so
        a residual the policy has not sold by then cannot be unwound in one
        trade regardless of the volume available.
        """
        schedule_cap = self.pace_max_multiplier * self.reference_trade_size()
        liquidity_cap = self.max_participation_rate * market.volume
        return float(min(self.remaining_shares, max(1e-8, min(schedule_cap, liquidity_cap))))

    def action_to_target_shares(
        self, action: np.ndarray | float, market: CryptoMarketState | None = None
    ) -> float:
        current_market = self.market_state() if market is None else market
        action_frac = self._coerce_action_fraction(action)
        multiplier = self.pace_min_multiplier + action_frac * (
            self.pace_max_multiplier - self.pace_min_multiplier
        )
        desired_shares = multiplier * self.reference_trade_size()
        return float(
            min(self.remaining_shares, self.max_trade_size(current_market), desired_shares)
        )

    def target_shares_to_action(self, target_shares: float) -> np.ndarray:
        """Inverse of :meth:`action_to_target_shares`, kept consistent with it."""
        reference = max(self.reference_trade_size(), 1e-8)
        multiplier = target_shares / reference
        normalized = (multiplier - self.pace_min_multiplier) / (
            self.pace_max_multiplier - self.pace_min_multiplier
        )
        return np.array([np.clip(normalized, 0.0, 1.0)], dtype=np.float32)

    def _trade_metrics(
        self,
        market: CryptoMarketState,
        shares_to_sell: float,
        concurrent_shares: float = 0.0,
    ) -> tuple[float, float]:
        """Execution price and shortfall for one trade.

        ``concurrent_shares`` are shares executed against the same bar by a
        second trade. Impact is charged on the *combined* participation, so
        splitting an order across two trades at one timestamp costs exactly
        what executing it in one trade costs. Without this, concave
        square-root impact would make splitting artificially cheap.
        """
        participation_rate = (shares_to_sell + concurrent_shares) / (market.volume + 1e-8)
        # Square-root plus linear: the marginal cost of trading more in one bar
        # rises with participation, and does so without limit only in the linear
        # term.
        market_impact = self.impact_coefficient * (
            np.sqrt(max(participation_rate, 0.0)) + participation_rate
        )
        execution_price = market.price * (1 - market_impact)
        shortfall = (self.arrival_price - execution_price) * shares_to_sell
        return execution_price, shortfall

    def _inventory_risk_penalty(self, market: CryptoMarketState, remaining_shares: float) -> float:
        sigma_price = market.price * (market.volatility if not np.isnan(market.volatility) else 0.0)
        inventory_ratio = remaining_shares / max(self.total_shares, 1e-8)
        return float(self.risk_aversion * sigma_price**2 * inventory_ratio**2 * self.total_shares)

    def _schedule_penalty(self, shares_to_sell: float, reference_shares: float) -> float:
        if self.schedule_penalty <= 0:
            return 0.0
        deviation_ratio = (shares_to_sell - reference_shares) / max(reference_shares, 1e-8)
        notional = self.arrival_price * self.total_shares
        return float(self.schedule_penalty * notional * deviation_ratio**2)

    def step(self, action: np.ndarray | float):
        market = self.market_state()
        # Capture both schedule quantities before inventory is reduced, so the
        # history row describes the trade that was actually executed. Computing
        # them after the fact could even report a cap below `shares_sold`.
        reference_shares = self.reference_trade_size()
        max_trade_shares = self.max_trade_size(market)
        shares_to_sell = self.action_to_target_shares(action, market)

        # Any residual left after the final step is liquidated against this same
        # bar, so it is known before the trade is priced and both legs are
        # charged on their combined participation.
        residual_tolerance = 1e-9 * max(self.total_shares, 1.0)
        forced_shares = (
            max(self.remaining_shares - shares_to_sell, 0.0)
            if self.step_idx >= self.horizon - 1
            else 0.0
        )
        if forced_shares <= residual_tolerance:
            forced_shares = 0.0

        execution_price, shortfall = self._trade_metrics(market, shares_to_sell, forced_shares)
        # Both legs are charged on the same combined participation, so they clear
        # at one price: the bar has a single execution price regardless of how
        # the order is split between the policy and the forced remainder.
        if forced_shares > 0:
            _, forced_shortfall = self._trade_metrics(market, forced_shares, shares_to_sell)
        else:
            forced_shortfall = 0.0

        # Update state
        self.remaining_shares -= shares_to_sell
        if self.remaining_shares <= residual_tolerance:
            self.remaining_shares = 0.0
        self.total_cost += shortfall + forced_shortfall

        # Inventory risk prices the exposure carried past this bar. A forced
        # remainder is liquidated against this same bar, so it is never carried
        # and is not charged; otherwise the terminal reward would depend on how
        # the final order splits between the two legs, which changes neither the
        # exposure nor the execution cost.
        carried_shares = max(self.remaining_shares - forced_shares, 0.0)
        risk_penalty = (
            0.0 if carried_shares <= 0 else self._inventory_risk_penalty(market, carried_shares)
        )
        schedule_penalty = self._schedule_penalty(shares_to_sell, reference_shares)

        if forced_shares > 0:
            self.remaining_shares = 0.0

        # One history row per bar, with the forced quantity as its own field, so
        # `shares_sold` always means the whole bar and no reader has to collapse
        # two rows for the final hour.
        self.execution_history.append(
            {
                "step": self.step_idx,
                "timestamp": str(market.timestamp),
                "price": market.price,
                "shares_sold": shares_to_sell + forced_shares,
                "forced_shares": forced_shares,
                "forced_liquidation": forced_shares > 0,
                "execution_price": execution_price,
                "shortfall": shortfall + forced_shortfall,
                "remaining": self.remaining_shares,
                "volume": market.volume,
                "premium_index": market.premium_index,
                "reference_shares": reference_shares,
                "max_trade_shares": max_trade_shares,
                "risk_penalty": risk_penalty,
                "schedule_penalty": schedule_penalty,
                "hour": market.hour,
                "hours_to_funding": market.hours_to_funding,
            }
        )

        self.step_idx += 1

        reward = (
            -(shortfall + forced_shortfall + risk_penalty + schedule_penalty)
            / (self.arrival_price * self.total_shares)
            * 10_000
        )

        # Terminal condition
        terminated = self.step_idx >= self.horizon or self.remaining_shares <= 0
        truncated = False

        # Return final observation
        if terminated:
            self.step_idx = min(self.step_idx, self.horizon - 1)

        obs = self._get_obs()
        info = {
            "shares_sold": shares_to_sell + forced_shares,
            "forced_shares": forced_shares,
            "remaining_shares": self.remaining_shares,
            "step_shortfall": shortfall + forced_shortfall,
            "total_shortfall": self.total_cost,
            "risk_penalty": risk_penalty,
            "schedule_penalty": schedule_penalty,
            "premium_index": market.premium_index,
            "volume": market.volume,
        }

        return obs, reward, terminated, truncated, info

```

在遵守原作品许可的前提下,附作者信息全文展示。 许可协议: MIT

此摘要由 Stratmill 研究智能体根据原文撰写,并非原文副本。