三十秒看懂OVERVIEW
写一个策略,只需要做一件事:实现 generate_orders(ctx),返回一串订单。
引擎在每个交易日收盘后调它一次,你只能看到当日及更早的数据;你返回的订单在下一个交易日撮合。
三个概念,就这么多:
| 概念 | 是什么 | 你要做什么 |
|---|---|---|
| Strategy | 策略基类,你继承它 | 实现 generate_orders;on_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: ... # 可选:结束后调用一次
| 成员 | 签名 | 说明 |
|---|---|---|
| name | str | 策略标识,出现在报告与订单归因里。子类务必覆盖。 |
| on_start | (ctx) -> None | 预算因子、复位内部状态。同一个实例可能先后喂给多个引擎(做对照回测),所有状态都要在这里复位。 |
| generate_orders | (ctx) -> list[Order] | 唯一必须实现的方法。返回空列表表示今天不动。 |
| on_finish | (ctx) -> None | 收尾(写日志、导出中间量)。不产生订单。 |
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")
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| code | str | 是 | 裸 6 位代码,如 600519 / 000001。不带 .SH / .SZ 后缀。 |
| side | Side | 是 | Side.BUY 或 Side.SELL。 |
| qty | int | 是 | 股数,不是手数、不是金额、不是比例。买入须对齐 100 的整数倍(用 ctx.lots_affordable() 直接拿到对齐后的股数)。 |
| reason | str | 否 | 信号来源标签,会一路带到成交明细与实盘归因里。强烈建议填——事后复盘时它是唯一能说清「这单为什么下」的东西。 |
Context 上下文quant.strategy.base.Context
策略拿到的一切都从 ctx 走。它是只读的:读数据、读持仓、读现金,然后返回订单——
策略不碰经纪商、不碰下单通道,所以同一份代码回测能跑、实盘也能跑。
数据访问(全部 point-in-time)
| 方法 | 返回 | 说明 |
|---|---|---|
| ctx.date | datetime.date | 当前决策日。 |
| ctx.codes | list[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.cash | float | 可用现金(元)。 |
| 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_size | int | 每手股数,A 股为 100。 |
| ctx.cost_model | AShareCostModel | 成本模型(佣金 / 印花税 / 过户费),策略可读来做成本感知的换手控制。 |
| ctx.calendar | TradingCalendar | 交易日历。 |
你拿不到未来数据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 按下标取值。这与「每天用截至当天的窗口现算」
逐点等价,只是快得多——内置策略用单元测试锁定了这个等价性(逐窗口等价 + 截断不变性两项)。
完整示例:双均线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 []
横截面策略的骨架
选股类策略(多因子、均值回归、低波动……)都是同一个骨架:先卖后买。 引擎按列表顺序撮合,卖单必须排在买单前面,否则现金不够、买单会被拒:
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
- 用
position_qty下卖单。当日买入的部分卖不掉,实盘会被券商直接拒单。一律用sellable_qty。 - 忘了判
ctx.bar(code) is None。停牌股会让你的价格计算变成None参与运算,一路炸到引擎。 - 买入股数没对齐 100。回测里也许被容忍,实盘一定报错。用
lots_affordable,别自己int(cash/price)。 - 买单排在卖单前面。引擎按列表顺序撮合,现金还没回来,买单就被拒了。先卖后买。
- 用
cash而不是equity()做仓位预算。满仓时现金接近 0,等权分配会算出 0 股,策略从此不动了。 - NaN 用 0 兜底。因子缺失的票用 0 填充,会在截面排序里排到中间偏上,悄悄进了组合。 正确做法是整只剔除(fail-closed),宁可候选不足今天不动。
- 在
__init__里初始化状态、不在on_start里复位。同一个策略实例被喂给第二个引擎时, 会带着上一次回测的残留状态,结果无法复现。 - 候选不足时重置换仓计时。会导致「今天凑不齐 → 空等一整个换仓周期」。 正确做法是不重置,次日自动重试。
内置策略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 | 吃分钟线,不能用日线回测 |
边界与规矩SCOPE
- 信号在服务器算,下单在你自己的电脑上执行。易维量化不代客户下单、不托管资金、不碰你的券商密码。
- 不提供投资建议。本页与本站的一切内容是研究工具与客观数据呈现,不构成荐股、目标价或收益承诺。
- 历史回测不代表未来收益。体检通过只说明「这份回测结论在统计上还站得住」,不说明未来会赚钱。
- 第一版策略工厂只开参数化模板(选因子、调参数),暂不开放自由代码上传—— 在沙箱(独立进程、只读文件系统、无网络、资源与时限配额、import 白名单)就绪之前, 在交易服务器上跑客户代码是不可接受的风险。见策略工厂。
接口若有变更,会在易维社区发版本说明; 破坏性变更会保留旧接口至少一个版本周期。