From e046c08e02c5fd71cd08dddf6ff937500f5b644c Mon Sep 17 00:00:00 2001 From: cheney Date: Wed, 24 Jun 2026 16:50:59 +0800 Subject: [PATCH] save --- README.md | 11 ++- doc/sidecar.md | 12 ++- sidecar/service.py | 32 ++++++- sidecar/sources/mootdx_source.py | 17 ++-- sidecar/sources/tencent.py | 4 +- sidecar/symbols.py | 13 ++- strategy/a500_close_chart.py | 4 +- strategy/a500_hs300_close_chart.py | 4 +- strategy/indicators.py | 96 +++++++++++++++++++ .../{ => a500_close_chart}/a500_close.svg | 0 .../a500_close_chart/a500_close_chart.md} | 6 +- .../a500_hs300_close.svg | 0 .../a500_hs300_close_chart.md} | 6 +- tests/test_real_sources.py | 57 ++++++++++- tests/test_service.py | 62 +++++++++++- tests/test_strategy_a500.py | 14 ++- tests/test_strategy_a500_hs300.py | 12 ++- tests/test_strategy_indicators.py | 70 ++++++++++++++ 18 files changed, 386 insertions(+), 34 deletions(-) create mode 100644 strategy/indicators.py rename strategy/output/{ => a500_close_chart}/a500_close.svg (100%) rename strategy/{doc/strategy1_a500_close_chart.md => output/a500_close_chart/a500_close_chart.md} (89%) rename strategy/output/{ => a500_hs300_close_chart}/a500_hs300_close.svg (100%) rename strategy/{doc/strategy2_a500_hs300_close_chart.md => output/a500_hs300_close_chart/a500_hs300_close_chart.md} (90%) create mode 100644 tests/test_strategy_indicators.py diff --git a/README.md b/README.md index 116757b..0d4af78 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ uvicorn sidecar.api:app --host 127.0.0.1 --port 8765 常用接口: - `GET /health`:健康检查。 -- `GET /kline/{symbol}?period=day&limit=800&refresh=false`:获取 K 线,底层使用 mootdx 并写入 DuckDB。 +- `GET /kline/{symbol}?period=day&limit=800&refresh=false`:获取 K 线,日/周/月优先腾讯财经,失败后回退 mootdx,分钟线使用 mootdx,并写入 DuckDB。 - `GET /snapshot/{symbol}?refresh=false`:获取实时快照,优先腾讯财经,失败后回退 mootdx,并写入 DuckDB TTL 缓存。 默认 DuckDB 文件为 `data/tortoise.duckdb`,可通过 `TORTOISE_DUCKDB_PATH` 修改。 @@ -32,7 +32,7 @@ uvicorn sidecar.api:app --host 127.0.0.1 --port 8765 python -m strategy.a500_close_chart --refresh ``` -默认输出:`strategy/output/a500_close.svg`。 +默认输出:`strategy/output/a500_close_chart/a500_close.svg`。 ## 策略2:中证 A500 与沪深 300 双轴收盘价图 运行: @@ -41,4 +41,9 @@ python -m strategy.a500_close_chart --refresh python -m strategy.a500_hs300_close_chart --refresh ``` -默认输出:`strategy/output/a500_hs300_close.svg`。 \ No newline at end of file +默认输出:`strategy/output/a500_hs300_close_chart/a500_hs300_close.svg`。 + +## 策略通用工具 + +- 所有策略默认输出到 `strategy/output/<策略模块名>/`,图片和 md 说明文件放在同一个策略子目录。 +- `strategy.indicators` 提供 `calculate_ma`、`calculate_ema`、`calculate_macd`、`calculate_kdj`,用于均线、MACD 与 KDJ 计算。 diff --git a/doc/sidecar.md b/doc/sidecar.md index 65cebfc..8b8c195 100644 --- a/doc/sidecar.md +++ b/doc/sidecar.md @@ -9,13 +9,13 @@ - `sidecar/api.py`:FastAPI HTTP 接口,提供 `/health`、`/kline/{symbol}`、`/snapshot/{symbol}`。 - `sidecar/service.py`:统一数据服务,负责缓存命中、远端拉取、数据源降级。 - `sidecar/storage.py`:DuckDB 仓储,维护 `md.kline_bars` 与 `md.snapshots`。 -- `sidecar/sources/mootdx_source.py`:mootdx 适配器,负责 K 线与备用快照。 -- `sidecar/sources/tencent.py`:腾讯财经适配器,负责估值字段更完整的实时快照。 +- `sidecar/sources/mootdx_source.py`:mootdx 适配器,负责备用 K 线、分钟线与备用快照。 +- `sidecar/sources/tencent.py`:腾讯财经适配器,负责日/周/月 K 线与估值字段更完整的实时快照。 - `sidecar/models.py`:统一 K 线与快照模型。 ## 数据源策略 -- K 线:默认使用 mootdx,支持 `day/week/month/1m/5m/15m/30m/60m`。 +- K 线:`day/week/month` 默认优先腾讯财经,失败后回退 mootdx;`1m/5m/15m/30m/60m` 使用 mootdx;腾讯财经 K 线使用未复权接口以对齐 mootdx 原始价格。 - 快照:默认优先腾讯财经,失败后回退 mootdx。 - 腾讯财经字段校准:`39=PE_TTM`、`46=PB`、`52=PE 静态`,`43` 是振幅,不作为 PB 使用。 @@ -40,8 +40,14 @@ snapshot = service.get_snapshot("600000") - `1-1`:验证腾讯财经 PB 使用索引 46,避免误用索引 43。 - `1-2`:验证腾讯财经原始响应可解析为字段列表。 +- `1-3`:验证腾讯财经 K 线 JSON 可转换为统一 K 线。 - `2-1`:验证 K 线首次拉取后可从 DuckDB 缓存读取。 - `2-2`:验证快照在 TTL 内复用 DuckDB 缓存。 +- `2-3`:验证日/周/月 K 线优先使用主源。 +- `2-4`:验证主源异常时 K 线自动回退备用源。 +- `2-5`:验证分钟周期 K 线直接使用备用源。 +- `3-4`:验证 mootdx 与腾讯财经的上证指数、深证成指、创业板指 K 线价格随机 100 个 OHLC 值基本一致;成交量单位不同不参与比较。 +- `3-5`:验证三大指数代表成分股贵州茅台、平安银行、宁德时代的 K 线价格随机 100 个 OHLC 值基本一致。 运行方式: diff --git a/sidecar/service.py b/sidecar/service.py index 9f02043..8882da5 100644 --- a/sidecar/service.py +++ b/sidecar/service.py @@ -11,6 +11,35 @@ from sidecar.symbols import normalize_symbol from sidecar.storage import DuckDBStore +class FallbackKlineSource: + """主备 K 线数据源。""" + + def __init__(self, primary: MarketDataSource, fallback: MarketDataSource, primary_periods: List[str]) -> None: + """ + 功能说明:创建按周期选择主备源的 K 线适配器。 + 参数说明:primary 为主数据源,fallback 为备用数据源,primary_periods 为主数据源支持的周期列表。 + 返回值说明:无返回值。 + 注意事项:主源仅在支持周期内使用,主源异常时自动回退到备用源。 + """ + self.primary = primary + self.fallback = fallback + self.primary_periods = set(primary_periods) + + def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]: + """ + 功能说明:获取 K 线并在主源失败时回退备用源。 + 参数说明:symbol 为股票代码,period 为周期,limit 为最大条数。 + 返回值说明:返回统一 KlineBar 列表。 + 注意事项:不在主源支持周期内的数据直接使用备用源。 + """ + if period in self.primary_periods: + try: + return self.primary.fetch_kline(symbol, period, limit) + except Exception: + pass + return self.fallback.fetch_kline(symbol, period, limit) + + class UnifiedDataService: """统一行情获取与缓存服务。""" @@ -84,4 +113,5 @@ def create_default_service(config: SidecarConfig) -> UnifiedDataService: store = DuckDBStore(config.db_path) mootdx = MootdxSource() tencent = TencentFinanceSource(config.request_timeout_seconds) - return UnifiedDataService(store, config, mootdx, [tencent, mootdx]) \ No newline at end of file + kline_source = FallbackKlineSource(tencent, mootdx, ["day", "week", "month"]) + return UnifiedDataService(store, config, kline_source, [tencent, mootdx]) \ No newline at end of file diff --git a/sidecar/sources/mootdx_source.py b/sidecar/sources/mootdx_source.py index b7a77d3..e6eac81 100644 --- a/sidecar/sources/mootdx_source.py +++ b/sidecar/sources/mootdx_source.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, Dict, List from sidecar.models import KlineBar, Snapshot, parse_date, parse_float -from sidecar.symbols import mootdx_market, normalize_symbol, split_symbol +from sidecar.symbols import is_index_symbol, mootdx_market, normalize_symbol, split_symbol PERIOD_CATEGORY = { @@ -48,7 +48,7 @@ class MootdxSource: if category is None: raise ValueError("不支持的 K 线周期: %s" % period) _, code = split_symbol(normalized) - raw = self._call_bars(code, mootdx_market(normalized), category, limit) + raw = self._call_bars(normalized, code, mootdx_market(normalized), category, limit) records = self._records(raw) bars = [] for item in records: @@ -111,17 +111,18 @@ class MootdxSource: source="mootdx", ) - def _call_bars(self, code: str, market: int, category: int, limit: int) -> Any: + def _call_bars(self, symbol: str, code: str, market: int, category: int, limit: int) -> Any: """ - 功能说明:兼容调用不同版本 mootdx 的 bars 接口。 - 参数说明:code 为六位股票代码,market 为市场编号,category 为周期编号,limit 为数量。 + 功能说明:兼容调用不同版本 mootdx 的 K 线接口。 + 参数说明:symbol 为标准代码,code 为六位代码,market 为市场编号,category 为周期编号,limit 为数量。 返回值说明:返回 mootdx 原始结果对象。 - 注意事项:不同 mootdx 版本参数名存在差异,因此保留一次兼容重试。 + 注意事项:沪深指数必须使用 index_bars,普通 bars 会返回错误价格或异常日期。 """ + method = self.client.index_bars if is_index_symbol(symbol) else self.client.bars try: - return self.client.bars(symbol=code, market=market, category=category, count=limit) + return method(symbol=code, market=market, category=category, count=limit) except TypeError: - return self.client.bars(symbol=code, market=market, frequency=category, offset=0) + return method(symbol=code, market=market, frequency=category, offset=limit) def _records(self, raw: Any) -> List[Dict[str, Any]]: """ diff --git a/sidecar/sources/tencent.py b/sidecar/sources/tencent.py index ed9f61b..9bbef81 100644 --- a/sidecar/sources/tencent.py +++ b/sidecar/sources/tencent.py @@ -36,8 +36,8 @@ class TencentFinanceSource: if tencent_period is None: raise ValueError("腾讯财经 K 线暂不支持周期: %s" % period) normalized = normalize_symbol(symbol) - url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?" + urlencode( - {"param": "%s,%s,,,%d,qfq" % (normalized, tencent_period, limit)} + url = "https://web.ifzq.gtimg.cn/appstock/app/kline/kline?" + urlencode( + {"param": "%s,%s,,,%d" % (normalized, tencent_period, limit)} ) request = Request(url, headers={"User-Agent": "Mozilla/5.0 TortoiseSidecar/0.1"}) with urlopen(request, timeout=self.timeout_seconds) as response: diff --git a/sidecar/symbols.py b/sidecar/symbols.py index 81d3e31..6670179 100644 --- a/sidecar/symbols.py +++ b/sidecar/symbols.py @@ -44,4 +44,15 @@ def mootdx_market(symbol: str) -> int: 注意事项:mootdx 对北交所支持随版本变化,这里按通达信常见约定归入 0。 """ market, _ = split_symbol(symbol) - return 1 if market == "sh" else 0 \ No newline at end of file + return 1 if market == "sh" else 0 + + +def is_index_symbol(symbol: str) -> bool: + """ + 功能说明:判断代码是否为常见沪深指数代码。 + 参数说明:symbol 为任意支持格式的股票或指数代码。 + 返回值说明:指数代码返回 True,其他代码返回 False。 + 注意事项:仅按沪市 000、深市 399 前缀识别,避免影响普通股票 K 线。 + """ + market, code = split_symbol(symbol) + return (market == "sh" and code.startswith("000")) or (market == "sz" and code.startswith("399")) diff --git a/strategy/a500_close_chart.py b/strategy/a500_close_chart.py index 62e0a4b..d8cae7a 100644 --- a/strategy/a500_close_chart.py +++ b/strategy/a500_close_chart.py @@ -17,7 +17,7 @@ from sidecar.storage import DuckDBStore A500_SYMBOL = "sh000510" A500_START_DATE = date(2024, 9, 23) -DEFAULT_OUTPUT_PATH = Path("strategy/output/a500_close.svg") +DEFAULT_OUTPUT_PATH = Path("strategy/output/a500_close_chart/a500_close.svg") @dataclass(frozen=True) @@ -133,7 +133,7 @@ def parse_args() -> argparse.Namespace: 功能说明:解析命令行参数。 参数说明:无。 返回值说明:返回 argparse.Namespace 参数对象。 - 注意事项:默认输出到 strategy/output/a500_close.svg。 + 注意事项:默认输出到 strategy/output/a500_close_chart/a500_close.svg。 """ parser = argparse.ArgumentParser(description="绘制中证 A500 指数成立以来收盘价") parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径") diff --git a/strategy/a500_hs300_close_chart.py b/strategy/a500_hs300_close_chart.py index 005aeef..ff43d9f 100644 --- a/strategy/a500_hs300_close_chart.py +++ b/strategy/a500_hs300_close_chart.py @@ -17,7 +17,7 @@ from sidecar.storage import DuckDBStore from strategy.a500_close_chart import A500_START_DATE, A500_SYMBOL, ClosePoint, filter_close_points HS300_SYMBOL = "sh000300" -DEFAULT_OUTPUT_PATH = Path("strategy/output/a500_hs300_close.svg") +DEFAULT_OUTPUT_PATH = Path("strategy/output/a500_hs300_close_chart/a500_hs300_close.svg") @dataclass(frozen=True) @@ -156,7 +156,7 @@ def parse_args() -> argparse.Namespace: 功能说明:解析命令行参数。 参数说明:无。 返回值说明:返回 argparse.Namespace 参数对象。 - 注意事项:默认输出到 strategy/output/a500_hs300_close.svg。 + 注意事项:默认输出到 strategy/output/a500_hs300_close_chart/a500_hs300_close.svg。 """ parser = argparse.ArgumentParser(description="绘制中证 A500 与沪深 300 双轴收盘价") parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径") diff --git a/strategy/indicators.py b/strategy/indicators.py new file mode 100644 index 0000000..2c7c9ab --- /dev/null +++ b/strategy/indicators.py @@ -0,0 +1,96 @@ +"""策略通用技术指标计算工具。""" + +from __future__ import annotations + +from typing import List, Optional, Sequence, Tuple + + +def calculate_ma(values: Sequence[float], period: int) -> List[Optional[float]]: + """ + 功能说明:计算简单移动平均线。 + 参数说明:values 为按时间升序排列的数值序列,period 为均线周期。 + 返回值说明:返回与输入等长的均线列表,样本不足的位置为 None。 + 注意事项:period 必须大于 0。 + """ + if period <= 0: + raise ValueError("均线周期必须大于 0") + result: List[Optional[float]] = [] + window_sum = 0.0 + for index, value in enumerate(values): + window_sum += value + if index >= period: + window_sum -= values[index - period] + result.append(window_sum / period if index + 1 >= period else None) + return result + + +def calculate_ema(values: Sequence[float], period: int) -> List[Optional[float]]: + """ + 功能说明:计算指数移动平均线。 + 参数说明:values 为按时间升序排列的数值序列,period 为 EMA 周期。 + 返回值说明:返回与输入等长的 EMA 列表,空输入返回空列表。 + 注意事项:首个 EMA 使用首个输入值初始化,period 必须大于 0。 + """ + if period <= 0: + raise ValueError("EMA 周期必须大于 0") + if not values: + return [] + factor = 2 / (period + 1) + result: List[Optional[float]] = [float(values[0])] + for value in values[1:]: + result.append(float(value) * factor + result[-1] * (1 - factor)) + return result + + +def calculate_macd(values: Sequence[float], fast_period: int = 12, slow_period: int = 26, signal_period: int = 9) -> Tuple[List[Optional[float]], List[Optional[float]], List[Optional[float]]]: + """ + 功能说明:计算 MACD 指标的 DIF、DEA 和柱值。 + 参数说明:values 为按时间升序排列的收盘价序列,fast_period 为快线周期,slow_period 为慢线周期,signal_period 为信号线周期。 + 返回值说明:返回 DIF、DEA、MACD 柱值三个等长列表。 + 注意事项:周期必须大于 0,MACD 柱值按 A 股常用口径计算为 2 * (DIF - DEA)。 + """ + if fast_period <= 0 or slow_period <= 0 or signal_period <= 0: + raise ValueError("MACD 周期必须大于 0") + fast_ema = calculate_ema(values, fast_period) + slow_ema = calculate_ema(values, slow_period) + dif = [fast_value - slow_value for fast_value, slow_value in zip(fast_ema, slow_ema)] + dea = calculate_ema(dif, signal_period) + macd = [2 * (dif_value - dea_value) for dif_value, dea_value in zip(dif, dea)] + return dif, dea, macd + + +def calculate_kdj(highs: Sequence[float], lows: Sequence[float], closes: Sequence[float], period: int = 9, k_period: int = 3, d_period: int = 3) -> Tuple[List[Optional[float]], List[Optional[float]], List[Optional[float]]]: + """ + 功能说明:计算 KDJ 指标的 K、D、J 值。 + 参数说明:highs、lows、closes 分别为按时间升序排列的最高价、最低价、收盘价序列,period 为 RSV 周期,k_period 和 d_period 为平滑周期。 + 返回值说明:返回 K、D、J 三个等长列表,样本不足的位置为 None。 + 注意事项:三个价格序列长度必须一致,周期必须大于 0,首个有效 K 和 D 从 50 开始平滑。 + """ + if len(highs) != len(lows) or len(highs) != len(closes): + raise ValueError("KDJ 输入序列长度必须一致") + if period <= 0 or k_period <= 0 or d_period <= 0: + raise ValueError("KDJ 周期必须大于 0") + k_values: List[Optional[float]] = [] + d_values: List[Optional[float]] = [] + j_values: List[Optional[float]] = [] + previous_k = 50.0 + previous_d = 50.0 + for index, close in enumerate(closes): + if index + 1 < period: + k_values.append(None) + d_values.append(None) + j_values.append(None) + continue + start_index = index + 1 - period + highest = max(highs[start_index : index + 1]) + lowest = min(lows[start_index : index + 1]) + rsv = 50.0 if highest == lowest else (close - lowest) / (highest - lowest) * 100 + current_k = (previous_k * (k_period - 1) + rsv) / k_period + current_d = (previous_d * (d_period - 1) + current_k) / d_period + current_j = 3 * current_k - 2 * current_d + k_values.append(current_k) + d_values.append(current_d) + j_values.append(current_j) + previous_k = current_k + previous_d = current_d + return k_values, d_values, j_values diff --git a/strategy/output/a500_close.svg b/strategy/output/a500_close_chart/a500_close.svg similarity index 100% rename from strategy/output/a500_close.svg rename to strategy/output/a500_close_chart/a500_close.svg diff --git a/strategy/doc/strategy1_a500_close_chart.md b/strategy/output/a500_close_chart/a500_close_chart.md similarity index 89% rename from strategy/doc/strategy1_a500_close_chart.md rename to strategy/output/a500_close_chart/a500_close_chart.md index 99f4b10..b6b5cd5 100644 --- a/strategy/doc/strategy1_a500_close_chart.md +++ b/strategy/output/a500_close_chart/a500_close_chart.md @@ -11,7 +11,7 @@ - 成立起始日:`2024-09-23`。 - 数据周期:日线。 - K 线数据源:`sidecar.sources.tencent.TencentFinanceSource`。 -- 输出格式:SVG 图片,默认路径为 `strategy/output/a500_close.svg`。 +- 输出格式:SVG 图片,默认路径为 `strategy/output/a500_close_chart/a500_close.svg`。 ## 数据源说明 @@ -26,14 +26,14 @@ python -m strategy.a500_close_chart --refresh 可指定输出路径: ```powershell -python -m strategy.a500_close_chart --output strategy/output/a500_close.svg --refresh +python -m strategy.a500_close_chart --output strategy/output/a500_close_chart/a500_close.svg --refresh ``` ## 输入 | 参数 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `--output` | string | `strategy/output/a500_close.svg` | 输出 SVG 文件路径 | +| `--output` | string | `strategy/output/a500_close_chart/a500_close.svg` | 输出 SVG 文件路径 | | `--refresh` | bool | `False` | 是否强制刷新 sidecar 缓存 | ## 输出 diff --git a/strategy/output/a500_hs300_close.svg b/strategy/output/a500_hs300_close_chart/a500_hs300_close.svg similarity index 100% rename from strategy/output/a500_hs300_close.svg rename to strategy/output/a500_hs300_close_chart/a500_hs300_close.svg diff --git a/strategy/doc/strategy2_a500_hs300_close_chart.md b/strategy/output/a500_hs300_close_chart/a500_hs300_close_chart.md similarity index 90% rename from strategy/doc/strategy2_a500_hs300_close_chart.md rename to strategy/output/a500_hs300_close_chart/a500_hs300_close_chart.md index a68f12b..17c2963 100644 --- a/strategy/doc/strategy2_a500_hs300_close_chart.md +++ b/strategy/output/a500_hs300_close_chart/a500_hs300_close_chart.md @@ -11,7 +11,7 @@ - 横轴起始日:`2024-09-23`。 - 数据周期:日线。 - K 线数据源:`sidecar.sources.tencent.TencentFinanceSource`。 -- 输出格式:SVG 图片,默认路径为 `strategy/output/a500_hs300_close.svg`。 +- 输出格式:SVG 图片,默认路径为 `strategy/output/a500_hs300_close_chart/a500_hs300_close.svg`。 ## 双轴缩放规则 @@ -36,14 +36,14 @@ python -m strategy.a500_hs300_close_chart --refresh 可指定输出路径: ```powershell -python -m strategy.a500_hs300_close_chart --output strategy/output/a500_hs300_close.svg --refresh +python -m strategy.a500_hs300_close_chart --output strategy/output/a500_hs300_close_chart/a500_hs300_close.svg --refresh ``` ## 输入 | 参数 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `--output` | string | `strategy/output/a500_hs300_close.svg` | 输出 SVG 文件路径 | +| `--output` | string | `strategy/output/a500_hs300_close_chart/a500_hs300_close.svg` | 输出 SVG 文件路径 | | `--refresh` | bool | `False` | 是否强制刷新 sidecar 缓存 | ## 输出 diff --git a/tests/test_real_sources.py b/tests/test_real_sources.py index 76cc9ba..80dfbff 100644 --- a/tests/test_real_sources.py +++ b/tests/test_real_sources.py @@ -1,6 +1,7 @@ """真实数据源集成测试。""" import os +import random from pathlib import Path import pytest @@ -65,4 +66,58 @@ def test_3_3_real_unified_service_cache(tmp_path: Path) -> None: assert len(bars) > 0 assert store.load_kline("sh600000", "day", 5) assert snapshot.price is not None - assert store.load_snapshot("sh600000") is not None \ No newline at end of file + assert store.load_snapshot("sh600000") is not None + + +def assert_random_kline_prices_match(symbols, sample_size: int, seed: int) -> None: + """ + 功能说明:随机抽取多个代码的 K 线价格并比较 mootdx 与腾讯财经是否基本一致。 + 参数说明:symbols 为待比较代码列表,sample_size 为抽样值数量,seed 为固定随机种子。 + 返回值说明:无返回值。 + 注意事项:仅比较 OHLC 价格,成交量和成交额因接口口径差异不参与比较。 + """ + fields = ["open", "high", "low", "close"] + mootdx = MootdxSource() + tencent = TencentFinanceSource(timeout_seconds=10) + candidates = [] + + for symbol in symbols: + mootdx_bars = {bar.trade_date: bar for bar in mootdx.fetch_kline(symbol, "day", 60)} + tencent_bars = {bar.trade_date: bar for bar in tencent.fetch_kline(symbol, "day", 60)} + for trade_date in sorted(set(mootdx_bars) & set(tencent_bars)): + for field in fields: + mootdx_value = getattr(mootdx_bars[trade_date], field) + tencent_value = getattr(tencent_bars[trade_date], field) + if mootdx_value is not None and tencent_value is not None: + candidates.append((symbol, trade_date, field, mootdx_value, tencent_value)) + + assert len(candidates) >= sample_size + + samples = random.Random(seed).sample(candidates, sample_size) + mismatches = [ + (symbol, trade_date, field, mootdx_value, tencent_value) + for symbol, trade_date, field, mootdx_value, tencent_value in samples + if abs(mootdx_value - tencent_value) > 0.05 + ] + + assert mismatches == [] + + +def test_3_4_real_mootdx_index_kline_matches_tencent() -> None: + """ + 功能说明:验证 mootdx 指数 K 线与腾讯财经 K 线价格基本一致。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 3-4,随机抽取上证指数、深证成指、创业板指共 100 个 OHLC 值对比。 + """ + assert_random_kline_prices_match(["sh000001", "sz399001", "sz399006"], 100, 20260623) + + +def test_3_5_real_mootdx_component_kline_matches_tencent() -> None: + """ + 功能说明:验证三大指数代表成分股的 mootdx K 线与腾讯财经 K 线价格基本一致。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 3-5,分别选取贵州茅台、平安银行、宁德时代并随机抽取 100 个 OHLC 值对比。 + """ + assert_random_kline_prices_match(["sh600519", "sz000001", "sz300750"], 100, 20260624) diff --git a/tests/test_service.py b/tests/test_service.py index 104c11d..bec09bb 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -8,7 +8,7 @@ import pytest from sidecar.config import SidecarConfig from sidecar.models import KlineBar, Snapshot -from sidecar.service import UnifiedDataService +from sidecar.service import FallbackKlineSource, UnifiedDataService from sidecar.storage import DuckDBStore @@ -24,6 +24,7 @@ class FakeSource: """ self.kline_calls = 0 self.snapshot_calls = 0 + self.fail_kline = False def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]: """ @@ -33,6 +34,8 @@ class FakeSource: 注意事项:会记录调用次数用于验证缓存。 """ self.kline_calls += 1 + if self.fail_kline: + raise RuntimeError("K ??????") return [ KlineBar(symbol, period, date(2026, 6, 22), 1, 2, 1, 2, 100, 200, "fake"), KlineBar(symbol, period, date(2026, 6, 23), 2, 3, 2, 3, 200, 300, "fake"), @@ -117,4 +120,59 @@ def test_2_2_snapshot_uses_ttl_cache(service: Tuple[UnifiedDataService, FakeSour assert first.price == 10 assert second.price == 10 - assert source.snapshot_calls == 1 \ No newline at end of file + assert source.snapshot_calls == 1 + + +def test_2_3_fallback_kline_uses_primary_period() -> None: + """ + 功能说明:验证主备 K 线源在日线周期优先使用主源。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 2-3,day/week/month 表示腾讯财经主源周期。 + """ + primary = FakeSource() + fallback = FakeSource() + source = FallbackKlineSource(primary, fallback, ["day", "week", "month"]) + + bars = source.fetch_kline("sh600000", "day", 1) + + assert bars[0].source == "fake" + assert primary.kline_calls == 1 + assert fallback.kline_calls == 0 + + +def test_2_4_fallback_kline_uses_fallback_on_primary_error() -> None: + """ + 功能说明:验证主备 K 线源在主源异常时自动使用备用源。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 2-4,用于覆盖腾讯财经失败时回退 mootdx 的行为。 + """ + primary = FakeSource() + fallback = FakeSource() + primary.fail_kline = True + source = FallbackKlineSource(primary, fallback, ["day", "week", "month"]) + + bars = source.fetch_kline("sh600000", "day", 1) + + assert len(bars) == 1 + assert primary.kline_calls == 1 + assert fallback.kline_calls == 1 + + +def test_2_5_fallback_kline_uses_fallback_for_minute_period() -> None: + """ + 功能说明:验证主备 K 线源在分钟周期直接使用备用源。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 2-5,腾讯财经适配器当前不作为分钟线主源。 + """ + primary = FakeSource() + fallback = FakeSource() + source = FallbackKlineSource(primary, fallback, ["day", "week", "month"]) + + bars = source.fetch_kline("sh600000", "5m", 1) + + assert len(bars) == 1 + assert primary.kline_calls == 0 + assert fallback.kline_calls == 1 diff --git a/tests/test_strategy_a500.py b/tests/test_strategy_a500.py index 6704d0e..1b3ecbc 100644 --- a/tests/test_strategy_a500.py +++ b/tests/test_strategy_a500.py @@ -3,7 +3,7 @@ from datetime import date from sidecar.models import KlineBar -from strategy.a500_close_chart import A500_START_DATE, ClosePoint, build_svg_line_chart, filter_close_points +from strategy.a500_close_chart import A500_START_DATE, DEFAULT_OUTPUT_PATH, ClosePoint, build_svg_line_chart, filter_close_points def test_4_1_filter_a500_close_points() -> None: @@ -42,4 +42,14 @@ def test_4_2_build_svg_line_chart() -> None: assert svg.startswith(" None: + """ + 功能说明:验证策略1默认输出到同名子目录。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 4-3。 + """ + assert DEFAULT_OUTPUT_PATH.as_posix() == "strategy/output/a500_close_chart/a500_close.svg" diff --git a/tests/test_strategy_a500_hs300.py b/tests/test_strategy_a500_hs300.py index 13c0d2f..26f9675 100644 --- a/tests/test_strategy_a500_hs300.py +++ b/tests/test_strategy_a500_hs300.py @@ -3,7 +3,7 @@ from datetime import date from strategy.a500_close_chart import ClosePoint -from strategy.a500_hs300_close_chart import DualAxisSeries, _scale_points_by_ratio, align_points_by_date, build_dual_axis_svg_chart +from strategy.a500_hs300_close_chart import DEFAULT_OUTPUT_PATH, DualAxisSeries, _scale_points_by_ratio, align_points_by_date, build_dual_axis_svg_chart def test_5_1_align_points_by_common_date() -> None: @@ -66,3 +66,13 @@ def test_5_3_same_return_ratio_maps_to_same_y() -> None: assert left_coordinates[0][1] == right_coordinates[0][1] assert left_coordinates[1][1] == right_coordinates[1][1] + + +def test_5_4_default_output_path_uses_strategy_directory() -> None: + """ + 功能说明:验证策略2默认输出到同名子目录。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 5-4。 + """ + assert DEFAULT_OUTPUT_PATH.as_posix() == "strategy/output/a500_hs300_close_chart/a500_hs300_close.svg" diff --git a/tests/test_strategy_indicators.py b/tests/test_strategy_indicators.py new file mode 100644 index 0000000..d5d858e --- /dev/null +++ b/tests/test_strategy_indicators.py @@ -0,0 +1,70 @@ +"""策略通用技术指标工具测试。""" + +import pytest + +from strategy.indicators import calculate_ema, calculate_kdj, calculate_ma, calculate_macd + + +def test_6_1_calculate_ma() -> None: + """ + 功能说明:验证简单移动平均线会在样本不足时返回 None。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 6-1。 + """ + assert calculate_ma([1, 2, 3, 4], 3) == [None, None, 2.0, 3.0] + + +def test_6_2_calculate_ema() -> None: + """ + 功能说明:验证指数移动平均线按首个值初始化并递推。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 6-2。 + """ + assert calculate_ema([1, 2, 3], 2) == pytest.approx([1.0, 1.6666666667, 2.5555555556]) + + +def test_6_3_calculate_macd() -> None: + """ + 功能说明:验证 MACD 返回 DIF、DEA 和柱值三个等长序列。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 6-3。 + """ + dif, dea, macd = calculate_macd([1, 2, 3, 4], fast_period=2, slow_period=3, signal_period=2) + + assert len(dif) == 4 + assert len(dea) == 4 + assert len(macd) == 4 + assert dif == pytest.approx([0.0, 0.1666666667, 0.3055555556, 0.3935185185]) + assert dea == pytest.approx([0.0, 0.1111111111, 0.2407407407, 0.3425925926]) + assert macd == pytest.approx([0.0, 0.1111111111, 0.1296296296, 0.1018518519]) + + +def test_6_4_calculate_kdj() -> None: + """ + 功能说明:验证 KDJ 在样本足够后计算 K、D、J 值。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 6-4。 + """ + k_values, d_values, j_values = calculate_kdj([3, 4, 5], [1, 1, 1], [2, 3, 4], period=3) + + assert k_values[:2] == [None, None] + assert d_values[:2] == [None, None] + assert j_values[:2] == [None, None] + assert k_values[2] == pytest.approx(58.3333333333) + assert d_values[2] == pytest.approx(52.7777777778) + assert j_values[2] == pytest.approx(69.4444444444) + + +def test_6_5_indicator_period_validation() -> None: + """ + 功能说明:验证通用指标工具会拒绝非法周期参数。 + 参数说明:无。 + 返回值说明:无返回值。 + 注意事项:用例编号 6-5。 + """ + with pytest.raises(ValueError): + calculate_ma([1], 0)