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

构建同合约期权收益和Delta对冲路径

代码 《交易机器学习》

总结

此工具使用入场和出场时相同的标的、行权价和到期日,为标普500跨式期权构建标签产物。它将特征日期与之后的市场时段对齐,构造五个和十个交易时段后的出场日期,并合并看涨期权与看跌期权价格,以计算跨式期权收益。它还记录买卖报价,并可保存源价格面板。产物会一并保存缓存范围,避免将减少标的数量或缩短日期范围的运行误认为完整覆盖。

单独的对冲路径会跟踪持有期间看涨期权和看跌期权的Delta及标的价格。累计对冲盈亏将前一收盘时两者合计的Delta应用于下一次标的价格变动,并统计实际观测到的对冲天数,以便识别不完整路径,而不将其视为完全对冲。即使减少标的数量,时段偏移也仍来自整个面板,从而保留预期期限。摘录介绍的是标签构建和数据处理,并非经过测试的期权策略或表现结果。报价或后续时段缺失时,标签可能不可用;对冲路径也明确将未观测到的合约日视为不完整。

核心观点

  • 合并入场和出场日期的期权价格时,保持标的、行权价和到期日不变。
  • 即使缩小标的范围,也应使用完整面板日历,按市场交易时段计算持有期限。
  • 根据入场和出场时看涨与看跌期权的中间价之和计算跨式期权收益。
  • 使用前一收盘时两种期权的合计Delta和下一次标的价格变动,累计Delta对冲盈亏。
  • 跟踪对冲观测次数,避免将缺少合约报价的头寸误认为已完整对冲。

标签

全文
# _label_artifacts.py


```py
"""Build same-contract label artifacts for the S&P 500 options case study."""

from __future__ import annotations

import json
from pathlib import Path

import polars as pl

from data import load_sp500_options_straddles, load_sp500_options_straddles_raw
from utils.data_quality import top_entities
from utils.paths import get_case_study_dir

HORIZONS = (5, 10)
MAX_HOLDING = max(HORIZONS)
JOIN_KEYS = ["symbol", "strike", "expiration"]


def ensure_label_artifacts(
    *,
    case_study_id: str = "sp500_options",
    max_symbols: int = 0,
    start_date: str | None = None,
    force_rebuild: bool = False,
    save_prices: bool = False,
) -> dict[str, Path]:
    """Ensure same-contract option artifacts exist for label construction."""
    case_dir = get_case_study_dir(case_study_id)
    labels_dir = case_dir / "labels"
    labels_dir.mkdir(parents=True, exist_ok=True)

    contract_returns_path = labels_dir / "contract_returns.parquet"
    hedge_path_path = labels_dir / "hedge_path.parquet"
    prices_path = labels_dir / "prices.parquet"

    required = [contract_returns_path, hedge_path_path]
    # A cached artifact was built over whatever window the run that wrote it saw, so the
    # scope is recorded beside it and the cache is only reused for the same request.
    # Without that, a narrower run returns the full panel and leaves the reduction
    # silently unapplied, and the reduced files a narrower run writes are then accepted
    # by the next default run as if they covered everything.
    scope_path = labels_dir / "contract_returns.scope.json"
    scope = {"max_symbols": max_symbols, "start_date": start_date}
    cached_scope = json.loads(scope_path.read_text()) if scope_path.exists() else None
    if not force_rebuild and cached_scope == scope and all(path.exists() for path in required):
        return {
            "contract_returns": contract_returns_path,
            "hedge_path": hedge_path_path,
            "prices": prices_path,
        }

    straddles = load_sp500_options_straddles()
    if start_date is not None:
        straddles = straddles.filter(pl.col("timestamp") >= pl.lit(start_date).str.to_date())

    # Horizons are counted in market sessions, so the calendar comes from the whole panel
    # and `max_symbols` thins only the entries it is applied to. Deriving the offsets from
    # a symbol subset instead would drop every session none of those symbols was quoted
    # on, and the exit dates would then be that many sessions further out than declared.
    trading_dates = straddles["timestamp"].unique().sort().to_list()
    entry_rows = straddles
    if max_symbols > 0:
        # `top_entities` and not a local sort: it breaks the tie on the symbol, and this
        # reduction has to agree with the one the feature and modelling stages make on the
        # same panel. A symbol the labels kept and the features dropped joins to nulls.
        entry_rows = straddles.filter(pl.col("symbol").is_in(top_entities(straddles, max_symbols)))

    # A signal date qualifies once the panel has a session to enter on; how far past
    # that the panel has to run is a property of each horizon, so every offset column
    # runs off the end of the panel as a null rather than shortening the frame. Sizing
    # one frame for the longest horizon instead trims the five-session labels by the
    # ten-session one, and drops signal dates from the hold-to-expiry label, which
    # needs only an entry price and an expiration.
    def _shifted(step: int) -> list[object]:
        return trading_dates[step:] + [None] * step

    offset_data = {"feature_date": trading_dates, "entry_date": _shifted(1)}
    for horizon in HORIZONS:
        offset_data[f"exit_{horizon}d_date"] = _shifted(1 + horizon)
    for day in range(MAX_HOLDING + 1):
        offset_data[f"path_date_{day}"] = _shifted(1 + day)

    date_offsets = pl.DataFrame(offset_data).drop_nulls("entry_date")
    entries = (
        entry_rows.select(["timestamp", "symbol", "strike", "expiration"])
        .join(date_offsets.rename({"feature_date": "timestamp"}), on="timestamp", how="inner")
        .rename({"timestamp": "feature_date"})
    )

    contracts = entries.select("symbol", "strike", "expiration").unique()
    raw_lookup = (
        load_sp500_options_straddles_raw(lazy=True)
        .join(contracts.lazy(), on=JOIN_KEYS, how="semi")
        .filter(pl.col("bid") >= 0.01)
        .select(
            [
                pl.col("timestamp").alias("date"),
                "symbol",
                "strike",
                "expiration",
                "call_put",
                "mid_price",
                "bid",
                "ask",
                "delta",
                "underlying_price",
                "days_to_maturity",
            ]
        )
        .collect()
    )

    raw_calls = raw_lookup.filter(pl.col("call_put") == "C")
    raw_puts = raw_lookup.filter(pl.col("call_put") == "P")
    result = entries

    result = result.join(
        _build_price_lookup(raw_calls, "entry_date", "entry_call"),
        on=["entry_date"] + JOIN_KEYS,
        how="left",
    )
    result = result.join(
        _build_price_lookup(raw_puts, "entry_date", "entry_put"),
        on=["entry_date"] + JOIN_KEYS,
        how="left",
    )

    for horizon in HORIZONS:
        date_col = f"exit_{horizon}d_date"
        result = result.join(
            _build_price_lookup(raw_calls, date_col, f"exit_call_{horizon}d"),
            on=[date_col] + JOIN_KEYS,
            how="left",
        )
        result = result.join(
            _build_price_lookup(raw_puts, date_col, f"exit_put_{horizon}d"),
            on=[date_col] + JOIN_KEYS,
            how="left",
        )

    result = result.with_columns(
        (pl.col("entry_call_mid") + pl.col("entry_put_mid")).alias("entry_straddle_mid"),
    )
    for horizon in HORIZONS:
        exit_straddle = f"exit_straddle_mid_{horizon}d"
        result = result.with_columns(
            (pl.col(f"exit_call_{horizon}d_mid") + pl.col(f"exit_put_{horizon}d_mid")).alias(
                exit_straddle
            ),
            pl.col(f"exit_call_{horizon}d_mid").is_not_null().alias(f"exit_found_{horizon}d"),
        )
        result = result.with_columns(
            (
                (pl.col("entry_straddle_mid") - pl.col(exit_straddle))
                / pl.col("entry_straddle_mid")
            ).alias(f"fwd_ret_{horizon}d"),
        )

    hedge_needs = (
        entries.select(
            ["feature_date", "symbol", "strike", "expiration"]
            + [f"path_date_{d}" for d in range(MAX_HOLDING + 1)]
        )
        .unpivot(
            [f"path_date_{d}" for d in range(MAX_HOLDING + 1)],
            index=["feature_date", "symbol", "strike", "expiration"],
            variable_name="holding_day_str",
            value_name="holding_date",
        )
        .with_columns(
            pl.col("holding_day_str").str.extract(r"(\d+)").cast(pl.Int32).alias("holding_day")
        )
        .drop("holding_day_str")
    )

    hedge_call = raw_calls.select(
        [
            pl.col("date").alias("holding_date"),
            "symbol",
            "strike",
            "expiration",
            pl.col("delta").alias("call_delta"),
        ]
    )
    hedge_put = raw_puts.select(
        [
            pl.col("date").alias("holding_date"),
            "symbol",
            "strike",
            "expiration",
            pl.col("delta").alias("put_delta"),
        ]
    )
    underlying_prices = raw_calls.select(
        [pl.col("date").alias("holding_date"), "symbol", "strike", "expiration", "underlying_price"]
    ).unique(subset=["holding_date", "symbol", "strike", "expiration"])

    hedge_path = (
        hedge_needs.join(hedge_call, on=["holding_date"] + JOIN_KEYS, how="left")
        .join(hedge_put, on=["holding_date"] + JOIN_KEYS, how="left")
        .join(underlying_prices, on=["holding_date"] + JOIN_KEYS, how="left")
        .with_columns((pl.col("call_delta") + pl.col("put_delta")).alias("instr_delta"))
    )

    return_cols = (
        ["feature_date", "symbol", "strike", "expiration", "entry_date"]
        + [f"exit_{horizon}d_date" for horizon in HORIZONS]
        + ["entry_call_mid", "entry_put_mid", "entry_straddle_mid"]
        + ["entry_call_bid", "entry_call_ask", "entry_put_bid", "entry_put_ask"]
    )
    for horizon in HORIZONS:
        return_cols += [
            f"exit_call_{horizon}d_mid",
            f"exit_put_{horizon}d_mid",
            f"exit_straddle_mid_{horizon}d",
            f"fwd_ret_{horizon}d",
            f"exit_found_{horizon}d",
            f"exit_call_{horizon}d_bid",
            f"exit_call_{horizon}d_ask",
            f"exit_put_{horizon}d_bid",
            f"exit_put_{horizon}d_ask",
        ]
    contract_returns = result.select(return_cols)
    contract_returns.write_parquet(contract_returns_path)

    hedge_path_out = hedge_path.select(
        [
            "feature_date",
            "symbol",
            "strike",
            "expiration",
            "holding_day",
            "holding_date",
            "call_delta",
            "put_delta",
            "instr_delta",
            "underlying_price",
        ]
    ).sort(["symbol", "feature_date", "holding_day"])
    hedge_path_out.write_parquet(hedge_path_path)

    scope_path.write_text(json.dumps(scope, sort_keys=True) + "\n")

    if save_prices:
        straddles.write_parquet(prices_path)

    return {
        "contract_returns": contract_returns_path,
        "hedge_path": hedge_path_path,
        "prices": prices_path,
    }


def accrued_hedge_pnl(
    hedge_path: pl.DataFrame, horizons: tuple[int, ...] = HORIZONS
) -> pl.DataFrame:
    """Hedge P&L accrued over each horizon, with the number of days it was observed on.

    The hedge is rebalanced at each close, so the P&L on holding day ``d`` is the delta
    set on day ``d-1`` applied to that day's move in the underlying. A day the contract
    was not quoted on contributes nothing and is *counted*: a sum over a path with holes
    is a partial hedge, and the count is what lets the caller null the label rather than
    present it as fully hedged.

    Summed in day order. An unordered parallel sum re-associates the floating point, which
    moves the label's last bit and its content digest from one run to the next.
    """
    cohort = ["symbol", "feature_date"]
    move = pl.col("underlying_price") - pl.col("underlying_price").shift(1).over(cohort)
    daily = hedge_path.sort([*cohort, "holding_day"]).with_columns(
        (pl.col("instr_delta").shift(1).over(cohort) * move).alias("daily_pnl")
    )
    accrued = daily.group_by(cohort).agg(
        *[
            expr
            for horizon in horizons
            for expr in (
                pl.col("daily_pnl")
                .filter(pl.col("holding_day").is_between(1, horizon))
                .sort_by(pl.col("holding_day").filter(pl.col("holding_day").is_between(1, horizon)))
                .sum()
                .alias(f"hedge_pnl_{horizon}d"),
                pl.col("daily_pnl")
                .filter(pl.col("holding_day").is_between(1, horizon))
                .is_not_null()
                .sum()
                .alias(f"hedge_days_{horizon}d"),
            )
        ]
    )
    return accrued.rename({"feature_date": "timestamp"})


def summarize_label_artifacts(
    *,
    case_study_id: str = "sp500_options",
) -> dict[str, int]:
    """Return row counts for persisted same-contract artifacts."""
    case_dir = get_case_study_dir(case_study_id)
    labels_dir = case_dir / "labels"
    summary: dict[str, int] = {}
    for name in ("contract_returns", "hedge_path", "prices"):
        path = labels_dir / f"{name}.parquet"
        if path.exists():
            summary[name] = int(pl.scan_parquet(path).select(pl.len()).collect().item())
    return summary


def _build_price_lookup(raw_leg: pl.DataFrame, date_alias: str, prefix: str) -> pl.DataFrame:
    return raw_leg.select(
        [
            pl.col("date").alias(date_alias),
            "symbol",
            "strike",
            "expiration",
            pl.col("mid_price").alias(f"{prefix}_mid"),
            pl.col("bid").alias(f"{prefix}_bid"),
            pl.col("ask").alias(f"{prefix}_ask"),
        ]
    )

```

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

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