写策略时,大部分代码都在重复几件事:取当前时间和价格、查持仓、拿一段历史 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=24 | RSI 序列 |
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) | 14 | CCI 序列 |
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 线都取一遍历史数据再算指标,会很慢。更快的做法是把指标提前算好、写进本地行情库,回测时框架和行情一起一次性加载进内存,策略里直接读。分三步:
- 预先写库:在一个独立脚本里(不是策略文件),用
khDuckDB读行情、算指标,再用khDuckWrite写回库里。 - 注册字段:策略的
init里用khAddExtraFields告诉框架要多加载哪几列。 - 读取:
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 编写。