Skip to content

fix(options): annualize pricing volatility on the run's bar cadence - #1753

Merged
warren618 merged 1 commit into
HKUDS:mainfrom
remote-controlled-man:codex/vibe-trading-options-hv-annualisation
Oct 9, 2026
Merged

warren618 merged 1 commit into
HKUDS:mainfrom
remote-controlled-man:codex/vibe-trading-options-hv-annualisation

Conversation

@remote-controlled-man

Copy link
Copy Markdown
Contributor

Summary

  • historical_volatility() annualized at a hardcoded np.sqrt(252), and the call site in run_options_backtest() never forwarded the bars_per_year the run had already resolved — so every option leg was priced on 252 bars/year no matter what the venue actually serves.
  • The same run's metrics already annualize on the resolved cadence, so an options backtest priced its legs on one cadence and reported Sharpe/CAGR/Sortino on another.
  • Forward the factor, and resolve None through effective_bars_per_year exactly as _calc_options_metrics already does. The parameter defaults to 252, so existing daily-equity behaviour is unchanged.

Why

backtest/metrics.py::calc_bars_per_year maps every crypto source to 365 daily bars and okx hourly bars to 8760, and runner.py passes bars_per_year=None for a cross-market basket. run_options_backtest() receives that value and forwards it to _calc_options_metrics, which resolves None through effective_bars_per_year and honours it for CAGR, Sharpe and Sortino. It was not forwarded to historical_volatility, which produces the per-bar vol every leg is opened, marked and greeked at.

Measured on a deterministic 60-bar daily series (alternating +2% / -1.5%), one at-the-money 30-day call, risk_free_rate=0.05, flat smile:

Cadence passed to the run Vol the leg was priced at Recorded fill price
bars_per_year=252 (current behaviour) 0.2826 3.8135
bars_per_year=365 (what an okx/binance daily run resolves to) 0.3401 4.5409

The stale factor marked the leg 16.0% below the price it should have filled at, with status: ok and no warning, and that number propagated into artifacts/trades.csv, greeks.csv, equity.csv, metrics.csv and the backtest_summary tool result. Intraday is further off, since okx 1H resolves to 8760 bars/year.

effective_bars_per_year documents the intended invariant:

Every consumer of bars_per_year=None (portfolio metrics, risk x-ray, options metrics, validation) resolves it through this one function, so the Sharpe, the annualised volatility, and the validation Sharpe in a single run card are annualised identically.

The annualised volatility is exactly this function's output, and backtest/validation.py already annualizes with sqrt(bars_per_year). Pricing was the one consumer left out. This is the options engine's copy of the bug fixed in 8afa3879 ("fix(portfolio): annualize weekly and monthly risk bars correctly"), which resolved the risk x-ray's hardcoded 252 through calc_bars_per_year.

Changes

  • agent/backtest/engines/options_portfolio.py
    • historical_volatility() takes bars_per_year: int | None = 252 and scales by np.sqrt(bars_per_year). None resolves through effective_bars_per_year(close.index), the same helper the metrics path uses, so a leg is priced and graded on one cadence. Its Google-style Args: docstring documents the parameter, the None convention and the 365/8760 venue factors — that docstring entry is the documentation change behind the checklist box below.
    • The iv_map build in run_options_backtest() forwards the factor it was already given. It resolves per code, because a cross-market basket has no single factor — which is why the runner passes None at all.
  • agent/tests/test_options_hv_bars_per_year.py (new, offline): five regressions — a supplied cadence, the unchanged 252 default, None resolved from the observed span (the convention restated from len / elapsed years rather than by calling the helper), and two end-to-end runs through run_options_backtest that assert the recorded fill price against an independent Black-Scholes oracle.

Deliberately out of scope:

  • No change to the calc_bars_per_year mapping table, to Sharpe/CAGR/Sortino, or to validation.py.
  • No reformatting. options_portfolio.py is already 876 lines on main against the 800-line hard cap; splitting it is a separate change, and this PR adds a net 16 lines to it.
  • effective_bars_per_year can return a small factor for a pathologically sparse span. That behaviour is shared with the metrics path and is unchanged here.

Test Plan

  • Existing tests pass (pytest --ignore=agent/tests/e2e_backtest --tb=short -q)
  • New tests added (if applicable)
  • Tested manually (describe below)

Focused:

pytest agent/tests/test_options_hv_bars_per_year.py agent/tests/test_options_hv_warmup.py \
       agent/tests/test_options_portfolio_correctness.py agent/tests/test_options_smile_consistency.py -q
68 passed

test_options_hv_warmup.py::test_full_window_bars_are_unchanged_by_the_warmup_fix pins rolling(30).std() * np.sqrt(252); it still passes because the new parameter defaults to 252.

Every test file referencing the changed symbols, the runner or the backtest tools (41 files):

1231 passed, 3 failed, 1 skipped

The 3 failures are OSError: [WinError 1314] symlink-privilege guards in test_backtest_summary.py. They are host artifacts, not regressions: reverting the changed file to main and re-running the same 40 pre-existing files gives 1226 passed, 3 failed, 1 skipped with an identical failure node-ID set. The patch adds 5 passing tests and zero failures.

Full offline selection on this Windows host (pytest --ignore=agent/tests/e2e_backtest --ignore=agent/tests/test_e2e_harness_v2.py -q): 16977 passed, 43 failed, 168 skipped, 4 errors. The same selection on main with this change reverted gives 16971 passed, 44 failed, 168 skipped, 4 errors, and the patched failure node-ID set is a strict subset of the baseline's, so nothing new fails. The classes are Windows symlink-privilege guards (WinError 1314), packaging-metadata assertions that assume a non-editable install, one Unix-domain-socket test, console-encoding collection errors on very large unicode parametrizations, and assorted environment/registry assertions in unrelated modules. The single baseline-only failure, test_runtime_jobstore.py::test_concurrent_saves_use_distinct_temporary_files, is nondeterministic on unmodified main — 2 failed / 4 passed across six identical runs. CI runs this selection on Ubuntu, where those classes do not apply.

Manual reproduction: run_options_backtest driven directly by the two end-to-end tests above, reading the fill price back out of artifacts/trades.csv. Synthetic deterministic closes only.

ruff check clean on both changed files. black --check is not satisfied, but it is not satisfied by main either — pyproject.toml has no [tool.black] section, so black applies its 88-column default while the repo configures ruff at line-length = 120, and every pre-existing sibling options test fails black --check as committed. Per CONTRIBUTING's "avoid mixing unrelated formatting cleanup into a focused pull request", no reformatting was applied. Longest new line is 96 characters.

Risk

No live, broker, MCP, network, secret or deployment risk: the change is pure arithmetic on an already-resolved integer, exercised offline against synthetic series. No order gate, mandate check, OAuth path, provider connector or external call is touched. No dependency, lockfile, CI or config change.

Rollback

Single commit; git revert restores the hardcoded np.sqrt(252) and drops the new test file. No schema, artifact-format or config change, so runs produced before and after remain readable either way.

Checklist

  • No changes to protected areas (src/agent/, src/session/, src/providers/) without prior discussion
  • No hardcoded values (API keys, file paths, magic numbers)
  • Code follows CONTRIBUTING.md guidelines
  • Documentation updated (if user-facing change)

historical_volatility() scaled the rolling log-return standard deviation by a
hardcoded sqrt(252), and run_options_backtest() never forwarded the
bars_per_year the runner had already resolved for it, so every option leg was
priced on 252 bars/year whatever the venue actually serves. The same run's
metrics already annualize through effective_bars_per_year, whose contract is
that the Sharpe, the annualised volatility and the validation Sharpe of one run
card are annualised identically; pricing was the one consumer left out.

Crypto sources map to 365 daily bars and okx hourly bars to 8760, and a
cross-market basket passes None. On a deterministic daily series an
at-the-money 30-day call was marked 16.02% below the price it should have
filled at, with no warning, into trades.csv, greeks.csv, equity.csv,
metrics.csv and the backtest_summary tool result.

Forward the factor and resolve None through effective_bars_per_year, exactly as
_calc_options_metrics does. The parameter defaults to 252, so daily-equity
behaviour and the pinned warm-up regression are unchanged. Same convention as
8afa387, which resolved the risk x-ray's hardcoded 252 through
calc_bars_per_year.

Signed-off-by: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 02:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@he-yufeng he-yufeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally against current main (e532650).

Red/green: copied the new test file onto the parent tree, and all 5 tests fail there, the end-to-end one for exactly the right reason. On main the leg fills at 3.8135 where the 365-annualised vol says 4.5409, the 16% gap from your table. On the PR head all 5 pass, and the full option subset (369 passed, 6 skipped, including the pinned warm-up suite) stays green.

Mechanism checks, all hold:

  • effective_bars_per_year was already imported in options_portfolio.py, and _calc_options_metrics already receives the factor at the line 615 call site, so pricing really was the one consumer left out of the run's cadence.
  • runner.py:1295-1314 confirms the None convention for cross-market baskets and that the resolved factor reaches run_options_backtest.
  • 8afa387 is real and this follows the same convention.
  • effective_bars_per_year falls back to 252 on an empty or non-datetime index, so a None paired with an odd index degrades to the old behaviour instead of crashing.

One non-blocking thought: historical_volatility now accepts a bars_per_year of 0 or below, where _calc_options_metrics has an explicit guard with a warning for that case. Only reachable by a direct caller passing garbage, since every real call site resolves through calc/effective, so fine as is.

For the record, this touches the same file as my #1750 but disjoint regions (HV and the iv_map call site here, the maxdd seed inside _calc_options_metrics there), so either merge order should apply clean.

@warren618
warren618 merged commit 0faec79 into HKUDS:main Oct 9, 2026
@remote-controlled-man
remote-controlled-man deleted the codex/vibe-trading-options-hv-annualisation branch October 13, 2026 01:26
warren618 added a commit that referenced this pull request Oct 15, 2026
wiliao pushed a commit to wiliao/Vibe-Trading that referenced this pull request Oct 19, 2026
…lity on the run's bar cadence

(cherry picked from commit 45d6713)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants