3.2 策略工具箱

适用版本 V2V3
先读这里:粗读即可,用时再查

写策略时,大部分代码都在重复几件事:取当前时间和价格、查持仓、拿一段历史 K 线算指标、算能买多少股、拼交易信号。khQuant 把这些封装成了一组现成函数,策略文件开头写一行 from khQuantImport import * 就能全部直接调用,不用关心它们来自哪个内部模块。

这一篇按用途把这些函数分成几组,每个函数给出签名、用途、关键参数和一个短例子。常用的放在前面讲透,不常用的放在文末速查表。V2 和 V3 的策略接口完全相同,本篇的函数两版都能用。策略文件整体怎么组织、几个回调分别在什么时候被调用,请先看 3.1 框架介绍。

这些函数的签名、参数和用法的完整定义都已经写进 khQuant Skill(见 3.3)。用 AI 写策略时,AI 会自己去查这些规则,你并不需要深入掌握框架细节。这一章粗略读一遍、知道有哪些东西就够了,以后需要时再把它当手册查阅。想直接让 AI 帮你写策略,看 3.4;V3 用户的上手路线见 2.2。

本篇重点:本篇是策略里可用函数的完整清单:取数、历史数据、指标、下单与挂单、日内再触发和自定义指标。

1 先看全貌:按用途找函数

下表是本篇涉及的函数分组。表里没有的冷门函数,在第 11 节速查表里;第 10 节是一个把常用函数串起来的模板。

用途函数本篇位置
取时间与行情khGet、khPrice、khHas、is_trade_day、get_trade_days_count第 2 节
历史数据与指标khHistory、khMA、khKline、MyTT 指标、缠论工具第 3 节
账户与持仓khGet(data, 'positions')、PositionParser第 4 节
下单与仓位generate_signal、calculate_max_buy_volume、round_price、订单类型与有效期第 5 节
挂单管理khGetPendingOrders、khGetPendingSummary、khCancelOrder、khCancelOrdersByCode、khCancelAllOrders第 6 节
日内多次触发khRequestNextDailyTrigger第 7 节
自定义指标khDuckWrite、khDuckDB、khAddExtraFields、khIndex第 8 节
日志logging第 9 节

所有例子都默认写在策略文件里,开头已经有:

from khQuantImport import *

这一行除了导入 khQuant 自己的函数,还导入了 os、sys、json、logging、datetime(注意导入的是 datetime 类,不是模块,写 datetime.now(),不要写 datetime.datetime.now())、dt、timedelta、np、pd,以及类型标注用的 Dict、List、Optional 等,不需要再单独 import。

2 取时间与行情

2.1 khGet:取时间、资金、股票池、持仓

khGet(data: Dict, key: str) -> Any

用途:从回调收到的 data 里按「别名」取值,省掉手写多层字典访问。key 支持下面这些别名:

key返回示例值
'date' / 'date_str'日期字符串"2024-07-15"
'date_num'无分隔符的日期字符串"20240715"
'time' / 'time_str'时间字符串"09:31:00"
'datetime' / 'datetime_str'日期时间字符串"2024-07-15 09:31:00"
'datetime_obj'Python datetime 对象—
'timestamp'当前时间戳—
'cash'可用资金(float)—
'market_value'持仓总市值(float)—
'total_asset'总资产(float)—
'stocks'股票池代码列表(list)["000001.SZ", ...]
'first_stock'股票池第一只代码;股票池为空时为 None"000001.SZ"
'positions'持仓字典的副本(结构见第 4 节)—

返回值:按 key 返回不同类型。取不到时返回安全默认值:时间类返回空字符串,资金类返回 0。不在上表里的 key,会原样当作 data 的键去取,取不到返回 None。

def khHandlebar(data: Dict) -> List[Dict]:
    today = khGet(data, 'date_num')        # "20240715"
    now = khGet(data, 'datetime_str')      # "2024-07-15 09:31:00"
    cash = khGet(data, 'cash')
    stocks = khGet(data, 'stocks')
    logging.info(f"{now} 可用资金 {cash:.2f},股票池 {len(stocks)} 只")
    return []
提示

这里的「当前时间」是回测时间,不是电脑时间。date_num 和 datetime_str 最常用的地方,是作为 khHistory 的 current_time 参数传进去,见第 3 节。

2.2 khPrice:取当前行情字段

khPrice(data: Dict, stock_code: str, field: str = 'close') -> float

用途:取某只股票在当前这根 K 线(或当前 tick)上的某个行情字段。

  • field:默认 'close',也可以是 'open'、'high'、'low'、'volume'、'amount' 等数据里有的字段。
  • tick 数据没有有效的 close,传 'close' 时会自动改取 lastPrice(最新价),日线、分钟线和 tick 策略可以用同一种写法。
  • 返回值:float。数据不存在、为 None、为 NaN 时都返回 0.0,不会抛异常。
price = khPrice(data, '000001.SZ')            # 当前价(K 线收盘价 / tick 最新价)
open_price = khPrice(data, '000001.SZ', 'open')
if price <= 0:
    return []    # 当前没有有效价格(停牌、数据缺失),跳过
注意

返回 0.0 表示「没取到」,不是价格真的为 0。拿价格去下单或算股数之前,一定先判断 price <= 0 就跳过。

2.3 khHas:是否持有某只股票

khHas(data: Dict, stock_code: str) -> bool

用途:判断当前是否持有该股票。持仓里有这只股票且数量大于 0 时返回 True,否则 False。

stock = '600519.SH'
if not khHas(data, stock):
    pass   # 未持仓:写买入逻辑
else:
    pass   # 已持仓:写卖出逻辑

注意它只看总持仓,不看能不能卖。T+1 下当天买入的股票 khHas 已经是 True,但当天卖不出去。要判断可卖数量,看第 4 节。

2.4 交易日工具

is_trade_day:判断某天是不是 A 股交易日。

is_trade_day(date_str: str = None) -> bool

date_str 支持 "YYYY-MM-DD" 和 "YYYYMMDD",不传时判断电脑上的今天。查询优先读本地交易日历缓存,缓存没覆盖该年份时会联网补齐;都取不到时退化为「非周末即交易日」。

is_trade_day("20241001")    # False,国庆休市
is_trade_day(khGet(data, 'date_num'))

get_trade_days_count:数两个日期之间(含首尾)有多少个交易日。

get_trade_days_count(start_date: str, end_date: str) -> int

两个日期只接受 "YYYY-MM-DD" 格式,传 "20240101" 会记一条错误日志并返回 0。开始日期晚于结束日期也返回 0。

days = get_trade_days_count("2024-01-01", "2024-03-31")

需要拿到具体日期列表时,用速查表里的 get_trade_days_set(start_date, end_date),它返回 "YYYYMMDD" 字符串组成的集合,两种日期格式都接受。

注意

is_trade_time() 判断的是电脑当前时钟是否在 09:30–11:30、13:00–15:00 之内,和回测时间无关。回测里用它做时段过滤,结果取决于你几点点的「开始回测」,几乎一定是错的。回测里要按时段过滤,请用 khGet(data, 'time_str') 自己比较,例如 if khGet(data, 'time_str') >= "14:50:00":。

3 历史数据与指标

3.1 khHistory:取历史 K 线(最常用)

khHistory(symbol_list, fields, bar_count, fre_step,
          current_time=None, skip_paused=False, fq=None, force_download=False)

用途:取一只或多只股票在某个时间点之前的若干根 K 线,是算指标的基础。

参数说明
symbol_list单个代码字符串或代码列表,必须带交易所后缀,如 '000001.SZ'。
fields字段列表,如 ['open', 'high', 'low', 'close', 'volume']。不能为空。
bar_count要几根 K 线,必须大于 0。
fre_step周期:'1d'、'1m'、'5m'、'tick'。
current_time截止时间点,不含该时间点。格式见下文。不传时,回测中自动用当前这根 K 线的时间。
skip_paused是否跳过停牌数据,默认 False。
fq复权方式:'pre' 前复权、'post' 后复权、'none' 不复权。默认 None:回测中跟随界面(配置)里选的复权方式,单独在回测外调用时按前复权。
force_download是否先强制下载最新数据,默认 False。回测里保持默认即可。

返回值:字典 {股票代码: DataFrame}。DataFrame 有一列 time 加上你要的字段,按时间升序排列,最后一行是离截止时间最近的那根。参数写错(股票列表或字段为空、bar_count 不大于 0、时间格式不对)会抛出 ValueError;取数过程中出错返回空字典 {};数据不足时返回的行数会少于 bar_count,并在日志里提示。

current_time 的格式和「不含」规则

格式适用过滤规则
"YYYYMMDD"、"YYYY-MM-DD"日线只要日期早于该日的 K 线。传 "20240115",最后一根是 15 日前一个交易日。
"YYYYMMDD HHMMSS"、"YYYY-MM-DD HH:MM:SS"分钟线、tick只要时间早于该时刻的数据。传 "2024-01-15 10:30:00",不含 10:30 这根。

日线周期下即使传了带时分秒的时间,也只按日期比较,所以日线策略直接传 khGet(data, 'datetime_str') 也能得到「截至昨天」的数据。日期和时分秒之间要有空格,"20240115103000" 这种连写的格式解析不了,会报错。

为什么要「不含」:策略在这根 K 线上做决定时,这根 K 线本身的数据通过 khPrice 已经能看到;历史数据只给它之前的部分,算均线、算指标时就不会把「当前」和「过去」混在一起,也不会用到未来数据。

例 1:日线策略取最近 20 个交易日收盘价

def khHandlebar(data: Dict) -> List[Dict]:
    stock = '000001.SZ'
    hist = khHistory(stock, ['close'], 20, '1d',
                     current_time=khGet(data, 'date_num'))
    if stock not in hist or len(hist[stock]) < 20:
        return []                      # 数据不够,先不做判断
    closes = hist[stock]['close'].values
    ma20 = closes.mean()
    return []

例 2:分钟策略取最近 60 根 1 分钟线

now = khGet(data, 'datetime_str')     # "2024-01-15 14:00:00"
hist = khHistory(['000001.SZ', '600000.SH'], ['close', 'volume'], 60, '1m',
                 current_time=now)
for code, df in hist.items():
    if df.empty:
        continue
    last_close = df['close'].iloc[-1]  # 13:59 那根的收盘价

例 3:在回测外查某个历史时点(复盘、调试)

hist = khHistory('000001.SZ', ['close', 'volume'], 10, '1d',
                 current_time='2023-01-21')
提示

三条习惯:fre_step 和回测配置里的触发周期、K 线周期保持一致(日线策略用 '1d');current_time 显式传入,代码一眼能看出截止点;用结果前检查 code in hist 和行数。不传 fq 时,指标和界面行情用的是同一种复权,这也是推荐写法;显式传了与界面不一致的 fq,日志里会提示一次。

3.2 khMA:一行算均线

khMA(stock_code: str, period: int, field: str = 'close', fre_step: str = '1d',
     end_time: str = None, fq: str = None, data: Dict = None) -> float

用途:直接算某只股票的 N 周期简单均线,内部就是 khHistory 取 period 根再求平均。

  • end_time:截止时间,规则同 khHistory 的 current_time(不含该时间点)。回测中不传时自动用当前 K 线时间,所以日线上 khMA 用的是截至前一天的数据,不含当天。
  • fq:同 khHistory,默认跟随回测的复权方式。
  • data:可选,传入后按框架设置的价格精度取整。
  • 返回值:按价格精度四舍五入后的 float(股票 2 位小数,ETF 等场内基金 3 位)。
  • 数据不足 period 根时会抛出 ValueError,不是返回 0。
try:
    ma5 = khMA('000001.SZ', 5, data=data)
    ma20 = khMA('000001.SZ', 20, data=data)
except ValueError as e:
    logging.warning(f"均线数据不足: {e}")
    return []

只要一两条均线时 khMA 最省事。要同时算多条均线或别的指标,一次 khHistory 取够数据再交给 MyTT 计算会更快,避免重复取数。

3.3 khKline:任意周期 K 线

khKline(symbol_list, period: str, bar_count: int, fields: List[str] = None,
        end_time: str = None, fq: str = None, force_download: bool = False)
        -> Dict[str, pd.DataFrame]

用途:把日线或分钟线拼成任意周期,例如 '15m'、'30m'、'2h'、'3d',常用于缠论等多级别分析。

  • period:数字加单位,单位是 m(分钟)、h(小时)、d(天)。
  • fields:默认 ['open', 'high', 'low', 'close', 'volume']。
  • end_time:支持 '2024-12-01 14:30:00'、'2024-12-01 14:30'、'20241201 1430'、'20241201' 等,秒数会被忽略。回测中不传时用当前 K 线时间。
  • 多日周期(2d、3d…)每年都以第一个交易日为起点分组;年初根数不够时,会接着往前一年取。
  • 返回值:{股票代码: DataFrame},出错时返回空字典。
k15 = khKline('000001.SZ', '15m', 50)
df = k15.get('000001.SZ')
if df is not None and len(df) >= 3:
    logging.info(f"最新一根 15 分钟线收盘 {df['close'].iloc[-1]}")
注意

khKline 与 khHistory 的截止规则相反:它保留时间小于等于 end_time 的数据,包含当前这根。还没走完的周期,最后一根是到当前时刻为止的「半截」K 线。日线回测里用 '1d' 或多日周期,最后一根就包含当天的收盘价。用它判断形态时,要想清楚最后一根是否已经收线,必要时用倒数第二根。另外,多日周期每年按年初对齐,年初根数不够时会向前一年接着取(3.4.1.9 及更早只在当年范围内取数,年初拿到的根数会少于 bar_count)。

3.4 MyTT 常用指标

khQuant 内置 MyTT 指标库,函数名和常见行情软件的公式写法一致,输入是 numpy 数组(或 pandas Series 的 .values),输出是同样长度的数组,取 [-1] 就是最新值。用法固定为两步:先用 khHistory 取数据,再交给 MyTT 计算。

stock = '000001.SZ'
hist = khHistory(stock, ['close', 'high', 'low'], 100, '1d',
                 current_time=khGet(data, 'date_num'))
if stock not in hist or len(hist[stock]) < 60:
    return []
C = hist[stock]['close'].values
H = hist[stock]['high'].values
L = hist[stock]['low'].values

DIF, DEA, MACD_BAR = MACD(C)            # 默认 12, 26, 9
if DIF[-1] > DEA[-1] and DIF[-2] <= DEA[-2]:
    logging.info(f"{stock} MACD 金叉")
K, D, J = KDJ(C, H, L)
UPPER, MID, LOWER = BOLL(C)
rsi14 = RSI(C, 14)[-1]                  # 想要 14 日 RSI 要显式写 14

常用指标的签名和默认参数(以源码 MyTT.py 为准):

函数默认参数返回
MA(S, N) / EMA(S, N)无默认,N 必填均线序列
SMA(S, N, M=1)M=1中国式 SMA
MACD(CLOSE, SHORT=12, LONG=26, M=9)12, 26, 9(DIF, DEA, MACD),MACD 柱 = (DIF−DEA)×2
KDJ(CLOSE, HIGH, LOW, N=9, M1=3, M2=3)9, 3, 3(K, D, J)
RSI(CLOSE, N=24)N=24RSI 序列
BOLL(CLOSE, N=20, P=2)20, 2(UPPER, MID, LOWER)
WR(CLOSE, HIGH, LOW, N=10, N1=6)10, 6(WR, WR1)
BIAS(CLOSE, L1=6, L2=12, L3=24)6, 12, 24(BIAS1, BIAS2, BIAS3)
CCI(CLOSE, HIGH, LOW, N=14)14CCI 序列
ATR(CLOSE, HIGH, LOW, N=20)20真实波幅 TR 的 N 日均值
TRIX(CLOSE, M1=12, M2=20)12, 20(TRIX, TRMA)
DMI(CLOSE, HIGH, LOW, M1=14, M2=6)14, 6多个序列(PDI、MDI、ADX、ADXR)
BBI(CLOSE, M1=3, M2=6, M3=12, M4=20)3, 6, 12, 20多空均线
OBV(CLOSE, VOL)—能量潮
SAR(HIGH, LOW, N=10, S=2, M=20)10, 2, 20抛物线转向

写条件时常用的基础函数:REF(S, N=1) 取 N 周期前的值;HHV(S, N) / LLV(S, N) 取 N 周期最高 / 最低;CROSS(S1, S2) 判断上穿;COUNT(S, N)、EVERY(S, N)、EXIST(S, N) 统计 N 周期内条件成立的次数、是否全部成立、是否有一次成立;BARSLAST(S) 上次条件成立到现在的周期数。其余 PSY、MTM、ROC、KTN、VR、EMV、MFI、CR、BRAR、ASI、DPO 等指标也都能直接调用,名字和参数见 MyTT.py。

注意

MyTT 的 MA、RSI 和 khQuant 的 khMA 是两回事:前者对你传入的数组计算,返回数组;后者自己去取历史数据,返回一个数。另外,取数的根数要明显多于指标周期,像 MACD 这类基于 EMA 的指标,数据太短时前面的值还没收敛,建议取 100 根以上。

3.5 缠论工具

六个函数都以一段 K 线 DataFrame 为输入,DataFrame 至少要有 high、low 列(判断趋势还要 close)。数据通常来自 khKline 或 khHistory。

函数用途与返回
is_bottom_fractal(kline_data, index=-2)以 index 处为中间 K 线,判断是否为底分型:中间一根的高点、低点都低于左右两根。返回 bool。
is_top_fractal(kline_data, index=-2)同上,判断顶分型:中间一根的高点、低点都高于左右两根。
find_all_fractals(kline_data)扫描整段数据,返回 (顶分型位置列表, 底分型位置列表)。
get_trend_direction(kline_data, period=20)综合均线方向、高低点和收盘位置判断趋势,返回 'up'、'down' 或 'sideways'。
get_fractal_price(kline_data, index, fractal_type)fractal_type 为 'top' 返回该根最高价,'bottom' 返回最低价。
check_fractal_break(kline_data, fractal_index, fractal_type, current_index=-1)后续 K 线是否突破该分型的极值:顶分型被向上突破、底分型被向下跌破时返回 True。
注意

分型需要中间 K 线左右各有一根。index 默认是 -2,也就是用最新的三根 K 线判断(中间那根是倒数第二根),默认即可;也可以传 index 判断更早的位置。3.4.1.9 及更早的版本默认是 -1,用默认值调用永远返回 False,旧版本请显式传 index=-2。

stock = '000001.SZ'
df = khKline(stock, '15m', 50).get(stock)
if df is not None and len(df) >= 20:
    if is_bottom_fractal(df, index=-2) and get_trend_direction(df) != 'down':
        stop_price = get_fractal_price(df, -2, 'bottom')
        logging.info(f"{stock} 15 分钟底分型确立,分型低点 {stop_price}")

注意上一小节说过的:khKline 的最后一根可能还没收线,用 index=-2 判断时,右边那根就是这根未收线的 K 线。如果要求分型完全确认,可以再往前错一根,或只在周期收线的时刻判断。

4 账户与持仓

4.1 账户资金

资金类数据直接用 khGet 取:khGet(data, 'cash') 可用资金、khGet(data, 'market_value') 持仓市值、khGet(data, 'total_asset') 总资产。在同一个回调里返回的信号还没执行,资金不会在你生成信号时立刻扣减,多只股票同时买入时要自己分配比例(见第 5 节)。

4.2 持仓字典

khGet(data, 'positions') 返回 {股票代码: 持仓信息},每只股票常用的字段:

字段含义
volume总持仓数量(股)
can_use_volume当前可卖数量(股)。T+1 下当天买入的部分不计入。
avg_price持仓均价
current_price最新价
market_value持仓市值
for code, pos in khGet(data, 'positions').items():
    cost = pos.get('avg_price', 0)
    price = khPrice(data, code)
    if cost > 0 and price > 0 and price / cost - 1 < -0.08:
        logging.info(f"{code} 浮亏超过 8%,可卖 {pos.get('can_use_volume', 0)} 股")

4.3 PositionParser:持仓解析器

不想手写字典访问时,可以用解析器:

pp = PositionParser(data)
pp.has('000001.SZ')         # 是否持有,等同 khHas
pp.get_volume('000001.SZ')  # 总持仓,未持有返回 0
pp.get_cost('000001.SZ')    # 持仓均价,未持有返回 0
pp.get_all()                # 全部持仓的副本

要取可卖数量,读持仓里的 can_use_volume:khGet(data, 'positions')[code].get('can_use_volume', 0)。

5 下单与仓位

5.1 信号字典

khHandlebar 返回一个信号列表,每个信号是一个字典。必填四个字段,其余可选:

字段必填说明
code是股票代码,带交易所后缀。
action是'buy' 或 'sell'。
price是委托价。市价单也要填,一般填当前价。
volume是委托数量,单位是股,不是手。A 股买入按 100 股整数倍填。数量小于等于 0 的信号会被忽略。
reason否交易原因,写进交易记录。
order_type否'market'、'limit'、'stop'、'stop_limit',见第 5.5 节。
stop_price否止损触发价,stop / stop_limit 必须填。
time_in_force否有效期,代码默认 'day'(当日有效)。
expire_date否配合 time_in_force='gtd',格式 "YYYY-MM-DD"。
max_pending_bars否挂单最多等待几根 K 线,超过即过期;0 或不填表示不限。
signals = [{
    "code": "000001.SZ",
    "action": "buy",
    "price": khPrice(data, "000001.SZ"),
    "volume": 1000,           # 1000 股,即 10 手
    "reason": "手写信号示例",
}]
return signals

5.2 generate_signal:按比例生成信号(推荐)

generate_signal(data: Dict, stock_code: str, price: float, ratio: float,
                action: str, reason: str = "") -> List[Dict]

用途:只给方向和比例,由它算好股数、拼好信号。

  • ratio,买入时:小于等于 1 表示用可用资金的比例(0.5 即一半资金);大于 1 表示买入的股数,必须是 100 的整数倍,否则返回空列表;股数超过资金能买的上限时,自动降到上限。
  • ratio,卖出时:可卖数量(can_use_volume)的比例,1.0 即全部可卖部分。结果向下取整到 100 股。
  • price:按价格精度四舍五入后写入信号。
  • 返回值:只含一个信号的列表;资金不足、没有持仓、算出来股数为 0 时返回空列表,并在日志里说明原因。
def khHandlebar(data: Dict) -> List[Dict]:
    signals = []
    stock = '000001.SZ'
    price = khPrice(data, stock)
    if price <= 0:
        return signals
    if not khHas(data, stock):
        signals.extend(generate_signal(data, stock, price, 0.5, 'buy', "用一半资金买入"))
    else:
        signals.extend(generate_signal(data, stock, price, 1.0, 'sell', "全部卖出"))
    return signals
注意

generate_signal 返回的是列表,要用 signals.extend(...) 合并,不要用 append,否则信号列表里会嵌一层列表。

同时买多只股票时,ratio 每次都是按「当前可用资金」算的,而同一轮里前一个信号还没成交、不会扣资金。所以想三只股票各用三分之一资金,每只都传 1/3,不要依次传 1/3、1/2、1。

5.3 calculate_max_buy_volume:最多能买多少股

calculate_max_buy_volume(data: Dict, stock_code: str, price: float,
                         cash_ratio: float = 1.0) -> int

用途:按可用资金乘以 cash_ratio,在扣除佣金、印花税、过户费和滑点之后,算出最多能买的股数,结果是 100 的整数倍。不足 100 股或价格无效时返回 0。generate_signal 买入时内部就是调用它。

price = khPrice(data, '000001.SZ')
vol = calculate_max_buy_volume(data, '000001.SZ', price, cash_ratio=0.3)
if vol > 0:
    signals.append({"code": "000001.SZ", "action": "buy",
                    "price": price, "volume": vol, "reason": "30% 资金"})

手写信号时用 append 加单个字典是对的;只有 generate_signal 这种返回列表的函数才用 extend。

5.4 价格精度

股票报价两位小数,ETF、LOF 等场内基金三位小数。自己算出来的价格(比如「现价下浮 1% 挂单」)要先取整再填进信号:

round_price(price: float, decimals: int = None, data: Dict = None, stock_code: str = None) -> float
get_price_decimals(data: Dict = None, stock_code: str = None) -> int

decimals 不传时按 stock_code 判断:场内基金 3 位、普通 A 股代码 2 位;也不传 stock_code 时,用框架按股票池设置的精度(纯股票池 2 位,含基金 3 位)。

code = '510300.SH'
limit_price = round_price(khPrice(data, code) * 0.99, data=data, stock_code=code)

框架在处理信号时也会按精度再取整一次,所以就算忘了取整也不会报错,但提前取整能让日志里看到的价格和实际委托价一致。

5.5 订单类型与有效期

撮合引擎在 V2 和 V3 中都始终开启,信号会按 order_type 撮合:

order_type撮合方式未成交时
'market'(khHandlebar 信号不填时的默认值)当根直接成交,成交价为信号价(加滑点);只有买入价高于当根最高价、或卖出价低于当根最低价时,才改按当前价成交(K 线取开盘价,tick 取最新价)。买入价低于最低价不修正。—
'limit'买入:当根最低价 ≤ 限价即成交,开盘价已低于限价时按开盘价成交,否则按限价。卖出反之。委托价超出当日涨跌停范围直接拒单。进入挂单队列,后续 K 线继续撮合
'stop'买入止损:当根最高价 ≥ stop_price 时触发;卖出止损:最低价 ≤ stop_price 时触发。触发后按止损价成交;跳空越过止损价时按开盘价,再限制在当根最高、最低价之间。未触发则挂单等待
'stop_limit'触发条件同 stop,触发后转为以 price 为限价的限价单。未触发或限价不满足时挂单

所有成交都计入滑点和交易费用;限价单加滑点后的成交价不会超过限价。一字涨停买不进、一字跌停卖不出。如果在软件设置里开启了成交量限制,单根成交量不够时会部分成交,剩余数量继续挂单。

有效期由 time_in_force 决定:

写法含义
'day'(默认)当日有效,当天收盘后仍未成交的挂单自动过期。
'gtd' + expire_date有效到 expire_date 当天收盘,之后过期。expire_date 写 "YYYY-MM-DD"。
max_pending_bars=N与上面两种可同时使用:挂单超过 N 根 K 线仍未成交就过期。
# 现价下方 2% 挂一张限价买单,最多等到 3 天后
code = '000001.SZ'
price = khPrice(data, code)
limit_price = round_price(price * 0.98, data=data, stock_code=code)
expire = (khGet(data, 'datetime_obj') + timedelta(days=3)).strftime('%Y-%m-%d')
signals.append({
    "code": code, "action": "buy", "price": limit_price, "volume": 500,
    "order_type": "limit", "time_in_force": "gtd", "expire_date": expire,
    "reason": "回调 2% 挂单",
})

# 持仓设一个止损:跌到成本价的 92% 时卖出
pos = khGet(data, 'positions').get(code)
if pos and pos.get('can_use_volume', 0) > 0:
    stop = round_price(pos['avg_price'] * 0.92, data=data, stock_code=code)
    signals.append({
        "code": code, "action": "sell", "price": stop, "stop_price": stop,
        "volume": pos['can_use_volume'], "order_type": "stop",
        "reason": "止损",
    })

上面的止损单默认当日有效,每天都要重新下;也可以用 gtd 把有效期拉长,但要注意不要每天重复下单。下一节讲怎么先查挂单再决定是否下单。

提示

盘前回调 khPreMarket 返回的信号,没写 order_type 时默认转为限价单、当日有效;盘后回调 khPostMarket 返回的信号默认转为限价单、有效到下一个交易日收盘。盘前盘后回调需要在配置里开启,详见 1.8 触发方式与盘前盘后。

6 挂单管理:查询与撤单

限价单、止损单没成交时会留在挂单队列里。下面六个函数用来查看和撤销挂单,两版都有,参数里的 data 就是回调收到的 data。

函数返回用途
khGetPendingOrders(data, code=None)List[Dict]当前未成交挂单列表;传 code 只返回该股票的挂单。
khGetPendingSummary(data)Dict汇总:total_count、buy_count、sell_count、total_buy_volume、total_sell_volume(数量按剩余未成交股数算)。
khCancelOrder(data, order_id)bool撤销一笔挂单,成功返回 True;单号不存在或已不在挂单状态返回 False。
khCancelOrdersByCode(data, code)int撤销某只股票的全部挂单,返回撤销笔数。
khCancelAllOrders(data)int撤销全部挂单,返回撤销笔数。
khIsMatchEngineEnabled(data)bool撮合引擎是否启用。现在撮合引擎始终开启,回调里调用总是返回 True,保留它只是为了兼容旧代码。

khGetPendingOrders 返回的每笔挂单,常用字段有:order_id(单号,撤单时用)、code、action、price(委托价)、volume(委托数量)、remaining_volume(剩余未成交数量)、order_type、time_in_force、expire_date、create_date(下单日期)。返回的是副本,改它不会影响真实挂单。

def khHandlebar(data: Dict) -> List[Dict]:
    signals = []
    code = '000001.SZ'
    price = khPrice(data, code)
    if price <= 0:
        return signals

    pending = khGetPendingOrders(data, code)
    # 已有买单挂着,但价格已经比挂单价高出 3%:撤掉旧单,按新价重挂
    for order in pending:
        if order['action'] == 'buy' and price > order['price'] * 1.03:
            if khCancelOrder(data, order['order_id']):
                logging.info(f"撤单 {order['order_id']},原价 {order['price']}")
                pending = []

    if not pending and not khHas(data, code):
        signals.append({
            "code": code, "action": "buy", "volume": 500,
            "price": round_price(price * 0.99, data=data, stock_code=code),
            "order_type": "limit", "reason": "低 1% 挂单",
        })

    summary = khGetPendingSummary(data)
    if summary.get('total_count', 0) > 0:
        logging.info(f"当前挂单 {summary['total_count']} 笔")
    return signals
提示

撤单是在函数调用时立刻生效的,不需要返回什么信号。挂单是否成交,是在之后的 K 线上撮合的,所以同一根 K 线里刚返回的限价单,要到下一次回调才能在 khGetPendingOrders 里看到(若当根已成交则看不到)。

7 日内多次触发:khRequestNextDailyTrigger

khRequestNextDailyTrigger() -> bool

用途:日线策略默认每天只调用一次 khHandlebar。在 khHandlebar 里调用这个函数,可以申请当天处理完本轮信号后再调用一次。典型用法是先卖后买:第一轮卖出,卖出成交后资金回到账户,第二轮再用这笔资金买入。

前提:触发方式是日线(1d),并在界面的「日内触发上限」(配置里的 daily_trigger_cap)设为大于 1 的数。上限是每天最多调用 khHandlebar 的总次数,包含第一次。

返回 True 表示申请已登记。下面几种情况返回 False:

  • 触发方式不是日线(分钟、tick、自定义时间点都不支持);
  • 当天已调用次数达到「日内触发上限」;
  • 不在策略回调执行过程中调用(比如在模块顶层调用);
  • 框架内部出错。
注意

只有在 khHandlebar 里申请才会生效。在 init、khPreMarket、khPostMarket 里调用,即使返回 True,也会在下一次进入 khHandlebar 前被清掉,不会多触发。

再次触发时,行情数据和上一轮完全一样(同一天、同一根日线),但 __account__、__positions__ 已经反映了上一轮的成交结果。策略要自己用全局变量记住「今天是第几轮」:

STATE = {"date": None, "round": 0}

def init(stock_codes, init_data):
    STATE["date"], STATE["round"] = None, 0

def khHandlebar(data: Dict) -> List[Dict]:
    signals = []
    today = khGet(data, 'date')
    if STATE["date"] != today:
        STATE["date"], STATE["round"] = today, 1
    else:
        STATE["round"] += 1

    if STATE["round"] == 1:
        # 第 1 轮:只做卖出
        for code in khGet(data, 'positions'):
            price = khPrice(data, code)
            if price > 0 and should_sell(code, data):   # should_sell 为你自己的卖出条件
                signals.extend(generate_signal(data, code, price, 1.0, 'sell', "第 1 轮卖出"))
        if khRequestNextDailyTrigger():
            logging.info(f"{today} 已申请第 2 轮")
    elif STATE["round"] == 2:
        # 第 2 轮:用释放出来的资金买入
        logging.info(f"{today} 第 2 轮,可用资金 {khGet(data, 'cash'):.2f}")
        # ……买入逻辑……
    return signals

完整可运行的案例见 3.5 V3 内置策略一览 · 日内多次触发。

8 自定义指标:khDuckWrite、khDuckDB、khAddExtraFields、khIndex

回测几百只股票、好几年时,在 khHandlebar 里每根 K 线都取一遍历史数据再算指标,会很慢。更快的做法是把指标提前算好、写进本地行情库,回测时框架和行情一起一次性加载进内存,策略里直接读。分三步:

  1. 预先写库:在一个独立脚本里(不是策略文件),用 khDuckDB 读行情、算指标,再用 khDuckWrite 写回库里。
  2. 注册字段:策略的 init 里用 khAddExtraFields 告诉框架要多加载哪几列。
  3. 读取:khHandlebar 里用 khIndex 读当前 K 线上的指标值。

这四个函数 V2 和 V3 都有。

8.1 khDuckDB:直接读本地行情库

khDuckDB(stock_list, period, fields=None, start_time=None, end_time=None,
         dividend_type='none', duckdb_path=None, return_format='dict')
  • period:'1d'、'1m'、'5m'、'tick'。
  • fields:要的列,None 返回全部列(包括你写进去的自定义列)。
  • start_time / end_time:'YYYYMMDD' 或 'YYYYMMDDHHmmss'。只写日期的 end_time 会取到当天 23:59:59,包含当天。
  • dividend_type:'none'(默认)、'front'、'back'、'front_ratio'、'back_ratio'。注意默认是不复权,和 khHistory 默认跟随界面不同。
  • duckdb_path:数据目录,不传时用当前回测的数据目录,再退到软件设置里的数据目录。
  • 返回值:{股票代码: DataFrame},读不到的股票对应空 DataFrame。
res = khDuckDB(['000001.SZ'], '1d', fields=['time', 'close'],
               start_time='20230101', end_time='20231231')
df = res['000001.SZ']
注意

khDuckDB 不认回测时钟,你给什么时间范围就返回什么,end_time 还包含当天。在策略里用它取数,很容易读到当前 K 线之后的数据。策略里取历史 K 线请用 khHistory;khDuckDB 主要用在写库脚本里。

8.2 khDuckWrite:把指标写进库

khDuckWrite(stock_list, period, data, fields=None, time_col="time",
            duckdb_path=None, field_types=None, insert_missing=False,
            update_time=True, allow_builtin_fields=False)
  • data:{股票代码: DataFrame},或单只股票时直接传 DataFrame。DataFrame 必须有时间列(默认叫 time)。
  • fields:要写入的列名,None 时写入除时间列外的全部列。库里没有的列会自动新增。
  • 日线按交易日匹配,时间列写纯日期即可。
  • insert_missing:默认 False,只更新库里已有时间的行;时间对不上的行不会写入,并在日志里提示多少行没对上。
  • allow_builtin_fields:默认 False,不允许改写 open、close、volume 等内置行情列和复权列。自定义指标请换个名字。
  • 列名不区分大小写,dif 和 DIF 是同一列。
  • 返回值:{股票代码: {'updated': 更新行数, 'inserted': 插入行数, 'rows': 输入行数, 'fields': 写入的列}}。
# 独立脚本:给股票池算 MACD 并写库(运行前关掉占用数据目录的回测和下载任务)
from khQTTools import khDuckDB, khDuckWrite
import pandas as pd

for code in ['000001.SZ', '600000.SH']:
    df = khDuckDB([code], '1d', fields=['time', 'close'])[code]
    if df.empty:
        continue
    fast = df['close'].ewm(span=12, adjust=False).mean()
    slow = df['close'].ewm(span=26, adjust=False).mean()
    df['DIF'] = fast - slow
    df['DEA'] = df['DIF'].ewm(span=9, adjust=False).mean()
    df['MACD'] = (df['DIF'] - df['DEA']) * 2
    print(khDuckWrite([code], '1d', df[['time', 'DIF', 'DEA', 'MACD']],
                      fields=['DIF', 'DEA', 'MACD']))

8.3 khAddExtraFields:在 init 里注册字段

khAddExtraFields(context: Dict, fields: List[str]) -> None

用途:告诉框架回测加载行情时,把这些列也一起读进来。context 传 init 收到的 data。只能在 init 里调用,到了 khHandlebar 再调用就晚了。

它不会计算指标,只负责从库里读出已有的列。列名要和写库时完全一致。库里没有这一列的股票,整只股票的行情都读不出来(日志里报一条错误「查询失败 … Referenced column “DIF” not found」):这只股票在回测里等于没有数据,khIndex 读到 0,也取不到价格、不会交易;其他股票照常回测。所有股票都缺这一列时,回测报「没有找到任何有效的时间点」并停止。

8.4 khIndex:读当前 K 线上的指标值

khIndex(data: Dict, stock_code: str, field: str) -> float

用途:读当前 K 线上某个自定义指标的值。底层和 khPrice 相同,只是语义上区分「行情」和「指标」。field 没有默认值,必须写。取不到时返回 0.0。

PREV = {}

def init(stock_codes, init_data):
    khAddExtraFields(init_data, ["DIF", "DEA", "MACD"])

def khHandlebar(data: Dict) -> List[Dict]:
    signals = []
    for code in khGet(data, 'stocks'):
        price = khPrice(data, code)
        if price <= 0:
            continue
        dif, dea = khIndex(data, code, "DIF"), khIndex(data, code, "DEA")
        last = PREV.get(code)
        PREV[code] = (dif, dea)
        if last is None:
            continue
        if last[0] < last[1] and dif > dea and not khHas(data, code):
            signals.extend(generate_signal(data, code, price, 0.1, 'buy', "DIF 上穿 DEA"))
    return signals
注意

写进哪一天的值,khIndex 就在哪一天读到。上面的 MACD 用了当天收盘价,所以当天读到的指标已经包含当天收盘。这和用 khPrice 取当天收盘价的时点是一致的,但如果你的指标用到了当天之后的数据(比如「未来 5 日收益」),写进去再读就成了未来函数。

写库脚本、注册和读取的完整案例,见 3.5 V3 内置策略一览 · 自定义指标。

9 日志

策略里直接用 Python 标准库 logging(已随 khQuantImport 导入),输出会显示在界面的日志区,也会写进本次回测的日志文件。

logging.info("普通信息")
logging.warning("警告:数据不足,跳过")
logging.error("出错了")
logging.info(f"[TRADE] {code} 满足买入条件,价格 {price}")   # 带 [TRADE] 的消息在界面里高亮

消息里带 [TRADE] 标签时,界面会把它当作交易类日志高亮显示(标签本身不显示),适合标出关键决策点。日志在界面上怎么筛选、在哪里找日志文件,见 1.9 节。

两点建议:只在关键决策处写 info,循环里每根 K 线都打日志会拖慢回测;用 try / except 包住单只股票的处理,在 except 里 logging.error,一只股票出错不至于让整个回测停下。

10 把工具串起来:一个多股票模板

下面这个模板把前面几节最常用的函数串在一起:遍历股票池,一次 khHistory 取够数据,用 MyTT 算两条均线,先处理卖出、再处理买入,买入资金按股票数平均分配,还挂着的单先撤掉。它不是一个推荐的交易策略,只是演示各个工具在一个 khHandlebar 里怎么配合。

from khQuantImport import *

SHORT, LONG = 5, 20          # 演示参数

def init(stock_codes, init_data):
    logging.info(f"股票池 {len(stock_codes)} 只,均线 {SHORT}/{LONG}")

def khHandlebar(data: Dict) -> List[Dict]:
    signals = []
    stocks = khGet(data, 'stocks')
    if not stocks:
        return signals

    # 1. 一次取够所有股票的历史数据(不含当天)
    hist = khHistory(stocks, ['close'], LONG + 5, '1d',
                     current_time=khGet(data, 'date_num'))

    # 2. 还挂着没成交的单(如 gtd 单、盘后单)先撤掉,避免重复下单
    if khGetPendingSummary(data).get('total_count', 0) > 0:
        n = khCancelAllOrders(data)
        logging.info(f"撤销未成交挂单 {n} 笔")

    buy_list = []
    for code in stocks:
        try:
            price = khPrice(data, code)
            df = hist.get(code)
            if price <= 0 or df is None or len(df) < LONG + 1:
                continue
            closes = df['close'].values
            ma_s, ma_l = MA(closes, SHORT), MA(closes, LONG)
            golden = ma_s[-2] <= ma_l[-2] and ma_s[-1] > ma_l[-1]
            dead = ma_s[-2] >= ma_l[-2] and ma_s[-1] < ma_l[-1]

            # 3. 先卖:死叉且有持仓
            if dead and khHas(data, code):
                signals.extend(generate_signal(data, code, price, 1.0, 'sell',
                                               f"MA{SHORT} 下穿 MA{LONG}"))
            # 记下要买的,稍后统一分配资金
            elif golden and not khHas(data, code):
                buy_list.append((code, price))
        except Exception as e:
            logging.error(f"{code} 处理出错: {e}")

    # 4. 再买:每只股票用同样比例的当前可用资金
    if buy_list:
        ratio = min(1.0 / len(buy_list), 0.3)     # 单只最多 30%
        for code, price in buy_list:
            sig = generate_signal(data, code, price, ratio, 'buy',
                                  f"MA{SHORT} 上穿 MA{LONG}")
            if sig:
                logging.info(f"[TRADE] 买入 {code} {sig[0]['volume']} 股")
            signals.extend(sig)
    return signals

几个值得注意的写法:历史数据在循环外一次性取,避免每只股票各取一次;均线用 [-1] 和 [-2] 判断交叉,二者都来自截至昨天的数据;卖出信号和买入信号在同一轮返回时,卖出的资金在这一轮还没回到账户,所以买入比例只按当前可用资金算。如果想让卖出的钱当天就能用来买入,就要用第 7 节的日内多次触发,把买入放到第二轮。

想把这样的思路交给 AI 去写、去回测,可以看 3.4 使用 AI 编写和回测验证策略;想看更多现成写法,可以看 3.5 V3 内置策略一览。

11 冷门函数速查表

函数 / 类签名要点用途
get_trade_days_set(start_date, end_date) -> set区间内交易日集合,元素为 "YYYYMMDD"。
is_trade_time() -> bool电脑当前时钟是否在交易时段。回测中不要用,见第 2 节。
is_etf(stock_code) -> bool是否 ETF(不含 LOF)。
is_t0_etf(stock_code) -> bool是否在 T+0 ETF 清单里。股票池全是 T+0 品种时框架自动按 T+0 回测。
format_price(price, decimals=None, data=None) -> str按精度把价格格式化成字符串,用于日志。
get_stock_name(stock_code) -> str查股票名称,查不到返回空字符串,首次调用会加载名称缓存。
get_stock_display(stock_code) -> str返回 "000001.SZ (平安银行)" 这种显示用字符串。
get_stock_names(stock_codes, stock_list_file) -> dict从指定 CSV 文件查一批名称。需要自己给文件路径,一般用 get_stock_name 更方便。
normalize_stock_code(stock_code) -> str"sh.600000" 转成 "600000.SH";不带后缀的纯数字代码原样返回。
denormalize_stock_code(stock_code, to_format='miniQMT') -> str反向转换,"600000.SH" 转成 "sh.600000"。
clear_khHistory_cache() -> None清空 khHistory 缓存。连续跑多次回测、怀疑数据被缓存影响时,在 init 里调用。
clear_khDuckDB_cache() -> None清空 khDuckDB 缓存。
TimeInfo(data)属性 .date_str、.date_num、.time_str、.datetime_str、.datetime_num、.datetime_obj、.timestamp时间解析器。.datetime_num 是 "20240603093000" 这种连写格式,khGet 没有对应别名。
StockDataParser(data).get_price(code, field)、.get_close、.get_open、.get_high、.get_low、.get_volume行情解析器,效果同 khPrice。
StockPoolParser(data).get_all()、.size()、.contains(code)、.first()股票池解析器。
StrategyContext(data) / parse_context(data).time、.stocks、.positions、.pool;.buy_signal(code, ratio=1.0, reason="")、.sell_signal(...)把上面四个解析器打包在一起。buy_signal / sell_signal 用当前价调用 generate_signal,返回单个字典,失败时返回空字典 {},加入信号列表前要判断非空。它们的 volume 参数目前不起作用,按股数买请用 ratio 传股数。
get_default_risk_params() -> dict返回一组示例风控参数(最大仓位 1.0、单只 0.3、止损 0.1、止盈 0.2),框架不会自动执行,只是给你自己写风控时参考。
KhQuTools类旧版工具类,保留兼容,新代码直接用模块级函数。

常见问题

问:khPrice 一直返回 0 是怎么回事?
答:0 表示没取到数据。常见原因:代码没写交易所后缀(要写 000001.SZ,不是 000001);这只股票不在股票池里,data 里没有它的行情;当天停牌或数据缺失;字段名写错。先 logging.info(khGet(data, 'stocks')) 确认股票池,再检查数据是否下载。

问:khHistory 返回空字典或者行数不够?
答:先看日志里的错误或「历史数据不足」提示。最常见的是本地没有这段时间的数据(回测开始日期往前还需要 bar_count 根,数据要多下载一段),其次是 fre_step 和下载的数据周期不一致。数据下载见 1.4 数据管理模块。

问:同一根日线,khHistory 算出来的均线和 khKline 算出来的不一样?
答:两者截止规则相反。khHistory 不含当天,khKline 含当天。用 khKline 时去掉最后一根再算,就和 khHistory 对得上。

问:generate_signal 卖出时为什么少卖了几十股?
答:卖出数量按「可卖数量 × ratio」向下取整到 100 股。如果持仓里有不足 100 股的零股(比如送股后),全部卖出时这部分会被舍掉。要清掉零股,可以手写信号,把 volume 填成 can_use_volume。

问:为什么今天买的股票,generate_signal 卖出返回空列表?
答:A 股是 T+1,当天买入的股票不计入可卖数量,generate_signal 卖出看的是可卖数量。只有股票池全部是 T+0 品种时,框架才按 T+0 回测。

问:限价单一直没成交,也没看到报错?
答:没满足成交条件的限价单会进挂单队列,默认当天收盘后过期,不算错误。用 khGetPendingOrders(data) 看它还在不在,日志里也会有「已加入挂单队列」「挂单过期」的记录。另外,委托价超出当日涨跌停范围会直接拒单,日志里有拒单原因。

问:khRequestNextDailyTrigger 一直返回 False?
答:依次检查:触发方式是不是日线;「日内触发上限」是不是大于 1;当天是不是已经用满次数;是不是在 khHandlebar 里调用的。

问:khIndex 读出来全是 0?
答:多半是库里没有这一列(这时这只股票整只没有行情,日志里有「Referenced column … not found」),或者列名和 khAddExtraFields 里写的不一致,或者 khAddExtraFields 没写在 init 里。先用 khDuckDB 查一下这一列在不在。

问:V2 能用 khIndex、khAddExtraFields、挂单管理和日内多次触发吗?
答:能。V2 和 V3 的策略接口完全相同,这些用法也相同。V3 多出来的是命令行、Web 工作台、更多数据源这些软件功能,不在策略接口上。

问:能不能让 AI 帮我查这些函数怎么用?
答:可以。khQuant 的 Skill 里带有完整的接口说明,AI 写策略时会按它来调用这些函数,见 3.3 khQuant 的 Skill 介绍 和 3.4 使用 AI 编写和回测验证策略。V3 用户也可以在命令行模式里让 AI 直接写策略、跑回测。

本篇的代码片段只为演示函数写法,其中的标的、参数和条件都不构成投资建议;据此跑出的回测结果不代表未来收益。

本篇基于 V3.4.1.10 和开源 V2 编写。

×
没有账号?注册  忘记密码?

风险提示

投资有风险,开户需谨慎。本系统仅为投资者提供量化交易相关的数据处理与分析工具,不构成任何投资建议。 请您在审慎思考后作出选择。特别声明:本系统对您与券商之间的交易、合作不承担任何法律责任。 市场有风险,投资需谨慎。

© 2024 khQuant看海量化回测平台 版权所有

官网:www.khsci.com/khQuant