diff --git a/.gitignore b/.gitignore
index 75b149e..291e668 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,7 @@
*.pyc
*.egg-info/**
+
+data/*.duckdb
+data/*.duckdb.wal
+.pytest_cache/
+__pycache__/
diff --git a/README.md b/README.md
index 8e7728d..0fb42b6 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Tortoise Stock
+# Tortoise Stock
轻量级 A 股投研回测系统。当前已提供 Python sidecar 的统一数据获取与 DuckDB 本地缓存接口,后续策略模块可直接基于该接口读取 K 线与行情快照。
@@ -23,3 +23,13 @@ uvicorn sidecar.api:app --host 127.0.0.1 --port 8765
- `GET /snapshot/{symbol}?refresh=false`:获取实时快照,优先腾讯财经,失败后回退 mootdx,并写入 DuckDB TTL 缓存。
默认 DuckDB 文件为 `data/tortoise.duckdb`,可通过 `TORTOISE_DUCKDB_PATH` 修改。
+
+## 策略1:中证 A500 收盘价图
+
+运行:
+
+```powershell
+python -m strategy.a500_close_chart --refresh
+```
+
+默认输出:`strategy/output/a500_close.svg`。
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 785e788..983a453 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@ setup(
name="tortoise-stock-sidecar",
version="0.1.0",
description="Python sidecar for unified A-share data fetching and DuckDB cache.",
- packages=find_packages(include=["sidecar", "sidecar.*"]),
+ packages=find_packages(include=["sidecar", "sidecar.*", "strategy", "strategy.*"]),
python_requires=">=3.8",
install_requires=[
"duckdb>=0.10.3,<1.2",
diff --git a/sidecar/sources/tencent.py b/sidecar/sources/tencent.py
index 5f1cfae..ed9f61b 100644
--- a/sidecar/sources/tencent.py
+++ b/sidecar/sources/tencent.py
@@ -2,12 +2,13 @@
from __future__ import annotations
+import json
from datetime import datetime
-from typing import List, Optional
+from typing import Any, Dict, List, Optional
from urllib.parse import urlencode
from urllib.request import Request, urlopen
-from sidecar.models import KlineBar, Snapshot, parse_float
+from sidecar.models import KlineBar, Snapshot, parse_date, parse_float
from sidecar.symbols import normalize_symbol
@@ -25,12 +26,23 @@ class TencentFinanceSource:
def fetch_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]:
"""
- 功能说明:获取 K 线数据。
- 参数说明:symbol 为股票代码,period 为周期,limit 为最大条数。
- 返回值说明:腾讯适配器暂不提供 K 线,固定抛出 NotImplementedError。
- 注意事项:本项目 K 线优先由 mootdx 提供。
+ 功能说明:获取腾讯财经 K 线数据。
+ 参数说明:symbol 为股票或指数代码,period 为周期,limit 为最大条数。
+ 返回值说明:返回统一 KlineBar 列表。
+ 注意事项:当前仅支持 day/week/month,用于补充指数历史 K 线。
"""
- raise NotImplementedError("腾讯财经适配器暂不提供 K 线")
+ period_map = {"day": "day", "week": "week", "month": "month"}
+ tencent_period = period_map.get(period)
+ 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)}
+ )
+ request = Request(url, headers={"User-Agent": "Mozilla/5.0 TortoiseSidecar/0.1"})
+ with urlopen(request, timeout=self.timeout_seconds) as response:
+ payload = response.read().decode("utf-8", errors="ignore")
+ return kline_from_tencent_payload(normalized, period, payload)
def fetch_snapshot(self, symbol: str) -> Snapshot:
"""
@@ -67,6 +79,40 @@ def parse_tencent_payload(payload: str) -> List[str]:
return fields
+def kline_from_tencent_payload(symbol: str, period: str, payload: str) -> List[KlineBar]:
+ """
+ 功能说明:把腾讯财经 K 线 JSON 转换为统一 K 线列表。
+ 参数说明:symbol 为标准股票代码,period 为统一周期,payload 为腾讯 K 线 JSON 文本。
+ 返回值说明:返回 KlineBar 列表。
+ 注意事项:腾讯字段顺序为 日期、开盘、收盘、最高、最低、成交量,部分响应可能附带成交额。
+ """
+ data = json.loads(payload)
+ if data.get("code") != 0:
+ raise ValueError("腾讯财经 K 线响应失败: %s" % data.get("msg"))
+ symbol_data = data.get("data", {}).get(symbol, {})
+ rows = symbol_data.get(period) or symbol_data.get("qfq%s" % period) or []
+ bars = []
+ for row in rows:
+ trade_date = parse_date(_row_field(row, 0))
+ if trade_date is None:
+ continue
+ bars.append(
+ KlineBar(
+ symbol=symbol,
+ period=period,
+ trade_date=trade_date,
+ open=parse_float(_row_field(row, 1)),
+ close=parse_float(_row_field(row, 2)),
+ high=parse_float(_row_field(row, 3)),
+ low=parse_float(_row_field(row, 4)),
+ volume=parse_float(_row_field(row, 5)),
+ amount=parse_float(_row_field(row, 6)),
+ source="tencent",
+ )
+ )
+ return bars
+
+
def snapshot_from_tencent_fields(symbol: str, fields: List[str]) -> Snapshot:
"""
功能说明:把腾讯财经字段转换为统一快照。
@@ -109,6 +155,16 @@ def _field(fields: List[str], index: int) -> str:
return fields[index] if index < len(fields) else ""
+def _row_field(row: List[Any], index: int) -> Any:
+ """
+ 功能说明:安全读取腾讯 K 线行字段。
+ 参数说明:row 为腾讯 K 线数组,index 为字段索引。
+ 返回值说明:字段存在返回原始值,不存在返回 None。
+ 注意事项:不同标的可能缺少成交额字段。
+ """
+ return row[index] if index < len(row) else None
+
+
def _parse_trade_time(value: str) -> Optional[datetime]:
"""
功能说明:解析腾讯交易时间字段。
diff --git a/sidecar/storage.py b/sidecar/storage.py
index 250c7fd..be38c8a 100644
--- a/sidecar/storage.py
+++ b/sidecar/storage.py
@@ -88,38 +88,37 @@ class DuckDBStore:
功能说明:批量写入 K 线缓存。
参数说明:bars 为统一 KlineBar 列表。
返回值说明:无返回值。
- 注意事项:相同 symbol、period、trade_date 的记录会被覆盖。
+ 注意事项:相同 symbol、period、trade_date 的记录会被覆盖;为兼容 DuckDB 0.10 主键索引限制,先删除再插入。
"""
if not bars:
return
+ deduped = {}
+ for bar in bars:
+ deduped[(bar.symbol, bar.period, bar.trade_date)] = bar
+ bars = list(deduped.values())
now = datetime.now(timezone.utc).replace(tzinfo=None)
- self.conn.execute("BEGIN TRANSACTION")
- try:
- for bar in bars:
- self.conn.execute(
- "DELETE FROM md.kline_bars WHERE symbol = ? AND period = ? AND trade_date = ?",
- [bar.symbol, bar.period, bar.trade_date],
- )
- self.conn.execute(
- "INSERT INTO md.kline_bars VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- [
- bar.symbol,
- bar.period,
- bar.trade_date,
- bar.open,
- bar.high,
- bar.low,
- bar.close,
- bar.volume,
- bar.amount,
- bar.source,
- now,
- ],
- )
- self.conn.execute("COMMIT")
- except Exception:
- self.conn.execute("ROLLBACK")
- raise
+ for bar in bars:
+ self.conn.execute(
+ "DELETE FROM md.kline_bars WHERE symbol = ? AND period = ? AND trade_date = ?",
+ [bar.symbol, bar.period, bar.trade_date],
+ )
+ for bar in bars:
+ self.conn.execute(
+ "INSERT INTO md.kline_bars VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ [
+ bar.symbol,
+ bar.period,
+ bar.trade_date,
+ bar.open,
+ bar.high,
+ bar.low,
+ bar.close,
+ bar.volume,
+ bar.amount,
+ bar.source,
+ now,
+ ],
+ )
def load_kline(self, symbol: str, period: str, limit: int) -> List[KlineBar]:
"""
diff --git a/strategy/__init__.py b/strategy/__init__.py
new file mode 100644
index 0000000..5e719bc
--- /dev/null
+++ b/strategy/__init__.py
@@ -0,0 +1 @@
+"""本地策略模块。"""
\ No newline at end of file
diff --git a/strategy/a500_close_chart.py b/strategy/a500_close_chart.py
new file mode 100644
index 0000000..62e0a4b
--- /dev/null
+++ b/strategy/a500_close_chart.py
@@ -0,0 +1,227 @@
+"""策略1:绘制中证 A500 指数成立以来收盘价。"""
+
+from __future__ import annotations
+
+import argparse
+from dataclasses import dataclass
+from datetime import date
+from pathlib import Path
+from typing import Iterable, List, Sequence, Tuple
+from xml.sax.saxutils import escape
+
+from sidecar.config import load_config
+from sidecar.models import KlineBar
+from sidecar.service import UnifiedDataService
+from sidecar.sources.tencent import TencentFinanceSource
+from sidecar.storage import DuckDBStore
+
+A500_SYMBOL = "sh000510"
+A500_START_DATE = date(2024, 9, 23)
+DEFAULT_OUTPUT_PATH = Path("strategy/output/a500_close.svg")
+
+
+@dataclass(frozen=True)
+class ClosePoint:
+ """收盘价绘图点。"""
+
+ trade_date: date
+ close: float
+
+
+def filter_close_points(bars: Iterable[KlineBar], start_date: date) -> List[ClosePoint]:
+ """
+ 功能说明:从 K 线列表中筛选指定起始日之后的有效收盘价。
+ 参数说明:bars 为 KlineBar 可迭代对象,start_date 为保留数据的起始交易日。
+ 返回值说明:返回按交易日期升序排列的 ClosePoint 列表。
+ 注意事项:close 为空的数据会被忽略,避免绘图断线。
+ """
+ points = [ClosePoint(bar.trade_date, float(bar.close)) for bar in bars if bar.trade_date >= start_date and bar.close is not None]
+ return sorted(points, key=lambda point: point.trade_date)
+
+
+def build_svg_line_chart(points: Sequence[ClosePoint], title: str, width: int = 1200, height: int = 680) -> str:
+ """
+ 功能说明:把收盘价序列渲染为 SVG 折线图。
+ 参数说明:points 为收盘价点序列,title 为图标题,width 和 height 为画布尺寸。
+ 返回值说明:返回完整 SVG 文本。
+ 注意事项:该函数只依赖标准库,方便在无绘图库环境中运行。
+ """
+ if not points:
+ raise ValueError("没有可绘制的收盘价数据")
+ margin_left = 82
+ margin_right = 36
+ margin_top = 64
+ margin_bottom = 82
+ chart_width = width - margin_left - margin_right
+ chart_height = height - margin_top - margin_bottom
+ closes = [point.close for point in points]
+ min_close = min(closes)
+ max_close = max(closes)
+ close_range = max_close - min_close or 1.0
+ coordinates = _scale_points(points, margin_left, margin_top, chart_width, chart_height, min_close, close_range)
+ polyline = " ".join("%.2f,%.2f" % coordinate for coordinate in coordinates)
+ y_axis = _render_y_axis(_build_y_ticks(min_close, max_close, 5), margin_left, margin_top, chart_width, chart_height, min_close, close_range)
+ x_axis = _render_x_axis(_build_x_ticks(points, 6), coordinates, margin_top + chart_height)
+ first_point = points[0]
+ last_point = points[-1]
+ subtitle = "%s 至 %s,共 %d 个交易日" % (first_point.trade_date.isoformat(), last_point.trade_date.isoformat(), len(points))
+ return """
+""".format(
+ width=width,
+ height=height,
+ center_x=width / 2,
+ title=escape(title),
+ subtitle=escape(subtitle),
+ y_axis="\n ".join(y_axis),
+ x_axis="\n ".join(x_axis),
+ left=margin_left,
+ top=margin_top,
+ bottom=margin_top + chart_height,
+ right=width - margin_right,
+ polyline=polyline,
+ last_x=coordinates[-1][0],
+ last_y=coordinates[-1][1],
+ last_label_x=min(coordinates[-1][0] + 10, width - margin_right - 130),
+ last_label_y=max(coordinates[-1][1] - 10, margin_top + 14),
+ last_close=last_point.close,
+ footer_y=height - 18,
+ )
+
+
+def fetch_a500_close_points(refresh: bool = False) -> List[ClosePoint]:
+ """
+ 功能说明:通过 sidecar 获取中证 A500 成立以来的日线收盘价。
+ 参数说明:refresh 表示是否强制刷新 sidecar 缓存。
+ 返回值说明:返回中证 A500 成立以来的 ClosePoint 列表。
+ 注意事项:指数历史 K 线使用腾讯财经源,避免 mootdx 对 sh000510 解析为非指数行情。
+ """
+ config = load_config()
+ tencent = TencentFinanceSource(config.request_timeout_seconds)
+ service = UnifiedDataService(DuckDBStore(config.db_path), config, tencent, [tencent])
+ bars = service.get_kline(A500_SYMBOL, period="day", limit=800, refresh=refresh)
+ return filter_close_points(bars, A500_START_DATE)
+
+
+def write_a500_close_chart(output_path: Path = DEFAULT_OUTPUT_PATH, refresh: bool = False) -> Path:
+ """
+ 功能说明:生成中证 A500 成立以来收盘价 SVG 图。
+ 参数说明:output_path 为输出 SVG 路径,refresh 表示是否强制刷新 sidecar 缓存。
+ 返回值说明:返回实际写入的图片路径。
+ 注意事项:输出目录不存在时会自动创建。
+ """
+ points = fetch_a500_close_points(refresh=refresh)
+ svg = build_svg_line_chart(points, "中证 A500 指数成立以来收盘价")
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(svg, encoding="utf-8")
+ return output_path
+
+
+def parse_args() -> argparse.Namespace:
+ """
+ 功能说明:解析命令行参数。
+ 参数说明:无。
+ 返回值说明:返回 argparse.Namespace 参数对象。
+ 注意事项:默认输出到 strategy/output/a500_close.svg。
+ """
+ parser = argparse.ArgumentParser(description="绘制中证 A500 指数成立以来收盘价")
+ parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="输出 SVG 文件路径")
+ parser.add_argument("--refresh", action="store_true", help="强制刷新 sidecar 缓存")
+ return parser.parse_args()
+
+
+def main() -> None:
+ """
+ 功能说明:命令行入口函数。
+ 参数说明:无。
+ 返回值说明:无返回值。
+ 注意事项:执行成功后会在终端打印输出文件路径。
+ """
+ args = parse_args()
+ output_path = write_a500_close_chart(Path(args.output), refresh=args.refresh)
+ print("已生成图表: %s" % output_path)
+
+
+def _scale_points(points: Sequence[ClosePoint], margin_left: int, margin_top: int, chart_width: int, chart_height: int, min_close: float, close_range: float) -> List[Tuple[float, float]]:
+ """
+ 功能说明:把收盘价点转换为 SVG 坐标。
+ 参数说明:points 为收盘价点,margin_left 和 margin_top 为边距,chart_width 和 chart_height 为绘图区尺寸,min_close 和 close_range 为价格缩放参数。
+ 返回值说明:返回 SVG 坐标列表。
+ 注意事项:单点数据会绘制在横轴起点。
+ """
+ max_index = max(len(points) - 1, 1)
+ return [(margin_left + index / max_index * chart_width, margin_top + chart_height - ((point.close - min_close) / close_range) * chart_height) for index, point in enumerate(points)]
+
+
+def _build_y_ticks(min_close: float, max_close: float, tick_count: int) -> List[float]:
+ """
+ 功能说明:生成纵轴价格刻度。
+ 参数说明:min_close 为最低收盘价,max_close 为最高收盘价,tick_count 为刻度数量。
+ 返回值说明:返回价格刻度列表。
+ 注意事项:当最高最低相等时返回单一价格刻度。
+ """
+ if tick_count <= 1 or min_close == max_close:
+ return [min_close]
+ step = (max_close - min_close) / (tick_count - 1)
+ return [min_close + step * index for index in range(tick_count)]
+
+
+def _build_x_ticks(points: Sequence[ClosePoint], tick_count: int) -> List[Tuple[int, date]]:
+ """
+ 功能说明:生成横轴日期刻度。
+ 参数说明:points 为收盘价点序列,tick_count 为目标刻度数量。
+ 返回值说明:返回二元组列表,包含点索引和日期。
+ 注意事项:会去重索引,避免短序列重复显示同一天。
+ """
+ if not points:
+ return []
+ max_index = len(points) - 1
+ if tick_count <= 1 or max_index == 0:
+ return [(0, points[0].trade_date)]
+ indexes = sorted({round(index * max_index / (tick_count - 1)) for index in range(tick_count)})
+ return [(index, points[index].trade_date) for index in indexes]
+
+
+def _render_y_axis(ticks: Sequence[float], margin_left: int, margin_top: int, chart_width: int, chart_height: int, min_close: float, close_range: float) -> List[str]:
+ """
+ 功能说明:渲染纵轴网格线和文本。
+ 参数说明:ticks 为价格刻度,margin_left 和 margin_top 为边距,chart_width 和 chart_height 为绘图区尺寸,min_close 和 close_range 为价格缩放参数。
+ 返回值说明:返回 SVG 片段列表。
+ 注意事项:该函数仅生成轴元素,不生成折线。
+ """
+ lines = []
+ for tick_value in ticks:
+ y_position = margin_top + chart_height - ((tick_value - min_close) / close_range) * chart_height
+ lines.append('' % (margin_left, y_position, margin_left + chart_width, y_position))
+ lines.append('%.2f' % (margin_left - 10, y_position + 4, tick_value))
+ return lines
+
+
+def _render_x_axis(ticks: Sequence[Tuple[int, date]], coordinates: Sequence[Tuple[float, float]], bottom: int) -> List[str]:
+ """
+ 功能说明:渲染横轴日期刻度和文本。
+ 参数说明:ticks 为日期刻度,coordinates 为已缩放坐标,bottom 为横轴纵坐标。
+ 返回值说明:返回 SVG 片段列表。
+ 注意事项:tick 索引必须存在于 coordinates 中。
+ """
+ lines = []
+ for index, tick_date in ticks:
+ x_position = coordinates[index][0]
+ lines.append('' % (x_position, bottom, x_position, bottom + 6))
+ lines.append('%s' % (x_position, bottom + 26, tick_date.isoformat()))
+ return lines
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/strategy/doc/strategy1_a500_close_chart.md b/strategy/doc/strategy1_a500_close_chart.md
new file mode 100644
index 0000000..99f4b10
--- /dev/null
+++ b/strategy/doc/strategy1_a500_close_chart.md
@@ -0,0 +1,50 @@
+# 策略1:中证 A500 成立以来收盘价图
+
+## 目标
+
+从 `sidecar` 模块获取中证 A500 指数日线数据,将指数自成立日至今日的收盘价绘制为一张 SVG 折线图。
+
+## 假设
+
+- 指数名称:中证 A500 指数。
+- 指数代码:`sh000510`。
+- 成立起始日:`2024-09-23`。
+- 数据周期:日线。
+- K 线数据源:`sidecar.sources.tencent.TencentFinanceSource`。
+- 输出格式:SVG 图片,默认路径为 `strategy/output/a500_close.svg`。
+
+## 数据源说明
+
+策略通过 `sidecar` 的统一服务读取和缓存数据,但 K 线源显式使用腾讯财经。原因是当前 mootdx 对 `sh000510` 返回的日线价格量级异常,腾讯财经快照和 K 线价格量级一致,更适合绘制中证 A500 指数历史走势。
+
+## 运行方式
+
+```powershell
+python -m strategy.a500_close_chart --refresh
+```
+
+可指定输出路径:
+
+```powershell
+python -m strategy.a500_close_chart --output strategy/output/a500_close.svg --refresh
+```
+
+## 输入
+
+| 参数 | 类型 | 默认值 | 说明 |
+|---|---|---|---|
+| `--output` | string | `strategy/output/a500_close.svg` | 输出 SVG 文件路径 |
+| `--refresh` | bool | `False` | 是否强制刷新 sidecar 缓存 |
+
+## 输出
+
+生成一张 SVG 图片:
+
+- 横轴:交易日期,范围为中证 A500 成立日 `2024-09-23` 至当前可获取的最新交易日。
+- 纵轴:日收盘价。
+- 标题:`中证 A500 指数成立以来收盘价`。
+
+## 测试用例
+
+- `4-1`:过滤成立日之前的数据,并忽略空收盘价。
+- `4-2`:根据收盘价点生成包含标题、日期和折线的 SVG。
\ No newline at end of file
diff --git a/strategy/output/a500_close.svg b/strategy/output/a500_close.svg
new file mode 100644
index 0000000..85e13b0
--- /dev/null
+++ b/strategy/output/a500_close.svg
@@ -0,0 +1,33 @@
+
diff --git a/tests/test_strategy_a500.py b/tests/test_strategy_a500.py
new file mode 100644
index 0000000..6704d0e
--- /dev/null
+++ b/tests/test_strategy_a500.py
@@ -0,0 +1,45 @@
+"""策略1中证 A500 收盘价图测试。"""
+
+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
+
+
+def test_4_1_filter_a500_close_points() -> None:
+ """
+ 功能说明:验证策略1会过滤成立日之前和空收盘价数据。
+ 参数说明:无。
+ 返回值说明:无返回值。
+ 注意事项:用例编号 4-1。
+ """
+ bars = [
+ KlineBar("sh000510", "day", date(2024, 9, 20), 1, 1, 1, 1, 1, 1, "fake"),
+ KlineBar("sh000510", "day", A500_START_DATE, 1, 1, 1, None, 1, 1, "fake"),
+ KlineBar("sh000510", "day", date(2024, 9, 24), 1, 1, 1, 1000.5, 1, 1, "fake"),
+ ]
+
+ points = filter_close_points(bars, A500_START_DATE)
+
+ assert points == [ClosePoint(date(2024, 9, 24), 1000.5)]
+
+
+def test_4_2_build_svg_line_chart() -> None:
+ """
+ 功能说明:验证策略1可生成 SVG 折线图文本。
+ 参数说明:无。
+ 返回值说明:无返回值。
+ 注意事项:用例编号 4-2。
+ """
+ points = [
+ ClosePoint(date(2024, 9, 23), 1000.0),
+ ClosePoint(date(2024, 9, 24), 1010.0),
+ ClosePoint(date(2024, 9, 25), 990.0),
+ ]
+
+ svg = build_svg_line_chart(points, "中证 A500 指数成立以来收盘价")
+
+ assert svg.startswith("