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 /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 计算。
|
||||||
|
|||||||
@ -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 值基本一致。
|
||||||
|
|
||||||
运行方式:
|
运行方式:
|
||||||
|
|
||||||
|
|||||||
@ -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])
|
||||||
@ -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]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"))
|
||||||
|
|||||||
@ -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 文件路径")
|
||||||
|
|||||||
@ -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
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`。
|
- 成立起始日:`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 缓存 |
|
||||||
|
|
||||||
## 输出
|
## 输出
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -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 缓存 |
|
||||||
|
|
||||||
## 输出
|
## 输出
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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-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 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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
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