开行情看板、盘中监控或回测前的数据预热任务时,经常会遇到一个看似简单的问题:同一批股票既要最新价,又要最近 N 根 K 线。很多程序一开始会写成两层循环:每只股票先取一次最新价,再取一次 K 线,顺手把清洗、重试、拼表、业务计算都塞进同一个函数。代码能跑,但很快会变成难维护的接口意大利面。
更稳的做法是:按数据语义拆请求,而不是按页面字段拆请求。最新价是快照数据,K 线是时间序列数据,它们的更新频率、返回形态、校验规则和缓存策略都不同。工程上应把它们拆成两个批量数据源,再在领域层组合,而不是在接口层互相嵌套。
下面用 Python、Pandas 和 QuantDash 的公开 SDK 形态演示一种最小可复用结构。示例基于 Python 3.10+、pandas、quantdash==0.1.0 的接口约定;如果发布或运行时 SDK 版本变化,应先打印实际字段再放入生产流程。
最新价通常是某个时刻的行情快照。它回答的是:这批股票当前最新成交价、昨收、成交量等字段是多少。它天然适合按标的批量查询,然后得到一张宽表,每个标的一行。
K 线是按时间聚合后的 OHLCV 序列,OHLCV 分别表示开盘价、最高价、最低价、收盘价和成交量。它回答的是:每个标的最近一段时间的价格路径是什么。它天然适合返回 dict[str, DataFrame],也就是每个标的一张时间序列表。
因此,不建议把代码写成下面这种结构:
try/except。这种写法的问题不是不够优雅,而是边界错误:网络调用、字段清洗、数据对齐、业务计算混在一起,后面要加缓存、限流、重试或替换数据源时都会很痛。
可以把程序拆成三层。
层次 | 负责什么 | 不负责什么 |
|---|---|---|
数据源适配层 | 调用行情接口,拿到原始 DataFrame | 不计算策略指标 |
标准化层 | 统一字段、排序、去重、索引 | 不关心页面怎么展示 |
领域组合层 | 把最新价和 K 线组合成看板或任务输入 | 不直接调用外部接口 |
请求层面只需要两类批量请求:
quotes.get(symbols=...):一次取同一批股票的最新行情;klines.batch(symbols, period=..., count=...):一次取同一批股票的 K 线。这样拆分后,接口数量和业务字段不再互相污染。看板要不要展示涨跌幅,是组合层的事;K 线使用前复权还是不复权,是研究口径的事;接口层只负责稳定地拿到数据。
安装依赖时建议固定 SDK 版本,例如使用 quantdash==0.1.0。API Key 建议放在环境变量 QUANTDASH_API_KEY 中,代码里不要硬编码真实密钥。
from dataclasses import dataclass
from typing import Iterable
import pandas as pd
from quantdash import QuantDash
@dataclass(frozen=True)
class MarketRequest:
symbols: list[str]
period: str = '1d'
count: int = 100
adjust: str = 'forward'
@dataclass
class MarketBundle:
quotes: pd.DataFrame
klines: pd.DataFrame
def normalize_symbols(symbols: Iterable[str]) -> list[str]:
cleaned = []
seen = set()
for raw in symbols:
symbol = str(raw).strip().upper()
if not symbol or symbol in seen:
continue
cleaned.append(symbol)
seen.add(symbol)
return cleaned
class QuantDashMarketSource:
def __init__(self) -> None:
self.qd = QuantDash()
def fetch_quotes(self, symbols: list[str]) -> pd.DataFrame:
return self.qd.quotes.get(
symbols=symbols,
to_dataframe=True,
)
def fetch_klines(
self,
symbols: list[str],
period: str,
count: int,
adjust: str,
) -> dict[str, pd.DataFrame]:
return self.qd.klines.batch(
symbols,
period=period,
count=count,
adjust=adjust,
to_dataframe=True,
show_progress=True,
)这里的关键不是类名,而是边界:QuantDashMarketSource 只负责对接外部数据源。它不计算均线,不决定前端展示,也不把最新价强行塞进每根 K 线。
QuantDash 的标的代码使用 {代码}.{交易所后缀},例如 600519.SH、000001.SZ、AAPL.US、00700.HK。K 线周期大小写有意义,1m 是 1 分钟,1M 是月线。复权参数中,forward 表示前复权、比例复权,通常更适合收益率计算;none 表示不复权,更接近原始成交价格口径。具体用哪一种应由研究目的决定,不应在接口层写死为唯一正确答案。
最新价返回的是一张表,K 线批量返回的是多张表。组合之前先做标准化,至少处理四件事:字段存在性、重复标的、时间排序和缺失值。
def prepare_quotes(df: pd.DataFrame, symbols: list[str]) -> pd.DataFrame:
if df is None or df.empty:
raise ValueError('quotes is empty')
if 'symbol' not in df.columns:
raise ValueError('quotes must contain symbol column')
quotes = df.copy()
if 'timestamp' in quotes.columns:
quotes = quotes.sort_values(['symbol', 'timestamp'])
quotes = quotes.drop_duplicates(subset=['symbol'], keep='last')
quotes = quotes.set_index('symbol', drop=False)
missing = sorted(set(symbols) - set(quotes.index))
if missing:
raise ValueError(f'missing quote rows: {missing}')
return quotes.loc[symbols]
def prepare_klines(dfs: dict[str, pd.DataFrame], symbols: list[str]) -> pd.DataFrame:
frames = []
for symbol in symbols:
df = dfs.get(symbol)
if df is None or df.empty:
raise ValueError(f'empty kline data: {symbol}')
one = df.copy()
time_col = 'trade_time' if 'trade_time' in one.columns else 'trade_date'
if time_col not in one.columns:
raise ValueError(f'kline data has no trade_date or trade_time: {symbol}')
if 'symbol' not in one.columns:
one['symbol'] = symbol
one = one.sort_values(time_col)
one = one.drop_duplicates(subset=['symbol', time_col], keep='last')
one = one.set_index(['symbol', time_col])
frames.append(one)
panel = pd.concat(frames).sort_index()
return panel这段代码没有假设所有扩展字段都存在。比如实时行情可能包含 ext.name、ext.change_pct 这类展开列,但生产代码不应在没打印实际列名前直接依赖它们。change_pct 如果出现,在 OpenAPI 口径中是小数形式,例如 0.01 表示 1%。
K 线侧也没有强行假设只有日线。日线 DataFrame 常见时间列是 trade_date,分钟线常见时间列是 trade_time。标准化层只识别时间列并排序,不把日线和分钟线混成同一种业务含义。
有了数据源适配和标准化函数,组合层就很薄:先规范标的列表,再分别拉取两类批量数据,最后返回一个明确的数据包。
def load_market_bundle(request: MarketRequest) -> MarketBundle:
symbols = normalize_symbols(request.symbols)
if not symbols:
raise ValueError('symbols is empty')
source = QuantDashMarketSource()
raw_quotes = source.fetch_quotes(symbols)
raw_klines = source.fetch_klines(
symbols=symbols,
period=request.period,
count=request.count,
adjust=request.adjust,
)
quotes = prepare_quotes(raw_quotes, symbols)
klines = prepare_klines(raw_klines, symbols)
return MarketBundle(quotes=quotes, klines=klines)
if __name__ == '__main__':
req = MarketRequest(
symbols=['600519.SH', '000001.SZ'],
period='1d',
count=60,
adjust='forward',
)
bundle = load_market_bundle(req)
print(bundle.quotes.columns.tolist())
print(bundle.klines.index.names)这不是为了多写几层抽象,而是为了让后续变化有位置可放:
fetch_quotes 和 fetch_klines 外层;429 限流:放在数据源适配层;fetch_quotes、fetch_klines;同一批股票取两种数据,最容易出错的地方不是请求本身,而是请求后的隐含假设。建议至少加这些校验:
def validate_bundle(bundle: MarketBundle, symbols: list[str]) -> None:
quote_symbols = set(bundle.quotes.index)
expected_symbols = set(symbols)
if quote_symbols != expected_symbols:
raise ValueError('quote symbols are not aligned with request symbols')
if not bundle.klines.index.is_monotonic_increasing:
raise ValueError('kline index should be sorted')
required_price_cols = {'open', 'high', 'low', 'close'}
missing_price_cols = required_price_cols - set(bundle.klines.columns)
if missing_price_cols:
raise ValueError(f'missing kline columns: {sorted(missing_price_cols)}')
null_close = bundle.klines['close'].isna().sum()
if null_close > 0:
raise ValueError(f'close contains null values: {null_close}')验证时应重点看三类结果:第一,quotes 是否每个标的只有一行;第二,klines 是否按 symbol + trade_date/trade_time 建立了有序索引;第三,价格列是否存在空值或重复时间。实际结果会受到数据范围、权限和接口状态影响,不应在未执行前假设返回一定完整。
行情任务遇到失败很正常,关键是失败处理不能散落在页面逻辑或策略逻辑里。QuantDash 官方错误边界中,401 通常表示 API Key 缺失或无效,403 表示当前权限不包含目标功能或市场,429 表示请求频率超限;限流响应可能包含 retry_after_ms,单位是毫秒。
工程上可以把这些处理放到适配层。没有确认 SDK 内置重试策略时,不要假设它会自动退避。更稳妥的方式是:数据源层捕获可识别异常,按错误类型决定是否重试;业务层只看到成功的数据包或明确失败。
对于看板类任务,还可以把刷新频率拆开:最新价可以更频繁刷新,日线 K 线通常不需要每秒刷新。两者使用同一批标的,但不一定使用同一个调度周期。这个取舍能显著降低代码耦合,也减少不必要的接口压力。
第一种反模式是按字段写接口:页面需要最新价、昨收、最近 60 日 K 线,就在一个函数里把所有字段都取回来。这样页面一变,接口调用也跟着变。
第二种反模式是按股票循环调用:每个标的依次请求最新价和 K 线。这样失败粒度看似清楚,但请求数量、重试逻辑和限流处理都会膨胀。
第三种反模式是过早合并:把最新价作为一列写进每一根历史 K 线。最新价是快照,K 线是序列,它们可以在展示或计算入口临时关联,不应污染历史数据本身。
第四种反模式是忽略复权口径。用前复权 K 线计算收益率、用不复权价格核对成交显示,都是合理场景;错误在于没有把口径写进请求参数和数据包元信息。
同一批股票既要最新价又要 K 线时,推荐的请求拆法是:最新价走一次批量快照请求,K 线走一次批量时间序列请求;两者在接口层分离,在标准化层各自清洗,在领域组合层再装配。
这样做的收益不是少写几行代码,而是把变化隔离开:快照刷新、K 线复权、字段校验、限流重试、业务计算都有清晰位置。程序不会因为多接几个行情字段就变成接口意大利面。
从工程语义上不建议强行合并。最新价是快照,K 线是时间序列,返回结构和刷新节奏不同。即使底层数据源未来提供聚合接口,也建议在代码内部仍保留快照模型和 K 线模型。
循环请求容易把网络调用次数放大,也容易把重试、限流、字段清洗写散。批量请求更适合统一校验和统一失败处理。至于批量上限、并发数和性能提升比例,应以实际接口文档和运行结果为准,不能凭经验假设。
看业务场景。盘中看板通常把最新价作为独立快照展示,不需要塞进历史 K 线。若要计算相对昨收涨跌幅,应使用行情中的昨收或明确口径的前一交易日收盘价,避免把不同时间口径混在一起。
如果目的是计算历史收益率,比例复权通常更合适;如果目的是观察原始成交价格或与成交记录核对,可以使用不复权。关键是把 adjust 参数记录在数据请求和后续计算中,避免不同口径的数据被误合并。
多数历史回测不需要实时最新价,只需要按统一口径准备好的历史 K 线。最新价更适合盘中监控、行情看板、交易前检查或实时信号展示。把两类数据拆开后,回测任务可以只依赖 K 线数据层。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。