策略接口文档

一个策略类 · 一个 generate_orders · 回测与实盘同一套接口
这份文档描述的是正在跑的接口,不是规划。下面每一个类名、方法名、参数与默认值, 都取自易维量化引擎里正在执行的同一份代码;内置的十几套策略也都实现的是这套接口。 直接看示例 →

三十秒看懂OVERVIEW

写一个策略,只需要做一件事:实现 generate_orders(ctx),返回一串订单。 引擎在每个交易日收盘后调它一次,你只能看到当日及更早的数据;你返回的订单在下一个交易日撮合。

T 日
收盘
当日行情落库
T 日盘后
generate_orders
你的代码在这里跑,产出订单
T+1 日
撮合
默认次日开盘价 + 滑点
T+2 日起
可卖
A 股 T+1,买入次日才可卖
为什么这个结构值钱 「收盘后算、次日撮合」不是风格选择,是从结构上杜绝「同 bar 决策即成交」—— 回测里最常见、也最难自查的一类前视偏差。你写不出「用今天收盘价决定、又按今天收盘价成交」的策略, 因为引擎根本不给你这个机会。

三个概念,就这么多:

概念是什么你要做什么
Strategy策略基类,你继承它实现 generate_orderson_start / on_finish 可选
Context只读上下文:数据 + 资金 + 持仓只读不写,所有数据访问都从它走
Order订单意图(代码 / 方向 / 股数 / 理由)返回 list[Order],引擎负责撮合

Strategy 基类quant.strategy.base.Strategy

from quant.strategy.base import Context, Strategy

class Strategy(ABC):
    name: str = "strategy"

    def on_start(self, ctx: Context) -> None: ...      # 可选:回测/实盘开始前调用一次

    @abstractmethod
    def generate_orders(self, ctx: Context) -> list[Order]:
        """收盘后调用:基于 <= ctx.date 的数据产出订单,下一交易日撮合。"""

    def on_finish(self, ctx: Context) -> None: ...     # 可选:结束后调用一次
成员签名说明
namestr策略标识,出现在报告与订单归因里。子类务必覆盖。
on_start(ctx) -> None预算因子、复位内部状态。同一个实例可能先后喂给多个引擎(做对照回测),所有状态都要在这里复位。
generate_orders(ctx) -> list[Order]唯一必须实现的方法。返回空列表表示今天不动。
on_finish(ctx) -> None收尾(写日志、导出中间量)。不产生订单。
返回的是订单,不是目标权重 很多平台让你返回「目标持仓比例」,由框架反解成买卖。这里不是——你自己算股数。 好处是仓位与换手完全在你控制之下,成本也就实打实计提;代价是要自己处理「先卖后买释放现金」, 见 第 8 节

Order 订单quant.core.types.Order

from quant.core.enums import Side
from quant.core.types import Order

Order(code="600519", side=Side.BUY, qty=300, reason="mom_enter")
字段类型必填说明
codestr裸 6 位代码,如 600519 / 000001。不带 .SH / .SZ 后缀。
sideSideSide.BUYSide.SELL
qtyint股数,不是手数、不是金额、不是比例。买入须对齐 100 的整数倍(用 ctx.lots_affordable() 直接拿到对齐后的股数)。
reasonstr信号来源标签,会一路带到成交明细与实盘归因里。强烈建议填——事后复盘时它是唯一能说清「这单为什么下」的东西。
Order 是 frozen dataclass:构造后不可改,避免策略内部把订单改来改去导致难以复现。

Context 上下文quant.strategy.base.Context

策略拿到的一切都从 ctx 走。它是只读的:读数据、读持仓、读现金,然后返回订单—— 策略不碰经纪商、不碰下单通道,所以同一份代码回测能跑、实盘也能跑。

数据访问(全部 point-in-time)

方法返回说明
ctx.datedatetime.date当前决策日。
ctx.codeslist[str]当前可交易的代码列表(当日无数据的会被剔除)。
ctx.window(code, n)pandas.DataFrame该股截至当日(含)最后 n 根日线,列为 open/high/low/close/volume/amount。
ctx.bar(code)Bar 或 None当日 K 线;当日停牌返回 None——判 None 是必修课。
ctx.current_bars()dict[str, Bar]当日有行情的全部标的,一次拿齐。
ctx.data.frame(code)DataFrame 或 None该股截至当日的完整历史,用于 on_start 里一次性预算因子。
ctx.data.last_index(code, date)int 或 None「<= date 的最后一根」在整序列中的下标。配合预算数组做 O(1) 取值。
ctx.data.prev_close(code)float 或 None上一交易日收盘价。

资金与持仓

方法返回说明
ctx.cashfloat可用现金(元)。
ctx.equity()float总权益 = 现金 + 持仓市值。做仓位预算请用它,它与引擎权益曲线同口径(停牌持仓按最近收盘前向填充估值,不会被打回成本价)。
ctx.mark_price(code)float 或 None该股的 point-in-time 估值价(停牌沿用最近真实收盘)。
ctx.position_qty(code)int持仓总股数。
ctx.sellable_qty(code)int今日可卖股数。A 股 T+1:当日买入的部分不在其中。卖出请一律用它,别用 position_qty。
ctx.portfolio.holding_codes()list[str]当前有持仓的代码。

下单辅助

方法返回说明
ctx.lots_affordable(price, cash=None)int给定价格与预算,返回已对齐整手的最大可买股数。不传 cash 则用全部可用现金。费用由引擎撮合时再校验,所以它是「粗略上限」——留一点余量更稳。
ctx.lot_sizeint每手股数,A 股为 100。
ctx.cost_modelAShareCostModel成本模型(佣金 / 印花税 / 过户费),策略可读来做成本感知的换手控制。
ctx.calendarTradingCalendar交易日历。

你拿不到未来数据POINT-IN-TIME

这是整套引擎最重要的一条设计,也是我们敢把体检结论摆出来的前提。

策略看到的 ctx.data 不是完整数据集,而是一个被裁剪到当前决策时点的视图 (PointInTimeDataView)。frame / window / dates / codes 全都不含未来;请求一个晚于 ctx.date 的日期会直接抛错,而不是悄悄返回数据:

ValueError: point-in-time 数据越界:请求 2025-06-01,当前时点 2025-05-30
这意味着什么 策略作者想作弊也拿不到未来数据——不需要靠自觉,也不需要靠代码审查。 常见的前视偏差(提前知道收盘价、用未来均值做标准化、用全样本训练再回测同一段) 在这套接口下要么写不出来,要么当场报错。

唯一的例外是内置策略的启动期因子预算:on_start 里可以用 ctx.data.frame(code) 拿到「截至当日」的完整历史,一次性算完整条因子序列, 之后在每个交易日用 last_index 按下标取值。这与「每天用截至当天的窗口现算」 逐点等价,只是快得多——内置策略用单元测试锁定了这个等价性(逐窗口等价 + 截断不变性两项)。

仍然要你自己负责的两件事 ①幸存者偏差:如果股票池是「今天还活着的这些票」,那么池子本身就带了未来信息, PIT 数据视图救不了你——要用时点成分股。 ②参数是拿全样本挑的:数据没越界,但「你试了 300 组参数挑出最好的那组」这件事本身就是过拟合。 这一条归防过拟合体检管,不归接口管。

完整示例:双均线quant.strategy.examples.SmaCross

这不是伪代码,是仓库里 examples.py 的原文。短均线上穿长均线满仓买入、下穿清仓:

from typing import Optional

from quant.core.enums import Side
from quant.core.types import Order
from quant.strategy.base import Context, Strategy


class SmaCross(Strategy):
    """单只股票日线双均线:短均线上穿长均线满仓买入,下穿清仓卖出。"""

    name = "sma_cross"

    def __init__(self, code: str, fast: int = 5, slow: int = 20,
                 target_pct: float = 0.95) -> None:
        if fast >= slow:
            raise ValueError("fast 必须小于 slow")
        self.code = code
        self.fast = fast
        self.slow = slow
        self.target_pct = target_pct

    def _cross(self, ctx: Context) -> Optional[str]:
        w = ctx.window(self.code, self.slow + 1)          # 截至当日的最后 slow+1 根
        if len(w) < self.slow + 1:
            return None                                 # 预热不足,今天不动
        close = w["close"]
        fast_now  = close.iloc[-self.fast:].mean()
        slow_now  = close.iloc[-self.slow:].mean()
        fast_prev = close.iloc[-self.fast - 1:-1].mean()
        slow_prev = close.iloc[-self.slow - 1:-1].mean()
        if fast_prev <= slow_prev and fast_now > slow_now:
            return "golden"
        if fast_prev >= slow_prev and fast_now < slow_now:
            return "death"
        return None

    def generate_orders(self, ctx: Context) -> list[Order]:
        signal = self._cross(ctx)
        if signal is None:
            return []
        bar = ctx.bar(self.code)
        if bar is None:                                # 当日停牌,买不进也卖不出
            return []
        held = ctx.position_qty(self.code)
        if signal == "golden" and held == 0:
            qty = ctx.lots_affordable(bar.close, cash=ctx.cash * self.target_pct)
            if qty >= ctx.lot_size:                    # 不足一手就别下
                return [Order(self.code, Side.BUY, qty, reason="sma_golden_cross")]
        elif signal == "death":
            sellable = ctx.sellable_qty(self.code)         # T+1:只卖可卖的
            if sellable > 0:
                return [Order(self.code, Side.SELL, sellable, reason="sma_death_cross")]
        return []
40 行,五个防御全在里面:预热不足不动、停牌不动、不足一手不下单、卖出只卖 sellable、 买入用 lots_affordable 对齐整手。这五条就是内置策略与「回测能跑、实盘一下单就报错」之间的全部差别。

横截面策略的骨架

选股类策略(多因子、均值回归、低波动……)都是同一个骨架:先卖后买。 引擎按列表顺序撮合,卖单必须排在买单前面,否则现金不够、买单会被拒:

orders = []
# ① 先卖:已持有但不在今天目标里的
for code in ctx.portfolio.holding_codes():
    if code not in target:
        s = ctx.sellable_qty(code)
        if s > 0:
            orders.append(Order(code, Side.SELL, s, reason="exit"))

# ② 再买:目标里尚未持有的,等权分配
per = ctx.equity() / top_k
for code in target:
    if code in held:
        continue
    b = ctx.bar(code)
    if b is None:
        continue
    qty = ctx.lots_affordable(b.close, cash=per)
    if qty >= ctx.lot_size:
        orders.append(Order(code, Side.BUY, qty, reason="enter"))
return orders

撮合与成本口径MATCHING & COSTS

你返回订单之后发生的事,都由引擎负责,策略无从干预——这也是回测与实盘能共用一份策略的原因。

环节口径
撮合时点信号日的下一交易日,默认按开盘价成交。
滑点体现在成交价里(买贵卖便宜),不单列。
费用佣金 + 印花税(卖出单边)+ 过户费,按 AShareCostModel 计提;买入费用计入成本基础。
T+1当日买入的份额当日不可卖,由 Portfolio.start_new_day() 结算,ctx.sellable_qty() 如实反映。
停牌当日无 Bar 的标的买不进也卖不出;持仓估值按最近一个真实收盘前向填充。
整手买入必须 100 的整数倍;卖出可以是零股(清仓残余)。
风控引擎外挂 RiskManager(最大持仓数、总仓位上限、回撤停手、单日亏损上限),会在撮合前拦单。
同一套接口,两种运行时 回测把历史日线一天天喂给你的 generate_orders实盘在每个交易日收盘后用当天的真实行情调同一个方法,把订单交给下单通道,次日开盘执行。 策略代码一个字都不用改——因为它从头到尾就不知道「经纪商」的存在。

八个常见错误PITFALLS

  1. position_qty 下卖单。当日买入的部分卖不掉,实盘会被券商直接拒单。一律用 sellable_qty
  2. 忘了判 ctx.bar(code) is None停牌股会让你的价格计算变成 None 参与运算,一路炸到引擎。
  3. 买入股数没对齐 100。回测里也许被容忍,实盘一定报错。用 lots_affordable,别自己 int(cash/price)
  4. 买单排在卖单前面。引擎按列表顺序撮合,现金还没回来,买单就被拒了。先卖后买。
  5. cash 而不是 equity() 做仓位预算。满仓时现金接近 0,等权分配会算出 0 股,策略从此不动了。
  6. NaN 用 0 兜底。因子缺失的票用 0 填充,会在截面排序里排到中间偏上,悄悄进了组合。 正确做法是整只剔除(fail-closed),宁可候选不足今天不动。
  7. __init__ 里初始化状态、不在 on_start 里复位。同一个策略实例被喂给第二个引擎时, 会带着上一次回测的残留状态,结果无法复现。
  8. 候选不足时重置换仓计时。会导致「今天凑不齐 → 空等一整个换仓周期」。 正确做法是不重置,次日自动重试。

内置策略BUILT-IN

引擎自带 18 个日线策略类(其中 3 个是入门示例)与 2 个日内策略类,全部实现本页这套接口, 源码都可当模板抄。日线与日内是两套基类,日内策略不能喂给日线回测

类别策略类说明
入门示例BuyAndHold · EqualWeightHold · SmaCross几十行,用来读懂接口
横截面选股CrossSectionalMultiFactor · CrossSectionalMeanReversion · LowVolPortfolio · FastRebalanceMultiFactor · ICTimedMultiFactor打分 → 取 top_k → 等权持有 → 定期换仓
轮动IndexMomentumRotation · SectorRotation宽基指数 / 行业之间的相对强弱轮动
趋势与突破DonchianTrend · DonchianTrendEnhanced · VolumePriceBreakout · LegacyMaVwapDaily通道突破、量价确认、指数闸门
择时与风控MarketTimingStrategy · StopLossTakeProfit · GridTrading仓位择时、止盈止损包装器、区间网格
统计套利PairsTrading同板块配对价差(只做多腿)
日内(另一套基类)VwapBandMeanReversion · IntradayMomentum吃分钟线,不能用日线回测
说清楚一件事 这些策略不是「已验证能赚钱的 alpha」,是研究与对照样板。 本项目自己做过的证伪结论是:在免费日线数据上,调参得到的增益在样本外基本消失。 所以每一套策略——包括我们自己写的——都必须过防过拟合体检才谈得上部署, 而没过闸的照样公示,只是不卖、不进实盘

边界与规矩SCOPE

  • 信号在服务器算,下单在你自己的电脑上执行。易维量化不代客户下单、不托管资金、不碰你的券商密码。
  • 不提供投资建议。本页与本站的一切内容是研究工具与客观数据呈现,不构成荐股、目标价或收益承诺。
  • 历史回测不代表未来收益。体检通过只说明「这份回测结论在统计上还站得住」,不说明未来会赚钱。
  • 第一版策略工厂只开参数化模板(选因子、调参数),暂不开放自由代码上传—— 在沙箱(独立进程、只读文件系统、无网络、资源与时限配额、import 白名单)就绪之前, 在交易服务器上跑客户代码是不可接受的风险。见策略工厂

接口若有变更,会在易维社区发版本说明; 破坏性变更会保留旧接口至少一个版本周期。