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

使用 VectorBT 回测比特币 RSI 均值回归策略

代码 《交易机器学习》

总结

本文演示使用 VectorBT 实现比特币仅做多 RSI 均值回归规则的工作流。它根据每日收盘价计算 RSI;当上一日读数低于较低阈值时进场,高于较高阈值时出场。将信号延后一日后,订单可在下一个 UTC 日开盘时执行,避免在生成指标的收盘价成交。模拟包括手续费和滑点、交易记录、投资组合价值与回撤视图,以及在相同成本惯例下与买入并持有基准的比较。

笔记本还展示了参数扫描,并使用另一个库的投资组合统计数据进行会计核对。这些工具使信号时点、执行假设和表现诊断清晰可见,但笔记本明确将输出视为样本内教学结果。在同一样本上选择参数可能夸大其表面质量;报告的表现既不能证明均值回归溢价持续存在,也不能估计未来结果。每日柱线是 UTC 对持续交易的永续期货数据进行汇总后生成的,因此示例使用的日历惯例可能与各交易场所的具体执行细节不同。

核心观点

  • 将基于收盘价的 RSI 信号延后,使订单能在次日开盘成交,而非在生成信号的收盘时成交。
  • 策略和基准都应计入手续费与滑点,以便在一致假设下进行比较。
  • 向量化回测支持快速扫描参数,但在单一样本中选出胜者有过拟合风险。
  • 使用交易记录、回撤和投资组合统计数据,检查信号表现及其持仓时点。
  • 比特币样本内结果仅描述这一配置,不能证明预测表现持久可靠。

标签

全文
# 03_single_asset_vectorbt.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Single Asset Backtest with VectorBT
#
# **Docker image**: `ml4t`
#
# This notebook introduces **VectorBT**, a vectorized backtesting library that enables
# fast simulation of trading strategies. We start with a simple single-asset RSI mean-reversion
# strategy on Bitcoin before moving to multi-asset portfolio strategies. Every performance result
# is an in-sample teaching output, not a holdout estimate or deployable strategy claim.
#
# **Learning Objectives:**
#
# 1. Understand VectorBT's core API and portfolio simulation
# 2. Implement entry/exit signals from technical indicators
# 3. Analyze performance metrics and visualizations
# 4. Compare strategy vs buy-and-hold benchmark
#
# **Book Reference:** Chapter 16, Section 16.3 - vectorized and event-driven engines.
#
# **Prerequisites:** Ch16 NB 01 (backtesting first principles).
#
# **Strategy:** RSI Mean Reversion on BTC
#
# - **Long Entry**: RSI below the configured lower threshold
# - **Exit**: RSI above the configured upper threshold
# - **Rebalancing**: Daily
# - **Transaction Costs**: Fee plus slippage on each fill

# %% [markdown]
# ## Setup

# %%
"""Single-asset RSI mean-reversion strategy using vectorized backtesting."""

import plotly.graph_objects as go
import polars as pl
import vectorbt as vbt
from IPython.display import Markdown, display
from ml4t.diagnostic.evaluation import PortfolioAnalysis
from plotly.subplots import make_subplots

from data import load_crypto_perps
from utils.style import COLORS, ml4t_diverging, show_plotly_with_alt

# %% tags=["parameters"]
# Production defaults - Papermill injects overrides for CI
START_DATE = "2020-01-01"
END_DATE = "2024-01-01"
INITIAL_CASH = 100_000
RSI_WINDOW = 14
RSI_LOWER = 30
RSI_UPPER = 70
SIZE_FRACTION = 0.95
FEES = 0.001
SLIPPAGE = 0.0005

# %%
display(
    Markdown(
        f"The configured rule uses a **{RSI_WINDOW}-day RSI**, enters below **{RSI_LOWER}**, "
        f"exits above **{RSI_UPPER}**, and allocates **{SIZE_FRACTION:.0%}** of available capital. "
        f"Each fill pays a **{FEES:.2%} fee** plus **{SLIPPAGE:.2%} slippage**."
    )
)

# %% [markdown]
# ## 1. Data Acquisition
#
# Load BTC/USDT daily bars from the local crypto perpetuals dataset and
# convert to pandas Series indexed by timestamp at the VectorBT boundary. The UTC-day aggregation
# is a calendar convention for a continuously traded market, not an exchange session close.

# %%
_crypto = load_crypto_perps(
    symbols=["BTCUSDT"],
    start_date=START_DATE,
    end_date=END_DATE,
)
btc_df = (
    _crypto.filter(
        (pl.col("symbol") == "BTCUSDT")
        & (pl.col("timestamp") >= pl.lit(START_DATE).str.to_datetime().dt.replace_time_zone("UTC"))
        & (pl.col("timestamp") < pl.lit(END_DATE).str.to_datetime().dt.replace_time_zone("UTC"))
    )
    .sort("timestamp")
    .with_columns(pl.col("timestamp").dt.replace_time_zone(None))
    .group_by_dynamic("timestamp", every="1d", group_by="symbol")
    .agg(
        pl.col("open").first(),
        pl.col("high").max(),
        pl.col("low").min(),
        pl.col("close").last(),
        pl.col("volume").sum(),
    )
    .sort(["symbol", "timestamp"])
)

# %% [markdown]
# Canonical keys must remain unique through aggregation. Positive, internally consistent OHLC bars
# protect the backtest from malformed price records before the VectorBT boundary.

# %%
assert btc_df.n_unique(["symbol", "timestamp"]) == len(btc_df)
assert btc_df["symbol"].unique().to_list() == ["BTCUSDT"]
price_columns = ["open", "high", "low", "close"]
assert btc_df.select(pl.col(price_columns).is_not_null().all()).row(0) == (True,) * 4
assert btc_df.select((pl.col(price_columns) > 0).all()).row(0) == (True,) * 4
assert btc_df.select((pl.col("high") >= pl.max_horizontal("open", "close", "low")).all()).item()
assert btc_df.select((pl.col("low") <= pl.min_horizontal("open", "close", "high")).all()).item()
assert len(btc_df) > RSI_WINDOW

# Convert at the library boundary; subsequent pandas objects come from VectorBT.
btc_pd = btc_df.to_pandas()
btc_pd.set_index("timestamp", inplace=True)
close = btc_pd["close"]
execution_price = btc_pd["open"]

print(f"Loaded {len(btc_df):,} daily bars for BTCUSDT from the crypto perpetuals dataset")
print(f"Date range: {btc_df['timestamp'].min()} to {btc_df['timestamp'].max()}")

# %%
close.head(10)

# %% [markdown]
# ## 2. RSI Indicator Calculation
#
# VectorBT provides optimized indicator calculations. The RSI indicator
# can be computed for multiple parameter values simultaneously.

# %%
rsi = vbt.RSI.run(close, window=RSI_WINDOW)

# %%
rsi_series = rsi.rsi

# %%
fig = make_subplots(
    rows=2,
    cols=1,
    shared_xaxes=True,
    vertical_spacing=0.05,
    row_heights=[0.7, 0.3],
    subplot_titles=["BTC/USDT Price", f"RSI ({RSI_WINDOW})"],
)

fig.add_trace(
    go.Scatter(x=close.index, y=close, name="BTC", line=dict(color=COLORS["blue"])),
    row=1,
    col=1,
)

fig.add_trace(
    go.Scatter(x=rsi_series.index, y=rsi_series, name="RSI", line=dict(color=COLORS["amber"])),
    row=2,
    col=1,
)

fig.add_hline(y=RSI_LOWER, line_dash="dash", line_color=COLORS["positive"], row=2, col=1)
fig.add_hline(y=RSI_UPPER, line_dash="dash", line_color=COLORS["negative"], row=2, col=1)

fig.update_layout(
    height=600,
    title=(
        "BTC/USDT close and its RSI, with the entry and exit thresholds marked"
        "<br><sup>Daily BTCUSDT; trailing close-based indicator</sup>"
    ),
    showlegend=True,
    xaxis2_title="Date",
    yaxis_title="Price (USDT)",
    yaxis2_title="RSI",
)
show_plotly_with_alt(
    fig,
    (
        "Two stacked panels on a shared date axis. The upper panel is the BTC/USDT daily "
        f"close in USDT. The lower panel is the {RSI_WINDOW}-period RSI computed from those "
        "same closes, in amber, with dashed horizontal lines at the entry and exit "
        "thresholds. Stacked on one date axis so the indicator and the thresholds that act on "
        "it can be read against the price history they are derived from."
    ),
)

# %% [markdown]
# ## 3. Generate Trading Signals
#
# RSI Mean Reversion Logic:
#
# - **Enter Long**: the prior close's RSI is below the lower threshold
# - **Exit Long**: the prior close's RSI is above the upper threshold
#
# RSI is observed only after the daily close. Shifting both conditions by one row makes the order
# eligible at the next UTC day's open and prevents a same-close look-ahead fill.

# %%
entry_condition = rsi_series < RSI_LOWER
exit_condition = rsi_series > RSI_UPPER
entries = entry_condition.shift(1, fill_value=False)
exits = exit_condition.shift(1, fill_value=False)

# %%
entry_days = int(entries.sum())
exit_days = int(exits.sum())
display(
    Markdown(
        f"The conditions mark **{entry_days} entry-eligible days** and "
        f"**{exit_days} exit-eligible days**. Repeated signals while a position is already "
        "open do not create additional trades."
    )
)

# %% [markdown]
# ## 4. Run Backtest with VectorBT
#
# VectorBT's `Portfolio.from_signals()` is the core backtesting function.
# It simulates a portfolio based on entry/exit signals.

# %%
portfolio = vbt.Portfolio.from_signals(
    close=close,
    entries=entries,
    exits=exits,
    price=execution_price,
    open=execution_price,
    size=SIZE_FRACTION,
    size_type="percent",
    init_cash=INITIAL_CASH,
    fees=FEES,
    slippage=SLIPPAGE,
    freq="1D",
)

# %% [markdown]
# **Portfolio performance summary** (VectorBT's built-in strategy statistics):
#
# The matched-cost benchmark is constructed below. This view suppresses VectorBT's implicit
# frictionless close-to-close benchmark so the notebook does not mix execution assumptions.

# %%
strategy_stats = portfolio.stats()
strategy_stats.drop(labels=["Benchmark Return [%]"], errors="ignore")

# %% [markdown]
#
# %%
baseline_return = float(portfolio.total_return())
baseline_sharpe = float(portfolio.sharpe_ratio())
display(
    Markdown(
        f"The configured RSI rule returns **{baseline_return:.2%}** with a "
        f"**{baseline_sharpe:.2f} Sharpe ratio** in this sample. This descriptive output shows "
        "what the rule did, but does not identify a persistent mean-reversion premium or estimate "
        "out-of-sample performance."
    )
)

# %% [markdown]
# ## 5. Analyze Trade Statistics
#
# VectorBT records each trade lifecycle, including any position still open at the sample end. The
# table exposes entry and exit prices so the execution convention remains auditable.

# %%
trades = portfolio.trades.records_readable
display(Markdown(f"The simulation contains **{len(trades)} trade records**."))

# %% [markdown]
# **Trade statistics** (per-trade entry/exit, P&L, holding period summary):

# %%
portfolio.trades.stats()

# %% [markdown]
# **Sample individual trades** with entry/exit timing and realized P&L:

# %%
display_cols = [
    "Entry Timestamp",
    "Exit Timestamp",
    "Size",
    "Entry Price",
    "Exit Price",
    "PnL",
    "Return",
]
available_cols = [c for c in display_cols if c in trades.columns]
trades[available_cols].head(10)

# %% [markdown]
# ## 6. Performance Visualization
#
# Portfolio value reveals when the rule is invested and when capital is idle. The underwater curve
# measures each close relative to the running peak, with zero fixed at the top of the chart.

# %%
portfolio_value = portfolio.value()
fig = go.Figure(
    go.Scatter(
        x=portfolio_value.index,
        y=portfolio_value,
        name="Portfolio value",
        line=dict(color=COLORS["blue"]),
    )
)
fig.update_layout(
    title=(
        "Portfolio value under the RSI rule"
        "<br><sup>Net of configured fees and slippage; full sample, in-sample</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="Portfolio Value (USDT)",
    height=450,
    hovermode="x unified",
)
show_plotly_with_alt(
    fig,
    (
        "Line chart of the RSI rule's portfolio value in USDT over the sample, starting from "
        "the configured initial cash. The rule is in the market only between an entry and its "
        "exit, so the series is flat wherever it holds no position and moves only on the "
        "sessions it is invested. Drawn to show the shape of a rule that trades "
        "intermittently rather than holding continuously."
    ),
)

# %%
portfolio_drawdown = portfolio.drawdown() * 100
drawdown_floor = min(float(portfolio_drawdown.min()) * 1.05, -1.0)
fig_dd = go.Figure(
    go.Scatter(
        x=portfolio_drawdown.index,
        y=portfolio_drawdown,
        name="Drawdown",
        line=dict(color=COLORS["negative"]),
        fill="tozeroy",
        fillcolor=COLORS["silver_muted"],
    )
)
fig_dd.update_layout(
    title=(
        "Drawdown of the RSI portfolio from its running peak"
        "<br><sup>Close-to-close peak-to-trough drawdown; net, in-sample</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="Drawdown (%)",
    yaxis_range=[drawdown_floor, 0],
    height=400,
    hovermode="x unified",
)
show_plotly_with_alt(
    fig_dd,
    (
        "Filled drawdown chart of the RSI portfolio measured from its own running peak, zero "
        "at the top and losses below, in percent. Each point is the distance from the highest "
        "portfolio value reached up to that date, so the series returns to zero only on a new "
        "high. Drawn beside the equity curve above because the same path answers a different "
        "question when it is measured against its own maximum."
    ),
)

# %% [markdown]
# ## 7. Compare to Buy-and-Hold Benchmark
#
# Buy-and-hold is a simple exposure benchmark. Both paths deploy the same fraction of capital and
# use the same next-open price, fee, and slippage assumptions. The benchmark remains open at the
# sample end, so it does not pay a terminal exit cost.

# %%
bh_portfolio = vbt.Portfolio.from_holding(
    close=close,
    price=execution_price,
    open=execution_price,
    size=SIZE_FRACTION,
    size_type="percent",
    init_cash=INITIAL_CASH,
    fees=FEES,
    slippage=SLIPPAGE,
    freq="1D",
)

# %%
comparison = pl.DataFrame(
    {
        "metric": [
            "Total Return (%)",
            "Sharpe Ratio",
            "Max Drawdown (%)",
            "Total Trades",
            "Sortino Ratio",
            "Calmar Ratio",
        ],
        "RSI Strategy": [
            float(portfolio.total_return() * 100),
            float(portfolio.sharpe_ratio()),
            float(portfolio.max_drawdown() * 100),
            float(portfolio.trades.count()),
            float(portfolio.sortino_ratio()),
            float(portfolio.calmar_ratio()),
        ],
        "Buy & Hold": [
            float(bh_portfolio.total_return() * 100),
            float(bh_portfolio.sharpe_ratio()),
            float(bh_portfolio.max_drawdown() * 100),
            float(bh_portfolio.trades.count()),
            float(bh_portfolio.sortino_ratio()),
            float(bh_portfolio.calmar_ratio()),
        ],
    }
)

# %% [markdown]
# **Strategy vs buy-and-hold comparison** (daily data; risk ratios use daily annualization):

# %%
comparison.with_columns(pl.col(["RSI Strategy", "Buy & Hold"]).round(3))

# %% [markdown]
#
# %%
benchmark_return = float(bh_portfolio.total_return())
benchmark_sharpe = float(bh_portfolio.sharpe_ratio())
strategy_drawdown = float(portfolio.max_drawdown())
benchmark_drawdown = float(bh_portfolio.max_drawdown())
return_leader = "Buy-and-hold" if benchmark_return > baseline_return else "The RSI strategy"
display(
    Markdown(
        f"**{return_leader}** leads total return in this sample: "
        f"**{benchmark_return:.2%}** for buy-and-hold versus **{baseline_return:.2%}** for RSI. "
        f"Their Sharpe ratios are **{benchmark_sharpe:.2f}** and **{baseline_sharpe:.2f}**; maximum "
        f"drawdowns are **{benchmark_drawdown:.2%}** and **{strategy_drawdown:.2%}**, respectively. "
        "This is an in-sample exposure comparison, not evidence that either rule will dominate "
        "out of sample."
    )
)

# %%
fig = go.Figure()

strategy_cum = portfolio.cumulative_returns() * 100
bh_cum = bh_portfolio.cumulative_returns() * 100

fig.add_trace(
    go.Scatter(
        x=strategy_cum.index,
        y=strategy_cum,
        name="RSI Strategy",
        line=dict(color=COLORS["blue"]),
    )
)

fig.add_trace(
    go.Scatter(
        x=bh_cum.index,
        y=bh_cum,
        name="Buy & Hold",
        line=dict(color=COLORS["neutral"], dash="dash"),
    )
)

fig.update_layout(
    title=(
        "Cumulative return, RSI rule against buy-and-hold"
        "<br><sup>Matched capital allocation and costs; full sample, in-sample</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="Cumulative Return (%)",
    height=500,
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
    hovermode="x unified",
)
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"], line_width=1)
show_plotly_with_alt(
    fig,
    (
        "Cumulative return in percent for the RSI rule in solid navy and buy-and-hold in "
        "dashed grey, on one linear axis. Both are run on the same bars, from the same "
        "capital, and net of the same fees and slippage, so the only difference between them "
        "is when each is in the market. Drawn on a shared axis, which is what makes the two "
        "directly comparable and also what compresses the smaller of them."
    ),
)

# %% [markdown]
# ## 8. Parameter Sensitivity
#
# VectorBT makes parameter sweeps inexpensive. This section measures in-sample sensitivity; it does
# not treat its highest-Sharpe row as a deployment choice, because no holdout is used.

# %%
rsi_windows = [7, 14, 21]
lower_thresholds = [20, 25, 30, 35]
upper_thresholds = [65, 70, 75, 80]

# %%
results = []

for window in rsi_windows:
    rsi_vals = vbt.RSI.run(close, window=window).rsi

    for lower in lower_thresholds:
        for upper in upper_thresholds:
            if lower >= upper:
                continue

            entries = (rsi_vals < lower).shift(1, fill_value=False)
            exits = (rsi_vals > upper).shift(1, fill_value=False)

            pf = vbt.Portfolio.from_signals(
                close=close,
                entries=entries,
                exits=exits,
                price=execution_price,
                open=execution_price,
                size=SIZE_FRACTION,
                size_type="percent",
                init_cash=INITIAL_CASH,
                fees=FEES,
                slippage=SLIPPAGE,
                freq="1D",
            )

            results.append(
                {
                    "window": window,
                    "lower": lower,
                    "upper": upper,
                    "total_return": pf.total_return() * 100,
                    "sharpe": pf.sharpe_ratio(),
                    "max_dd": pf.max_drawdown() * 100,
                    "trades": pf.trades.count(),
                }
            )

# %%
results_df = pl.DataFrame(results)
ranked_results = results_df.filter(pl.col("sharpe").is_finite()).sort("sharpe", descending=True)
assert len(ranked_results) > 0

# %% [markdown]
# **Highest-Sharpe RSI parameter combinations** (in-sample sweep):

# %%
ranked_results.head(10)

# %%
window_results = results_df.filter(pl.col("window") == RSI_WINDOW)
heatmap_z = [
    [
        window_results.filter((pl.col("lower") == lower) & (pl.col("upper") == upper))[
            "sharpe"
        ].item()
        for lower in lower_thresholds
    ]
    for upper in upper_thresholds
]
best_parameters = ranked_results.row(0, named=True)
window_best = (
    window_results.filter(pl.col("sharpe").is_finite())
    .sort("sharpe", descending=True)
    .row(0, named=True)
)

# %% [markdown]
# The heatmap holds the RSI window fixed and exposes the threshold surface. Centering the diverging
# scale at zero distinguishes positive from negative in-sample Sharpe ratios without implying that
# the highest cell is a validated choice.

# %%
fig = go.Figure(
    data=go.Heatmap(
        x=lower_thresholds,
        y=upper_thresholds,
        z=heatmap_z,
        colorscale=ml4t_diverging(),
        zmid=0,
        colorbar=dict(title="Sharpe Ratio"),
    )
)

fig.update_layout(
    title=(
        f"In-sample Sharpe by threshold pair, RSI window {RSI_WINDOW}"
        "<br><sup>Full-sample sensitivity, net of costs; no holdout ranking</sup>"
    ),
    xaxis_title="Lower Threshold",
    yaxis_title="Upper Threshold",
    height=500,
)
show_plotly_with_alt(
    fig,
    (
        "Heatmap of in-sample Sharpe ratio over the RSI threshold grid, lower threshold on "
        "the horizontal axis and upper threshold on the vertical, one cell per pair, on a "
        f"diverging colour scale centred at zero. The RSI window is held at {RSI_WINDOW} "
        "throughout, so the surface varies in the two thresholds alone. Every cell is scored "
        "on the whole sample with no holdout, which is what makes this a sensitivity surface "
        "rather than a selection procedure."
    ),
)

# %% [markdown]
#
# %%
lower_column = window_results.filter(pl.col("lower") == window_best["lower"])["sharpe"]

display(
    Markdown(
        f"The highest-Sharpe grid row reaches **{best_parameters['sharpe']:.2f}** in sample, versus "
        f"**{baseline_sharpe:.2f}** for the configured baseline. That gap measures full-sample "
        "selection, not forecast improvement: every candidate was ranked after observing the same "
        "return path. NB 12 introduces the Deflated Sharpe Ratio for this multiple-testing problem."
        f"\n\nInside the window-{RSI_WINDOW} surface the peak sits at "
        f"{window_best['lower']}/{window_best['upper']} with a Sharpe of "
        f"{window_best['sharpe']:.2f}, but the whole lower-threshold-{window_best['lower']} column "
        f"spans only {float(lower_column.min()):.2f} to {float(lower_column.max()):.2f} across the "
        "four upper thresholds. The lower threshold is what moves this surface; reading the single "
        "best cell as a choice of upper threshold reads noise."
    )
)

# %% [markdown]
# ## 9. Evaluate with ml4t-diagnostic
#
# `PortfolioAnalysis` recomputes portfolio statistics from the return series. Agreement on total
# return and maximum drawdown provides a cross-library accounting check; the remaining rows extend
# the report with distribution and benchmark-relative diagnostics.

# %%
strategy_returns = portfolio.returns().values
benchmark_returns = bh_portfolio.returns().values

analysis = PortfolioAnalysis(
    returns=strategy_returns,
    benchmark=benchmark_returns,
    dates=portfolio.returns().index,
    periods_per_year=365,  # Crypto trades 365 days
)

metrics = analysis.compute_summary_stats()
assert abs(metrics.total_return - baseline_return) < 1e-10
assert abs(metrics.max_drawdown - strategy_drawdown) < 1e-10

# %% [markdown]
# **Selected ml4t-diagnostic portfolio statistics:**

# %%
percentage_metrics = [
    "total_return",
    "annual_return",
    "annual_volatility",
    "max_drawdown",
    "var_95",
    "cvar_95",
    "win_rate",
    "avg_win",
    "avg_loss",
    "alpha",
]
metrics_table = (
    metrics.to_dataframe()
    .unpivot(variable_name="metric", value_name="value")
    .filter(~pl.col("metric").is_in(["up_capture", "down_capture"]))
    .with_columns(
        pl.when(pl.col("metric").is_in(percentage_metrics))
        .then((pl.col("value") * 100).round(2))
        .otherwise(pl.col("value").round(3))
        .alias("value"),
        pl.when(pl.col("metric").is_in(percentage_metrics))
        .then(pl.lit("%"))
        .otherwise(pl.lit("unitless"))
        .alias("unit"),
    )
)
metrics_table

# %% [markdown]
# ## See Also
#
# **ml4t-backtest Implementation**: See [`04_single_asset_ml4t_backtest`](04_single_asset_ml4t_backtest.ipynb) for the same
# strategy implemented with ml4t-backtest's event-driven engine. The main difference
# is not capability - VectorBT also supports stops, fills, and risk rules - but
# representation: ml4t-backtest expresses strategy logic as a Python class with
# explicit state, which mirrors how live trading code is typically structured.

# %% [markdown]
# ## Key Takeaways
#
# 1. **VectorBT API**: `Portfolio.from_signals()` is the core function for signal-based backtests
#
# 2. **Signal timing**: Close-derived conditions are shifted to the next UTC-day open
#
# 3. **Parameter sensitivity**: Vectorized operations enable fast parameter sweeps, but ranking
#    candidates on one sample does not establish out-of-sample improvement
#
# 4. **Transaction costs**: Strategy and benchmark use the same fee and slippage convention
#
# 5. **Benchmarking**: A same-budget buy-and-hold path separates timing from passive exposure
#
# ## Next Steps
#
# - **06_framework_parity**: Compare vectorized and event-driven implementations
# - **08_signal_method_comparison**: Compare signal conversion methods

```

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

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