save
This commit is contained in:
parent
db4e519cc2
commit
e046c08e02
11
README.md
11
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`。
|
||||
默认输出:`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 计算。
|
||||
|
||||
@ -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 值基本一致。
|
||||
|
||||
运行方式:
|
||||
|
||||
|
||||
@ -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])
|
||||
kline_source = FallbackKlineSource(tencent, mootdx, ["day", "week", "month"])
|
||||
return UnifiedDataService(store, config, kline_source, [tencent, mootdx])
|
||||
@ -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]]:
|
||||
"""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -44,4 +44,15 @@ def mootdx_market(symbol: str) -> int:
|
||||
注意事项:mootdx 对北交所支持随版本变化,这里按通达信常见约定归入 0。
|
||||
"""
|
||||
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"))
|
||||
|
||||
@ -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 文件路径")
|
||||
|
||||
@ -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 文件路径")
|
||||
|
||||
96
strategy/indicators.py
Normal file
96
strategy/indicators.py
Normal 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 指标的 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
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
@ -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 缓存 |
|
||||
|
||||
## 输出
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -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 缓存 |
|
||||
|
||||
## 输出
|
||||
@ -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
|
||||
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)
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
@ -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("<svg")
|
||||
assert "中证 A500 指数成立以来收盘价" 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"
|
||||
|
||||
@ -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"
|
||||
|
||||
70
tests/test_strategy_indicators.py
Normal file
70
tests/test_strategy_indicators.py
Normal 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 返回 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)
|
||||
Loading…
Reference in New Issue
Block a user