量化数据进阶:多维实战篇(第 11 篇):历史涨跌停价:涨跌停序列与止损线测算
一、前言
上一篇《板块轮动:行业资金流向与龙头股捕捉》我们把视角从「个股」抬到了「行业」,用资金流与龙头股回答了「哪条资金链在动」。但还有一类问题始终悬着:如果我看错了,最多会亏到哪里?
资金流告诉你往哪走,止损线告诉你走错了往哪退。前者关乎收益,后者关乎生存。而 A 股市场里,一条最硬、最不需要预测的天然防线,就是跌停价------它是交易所制度画出来的、当天价格允许下落的最低边界。
本篇进入「模块五·风控与止损」,用两个接口把这条边界量化出来:
hsstock/stopprice/history:历史涨跌停价,按日期区间返回每日的涨停价 h 与跌停价 l;hsstock/real/time:个股实时行情,用来补上「实际价格」------最新价p、昨收yc、振幅zf。
二者结合,我们自研「涨跌停绝对价差」「涨跌停幅度反推」「板块限制类型识别」「剩余下跌空间」「分层止损线」「波动率自适应止损」「波动利用率」七组指标,把「今天最多能跌到哪」翻译成「我该在哪条线上动手」。
衔接说明:本篇是「模块五·风控与止损」的开篇,也是本系列倒数第二篇。上一篇解决了「资金往哪走」,本篇解决「走错了怎么退」,下一篇(第 12 篇)将做全系列收官,把 11 篇的接口与因子串成一条生产级流水线。
郑重说明:本文所有幅度反推、板块识别、价差测算、剩余空间、三档止损、波动自适应逻辑全部是自研因子,不是必盈接口提供的功能 。
stopprice/history只返回t / h / l三个裸字段,所有衍生结论均由本文代码在本地算出。
二、接口字段解读
2.1 历史涨跌停价 hsstock/stopprice/history
http://api.biyingapi.com/hsstock/stopprice/history/{代码.市场}/{LICENCE}?st={开始日期}&et={结束日期}
路径 / 查询参数:
| 参数 | 说明 |
|---|---|
| 代码.市场 | 带市场后缀的标的代码,如 600519.SH、000001.SZ、300750.SZ |
| LICENCE | 授权码 |
| st | 开始日期,格式 YYYY-MM-DD |
| et | 结束日期,格式 YYYY-MM-DD |
返回字段(接口只暴露这三个):
| 字段 | 类型 | 含义 |
|---|---|---|
| t | string | 交易日期 |
| h | number | 当日涨停价(元) |
| l | number | 当日跌停价(元) |
本篇最重要的技术前提 :该接口不返回收盘价、不返回昨收、不返回开盘价 。
h/l是「当日被允许的价格上界与下界」,它们由前一日收盘价乘以限制比例得出,但接口不会把这个前收价还给你。因此任何涉及「幅度」的计算,都必须引入第二个接口。
2.2 个股实时行情 hsstock/real/time
http://api.biyingapi.com/hsstock/real/time/{代码.市场}/{LICENCE}
| 字段 | 类型 | 含义 |
|---|---|---|
| p | number | 最新价(元) |
| o | number | 开盘价(元) |
| h | number | 最高价(元) |
| l | number | 最低价(元) |
| yc | number | 前收盘价(元) |
| v | number | 成交量 |
| tv | number | 成交量 |
| pv | number | 原始成交总量 |
| cje | number | 成交额(元) |
| pc | float | 涨跌幅(%) |
| ud | float | 涨跌额(元) |
| zf | float | 振幅(%) |
| tr | number | 换手率(%) |
| pe | number | 市盈率 |
| pb_ratio | number | 市净率 |
| t | string | 更新时间 |
字段同名陷阱(务必注意) :两个接口都有
h和l,但含义完全不同------
stopprice/history里:h= 涨停价 ,l= 跌停价;real/time里:h= 当日最高价 ,l= 当日最低价。一旦把两个接口的返回值塞进同一个字典或同一张宽表,字段会被静默覆盖,算出完全错误的结论。本文代码用独立的
up_limit / low_limit变量承接涨跌停价,用p_now / yc_now承接实时价,永不混用。
三、自研衍生指标 / 业务逻辑
接口只给裸字段,以下全部用 Python 手写:
- 涨跌停价序列(自研) :以
t为轴,把h/l拉成时间序列。由于涨跌停价 = 前收盘 × (1 ± 限制比例),而前收盘逐日变动,所以h/l会随行情逐日漂移 ;观察h.diff()即可看出这条边界的移动节奏。 - 涨跌停绝对价差(自研) :
spread = h - l,即当日价格被允许波动的绝对区间宽度 (元)。价格越高、限制比例越大,spread越宽------它直接决定了持仓的单日最大潜在损失金额。 - 隐含昨收反解(自研) :对涨跌停幅度对称的标的,
h = yc × (1 + a)、l = yc × (1 - a),两式相加得yc = (h + l) / 2。这是只靠 t/h/l 三个字段就能还原昨收的技巧,用于给历史序列补上基准。 - 涨跌停幅度反推(自研) :
limit_up_pct = (h / yc - 1) × 100,limit_down_pct = (1 - l / yc) × 100。这里必须用real/time的yc(昨收),因为当日涨跌停价正是以它为基准计算的。 - 板块涨跌幅限制类型识别(自研) :取涨跌停幅度的均值,按数值区间加容差 归类------5% 对应 ST 股、10% 对应主板、20% 对应创业板 / 科创板、30% 对应北交所,落在区间外则标记为「非标准」。这一步让我们不依赖任何外部股票列表,纯用价格数据反推标的的交易制度属性。
- 剩余下跌空间(自研,风控核心) :
room_down_pct = (p - l) / p × 100,即当前价距当日跌停价还有百分之多少的缓冲。它把「绝对价差」折算成「以现价为基准的百分比」,可直接跨标的比较。 - 分层止损线(自研) :以跌停价为极端防线,按剩余空间设三档------
stop = l + (p - l) × ratio,ratio 取 50% / 25% / 10% 分别对应预警线 / 减仓线 / 清仓线。跌破预警线提示关注,跌破减仓线主动降仓,跌破清仓线不留余地。 - 日波动基准与波动率自适应止损(自研) :
日波动基准 = 近期 (h - l) / yc 的均值(回看窗口可配置),止损幅度 = k × 日波动基准,自适应止损价 = p × (1 - 止损幅度 / 100)。波动大的标的自动放宽止损,避免在噪音里被扫出局。 - 波动利用率(自研) :
vol_util = zf / 日波动基准,用real/time的当日振幅除以制度允许的波动上限,衡量「今天实际用掉了多少额度」。利用率长期接近 1,说明标的已在制度边界上反复试探。 - 最终止损价取严(自研) :在「自适应止损价」与「清仓线」之间取更高者作为最终执行线------两者谁更严格就听谁的。
四、数据表设计
sql
CREATE TABLE IF NOT EXISTS stop_price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT,
t TEXT,
h REAL, -- 当日涨停价(接口原字段)
l REAL, -- 当日跌停价(接口原字段)
collected_at TEXT,
UNIQUE(code, t)
);
CREATE TABLE IF NOT EXISTS risk_stop_line (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT,
t TEXT,
up_limit REAL, -- 当日涨停价
low_limit REAL, -- 当日跌停价
up_limit_pct REAL, -- 自研:涨停幅度(%)
down_limit_pct REAL, -- 自研:跌停幅度(%)
limit_type TEXT, -- 自研:板块涨跌幅限制类型
room_down_pct REAL, -- 自研:剩余下跌空间(%)
stop_warn REAL, -- 自研:预警线(剩余空间 50%)
stop_reduce REAL, -- 自研:减仓线(剩余空间 25%)
stop_clear REAL, -- 自研:清仓线(剩余空间 10%)
vol_base_pct REAL, -- 自研:日波动基准(%)
vol_stop_pct REAL, -- 自研:自适应止损幅度(%)
vol_stop_price REAL, -- 自研:自适应止损价
vol_util REAL, -- 自研:波动利用率 = zf / 日波动基准
final_stop REAL, -- 自研:最终止损价(取严)
collected_at TEXT,
UNIQUE(code, t)
);
原样落库接口字段(
h/l),自研结果单独进risk_stop_line,保证「原始数据」与「衍生结论」可回溯、可对账。
五、完整可运行代码
python
import requests
import logging
import time
import sqlite3
import pandas as pd
import numpy as np
# ========== 全局配置 ==========
LICENCE = "你的licence"
DB_PATH = "quant.db"
LOG_FILE = "quant_collect.log"
API_BASE = "http://api.biyingapi.com"
# ========== 自研参数(可配置常量) ==========
LIMIT_TOL = 0.30 # 涨跌停幅度归类容差(百分点)
WARN_RATIO = 0.50 # 预警线:保留剩余下跌空间的 50%
REDUCE_RATIO = 0.25 # 减仓线:保留剩余下跌空间的 25%
CLEAR_RATIO = 0.10 # 清仓线:保留剩余下跌空间的 10%
VOL_K = 0.80 # 波动率自适应止损系数 k
VOL_WINDOW = 20 # 日波动基准回看窗口(交易日)
# ---------- 日志初始化 ----------
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
filemode="a"
)
logger = logging.getLogger(__name__)
# ---------- 带重试 HTTP 请求(复用) ----------
def biying_api_get_retry(full_url, timeout=15, max_retry=3):
for attempt in range(1, max_retry + 1):
try:
resp = requests.get(full_url, timeout=timeout)
if resp.status_code == 200:
return resp.json()
logger.warning(f"HTTP状态码异常:{resp.status_code},第{attempt}次重试")
except Exception as e:
logger.warning(f"网络请求异常,第{attempt}次重试,错误信息:{str(e)}")
time.sleep(2)
logger.error("达到最大重试次数,接口请求失败")
return []
# ---------- 代码补市场后缀 ----------
def with_exchange_suffix(code):
code = str(code).strip().upper()
if "." in code:
return code
if code.startswith(("60", "68", "5", "9")):
return code + ".SH"
if code.startswith(("00", "30", "1", "2")):
return code + ".SZ"
if code.startswith(("8", "4")):
return code + ".BJ"
return code + ".SH"
# ---------- 历史涨跌停价 ----------
def fetch_stopprice_history(code, st, et):
dm = with_exchange_suffix(code)
url = f"{API_BASE}/hsstock/stopprice/history/{dm}/{LICENCE}?st={st}&et={et}"
data = biying_api_get_retry(url)
if not data:
logger.warning(f"{dm} 历史涨跌停价返回为空 st={st} et={et}")
return pd.DataFrame(columns=["t", "h", "l"])
if isinstance(data, dict):
data = [data]
df = pd.DataFrame(data)
for col in ["t", "h", "l"]:
if col not in df.columns:
df[col] = np.nan
df = df[["t", "h", "l"]].copy()
df["t"] = df["t"].astype(str).str.slice(0, 10)
df["h"] = pd.to_numeric(df["h"], errors="coerce")
df["l"] = pd.to_numeric(df["l"], errors="coerce")
df = df.dropna(subset=["t", "h", "l"])
df = df[(df["h"] > 0) & (df["l"] > 0)]
df = df.drop_duplicates(subset=["t"], keep="last")
df = df.sort_values("t").reset_index(drop=True)
return df
# ---------- 个股实时行情(补实际价格) ----------
def fetch_realtime(code):
dm = with_exchange_suffix(code)
url = f"{API_BASE}/hsstock/real/time/{dm}/{LICENCE}"
data = biying_api_get_retry(url)
if isinstance(data, list) and data:
return data[0] if isinstance(data[0], dict) else {}
if isinstance(data, dict):
return data
logger.warning(f"{dm} 实时行情返回异常")
return {}
# ---------- 自研 1:涨跌停序列、绝对价差、隐含昨收 ----------
def build_stop_frame(df):
if df.empty:
return df
df = df.copy()
df["spread"] = (df["h"] - df["l"]).round(3)
df["implied_yc"] = ((df["h"] + df["l"]) / 2).round(3)
df["h_chg"] = df["h"].diff().round(3)
df["spread_chg"] = df["spread"].diff().round(3)
df["range_pct"] = ((df["h"] - df["l"]) / df["implied_yc"] * 100).round(3)
return df
# ---------- 自研 2:涨跌停幅度反推(必须用 real/time 的 yc) ----------
def calc_limit_pct(h_val, l_val, yc_val):
if yc_val is None or pd.isna(yc_val) or float(yc_val) <= 0:
return None, None
if h_val is None or l_val is None or pd.isna(h_val) or pd.isna(l_val):
return None, None
up_pct = (float(h_val) / float(yc_val) - 1) * 100
down_pct = (1 - float(l_val) / float(yc_val)) * 100
return round(up_pct, 3), round(down_pct, 3)
# ---------- 自研 3:板块涨跌幅限制类型识别 ----------
def classify_limit_type(up_pct, down_pct):
if up_pct is None or down_pct is None:
return "未知"
avg = (up_pct + down_pct) / 2
if abs(avg - 5) <= LIMIT_TOL:
return "5%限制(ST股)"
if abs(avg - 10) <= LIMIT_TOL:
return "10%限制(主板)"
if abs(avg - 20) <= LIMIT_TOL:
return "20%限制(创业板/科创板)"
if abs(avg - 30) <= LIMIT_TOL:
return "30%限制(北交所)"
return f"非标准({round(avg, 2)}%)"
# ---------- 自研 4:剩余下跌空间 + 分层止损线 ----------
def calc_room_and_stops(p_now, low_limit):
if p_now is None or low_limit is None:
return {}
if pd.isna(p_now) or pd.isna(low_limit):
return {}
p_now = float(p_now)
low_limit = float(low_limit)
if p_now <= 0:
return {}
room_pct = (p_now - low_limit) / p_now * 100
return {
"room_down_pct": round(room_pct, 3),
"stop_warn": round(low_limit + (p_now - low_limit) * WARN_RATIO, 3),
"stop_reduce": round(low_limit + (p_now - low_limit) * REDUCE_RATIO, 3),
"stop_clear": round(low_limit + (p_now - low_limit) * CLEAR_RATIO, 3),
}
# ---------- 自研 5:日波动基准与波动率自适应止损 ----------
def calc_vol_base(df, window=VOL_WINDOW):
if df.empty or "range_pct" not in df.columns:
return None
series = df["range_pct"].dropna()
if series.empty:
return None
return round(float(series.tail(window).mean()), 3)
def calc_vol_stop(p_now, vol_base):
if p_now is None or vol_base is None:
return None, None
if pd.isna(p_now) or pd.isna(vol_base) or float(p_now) <= 0:
return None, None
stop_pct = round(VOL_K * float(vol_base), 3)
stop_price = round(float(p_now) * (1 - stop_pct / 100), 3)
return stop_pct, stop_price
# ---------- 落库:历史涨跌停价 ----------
def save_stop_history(code, df):
if df.empty:
return
conn = sqlite3.connect(DB_PATH)
try:
out = pd.DataFrame({
"code": with_exchange_suffix(code),
"t": df["t"].values,
"h": df["h"].values,
"l": df["l"].values,
"collected_at": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S"),
})
out.to_sql("stop_price_history", conn, if_exists="append", index=False)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uk_sph ON stop_price_history(code, t)"
)
conn.commit()
except Exception as e:
logger.error(f"落库历史涨跌停价失败:{str(e)}")
finally:
conn.close()
# ---------- 落库:风控止损测算 ----------
def save_risk_stop_line(row):
if not row:
return
conn = sqlite3.connect(DB_PATH)
try:
pd.DataFrame([row]).to_sql("risk_stop_line", conn, if_exists="append",
index=False)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uk_rsl ON risk_stop_line(code, t)"
)
conn.commit()
except Exception as e:
logger.error(f"落库风控止损测算失败:{str(e)}")
finally:
conn.close()
# ---------- 单标的完整测算 ----------
def analyze(code, st, et):
raw = fetch_stopprice_history(code, st, et)
df = build_stop_frame(raw)
if df.empty:
logger.warning(f"{with_exchange_suffix(code)} 无有效涨跌停序列,跳过测算")
return None
save_stop_history(code, df)
rt = fetch_realtime(code)
p_now = pd.to_numeric(rt.get("p"), errors="coerce")
yc_now = pd.to_numeric(rt.get("yc"), errors="coerce")
zf_now = pd.to_numeric(rt.get("zf"), errors="coerce")
last = df.iloc[-1]
t_last = str(last["t"])
up_limit = float(last["h"])
low_limit = float(last["l"])
# 停牌 / 无数据保护:yc 为 0 或缺失时不做幅度反推
up_pct, down_pct = calc_limit_pct(up_limit, low_limit, yc_now)
limit_type = classify_limit_type(up_pct, down_pct)
stops = calc_room_and_stops(p_now, low_limit)
vol_base = calc_vol_base(df)
vol_stop_pct, vol_stop_price = calc_vol_stop(p_now, vol_base)
vol_util = None
if vol_base and not pd.isna(zf_now) and vol_base > 0:
vol_util = round(float(zf_now) / vol_base, 3)
final_stop = None
candidates = []
if vol_stop_price is not None:
candidates.append(vol_stop_price)
if stops.get("stop_clear") is not None:
candidates.append(stops["stop_clear"])
if candidates:
final_stop = round(max(candidates), 3)
row = {
"code": with_exchange_suffix(code),
"t": t_last,
"up_limit": up_limit,
"low_limit": low_limit,
"up_limit_pct": up_pct,
"down_limit_pct": down_pct,
"limit_type": limit_type,
"room_down_pct": stops.get("room_down_pct"),
"stop_warn": stops.get("stop_warn"),
"stop_reduce": stops.get("stop_reduce"),
"stop_clear": stops.get("stop_clear"),
"vol_base_pct": vol_base,
"vol_stop_pct": vol_stop_pct,
"vol_stop_price": vol_stop_price,
"vol_util": vol_util,
"final_stop": final_stop,
"collected_at": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S"),
}
save_risk_stop_line(row)
return df, row
# ---------- 演示入口 ----------
def demo(watchlist=None, st="2024-01-01", et="2024-12-31"):
if watchlist is None:
watchlist = ["600519", "000001", "300750", "688981"]
for code in watchlist:
result = analyze(code, st, et)
if not result:
print(f"\n=== {with_exchange_suffix(code)} === 无有效数据,已跳过")
continue
df, row = result
print(f"\n=== {row['code']} ===")
print(f"板块限制类型:{row['limit_type']}")
print(f"涨停幅度 {row['up_limit_pct']}% | 跌停幅度 {row['down_limit_pct']}%")
print(f"涨停价 {row['up_limit']} | 跌停价 {row['low_limit']}")
print(f"剩余下跌空间 {row['room_down_pct']}%")
print(f"预警线 {row['stop_warn']} | 减仓线 {row['stop_reduce']} | 清仓线 {row['stop_clear']}")
print(f"日波动基准 {row['vol_base_pct']}% | 自适应止损 {row['vol_stop_pct']}% -> {row['vol_stop_price']}")
print(f"波动利用率 {row['vol_util']} | 最终止损价 {row['final_stop']}")
print("近 5 日涨跌停序列:")
print(df[["t", "h", "l", "spread", "range_pct"]].tail(5).to_string(index=False))
print("\n【仅为数据演示,不构成投资建议】已完成涨跌停序列与止损线测算")
if __name__ == "__main__":
demo()
运行后控制台会打印每只标的的板块限制类型、涨跌停幅度、剩余下跌空间、三档止损线、自适应止损价与最近 5 个交易日的涨跌停价序列,同时把原始数据写入
stop_price_history、把测算结果写入risk_stop_line。实际数值取决于你的LICENCE与查询区间,本文不做任何真实数字的假设。
六、业务关键点
- 单接口算不出幅度,必须借
yc:stopprice/history只给t / h / l,涨跌停幅度是h与「昨收」之间的相对关系。缺了real/time的yc,你只能看到两条边界的绝对数值,看不到这条边界有多宽。这是本篇最核心的数据约束。 h/l同名不同义 :两个接口的h/l含义完全不同(涨停价/跌停价 vs 最高价/最低价)。合并两个接口的返回值时,必须重命名为up_limit / low_limit等专属变量,否则会被静默覆盖。- 停牌 / 无数据保护 :停牌、上市首日、退市整理期等场景下
yc可能为0或缺失。所有除法前都要判yc > 0,否则会出现除零或inf。本文calc_limit_pct与calc_room_and_stops都做了防御,返回None而不是抛异常。 - 隐含昨收只对「对称限制」成立 :
yc = (h + l) / 2依赖涨跌停幅度相等。对绝大多数 A 股标的成立,但若遇到不对称情形(如特殊处理期的单边限制),该反解会偏差。此时应改用real/time的yc逐日补齐------但接口仅提供当日yc,历史yc无法回溯,这是本接口的固有边界,需要接受。 - 日波动基准衡量的是「制度允许的上限」,不是实际波动 :
range_pct = (h - l) / yc在限制类型不变时近似恒等于2 × 限制幅度(10% 股约 20%、20% 股约 40%)。它衡量的是「制度这条天花板有多高」,因此用zf / vol_base得到的波动利用率 ,才是刻画当日实际活跃度的补充指标。若标的限制类型发生变更(如 ST 摘帽从 5% 变 10%),range_pct会跳变,回看窗口内应剔除类型切换点再做均值。 - 三档止损线的语义要说清 :
stop = l + (p - l) × ratio中,ratio = 50% 表示「保留一半剩余空间」,也就是吃掉 50% 的剩余缓冲 时触发预警;ratio = 10% 表示几乎逼近跌停。三档是递进的执行纪律(提示 → 降仓 → 离场),不是三个并列的候选值。 - 最终止损价取严:自适应止损价与清仓线谁更高就取谁,确保波动自适应不会把防线放得比制度底线还松。
- 落库唯一索引
(code, t):两个表都以(code, t)建唯一索引,重复采集同一交易日会自动覆盖/去重,便于按日回溯「某天我本该在哪条线上离场」。
七、拓展练习
- 批量扫描自选股池,统计各
limit_type的占比,验证你维护的股票池里「板块属性标注」是否与价格反推结果一致,找出标注错误的标的。 - 把
room_down_pct与real/time的pc(当日涨跌幅)结合:对「当日已大幅下跌、剩余下跌空间不足 2%」的标的打上「逼近跌停」标签,观察其次日表现。 - 调整
VOL_K与VOL_WINDOW,回看不同参数下final_stop的松紧变化,体会「止损系数」对策略存活率的敏感度。
八、下篇预告
下一篇《综合实战流水线:多接口调度与指标整合收官》是本系列的收官之作,将把前面 11 篇的接口与自研因子全部串起来------用 APScheduler 做分时调度,按「板块分类 → 实时快照 → 盘口五档 → 分时逐笔 → 技术指标 → 资金轮动 → 风控止损」的顺序编排采集任务,统一入库 quant.db,并输出一份可复用的「多接口编排模板」,让整套进阶数据能力真正跑在生产节奏上。
九、免责申明
免责申明:文中所有数据处理逻辑仅为编程演示,仅为数据演示,不构成投资建议。市场有风险,投资需谨慎。