Skip to content

回测不用重写就能上实盘,这事 NautilusTrader 真的做到了吗?

作 者:老余捞鱼

原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。

本文约 4800 字 | 预计阅读 12分钟,后有代码和开源仓库使用路径,建议先收藏。

NautilusTrader 不是一个策略库,也不是一个回测工具。它是一套完整的交易引擎,交易系统的地基。

GitHub 上 29740 个 star、3949 个 fork,LGPL-3.0 协议,2018 年立项,nautechsystems 维护,到今天还在高频提交。

它要解决的是这么一件事。市面上大多数量化框架,把研究阶段和交易阶段分成了两套东西。研究用 pandas 一把梭,策略逻辑验证完,等真要上市场,行情怎么接、订单怎么报、仓位怎么算、断线怎么重连、对不上账怎么办,全部推倒重写一遍。这套重写的活儿往往比写策略本身还费时间,而且改一个参数要记得两边都改,忘一边就等着实盘给你惊喜吧。

它的思路是把地基先铺好。研究和实盘踩在同一块地基上。

换句话说,你写策略的那部分代码,跑回测和跑实盘是同一份。差的是接哪个交易所。

一、它的结构长什么样

这一点是理解它的关键,不然后面全看不懂。

它是两层结构,底下那层用 Rust 写,上面那层用 Python。

底下那层 Rust 内核负责所有”不能慢”和”不能错”的东西。行情数据进来、订单撮合、持仓与资金计算、风控校验、执行算法、跟交易所的通信,全在这一层。它跑在 tokio 异步运行时上,内存分配器用的是 mimalloc,时间精度是纳秒级。

上面那层 Python 只负责一件事,写策略。你写一个类继承 Strategy,订阅 K 线或者盘口,在回调里下判断要不要下单。这部分是 Python 舒服的那部分,不用跟内存和线程打交道。

中间靠 PyO3 粘起来。这也是为什么装完包要 60MB,里面塞的是完整的 Rust 运行时。

它内部还有几个核心组件,值得知道名字,因为文档里到处是它们。

  • `Cache` 数据缓存。行情和账户状态都存在这里,策略查询走它
  • `MessageBus` 消息总线。组件之间靠它通信,不直接互相调用
  • `Portfolio` 持仓与账户。算仓位、算盈亏、算保证金
  • `RiskEngine` 风控。下单前校验,拒不合规的订单
  • `ExecutionEngine` 执行。把订单送到交易所,收回执
  • `Actors` 参与者。不止策略能做 Actor,行情订阅、对账逻辑都可以做成 Actor 挂进去

还有一点它的设计取向要留意,它的 API 分高低两档。低层 `BacktestEngine` 你可以随便拆开组装,控制最细,但没有通往实盘的路;高层 `BacktestNode` 配置化、有数据目录、能批量跑,官方推荐用这个,它才是能过渡到实盘的那条路。所以别一上来就用低层 API 写策略,写完发现上不了实盘。

资产类别上它是资产无关的,不绑定某一类品种。股票、期货、期权、外汇、加密货币、博彩投注,都能塞进同一套系统研究。

二、作者自己怎么说

它的自我介绍里有一句话我盯着看了很久。它说:回测和实盘用同一套架构,策略代码验证完可以接着往真实市场走。紧接着又补了一句,说实盘依然会引入场所、传输、时序、持久化、外部活动和对账这些行为,模拟未必能复现。

先承认一样再承认一句,这种写法在我看过的项目文档里不多。

我这次就想搞清楚的是,前半句到底成不成立,后半句在实操里会撞到什么样的墙。

下面按顺序来。先看它怎么装,再验卖点,然后是我踩的坑和跑出来的数。

它的 pip 包 60MB,因为里面塞了完整的 Rust 运行时(mimalloc 内存分配器、tokio 异步网络、rustls 加密、PyO3 绑定)。前置条件是 Python 3.13 或 3.14,装完不需要你碰 Rust 工具链,因为官方直接给了预编译轮子。

我是在一台 64GB 内存的 Mac mini 上装的。

终端里跑 `pip install` 时加上预发布开关(文档里写作双连字符加 pre 那个参数),一条命令搞定。装完我第一件事是去核实它到底装成了什么版本,而不是急着写策略。装完顺手看了一眼它自报的环境。

nautilus_trader 2.0.0rc6,nautilus_core 0.65.0,rustc 1.98.1,numpy 2.5.3,pandas 3.0.6,precision high(128-bit, 16 dp),profile release。

precision 那一行我盯着看了半天。128-bit,16 位小数。

你平时写 Python float 是 15 到 17 位有效数字,数字上看着差不多,但它内部用的是十进制定点数算钱。你在代码里写 0.1,它不会给你算出 0.1000000000000000055 这种惊喜。

这不是炫技,是交易所级别系统的基本要求。但它确实带来一个副作用。我写代码时得先想一遍这个数走的是哪条路径,不能像平时那样随手一算就信。

三、怎么装

安装这件事本身不难,难在你要知道自己装的是什么。

第一步是装 Python 3.13。注意不是 3.12,官方明确写了 3.13 起。3.14 也支持。

第二步是加预发布开关装 rc 版。

pip install -U nautilus_trader --pre

那个 pre 参数必须加。截至写稿,PyPI 上没有正式的 2.0.0,只有 `2.0.0rcN` 系列,我用的是 rc6。

第三步是验一下版本。

python -c "import nautilus_trader; print(nautilus_trader.__version__)"

打出 `2.0.0rc6` 就算成了。60MB 的包,几十秒下完,比装个带 CUDA 的 PyTorch 轻。

装完第一件事别急着写策略,先去看它自带的那句警告。文档里明确写着,不要在生产环境用开发轮子,特别是实盘控制真金白银的时候。这句话不是客套,因为它同时还提醒你 TRACE 级别的日志会把 WebSocket 原始报文打出来,里面可能夹着某些场所的认证信息,分享日志之前要脱敏。

一个交易引擎主动提醒你别乱开日志,这个细节比星标数更能说明团队的成熟度。

以上是装上就能用的部分,往下是动手部分。

四、核心卖点我验证了,是真的

我不想只读文档就下判断,所以写了个脚本,把同一个策略类分别挂到回测引擎和模拟实盘节点上跑。

这里要先讲一下它有两层 API。低层是 `BacktestEngine`,你可以直接往里塞组件,控制最细,但没有通往实盘的路。高层是 `BacktestNode`,配置对象、数据目录、批量跑都归它管,而且能平滑过渡到实盘,官方推荐用这个。

我要验证的是低层和高层能不能共用同一套东西。说实话,动手前我心里有点怀疑,因为这类承诺通常在第二个环节就露馅。官方 examples 目录里有个 `live/sandbox` 的例子,看完我就照着写了一段。

from nautilus_trader.live import LiveNode
from nautilus_trader.common import Environment

# 模拟实盘(真实行情 + 本地撮合引擎)
node = (
    LiveNode.builder("SANDBOX-001", trader_id, Environment.SANDBOX)
    .with_reconciliation(reconciliation=False)
    .with_risk_engine_config(LiveRiskEngineConfig(bypass=True))
    .add_simulated_exec_client(
        "SIM",
        SandboxExecutionClientFactory(),
        SandboxExecutionClientConfig(
            venue=venue,
            account_id=Account_id,
            starting_balances=[Money(1_000_000, USD)],
            fee_model=MakerTakerFeeModel(
                maker_rate=Decimal("0.00002"),
                taker_rate=Decimal("0.00002"),
            ),
        ),
    )
    .build()
)

跑出来两个节点都建成功了。同一份 instrument 定义、同一套费用模型、同一套账户类型,从回测走到模拟实盘,策略代码一个字没改。

我把这个结果反复看了两遍才敢下结论。

这就是它真正值钱的地方。别的框架里,回测和实盘是两套代码,你改一个参数要记得两边都改,忘了就等着实盘给你惊喜吧。它把这个坑填了。

五、然后我踩到了第一个坑

跑通不代表能用。我喂数据的时候卡了十几分钟,日志刷了一屏。

SubmitOrder ... DENIED: MARKET_PRICE_UNAVAILABLE: order_type=MARKET, instrument_id=AUD/USD.SIM

市价单全部被拒。我一开始以为是自己代码写错了,去翻文档,越查越慌。

后来想明白了,问题出在我只喂了 1 分钟 K 线,没喂报价。它的撮合引擎要撮合市价单,得有一个人家能拿来成交的价格;你只给 K 线,它就不肯凭空捏一个出来,于是每一单都拒。

我说实话,我当时心里咯噔了一下。这要是换个框架,很可能悄悄拿 K线的收盘价给我撮合了,回测跑出来一条漂亮曲线,实盘一单都成交不了。

这个坑最怕的不是 MARKET_PRICE_UNAVAILABLE 报错,是连报都不报。

它不。这是加分项。

我把逐笔报价喂进去,问题解决,两百多笔订单全部成交。改动的核心就一行数据准备,策略逻辑没碰。

六、同一份策略,结果差 19 万

数据跑通之后我做了五组对照。策略是最普通的 EMA 交叉,行情是固定随机种子的合成数据,5000 根 1 分钟 K 线,起始资金 100 万美金,每次单笔 100 万。

我把手续费当唯一变量往上加,然后顺手试了两组均线参数。

组别手续费(单边)均线订单/成交净利润最大回撤
A 基准0.002%EMA20/50172 / 172+87,492.491.42%
B 费率 ×200.04%EMA20/50172 / 172+14,397.622.61%
C 费率 ×500.1%EMA20/50172 / 172-101,017.4510.43%
D 慢线拉长0.002%EMA20/100112 / 112+79,492.791.96%
E 快线绞紧0.002%EMA10/20378 / 378+92,276.921.90%
同一份策略,只改手续费和均线参数,引擎没说谎但结果全变

先看A 到 C。策略代码一个字没改,订单数都是 172,撮合次数都是 172,唯一变的是我填进去的费率假设。净利润从 +87,492 走到 -101,017,中间隔了 188,509 美元将近 19 万。

换算成一句大白话。这份策略在 0.002% 的假设下赚钱,在 0.1% 的假设下亏钱。而这两个数字都是我自己填的,引擎不知道哪个是真的。

它忠实地执行了我的假设,没有替我兜着。

说实话我有点意外。我以为这类框架多少会替我兜一点底,比如自动填一个 0.1% 的滑点。结果它一点都没有。这就是”回测等于实盘”这句话里最容易被跳过的部分。代码确实共用,但成交价、费率、延迟、拒单概率,全是你的假设。

再看 D 和 E,这两组更有意思。均线参数一改,订单数从 172 变成 112 或者 378,成交次数同步跟着变。E 组交易最频繁,净利润最高,但它的收益是建立在更高换手上的,手续费一旦放大,最先死的就是它。

我在做这组对比时顺手算了最大回撤。C 组的 10.43% 是 A 组 1.42% 的七倍多。也就是说,费率假设不仅改变收益的符号,还成倍地改变了风险。这比我预想的更严重。

七、它报 Sharpe 16,我算出 0.0296

这节是我最想跟读者交代的。

跑完 A 组,引擎给我报了个 Sharpe 16.04,Sortino 1526.63,Profit Factor 216。这个数字太漂亮了。我一开始以为是它算错了,核完才知道不是。

我去查它的收益序列,发现 `returns_series` 里只有 5 个点。5 个点算出来的年化 Sharpe 能报 16 并不奇怪,分母太小,除出来自然是个大数。

我自己按每根 bar 的权益变化重算了一遍,4999 个逐 bar 收益点,算出来的 Sharpe 是 0.0296。

引擎报 16.04,我算出 0.0296,差 540 倍,问题出在收益点数量

差 540 倍。

这里我要说清楚。引擎没算错,它只是严格按它的定义在算。我一开始是把它当成 bug 的。是我的合成行情只覆盖了 3.5 天(5000 根 1 分钟 K 线),年化系数一乘就炸了。换成一年真实数据,这点差异会消失。

但这件事本身是个提醒。它默认给你输出的那套漂亮的绩效统计,样本量不足的时候是不可信的。这个坑不是我踩的,是任何人拿短周期数据跑一遍都会踩的。而一个交易框架把年化 Sharpe 直接打在回测结果里,也不提醒你”收益点只有 5 个”,这是设计上可以更谨慎的地方。

这个问题是我自己踩出来的。我起初以为是公式错了,核完发现公式没错,是我的样本太短。

后来我的处理方式变了。现在拿到回测结果,第一件事先看 returns_series 的长度,再看 Sharpe。这个顺序反过来,就是自欺欺人。

八、它适合谁,不适合谁

说点实际的。

先说不适合的。

只想快速验一个简单策略的话,它太重了。想测个均线交叉,pandas 加 matplotlib 十几行就完了,装一个 60MB 的 Rust 引擎、引入事件驱动的编程模型,收益不成正比。

做 A 股的话,它帮不上直接的忙。我把官方 integrations 页面整个过了一遍,17 个 adapter 里有 Binance、Bybit、OKX、Coinbase、Kraken、Deribit、dYdX、Hyperliquid、Polygon、Polymarket、Betfair、Databento、Tardis,还有 Interactive Brokers。券商只有一个 IB。

我把 17 个 adapter 的名字一个一个读完,结论是没有一家 A 股券商,也没有 CTP、期货公司或者券商柜台接口。

读到最后一个的时候,我确认自己没漏。这个结论对做 A 股的人有点扫兴,但它比让人以为「支持多资产」等于「支持你的资产」要诚实。想做A股,得自己按它的 adapter 规范写一个,这活儿不小。它自己在文档里也留了口子,任何有 REST API 或 WebSocket 行情的场所都能接,但”能接”和”有人接”之间还有很长的路。

数据量很小的话,它的速度优势用不上。它快是因为 Rust 内核加纳秒级时间戳,那是给盘口、高频、多场所套利准备的。你拿日线数据跑一个年线策略,瓶颈在Python 层的策略代码上,不在内核。

然后说适合的。

策略逻辑本身已经稳定、只是不想在实盘环节重复劳动的话,它值得看。假设你有一套跑通的 alpha,现在卡在接行情、对账、断线重连这些纯工程环节上,它能把你从这些活儿里捞出来。

做加密货币的话,这是它的主场。这它的主场,主流交易所基本都在,CEX 和 DEX 都有,数据商也配齐了。

需要多场所同时跑的话,它支持同时对多个场所做市和跨场所策略,单进程多场所这个能力在开源里不算多见。

九、两个必须知道的风险

第一件事是项目正在大版本切换,现在上手要选边。

最新正式版是 v1.231.0,标签后面写着 Beta,发布于 2026-08-02。它的 release note 里写得很清楚,这是 legacy Cython v1 核心的最后一个 1.x 版本;之后 `develop` 分支会变成 v2 专用,v1 核心挪到 `develop_v1` 分支,只收关键安全补丁,收大约三个月。

而我装的是 2.0.0rc6。也就是说现在 PyPI 上的正式版和一个Beta 级旧核心的继承者并存,功能都在往前推,但文档、示例、issue 里有一部分还在讲v1 的写法。

要选边就得想清楚。想跟新功能和长期维护走 v2,注意它列了几条不兼容的变更(Catalog 旧订单事件数据不读、CustomData 语义变了、`Bar.is_revision` 没了),迁移得另开一个虚拟环境测;想稳就留在 1.231.0,但那是安全补丁期,不会有新东西。

第二件事,这个项目的长期风险不来自技术,来自人。

我去查贡献者分布的时候,是带着「看看是不是个松散社区」的预期点的。

结果 GitHub 列出 210 个人,提交量却极度集中。第一位 19,216 次,第二位 339次,第三位 334 次。前一位一个人吃掉绝大部分,后面两百多人分剩下的零头。

我盯着这个数看了好几遍才反应过来不是我读错。

210 这个数字挺唬人的。我认为真正在维护的是一个人。

这个结构的含义很直接。LGPL-3.0 协议允许你商用、允许你改,但核心维护基本压在一个人身上。他哪天不干了,这个项目的迭代速度会断崖式下跌,而你要么自己接手维护,要么回到自己搭一套的状态。

不是说一个人主导的项目一定不靠谱,很多优秀项目都是这样起来的。但你得知道自己在依赖什么。用它做实盘基础设施之前,把这一点想清楚比把回测跑通更重要。

十、我的判断

回到开头那个问题。它宣传的”回测到实盘不用重写”,成立吗?

成立,但有前提。

成立的部分是真的。同一套 Strategy、订单逻辑、执行算法,回测和实盘共用,前提是你自己填的那些假设和实盘一致。

不成立的部分是,它保证不了假设和实盘一致,那是你自己的活儿。它给的是一把好尺子,量出来的数准不准,取决于你怎么用尺子。

我自己的取舍是这样的。

如果我现在有一个已经验证过、只是在工程环节卡住的策略,我会考虑用它,因为它替我省掉的正是最烦的那部分工作。

如果我只是想做研究阶段的快速验证,我不用它,pandas 加 polars 更快。

不管用不用它,有一条纪律我这篇是带走的。看到回测结果,先问收益点够不够,再看收益好不好。这个习惯在哪个框架里都成立。

最后一段代码在下面,我把它拆成了几段带注释的版本,可以直接跑。它只依赖 nautilus_trader 本身,不下载外部数据,所以你跑出来的数字和我文里的一致。

附录,完整脚本

脚本分成五段,都在同一个文件里,依次是行情生成、策略、回测装配、指标自查和主流程。

开头这几行 import 五段都要用,先放在最前面。整份脚本只依赖 nautilus_trader 本身。

import math
import random
from decimal import Decimal

from nautilus_trader.backtest import BacktestEngine
from nautilus_trader.config import BacktestEngineConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.indicators import ExponentialMovingAverage
from nautilus_trader.model import (
    BarType, Currency, Money, OrderSide, Price, Quantity, QuoteTick, TraderId, Venue,
)
from nautilus_trader.testkit.providers import TestInstrumentProvider
from nautilus_trader.trading import Strategy, StrategyConfig

SYMBOL = "AUD/USD"
BAR_SPEC = "AUD/USD.SIM-1-MINUTE-MID-EXTERNAL"

第一段,行情。用固定随机种子,不联网,保证可复现。

import math
import random


def synth_closes(n: int = 5000, seed: int = 7) -> list[float]:
    """确定性行情。不下载外部数据,保证谁跑都一样。"""
    rnd = random.Random(seed)
    px = 1.1000
    out = []
    for i in range(n):
        # 缓慢的正弦漂移 + 高斯噪声,构造一段有趋势但不平滑的行情
        drift = 0.00004 * math.sin(i / 260.0)
        px *= 1.0 + drift + rnd.gauss(0.0, 0.00045)
        out.append(round(px, 5))
    return out

第二段,报价。这一段是我踩坑之后补的。撮合市价单必须有报价,不然会被 MARKET_PRICE_UNAVAILABLE 拒掉。

写这段的时候我试了四遍才把参数顺序搞错再搞对。Bar 和 QuoteTick 都是一串位置参数,Price 和 Quantity 长得又不像,猜错一次报一个类型错误。第一遍我把成交量放在价格前面,直接被拒。

K 线那段用的是 Bar,签名不一样,顺序是 bar_type, open, high, low, close, volume, ts_event, ts_init。

from nautilus_trader.model import Bar, Quantity


def make_bars(closes: list[float], bar_spec: str = BAR_SPEC) -> list[Bar]:
    """把收盘价序列包成 1 分钟 K 线。"""
    bt = BarType.from_str(bar_spec)
    bars = []
    for i, c in enumerate(closes):
        prev = closes[i - 1] if i else c
        hi = max(prev, c) * 1.00012
        lo = min(prev, c) * 0.99988
        ts = 1_700_000_000_000_000_000 + i * 60_000_000_000
        bars.append(
            Bar(
                bt,
                Price.from_str(f"{prev:.5f}"),
                Price.from_str(f"{hi:.5f}"),
                Price.from_str(f"{lo:.5f}"),
                Price.from_str(f"{c:.5f}"),
                Quantity.from_int(1000),
                ts,
                ts,
            ),
        )
    return bars
def make_quotes(closes: list[float], spread: float = 0.00002):
    """给每个时间戳造一条双边报价。撮合必须有报价,否则市价单被拒。"""
    iid = TestInstrumentProvider.default_fx_ccy(SYMBOL, Venue("SIM")).id
    out = []
    for i, c in enumerate(closes):
        half = spread / 2
        ts = 1_700_000_000_000_000_000 + i * 60_000_000_000
        out.append(
            QuoteTick(
                iid,
                Price.from_str(f"{c - half:.5f}"),
                Price.from_str(f"{c + half:.5f}"),
                Quantity.from_int(1000),
                Quantity.from_int(1000),
                ts,
                ts,
            ),
        )
    return out

第三段,策略。EMA 交叉,用它自带的指标类。配置类要跟着策略一起给,漏了会 NameError。

class EmaXConfig(StrategyConfig):
    def __init__(self, instrument_id, bar_type, trade_size,
                 fast=20, slow=50, **kw):
        super().__init__()
        self.instrument_id = instrument_id
        self.bar_type = bar_type
        self.trade_size = trade_size
        self.fast = fast
        self.slow = slow


class EmaX(Strategy):
    def __init__(self, config: EmaXConfig) -> None:
        super().__init__(config)
        self.fast_ema = ExponentialMovingAverage(config.fast)
        self.slow_ema = ExponentialMovingAverage(config.slow)
        self.equity = []  # (ts, 权益),用来自己算指标

    def on_start(self) -> None:
        self.register_indicator_for_bars(self.config.bar_type, self.fast_ema)
        self.register_indicator_for_bars(self.config.bar_type, self.slow_ema)
        self.subscribe_bars(self.config.bar_type)

    def on_bar(self, bar) -> None:
        # 每根 bar 记一次权益,用来自己算指标(引擎自报的那套样本量不足)
        acct = self.portfolio.account(self.config.instrument_id.venue)
        if acct is not None:
            self.equity.append(
                (int(bar.ts_event), float(acct.balance_total().as_decimal())),
            )
        if not self.indicators_initialized():
            return
        iid = self.config.instrument_id
        if self.fast_ema.value >= self.slow_ema.value:
            if self.portfolio.is_net_flat(iid):
                self._market(1)
            elif self.portfolio.is_net_short(iid):
                self.close_all_positions(iid)
                self._market(1)
        elif self.fast_ema.value < self.slow_ema.value:
            if self.portfolio.is_net_flat(iid):
                self._market(-1)
            elif self.portfolio.is_net_long(iid):
                self.close_all_positions(iid)
                self._market(-1)

    def _market(self, sign: int) -> None:
        """市价单。sign 为 1 买、-1 卖。"""
        inst = self.cache.instrument(self.config.instrument_id)
        side = OrderSide.BUY if sign > 0 else OrderSide.SELL
        order = self.order_factory.market(
            self.config.instrument_id,
            side,
            inst.make_qty(Decimal(self.config.trade_size)),
        )
        self.submit_order(order)

    def on_stop(self) -> None:
        self.close_all_positions(self.config.instrument_id)

第四段,装配引擎。这段在一个 `run_case` 函数里,手续费是参数传进去的,五组实验唯一变的就是它。

from nautilus_trader.model import AccountType, OmsType


def run_case(label, taker_rate="0.00002", maker_rate="0.00002",
             fast=20, slow=50, starting=1_000_000):
    venue = Venue("SIM")
    usd = Currency.from_str("USD")

    engine = BacktestEngine(
        BacktestEngineConfig(trader_id=TraderId.from_str("BACKTESTER-001"), logging=None),
    )
    engine.add_venue(
        venue=venue,
        oms_type=OmsType.HEDGING,
        account_type=AccountType.MARGIN,
        base_currency=usd,
        starting_balances=[Money(starting, usd)],
        fee_model=MakerTakerFeeModel(
            maker_rate=Decimal(maker_rate),
            taker_rate=Decimal(taker_rate),
        ),
    )
    inst = TestInstrumentProvider.default_fx_ccy(SYMBOL, venue)
    engine.add_instrument(inst)

    closes = synth_closes()
    engine.add_data(make_quotes(closes))
    engine.add_data(make_bars(closes))
    strat = EmaX(EmaXConfig(
        instrument_id=inst.id,
        bar_type=BarType.from_str(BAR_SPEC),
        trade_size=Decimal("1000000"),
        fast=fast, slow=slow,
    ))
    engine.add_strategy(strat)
    engine.run()

    end_bal = float(engine.portfolio.account(venue).balance_total().as_decimal())
    out = {
        "label": label,
        "taker_rate": taker_rate,
        "pnl": round(end_bal - starting, 2),
        "pnl_pct": round((end_bal - starting) / starting * 100, 4),
        "n_orders": int(len(engine.generate_orders_report())),
        "n_fills": int(len(engine.generate_order_fills_report())),
    }
    out.update(metrics_from_equity(strat.equity, starting))
    engine.dispose()
    return out

第五段,自己算 Sharpe。这一点是我建议每个用这个框架的人都抄走的。

def metrics_from_equity(points, starting):
    """自己算最大回撤与逐点收益,避免直接引用引擎的年化指标。"""
    bals = [b for _, b in points]
    peak, mdd = bals[0], 0.0
    for b in bals:
        peak = max(peak, b)
        if peak:
            mdd = max(mdd, (peak - b) / peak)
    rets = []
    for a, b in zip(bals, bals[1:]):
        if a:
            rets.append(b / a - 1.0)
    out = {"eq_points": len(bals), "mdd_pct": round(mdd * 100, 4)}
    if len(rets) > 1:
        mean = sum(rets) / len(rets)
        sd = (sum((r - mean) ** 2 for r in rets) / (len(rets) - 1)) ** 0.5
        if sd > 0:
            out["sharpe_unannualized"] = round(mean / sd, 4)
        out["n_periods"] = len(rets)
    return out

跑完之后你会得到一张表,长这样。

case                            orders   fills            PnL      PnL%   mdd%
A基准 EMA20/50 taker 0.002%        172     172      87,492.49    8.7492  1.4226
B 费率x20 taker 0.04%              172     172      14,397.62    1.4398  2.6085
C 费率x50 taker 0.1%               172     172    -101,017.45  -10.1017 10.4297
D EMA20/100                        112     112      79,492.79    7.9493  1.9640
E EMA10/20                         378     378      92,276.92    9.2277  1.9034

想改手续费假设,就改主流程里的第二个参数。想验证自己那份策略能不能扛住成本,把 taker 往上调 50 倍看看还剩多少,就知道它是真的有Alpha 还是刚好盖住了手续费。


项目地址 https://github.com/nautechsystems/nautilus_trader


#NautilusTrader #量化工具 #开源项目 #回测框架 #实盘交易 #事件驱动 #交易系统 #量化工程 #Rust #A股量化

风险提示:本文所有数据来自公开仓库信息与本人本地实测,合成行情不代表任何真实市场,不构成任何操作建议。市场有风险,文中提到的任何工具都不构成荐股。开源框架的技术指标不能替代策略本身的逻辑验证。

Published inAI&Invest专栏

Be First to Comment

    发表回复