This commit is contained in:
cheney 2026-06-24 16:50:59 +08:00
parent db4e519cc2
commit e046c08e02
18 changed files with 386 additions and 34 deletions

View File

@ -19,7 +19,7 @@ uvicorn sidecar.api:app --host 127.0.0.1 --port 8765
常用接口: 常用接口:
- `GET /health`:健康检查。 - `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 缓存。 - `GET /snapshot/{symbol}?refresh=false`:获取实时快照,优先腾讯财经,失败后回退 mootdx并写入 DuckDB TTL 缓存。
默认 DuckDB 文件为 `data/tortoise.duckdb`,可通过 `TORTOISE_DUCKDB_PATH` 修改。 默认 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 python -m strategy.a500_close_chart --refresh
``` ```
默认输出:`strategy/output/a500_close.svg`。 默认输出:`strategy/output/a500_close_chart/a500_close.svg`。
## 策略2中证 A500 与沪深 300 双轴收盘价图 ## 策略2中证 A500 与沪深 300 双轴收盘价图
运行: 运行:
@ -41,4 +41,9 @@ python -m strategy.a500_close_chart --refresh
python -m strategy.a500_hs300_close_chart --refresh python -m strategy.a500_hs300_close_chart --refresh
``` ```
默认输出:`strategy/output/a500_hs300_close.svg`。 默认输出:`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 计算。

View File

@ -9,13 +9,13 @@
- `sidecar/api.py`FastAPI HTTP 接口,提供 `/health`、`/kline/{symbol}`、`/snapshot/{symbol}`。 - `sidecar/api.py`FastAPI HTTP 接口,提供 `/health`、`/kline/{symbol}`、`/snapshot/{symbol}`。
- `sidecar/service.py`:统一数据服务,负责缓存命中、远端拉取、数据源降级。 - `sidecar/service.py`:统一数据服务,负责缓存命中、远端拉取、数据源降级。
- `sidecar/storage.py`DuckDB 仓储,维护 `md.kline_bars``md.snapshots` - `sidecar/storage.py`DuckDB 仓储,维护 `md.kline_bars``md.snapshots`
- `sidecar/sources/mootdx_source.py`mootdx 适配器,负责 K 线与备用快照。 - `sidecar/sources/mootdx_source.py`mootdx 适配器,负责备用 K 线、分钟线与备用快照。
- `sidecar/sources/tencent.py`:腾讯财经适配器,负责估值字段更完整的实时快照。 - `sidecar/sources/tencent.py`:腾讯财经适配器,负责日/周/月 K 线与估值字段更完整的实时快照。
- `sidecar/models.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。 - 快照:默认优先腾讯财经,失败后回退 mootdx。
- 腾讯财经字段校准:`39=PE_TTM`、`46=PB`、`52=PE 静态``43` 是振幅,不作为 PB 使用。 - 腾讯财经字段校准:`39=PE_TTM`、`46=PB`、`52=PE 静态``43` 是振幅,不作为 PB 使用。
@ -40,8 +40,14 @@ snapshot = service.get_snapshot("600000")
- `1-1`:验证腾讯财经 PB 使用索引 46避免误用索引 43。 - `1-1`:验证腾讯财经 PB 使用索引 46避免误用索引 43。
- `1-2`:验证腾讯财经原始响应可解析为字段列表。 - `1-2`:验证腾讯财经原始响应可解析为字段列表。
- `1-3`:验证腾讯财经 K 线 JSON 可转换为统一 K 线。
- `2-1`:验证 K 线首次拉取后可从 DuckDB 缓存读取。 - `2-1`:验证 K 线首次拉取后可从 DuckDB 缓存读取。
- `2-2`:验证快照在 TTL 内复用 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 值基本一致。
运行方式: 运行方式:

View File

@ -11,6 +11,35 @@ from sidecar.symbols import normalize_symbol
from sidecar.storage import DuckDBStore 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: class UnifiedDataService:
"""统一行情获取与缓存服务。""" """统一行情获取与缓存服务。"""
@ -84,4 +113,5 @@ def create_default_service(config: SidecarConfig) -> UnifiedDataService:
store = DuckDBStore(config.db_path) store = DuckDBStore(config.db_path)
mootdx = MootdxSource() mootdx = MootdxSource()
tencent = TencentFinanceSource(config.request_timeout_seconds) tencent = TencentFinanceSource(config.request_timeout_seconds)
return UnifiedDataService(store, config, mootdx, [tencent, mootdx]) kline_source = FallbackKlineSource(tencent, mootdx, ["day", "week", "month"])
return UnifiedDataService(store, config, kline_source, [tencent, mootdx])

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List
from sidecar.models import KlineBar, Snapshot, parse_date, parse_float 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 = { PERIOD_CATEGORY = {
@ -48,7 +48,7 @@ class MootdxSource:
if category is None: if category is None:
raise ValueError("不支持的 K 线周期: %s" % period) raise ValueError("不支持的 K 线周期: %s" % period)
_, code = split_symbol(normalized) _, 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) records = self._records(raw)
bars = [] bars = []
for item in records: for item in records:
@ -111,17 +111,18 @@ class MootdxSource:
source="mootdx", 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 接口 功能说明兼容调用不同版本 mootdx K 线接口
参数说明code 为六位股票代码market 为市场编号category 为周期编号limit 为数量 参数说明symbol 为标准代码code 为六位代码market 为市场编号category 为周期编号limit 为数量
返回值说明返回 mootdx 原始结果对象 返回值说明返回 mootdx 原始结果对象
注意事项不同 mootdx 版本参数名存在差异因此保留一次兼容重试 注意事项沪深指数必须使用 index_bars普通 bars 会返回错误价格或异常日期
""" """
method = self.client.index_bars if is_index_symbol(symbol) else self.client.bars
try: 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: 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]]: def _records(self, raw: Any) -> List[Dict[str, Any]]:
""" """

View File

@ -36,8 +36,8 @@ class TencentFinanceSource:
if tencent_period is None: if tencent_period is None:
raise ValueError("腾讯财经 K 线暂不支持周期: %s" % period) raise ValueError("腾讯财经 K 线暂不支持周期: %s" % period)
normalized = normalize_symbol(symbol) normalized = normalize_symbol(symbol)
url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?" + urlencode( url = "https://web.ifzq.gtimg.cn/appstock/app/kline/kline?" + urlencode(
{"param": "%s,%s,,,%d,qfq" % (normalized, tencent_period, limit)} {"param": "%s,%s,,,%d" % (normalized, tencent_period, limit)}
) )
request = Request(url, headers={"User-Agent": "Mozilla/5.0 TortoiseSidecar/0.1"}) request = Request(url, headers={"User-Agent": "Mozilla/5.0 TortoiseSidecar/0.1"})
with urlopen(request, timeout=self.timeout_seconds) as response: with urlopen(request, timeout=self.timeout_seconds) as response:

View File

@ -45,3 +45,14 @@ def mootdx_market(symbol: str) -> int:
""" """
market, _ = split_symbol(symbol) market, _ = split_symbol(symbol)
return 1 if market == "sh" else 0 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"))

View File

@ -17,7 +17,7 @@ from sidecar.storage import DuckDBStore
A500_SYMBOL = "sh000510" A500_SYMBOL = "sh000510"
A500_START_DATE = date(2024, 9, 23) 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) @dataclass(frozen=True)
@ -133,7 +133,7 @@ def parse_args() -> argparse.Namespace:
功能说明解析命令行参数 功能说明解析命令行参数
参数说明 参数说明
返回值说明返回 argparse.Namespace 参数对象 返回值说明返回 argparse.Namespace 参数对象
注意事项默认输出到 strategy/output/a500_close.svg 注意事项默认输出到 strategy/output/a500_close_chart/a500_close.svg
""" """
parser = argparse.ArgumentParser(description="绘制中证 A500 指数成立以来收盘价") parser = argparse.ArgumentParser(description="绘制中证 A500 指数成立以来收盘价")
parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径") parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径")

View File

@ -17,7 +17,7 @@ from sidecar.storage import DuckDBStore
from strategy.a500_close_chart import A500_START_DATE, A500_SYMBOL, ClosePoint, filter_close_points from strategy.a500_close_chart import A500_START_DATE, A500_SYMBOL, ClosePoint, filter_close_points
HS300_SYMBOL = "sh000300" 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) @dataclass(frozen=True)
@ -156,7 +156,7 @@ def parse_args() -> argparse.Namespace:
功能说明解析命令行参数 功能说明解析命令行参数
参数说明 参数说明
返回值说明返回 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 = argparse.ArgumentParser(description="绘制中证 A500 与沪深 300 双轴收盘价")
parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径") parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径")

96
strategy/indicators.py Normal file
View File

@ -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 指标的 DIFDEA 和柱值
参数说明values 为按时间升序排列的收盘价序列fast_period 为快线周期slow_period 为慢线周期signal_period 为信号线周期
返回值说明返回 DIFDEAMACD 柱值三个等长列表
注意事项周期必须大于 0MACD 柱值按 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 指标的 KDJ
参数说明highslowscloses 分别为按时间升序排列的最高价最低价收盘价序列period RSV 周期k_period d_period 为平滑周期
返回值说明返回 KDJ 三个等长列表样本不足的位置为 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

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -11,7 +11,7 @@
- 成立起始日:`2024-09-23`。 - 成立起始日:`2024-09-23`。
- 数据周期:日线。 - 数据周期:日线。
- K 线数据源:`sidecar.sources.tencent.TencentFinanceSource`。 - 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 ```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 缓存 | | `--refresh` | bool | `False` | 是否强制刷新 sidecar 缓存 |
## 输出 ## 输出

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -11,7 +11,7 @@
- 横轴起始日:`2024-09-23`。 - 横轴起始日:`2024-09-23`。
- 数据周期:日线。 - 数据周期:日线。
- K 线数据源:`sidecar.sources.tencent.TencentFinanceSource`。 - 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 ```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 缓存 | | `--refresh` | bool | `False` | 是否强制刷新 sidecar 缓存 |
## 输出 ## 输出

View File

@ -1,6 +1,7 @@
"""真实数据源集成测试。""" """真实数据源集成测试。"""
import os import os
import random
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -66,3 +67,57 @@ def test_3_3_real_unified_service_cache(tmp_path: Path) -> None:
assert store.load_kline("sh600000", "day", 5) assert store.load_kline("sh600000", "day", 5)
assert snapshot.price is not None assert snapshot.price is not None
assert store.load_snapshot("sh600000") is not None 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)

View File

@ -8,7 +8,7 @@ import pytest
from sidecar.config import SidecarConfig from sidecar.config import SidecarConfig
from sidecar.models import KlineBar, Snapshot from sidecar.models import KlineBar, Snapshot
from sidecar.service import UnifiedDataService from sidecar.service import FallbackKlineSource, UnifiedDataService
from sidecar.storage import DuckDBStore from sidecar.storage import DuckDBStore
@ -24,6 +24,7 @@ class FakeSource:
""" """
self.kline_calls = 0 self.kline_calls = 0
self.snapshot_calls = 0 self.snapshot_calls = 0
self.fail_kline = False
def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]: def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]:
""" """
@ -33,6 +34,8 @@ class FakeSource:
注意事项会记录调用次数用于验证缓存 注意事项会记录调用次数用于验证缓存
""" """
self.kline_calls += 1 self.kline_calls += 1
if self.fail_kline:
raise RuntimeError("K ??????")
return [ return [
KlineBar(symbol, period, date(2026, 6, 22), 1, 2, 1, 2, 100, 200, "fake"), 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"), KlineBar(symbol, period, date(2026, 6, 23), 2, 3, 2, 3, 200, 300, "fake"),
@ -118,3 +121,58 @@ def test_2_2_snapshot_uses_ttl_cache(service: Tuple[UnifiedDataService, FakeSour
assert first.price == 10 assert first.price == 10
assert second.price == 10 assert second.price == 10
assert source.snapshot_calls == 1 assert source.snapshot_calls == 1
def test_2_3_fallback_kline_uses_primary_period() -> None:
"""
功能说明验证主备 K 线源在日线周期优先使用主源
参数说明
返回值说明无返回值
注意事项用例编号 2-3day/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

View File

@ -3,7 +3,7 @@
from datetime import date from datetime import date
from sidecar.models import KlineBar 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: def test_4_1_filter_a500_close_points() -> None:
@ -43,3 +43,13 @@ def test_4_2_build_svg_line_chart() -> None:
assert "中证 A500 指数成立以来收盘价" in svg assert "中证 A500 指数成立以来收盘价" in svg
assert "2024-09-23" in svg assert "2024-09-23" in svg
assert "<polyline" in svg assert "<polyline" in svg
def test_4_3_default_output_path_uses_strategy_directory() -> None:
"""
功能说明验证策略1默认输出到同名子目录
参数说明
返回值说明无返回值
注意事项用例编号 4-3
"""
assert DEFAULT_OUTPUT_PATH.as_posix() == "strategy/output/a500_close_chart/a500_close.svg"

View File

@ -3,7 +3,7 @@
from datetime import date from datetime import date
from strategy.a500_close_chart import ClosePoint 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: 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[0][1] == right_coordinates[0][1]
assert left_coordinates[1][1] == right_coordinates[1][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"

View File

@ -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 返回 DIFDEA 和柱值三个等长序列
参数说明
返回值说明无返回值
注意事项用例编号 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 在样本足够后计算 KDJ
参数说明
返回值说明无返回值
注意事项用例编号 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)