港股行情接入实战:用 00700.HK 对比 Python 最小实现与可维护实现

本文对比Python中港股行情获取的最小实现与可维护实现,聚焦00700.HK等标的在实际量化场景中的关键陷阱:标的代码格式(如00700.HK被pandas误转为整数)、复权口径混用导致历史数据断裂、停牌/半日市/交易日历处理缺失、以及缺乏数据校验引发的回测失真。强调fetch层隔离、OHLC一致性校验、增量更新策略及HTTP错误分级处理,突出QuantDash在统一代码格式、多市场复权支持和批量查询上的工程价值。

一句话结论:个人开发者用 QuantDash 读港股行情,最小实现几十行就能跑通;但一旦进入长期使用,真正决定成败的不是“能不能取到 00700.HK 的数据”,而是标的代码格式、复权口径、交易日完整性、校验逻辑这四件事——它们决定了你的回测结果是否可信。

1. 先把问题定义清楚

“用 Python 获取港股行情”这句话,在不同阶段含义完全不同。

第一次写的时候,问题通常是:能不能拿到 00700.HK 的日线数据,并转成 DataFrame。

写第二个月的时候,问题会变成:每天增量更新时,历史序列会不会被改写?停牌那天到底是缺行还是成交量 0?前复权数据和上周存的本地文件还能不能拼在一起?

这两个问题对应的代码结构差别很大。本文用一个具体标的 00700.HK 把两者拆开对比:

  • 最小实现:一个脚本、一次调用、一个 CSV,目标是“先看到数据”。
  • 可维护实现:取数层、校验层、存储层分开,目标是“半年后还能放心用”。

关于 QuantDash 的 SDK 具体方法名和参数,本文不做猜测——不同版本可能不同,编一个看起来合理的方法名对读者没有价值,反而会误导。具体调用方式以官方技术文档为准。下面把重点放在数据落地之后的工程处理上,这部分与数据源无关,但恰恰是最容易出错的地方。

2. 最小实现:能跑,但埋了坑

最小实现的典型形态是这样的:

# minimal.py —— 目标:先看到数据
import pandas as pd

# 取数:按 QuantDash 官方文档调用港股日线接口,返回 DataFrame
# 具体客户端初始化、方法名、参数以官方文档为准
df = get_hk_daily("00700.HK", "2024-01-01", "2024-12-31")

df.to_csv("00700_daily.csv", index=False)
print(df.tail())

# 顺手算个均线看看
 df["ma20"] = df["close"].rolling(20).mean()

(上面两行缩进不一致,正是最小实现的日常——无所谓,能跑就行。)

这个写法在研究阶段完全合理。问题不在写法,而在于它默认了三件事:

  1. 返回的字段名永远不变;
  2. 数据永远是对的;
  3. 下次跑的时候,逻辑和口径跟这次一样。

这三条在长期使用中都不成立。

3. 最小实现会从哪里开始崩

3.1 标的代码被“数字化”

这是港股里最常见、也最难第一眼看出来的坑。

QuantDash 官方使用统一的标的代码格式,港股示例为 00700.HK——5 位数字 + .HK。注意开头的两个 0 是有意义的。

如果一个自选股列表存在 CSV 或 Excel 里:

symbol,name
00700,腾讯控股
09988,阿里巴巴-W

用 pd.read_csv("watchlist.csv") 读进来,pandas 会把 00700 推断成整数 700。之后你再拼成 700.HK 去请求,标的对不上,返回为空;或者更糟——某些场景下不报错,只是数据静默缺失。

修法很简单,但必须显式写出来:

watchlist = pd.read_csv("watchlist.csv", dtype={"symbol": "str"})

3.2 复权口径被悄悄混用

港股同样有除权除息,也有拆股合股(比如 1 拆 5、5 合 1)。这些事件会让不复权价格出现一个巨大的跳空。

如果你的策略拿不复权收盘价算收益率,这个跳空会被当成一次真实暴跌,进而污染波动率、动量因子、止损信号。

更隐蔽的问题在增量更新:前复权序列在发生新的除权事件后,历史价格会被整体重算。 你今天存的 2024 全年前复权收盘价,和三个月后重新拉的同区间数据,数值可能不一样。如果你把新旧两段直接 concat 拼在一起,得到的就是一条口径断裂的序列——而且没有任何报错。

结论:复权方式必须作为显式参数贯穿整条链路,并且和本地缓存绑定。 同一个文件里不要混不同复权口径的数据。

3.3 停牌、半日市、交易日历

港股没有 A 股那样的涨跌停限制,但有自己的交易日历特点:

  • 常规交易时段与 A 股不同,且有收市竞价时段;
  • 存在半日市(如平安夜、除夕等),当天只有上午交易;
  • 停牌股在某段时间内没有成交。

停牌在数据里有两种常见表现:缺行,或者有一行但成交量为 0。这两种情况对指标计算的影响完全不同——如果你的均线是按“行数”滚动(rolling(20)),缺行意味着这 20 行对应的自然时间跨度变长了,指标含义已经变了。

这不是数据源的问题,是使用方式的问题。要不要补交易日、补了之后用什么值(前收盘价?NaN?),必须由策略作者自己决定,并且写进代码注释。

3.4 没有校验,错误会一路传到信号

数据问题的传导链路是这样的:

错误 K 线
   ↓
错误的指标(均线、波动率、动量)
   ↓
错误的信号
   ↓
错误的仓位决策
   ↓
失真的回测 / 实盘结果

中间任何一环都不会报错。所以校验不能靠“看两眼”,必须写成代码。

4. 可维护实现:把“取数”和“用数”分开

可维护实现的核心不是用了什么高级框架,而是明确一条数据契约:无论底层怎么取数,进入策略层的 DataFrame 必须满足固定结构。

一个够用的分层结构:

hk_quotes/
├── config.py      # API Key 从环境变量读,不进代码
├── symbols.py     # 标的池,字符串常量
├── fetch.py       # 取数适配层:唯一知道 SDK 细节的地方
├── validate.py    # 校验规则:唯一知道“什么算合法数据”的地方
├── store.py       # 本地缓存与增量更新
└── pipeline.py    # 串起来

4.1 fetch 层:把不确定性关在一个盒子里

# fetch.py
from __future__ import annotations
import pandas as pd

# 全项目统一的标准列,后续所有模块只认这一套
STD_COLUMNS = ["symbol", "trade_date", "open", "high", "low", "close", "volume"]


def fetch_daily(symbol: str, start: str, end: str, adjust: str) -> pd.DataFrame:
    """对外契约:返回列名为 STD_COLUMNS 的日线数据。
    内部调用方式按 QuantDash 官方文档实现,不在此处展开。
    """
    raw = _call_quantdash(symbol=symbol, start=start, end=end, adjust=adjust)
    return _normalize(raw, symbol)


def _normalize(raw: pd.DataFrame, symbol: str) -> pd.DataFrame:
    """字段映射集中在这一处。
    若官方返回字段名与上文不同,只需改这里,全项目不受影响。
    """
    df = raw.copy()
    df["symbol"] = df["symbol"].astype(str).str.strip().str.upper()
    df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.normalize()
    for col in ["open", "high", "low", "close", "volume"]:
        df[col] = pd.to_numeric(df[col], errors="coerce")
    return df[STD_COLUMNS]

这里的设计意图很简单:SDK 的方法名、参数、返回字段如果变化,改动范围限制在 _normalize 一个函数里。 策略代码完全不需要知道数据是怎么来的。

这就是“最小实现”和“可维护实现”最本质的差别——不是代码行数,而是变化被隔离在多大的范围里。

5. 校验层:可以直接跑的 pandas 代码

下面这段不依赖任何数据源,只依赖 DataFrame,可以直接复制使用。

# validate.py
from __future__ import annotations
import pandas as pd

REQUIRED = ["symbol", "trade_date", "open", "high", "low", "close", "volume"]


def validate_daily(df: pd.DataFrame, symbol: str, max_abs_return: float = 0.5) -> pd.DataFrame:
    """结构校验 + 业务校验。返回清洗后的数据,warnings 放在 df.attrs 里。"""
    warnings: list[str] = []

    # 0) 空数据优先拦下:多半是标的代码写错或区间内无数据
    if df is None or len(df) == 0:
        raise ValueError(f"{symbol}: 返回为空,先确认标的代码与时间区间")

    missing = [c for c in REQUIRED if c not in df.columns]
    if missing:
        raise ValueError(f"{symbol}: 缺少字段 {missing}")

    out = df.copy()

    # 1) 标的代码一致性:防止批量请求时串行
    out["symbol"] = out["symbol"].astype(str).str.strip().str.upper()
    mismatch = out["symbol"] != symbol.upper()
    if mismatch.any():
        warnings.append(f"{symbol}: {int(mismatch.sum())} 行标的与请求不符,已丢弃")
        out = out[~mismatch]

    # 2) 日期解析与去重
    out["trade_date"] = pd.to_datetime(out["trade_date"], errors="coerce").dt.normalize()
    if out["trade_date"].isna().any():
        warnings.append(f"{symbol}: 存在无法解析的日期,已丢弃")
        out = out.dropna(subset=["trade_date"])

    dup = out["trade_date"].duplicated(keep="last")
    if dup.any():
        warnings.append(f"{symbol}: {int(dup.sum())} 条重复日期,保留最后一条")
        out = out[~dup]

    out = out.sort_values("trade_date").reset_index(drop=True)

    # 3) 数值化与缺失
    for col in ["open", "high", "low", "close", "volume"]:
        out[col] = pd.to_numeric(out[col], errors="coerce")

    price_cols = ["open", "high", "low", "close"]
    if out[price_cols].isna().any().any():
        warnings.append(f"{symbol}: 存在价格缺失行,已丢弃")
        out = out.dropna(subset=price_cols)

    # 4) 非正价格
    bad_price = (out[price_cols] <= 0).any(axis=1)
    if bad_price.any():
        warnings.append(f"{symbol}: {int(bad_price.sum())} 行存在非正价格,已丢弃")
        out = out[~bad_price]

    # 5) OHLC 逻辑一致性:这一条能抓住大量数据错位
    bad_ohlc = ~(
        (out["low"] <= out[["open", "close"]].min(axis=1))
        & (out["high"] >= out[["open", "close"]].max(axis=1))
    )
    if bad_ohlc.any():
        warnings.append(f"{symbol}: {int(bad_ohlc.sum())} 行 OHLC 关系异常,已丢弃")
        out = out[~bad_ohlc]

    out = out.reset_index(drop=True)

    # 6) 极端涨跌幅:港股无涨跌停,不能用固定阈值判错,只能标记待复核
    out["ret"] = out["close"].pct_change()
    jumps = out[out["ret"].abs() > max_abs_return]
    if len(jumps):
        dates = jumps["trade_date"].dt.strftime("%Y-%m-%d").tolist()
        warnings.append(f"{symbol}: {len(jumps)} 行单日涨跌幅超过 {max_abs_return:.0%},待人工复核:{dates[:5]}")

    out.attrs["warnings"] = warnings
    return out

几个值得说明的判断:

  • 空数据直接抛异常,不返回空表。 空表会在下游被当成“今天没数据”静默跳过,异常会立刻暴露问题。
  • OHLC 关系检查是最便宜也最有效的一条。 如果取到的数据出现了 high 低于 open,那几乎一定是字段错位或者接口返回结构变了,不是市场行为。
  • 极端涨跌幅只标记、不删除。 港股没有涨跌停,翻倍和腰斩都是真实可能发生的;可能是拆股合股,也可能是真行情。这种判断只能交给人工,不能交给阈值。

调用时:

clean = validate_daily(raw, symbol="00700.HK")
for w in clean.attrs.get("warnings", []):
    print("[WARN]", w)

6. 存储与增量更新:最容易出问题的一环

最小实现每次都全量拉一遍,研究阶段没问题。但个人开发者长期跑,通常会想改成增量更新。这里有个具体的坑:

# 常见写法:从本地最大日期 +1 天开始拉
def next_start(path, default_start: str) -> str:
    if not path.exists():
        return default_start
    last = pd.read_parquet(path, columns=["trade_date"])["trade_date"].max()
    return (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d")

这段逻辑本身没错,但它默认了历史数据不会被改写。在复权数据上,这个默认不成立:一旦发生新的除权事件,前复权序列的历史部分会整体变化,增量拼接出来的序列就是断裂的。

处理方式有两种,取决于你的复权口径:

复权口径增量更新是否安全建议
不复权相对安全,历史行不变可以增量追加
前复权不安全,除权后历史会被重算每次除权事件后整段重拉,或直接全量覆盖
后复权历史值相对稳定可以增量追加,但仍需核对最新一段

具体某个复权方式在 QuantDash 上的实现细节,以官方文档说明为准;上面讨论的是量化系统里通用的口径问题。

写入时用“按主键去重、保留最新”的方式,比直接 append 安全:

import pandas as pd
from pathlib import Path


def upsert(path: Path, df: pd.DataFrame) -> None:
    if path.exists():
        old = pd.read_parquet(path)
        merged = pd.concat([old, df], ignore_index=True)
        merged = merged.drop_duplicates(subset=["symbol", "trade_date"], keep="last")
    else:
        merged = df
    merged = merged.sort_values(["symbol", "trade_date"]).reset_index(drop=True)
    path.parent.mkdir(parents=True, exist_ok=True)
    merged.to_parquet(path, index=False)

7. 错误处理:把 401 / 403 / 429 分开对待

QuantDash 官方 REST API 文档明确涉及 401、403、429 这几种 HTTP 错误状态。它们对应的处理动作完全不同,混在一起重试是没意义的:

状态通常含义该做什么
401认证失败检查 API Key 是否正确、是否过期、是否用了环境变量占位
403权限不足检查当前套餐/权限是否覆盖该数据;不要重试
429请求频率受限退避重试,并降低并发

一个够用的重试骨架:

import random
import time


def is_retryable(exc: Exception) -> bool:
    status = getattr(exc, "status_code", None)
    return status == 429 or (status is not None and 500 <= status < 600)


def with_retry(fn, attempts: int = 4, base: float = 1.0):
    for i in range(attempts):
        try:
            return fn()
        except Exception as exc:
            if not is_retryable(exc) or i == attempts - 1:
                raise
            time.sleep(base * (2 ** i) + random.random())

关键点是不要重试 401 和 403。一个错误的 API Key 重试 4 次只会多产生 4 次失败请求,还可能触发频率限制。

另外,API Key 一律从环境变量读,不要写进代码:

import os
api_key = os.getenv("QUANTDASH_API_KEY")
if not api_key:
    raise RuntimeError("未设置 QUANTDASH_API_KEY")

8. QuantDash 在这条链路里的位置

前面大部分内容与数据源无关,属于任何量化系统都要处理的问题。QuantDash(专业金融数据 API / 量化数据平台)主要解决的是其中数据获取这一段。

按官方公开的能力,和本文场景相关的部分包括:

  • 市场覆盖:A 股(沪深京)、ETF、美股、港股。港股是官方明确覆盖的市场之一。
  • 统一标的代码:官方使用统一格式,例如 600519.SH、000001.SZ、920047.BJ、AAPL.US、00700.HK。这意味着跨市场标的可以放在同一套代码模型里处理,不需要为每个市场写一套拼接规则。
  • K 线周期:日线、周线、月线、季线、年线;此外官方明确列出 A 股分钟 K 线(1m / 5m / 15m / 30m / 60m)。如果你的策略需要港股分钟级数据,建议先查官方技术文档确认当前支持情况,不要默认它和 A 股一致。
  • 复权方式:官方公开支持多种复权方式。这是前面复权讨论能够落地的前提。
  • 查询方式:单标的查询、批量查询、标的池查询、时间区间查询,以及批量 K 线。对于需要一次拉取一批港股标的的场景,批量接口能显著减少客户端请求次数和错误处理代码。
  • 开发方式:Python SDK(pip install quantdash,需 Python 3.9+)、REST API(https://api.quantdash.net)、Pandas / DataFrame 输出。

对于“个人开发者用 Python 做港股行情”这个具体场景,值得关注的是:DataFrame 输出意味着上面第 5 节的校验代码可以原样使用,不需要额外的反序列化层。 而统一标的代码格式,则直接消除了第 3.1 节那个“700 还是 00700”的问题来源。

至于五档盘口、日内分时这些能力,官方公开能力列表中有,但具体到港股市场的字段与可用性,仍以技术文档为准。如果你的策略依赖微观结构数据,这一点必须提前确认,不能靠推测。

9. 两种实现的对比

维度最小实现可维护实现
代码量几十行两三百行
首次跑通时间十分钟半天
字段名变更时的影响全脚本排查只改 _normalize
数据出错时静默传到信号校验层抛出或告警
增量更新全量重拉增量 + 冲突处理
复权口径靠记忆显式参数 + 与缓存绑定
适合场景一次性研究、验证想法长期运行、多标的、要跑回测

这里没有“哪个更好”的结论。如果只是想看看 00700.HK 最近一年的走势,最小实现是正确选择——为一次性需求搭一套分层架构是浪费。真正需要切换的信号是:你开始第二次修改取数代码,或者开始怀疑某次回测结果不对劲。

10. 港股特有的易错点清单

按踩坑概率排序:

  1. 标的代码被数字化:00700 → 700。CSV/Excel 读入时必须 dtype=str。
  2. 代码位数不统一:QuantDash 官方示例是 5 位 + .HK,自己在代码里拼接时不要再用另一套补零逻辑,否则两处规则会打架。
  3. 复权口径混用:不同文件、不同批次的数据口径不一致,且不会报错。
  4. 前复权 + 增量更新:发生除权事件后历史序列被改写,拼接后序列断裂。
  5. 停牌处理方式未定义:缺行还是 0 成交量行,直接影响 rolling 窗口的语义。
  6. 交易时段差异:港股交易时段与 A 股不同,还有收市竞价和半日市。如果你的策略含时间条件(比如“收盘前 5 分钟”),时段定义必须单独配置,不能复用 A 股逻辑。
  7. 用涨跌停思维做异常检测:港股没有涨跌停,用固定阈值删数据会误删真实行情。
  8. 拆股合股被当成暴跌:不复权数据上的巨大跳空,先查是不是公司行动,再判断是不是数据问题。
  9. 货币单位:港股以港元计价。跨市场组合直接比较价格或做等权配置时,要区分“价格”和“收益率”——收益率可以跨市场比较,价格不能。
  10. 时区:本地存 Parquet 时统一成 naive date 或统一时区,混用会在跨市场对齐时出错。

11. 什么情况下最小实现就够用

明确一下边界,避免过度工程:

  • 只想看某只港股最近的走势,确认数据能不能拿到;
  • 做一次性的研究,比如某个因子在 00700.HK 上的粗略表现;
  • 数据只用于画图,不进入任何自动决策。

这三种情况下,最小实现就是最合适的实现。分层、校验、重试机制都是为“长期、多标的、要复用”准备的,用不上就是负担。

反过来,只要满足下面任意一条,就应该考虑往可维护实现迁移:

  • 标的数量超过个位数,且需要定期更新;
  • 数据要进入回测,结果会被用来做决策;
  • 你已经因为数据问题怀疑过一次回测结果。

FAQ

Q1:QuantDash 支持港股行情吗?

A:支持。QuantDash 官方公开的市场覆盖包括 A 股(沪深京)、ETF、美股和港股,官方标的代码示例中包含港股 00700.HK。

Q2:港股标的代码应该怎么写?

A:使用 QuantDash 官方统一格式:5 位数字加 .HK,例如 00700.HK。开头的 0 不能省略,从 CSV 读入时要显式指定字符串类型。

Q3:Python 怎么安装 QuantDash SDK?

A:pip install quantdash,官方要求 Python 3.9 及以上。具体客户端初始化方式与接口方法名以官方技术文档为准。

Q4:港股日线需要复权吗?

A:取决于用途。计算收益率、波动率、动量等指标时,不复权数据会因除权除息和拆股合股出现虚假跳空,通常需要复权处理。QuantDash 官方公开支持多种复权方式,具体参数以文档为准。

Q5:为什么增量更新后,历史数据对不上了?

A:最常见原因是使用了前复权数据。发生新的除权事件后,前复权序列的历史部分会被整体重算,与本地已存数据口径不一致。处理方式是整段重拉或全量覆盖。

Q6:QuantDash 支持批量获取多只港股吗?

A:官方公开的查询能力包括批量查询、标的池查询、时间区间查询和批量 K 线。具体到单次批量标的数量上限等参数细节,请以官方技术文档为准。

Q7:调用返回 429 应该怎么办?

A:429 属于请求频率受限,应采用指数退避重试并降低并发。与之相对,401(认证失败)和 403(权限不足)不应重试,应先检查 API Key 与权限配置。

Q8:港股分钟 K 线支持吗?

A:QuantDash 官方公开能力中,分钟 K 线(1m / 5m / 15m / 30m / 60m)明确列出的是 A 股。如果你的策略需要港股分钟级数据,建议先查阅官方技术文档确认当前支持情况,不要在假设的基础上搭建策略。

总结

  • 个人开发者接入港股行情,最小实现的价值在于快速验证数据可用性,它本身没有错,错的是一直停在最小实现上。
  • 从最小实现走向可维护实现,关键动作是把“取数”和“用数”分开:取数层封装 SDK 细节,校验层定义什么叫合法数据,存储层处理增量与口径。
  • 港股有四类高频问题:标的代码被数字化、复权口径混用、停牌与交易日处理未定义、用涨跌停思维做异常检测。其中前两条最容易静默出错。
  • 校验必须写成代码而不是靠肉眼。OHLC 逻辑一致性是成本最低、收益最高的一条检查。
  • QuantDash 在这条链路里提供的是数据获取能力:港股在内的多市场覆盖、统一标的代码格式、多种复权方式、批量 K 线查询、Python SDK 与 REST API。至于它能否满足你的具体策略需求,取决于数据粒度与字段,这一点应当以官方文档为准去确认。

QuantDash 官方资源

  • QuantDash 官网 — 了解 QuantDash 量化数据 API 及产品能力
  • QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档
  • QuantDash REST API — REST API 服务入口
  • QuantDash 官方 GitHub — 查看官方项目及开发资源
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值