a500 绘图成功

This commit is contained in:
cheney 2026-06-23 10:57:20 +08:00
parent 31d28d8683
commit c4db32374e
11 changed files with 482 additions and 39 deletions

5
.gitignore vendored
View File

@ -1,2 +1,7 @@
*.pyc
*.egg-info/**
data/*.duckdb
data/*.duckdb.wal
.pytest_cache/
__pycache__/

View File

@ -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`。

View File

@ -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",

View File

@ -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]:
"""
功能说明解析腾讯交易时间字段

View File

@ -88,38 +88,37 @@ class DuckDBStore:
功能说明批量写入 K 线缓存
参数说明bars 为统一 KlineBar 列表
返回值说明无返回值
注意事项相同 symbolperiodtrade_date 的记录会被覆盖
注意事项相同 symbolperiodtrade_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]:
"""

1
strategy/__init__.py Normal file
View File

@ -0,0 +1 @@
"""本地策略模块。"""

View File

@ -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 """<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" viewBox=\"0 0 {width} {height}\">
<rect width=\"100%\" height=\"100%\" fill=\"#ffffff\" />
<text x=\"{center_x}\" y=\"32\" text-anchor=\"middle\" font-size=\"22\" font-weight=\"700\" fill=\"#111827\">{title}</text>
<text x=\"{center_x}\" y=\"54\" text-anchor=\"middle\" font-size=\"13\" fill=\"#6b7280\">{subtitle}</text>
{y_axis}
<line x1=\"{left}\" y1=\"{top}\" x2=\"{left}\" y2=\"{bottom}\" stroke=\"#374151\" />
<line x1=\"{left}\" y1=\"{bottom}\" x2=\"{right}\" y2=\"{bottom}\" stroke=\"#374151\" />
{x_axis}
<polyline fill=\"none\" stroke=\"#2563eb\" stroke-width=\"2.4\" points=\"{polyline}\" />
<circle cx=\"{last_x:.2f}\" cy=\"{last_y:.2f}\" r=\"4\" fill=\"#dc2626\" />
<text x=\"{last_label_x:.2f}\" y=\"{last_label_y:.2f}\" font-size=\"13\" fill=\"#dc2626\">最新收盘 {last_close:.2f}</text>
<text x=\"{center_x}\" y=\"{footer_y}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#9ca3af\">数据来源sidecar / 腾讯财经,指数代码 sh000510</text>
</svg>
""".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('<line x1="%d" y1="%.2f" x2="%d" y2="%.2f" stroke="#e5e7eb" />' % (margin_left, y_position, margin_left + chart_width, y_position))
lines.append('<text x="%d" y="%.2f" text-anchor="end" font-size="12" fill="#4b5563">%.2f</text>' % (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('<line x1="%.2f" y1="%d" x2="%.2f" y2="%d" stroke="#d1d5db" />' % (x_position, bottom, x_position, bottom + 6))
lines.append('<text x="%.2f" y="%d" text-anchor="middle" font-size="12" fill="#4b5563">%s</text>' % (x_position, bottom + 26, tick_date.isoformat()))
return lines
if __name__ == "__main__":
main()

View File

@ -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。

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -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("<svg")
assert "中证 A500 指数成立以来收盘价" in svg
assert "2024-09-23" in svg
assert "<polyline" in svg

View File

@ -1,6 +1,6 @@
"""腾讯财经适配器测试。"""
from sidecar.sources.tencent import parse_tencent_payload, snapshot_from_tencent_fields
from sidecar.sources.tencent import kline_from_tencent_payload, parse_tencent_payload, snapshot_from_tencent_fields
def test_1_1_tencent_snapshot_uses_correct_pb_index() -> None:
@ -47,4 +47,21 @@ def test_1_2_parse_tencent_payload() -> None:
parsed = parse_tencent_payload(payload)
assert len(parsed) == 88
assert parsed[0] == "v"
assert parsed[0] == "v"
def test_1_3_parse_tencent_kline_payload() -> None:
"""
功能说明验证腾讯财经 K 线 JSON 可转换为统一 K 线
参数说明
返回值说明无返回值
注意事项用例编号 1-3字段顺序为日期开盘收盘最高最低成交量
"""
payload = '{"code":0,"msg":"","data":{"sh000510":{"day":[["2024-09-23","3392.95","3368.53","3392.95","3368.53","1"]]}}}'
bars = kline_from_tencent_payload("sh000510", "day", payload)
assert len(bars) == 1
assert bars[0].symbol == "sh000510"
assert bars[0].close == 3368.53
assert bars[0].source == "tencent"