본문으로 건너뛰기
라이브러리 문서 전체

숏 스트래들 코호트에 일반 포트폴리오 리스크 오버레이가 맞지 않는 이유

노트북 Machine Learning for Trading

요약

이 노트북은 일반적인 목표 비중 리스크 오버레이로 설명된 S&P 500 숏 스트래들 전략을 제어할 수 없는 이유를 설명합니다. 옵션 엔진은 주간 코호트에 고정된 자본 비율을 배정하고 각 코호트 안에서 비중을 정규화합니다. 따라서 비중을 조정해도 정규화 과정에서 원래대로 돌아가며, 오버레이가 새로 배정할 현금도 남지 않습니다. 헤지 임계값, 결제, 비용, 코호트 수처럼 전략에 영향을 주는 제어는 이후 비중 변환이 아니라 전략 명세에 포함해야 합니다.

노트북은 설정된 포지션 및 포트폴리오 리스크 요청이 비어 있는지 확인하고, 지원하지 않는 오버레이를 실행 경로가 거부하는지 시험 요청을 제출합니다. 또한 전후 백테스트 레지스트리를 비교해 아무것도 기록되지 않았는지 확인합니다. 이는 구현상의 경계를 밝힐 뿐, 숏 변동성 전략에 리스크 제어가 바람직한지는 판단하지 않습니다. 옵션 특성을 반영한 제어는 코호트 구성, 계약 선택 또는 헤지에 작용해야 하며 여기서 설명하는 일반 오버레이 형식에는 포함되지 않습니다.

핵심 아이디어

  • 코호트 비중이 완전 투자 상태로 다시 정규화되면 비중 오버레이는 효과가 없습니다.
  • 옵션 전략 제어는 엔진의 코호트, 계약, 헤지 표현 방식에 맞아야 합니다.
  • 지원하지 않는 리스크 요청은 공통 실행 경로에서 거부해야 합니다.
  • 출력이 없는 단계의 주장은 실행 전후 레지스트리 내용을 비교해 확인할 수 있습니다.
  • 이 노트북은 인터페이스의 한계를 설명하며 숏 변동성 리스크 제어의 장점을 판단하지 않습니다.

태그

전문
# S&P 500 Options: The Risk-Overlay Boundary


# S&P 500 Options: The Risk-Overlay Boundary

In the other case studies this stage adds a risk overlay: a rule sitting on top of the
allocator's weights that caps a position, scales the book down after a drawdown, or targets a
volatility. The overlay is expressed as a target-weight transformation, which works because in
those case studies a position is a quantity of one instrument and its risk moves with that
quantity.

A short straddle does not have that shape here. Scaling the number of contracts would in fact
scale the legs, the hedge, the costs and the dollar Greeks together, so the objection is not that
option risk is independent of quantity. It is that the option engine never sees a quantity. It
holds five weekly cohorts at a fixed fifth of capital each and normalizes the weights inside a
cohort to sum to one, so an overlay that scales those weights down is renormalized straight back
up: there is no cash position for the book to move into. The execution path therefore refuses a
risk block rather than accept one it would silently discard. The controls that do govern this
strategy - the delta-hedge threshold, the settlement convention, the entry cost model, how many
weekly cohorts run at once - are fields of the strategy specification itself, fixed in
`12_backtest`; `15_costs` afterwards varies one of them to measure what the result depends on.

So this case study declares no risk-overlay variants, and this notebook is where that is
checked rather than assumed. It resolves the candidate set that came out of
`13_portfolio_management`, shows that the configured risk request set is empty, demonstrates that
a risk request would be refused if one were configured, and confirms it wrote nothing.

This is the third of the four backtest stages, and the last one that could add a run to the
candidate pool. It registers none, so the pool `15_costs` prices and `18_strategy_analysis`
reports is the one `13_portfolio_management` left. Costs runs after this notebook rather than
beside it so that the last stage to select is the last stage to run.

**Learning objectives**

- Recognise when a generic portfolio control cannot be applied to an instrument, and say what
  about the instrument makes it inapplicable.
- Read a stage that deliberately produces no results, and check that claim against the registry
  rather than against the notebook's own narration.

**Book reference**: Chapter 19

**Prerequisites**: the finalized candidate set published by
[`13_portfolio_management`](13_portfolio_management.ipynb), and through it
[`12_backtest`](12_backtest.ipynb).

```python
"""Validate the empty S&P 500 options risk-overlay request boundary."""

import polars as pl

from case_studies.research import CandidateSet, Result
from case_studies.sp500_options.research_workflow import (
    open_study,
    run_official_backtest_requests,
    strategy_request_frame,
)
from case_studies.utils.sweep_config import (
    get_portfolio_risk_controls,
    get_position_risk_controls,
)

CASE_STUDY = "sp500_options"
STRATEGY_CANDIDATES = "sp500-options-strategy-candidates-v1"
```

```python
EXECUTION_TIER = "canonical"
WORKSPACE: str = ""
```

## The candidate set that passes through

Every member is required to be a complete backtest before the set is allowed to move on, so a
partial result cannot reach selection by being carried through a stage that does nothing.

```python
if EXECUTION_TIER != "canonical":
    raise ValueError("risk-boundary validation requires the canonical candidate set")
study = open_study(execution_tier=EXECUTION_TIER, workspace=WORKSPACE or None)
candidates = CandidateSet.one(study, name=STRATEGY_CANDIDATES)
if candidates.member_kind != "backtest":
    raise TypeError("the finalized strategy candidate set must contain backtests")
backtests_before = study.backtests.table()
members = backtests_before.filter(pl.col("backtest_hash").is_in(candidates.members))
if members.height != len(candidates.members) or members.filter(~pl.col("complete")).height:
    raise RuntimeError("the finalized strategy candidate set is incomplete")
```

```python
pl.DataFrame(
    {
        "candidate_set": [candidates.name],
        "candidate_set_hash": [candidates.hash],
        "member_count": [len(candidates.members)],
        "stages": [", ".join(sorted(members.get_column("stage").unique().to_list()))],
    }
)
```

## The configured risk requests

Position-scope controls act on one holding, portfolio-scope controls act on the book. Both lists
come from `config/setup.yaml`, and both are empty for this case study. Reading them rather than
writing the emptiness into the notebook is what makes this a check: adding a control to the
configuration makes the cell below raise instead of quietly running an overlay the option path
cannot represent.

```python
risk_rows = [
    {"scope": "position", **request} for request in get_position_risk_controls(CASE_STUDY)
] + [{"scope": "portfolio", **request} for request in get_portfolio_risk_controls(CASE_STUDY)]
risk_requests = (
    pl.DataFrame(risk_rows)
    if risk_rows
    else pl.DataFrame(schema={"scope": pl.String, "name": pl.String, "method": pl.String})
)
if not risk_requests.is_empty():
    raise RuntimeError("risk variants require an implemented typed options path before execution")
risk_requests
```

## What happens to a risk request that is submitted anyway

The refusal lives in the execution path, not in this notebook, so it holds for a reader who
writes their own request as well. The cell below builds one against the highest-Sharpe candidate
and confirms it is rejected before anything is fitted or written.

The probe opens that candidate and copies its strategy verbatim, so the risk block is the only
thing about the request that is new. Substituting a signal of the notebook's own would make the
refusal a statement about that substitute rather than about a candidate the pipeline produced.

```python
probe_member = members.sort("sharpe", "backtest_hash", descending=[True, False]).row(0, named=True)
probe_strategy = Result.open(study, probe_member["backtest_hash"]).spec()["strategy"]
probe = strategy_request_frame(
    [
        {
            "request_name": "risk-overlay-probe",
            "prediction_hash": probe_member["prediction_hash"],
            "label": probe_member["label"],
            "signal": probe_strategy["signal"],
            "allocation": probe_strategy.get("allocation"),
            "risk": {"name": "position_cap", "method": "max_weight", "max_weight": 0.1},
            "costs": probe_strategy.get("costs"),
            "chapter": "ch19",
        }
    ]
)
try:
    run_official_backtest_requests(study, probe, population_name=None)
except ValueError as refusal:
    # The refusal has to name the risk overlay. The request also carries the candidate's costs
    # block, which this path refuses separately, so accepting any ValueError would let a refusal
    # about costs be reported as the risk boundary holding.
    if "risk overlay" not in str(refusal):
        raise RuntimeError(
            f"the request was refused for something other than risk: {refusal}"
        ) from refusal
    print(f"risk request refused: {refusal}")
else:
    raise RuntimeError("the option execution path accepted a risk overlay it cannot represent")
```

## Nothing was written

The registry is read back and compared against the snapshot taken before the probe. This is the
claim the stage makes, so it is checked against the store rather than against a counter this
notebook keeps.

```python
backtests_after = study.backtests.table()
if backtests_after.height != backtests_before.height:
    raise RuntimeError("the empty risk boundary wrote a backtest result")
if set(backtests_after.get_column("backtest_hash")) != set(
    backtests_before.get_column("backtest_hash")
):
    raise RuntimeError("the empty risk boundary changed the published backtest set")
pl.DataFrame(
    {
        "check": ["configured risk requests", "backtests before", "backtests after"],
        "value": [
            str(risk_requests.height),
            str(backtests_before.height),
            str(backtests_after.height),
        ],
    }
)
```

## Key takeaways

- A portfolio control is defined against a representation of a position. When the engine holds a
  fully invested book of normalized cohort weights, there is no quantity for a target-weight
  overlay to act on, whatever the instrument.
- A stage that produces nothing still has to prove it, and the proof is the store's contents
  before and after, not a statement in the notebook.
- Refusing an unsupported request in the shared execution path, rather than in the notebook, is
  what makes the boundary hold for a reader's own requests too.

**Known limitations**: this says nothing about whether risk controls on a short-volatility book
are a good idea, only that the generic target-weight form cannot express them here. Implementing
them would mean an option-aware overlay acting on cohort membership, contract selection or the
hedge rule, and that is a change to the strategy specification rather than a stage on top of it.

출처의 라이선스에 따라 출처를 표시하고 전문을 공개합니다. 라이선스: MIT

이 요약은 원문을 바탕으로 Stratmill의 리서치 에이전트가 작성했으며, 원문을 복사한 것이 아닙니다.