本地策略可行

This commit is contained in:
cheney 2026-06-23 11:15:04 +08:00
parent c4db32374e
commit db4e519cc2
5 changed files with 541 additions and 1 deletions

View File

@ -32,4 +32,13 @@ 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.svg`。
## 策略2中证 A500 与沪深 300 双轴收盘价图
运行:
```powershell
python -m strategy.a500_hs300_close_chart --refresh
```
默认输出:`strategy/output/a500_hs300_close.svg`。

View File

@ -0,0 +1,359 @@
"""策略2绘制中证 A500 与沪深 300 成立以来双轴收盘价。"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Dict, 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
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")
@dataclass(frozen=True)
class DualAxisSeries:
"""双轴图数据序列。"""
name: str
symbol: str
color: str
points: List[ClosePoint]
def align_points_by_date(left_points: Sequence[ClosePoint], right_points: Sequence[ClosePoint]) -> Tuple[List[ClosePoint], List[ClosePoint]]:
"""
功能说明按共同交易日对齐两组收盘价点
参数说明left_points 为左轴序列right_points 为右轴序列
返回值说明返回按日期升序排列的两组 ClosePoint
注意事项只保留双方同时存在的交易日避免横轴错位
"""
left_map = {point.trade_date: point for point in left_points}
right_map = {point.trade_date: point for point in right_points}
dates = sorted(set(left_map).intersection(right_map))
return [left_map[item] for item in dates], [right_map[item] for item in dates]
def build_dual_axis_svg_chart(left: DualAxisSeries, right: DualAxisSeries, title: str, width: int = 1200, height: int = 680) -> str:
"""
功能说明把两组收盘价序列渲染为双 Y SVG 折线图
参数说明left 为左轴序列right 为右轴序列title 为标题width height 为画布尺寸
返回值说明返回完整 SVG 文本
注意事项两组序列按首日价格归一化共享涨跌比例坐标确保相同涨跌比例对应相同纵向距离
"""
if not left.points or not right.points:
raise ValueError("没有可绘制的双轴收盘价数据")
if len(left.points) != len(right.points):
raise ValueError("双轴序列长度不一致,请先按交易日对齐")
margin_left = 82
margin_right = 82
margin_top = 78
margin_bottom = 86
chart_width = width - margin_left - margin_right
chart_height = height - margin_top - margin_bottom
left_base = left.points[0].close
right_base = right.points[0].close
ratio_min, ratio_max = _shared_ratio_range(left.points, right.points)
ratio_range = ratio_max - ratio_min or 1.0
left_coordinates = _scale_points_by_ratio(left.points, left_base, margin_left, margin_top, chart_width, chart_height, ratio_min, ratio_range)
right_coordinates = _scale_points_by_ratio(right.points, right_base, margin_left, margin_top, chart_width, chart_height, ratio_min, ratio_range)
ratio_ticks = _build_y_ticks(ratio_min, ratio_max, 5)
y_axis = _render_left_axis_by_ratio(ratio_ticks, left_base, margin_left, margin_top, chart_width, chart_height, ratio_min, ratio_range, left.color)
y_axis += _render_right_axis_by_ratio(ratio_ticks, right_base, width - margin_right, margin_top, chart_height, ratio_min, ratio_range, right.color)
x_axis = _render_x_axis(_build_x_ticks(left.points, 6), left_coordinates, margin_top + chart_height)
first_date = left.points[0].trade_date
last_date = left.points[-1].trade_date
subtitle = "%s%s,共 %d 个共同交易日;纵轴按首日收盘价归一化" % (first_date.isoformat(), last_date.isoformat(), len(left.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="56" text-anchor="middle" font-size="13" fill="#6b7280">{subtitle}</text>
<circle cx="{legend_left_x}" cy="74" r="5" fill="{left_color}" />
<text x="{legend_left_text_x}" y="78" font-size="13" fill="#374151">{left_name}左轴</text>
<circle cx="{legend_right_x}" cy="74" r="5" fill="{right_color}" />
<text x="{legend_right_text_x}" y="78" font-size="13" fill="#374151">{right_name}右轴</text>
{y_axis}
<line x1="{left_axis}" y1="{top}" x2="{left_axis}" y2="{bottom}" stroke="#374151" />
<line x1="{right_axis}" y1="{top}" x2="{right_axis}" y2="{bottom}" stroke="#374151" />
<line x1="{left_axis}" y1="{bottom}" x2="{right_axis}" y2="{bottom}" stroke="#374151" />
{x_axis}
<polyline fill="none" stroke="{left_color}" stroke-width="2.4" points="{left_polyline}" />
<polyline fill="none" stroke="{right_color}" stroke-width="2.4" points="{right_polyline}" />
<text x="{center_x}" y="{footer_y}" text-anchor="middle" font-size="12" fill="#9ca3af">数据来源sidecar / 腾讯财经双轴按首日价格归一化等距离代表等涨跌比例</text>
</svg>
""".format(
width=width,
height=height,
center_x=width / 2,
title=escape(title),
subtitle=escape(subtitle),
legend_left_x=width / 2 - 140,
legend_left_text_x=width / 2 - 128,
legend_right_x=width / 2 + 40,
legend_right_text_x=width / 2 + 52,
left_color=left.color,
right_color=right.color,
left_name=escape(left.name),
right_name=escape(right.name),
y_axis="\n ".join(y_axis),
x_axis="\n ".join(x_axis),
left_axis=margin_left,
right_axis=width - margin_right,
top=margin_top,
bottom=margin_top + chart_height,
left_polyline=" ".join("%.2f,%.2f" % coordinate for coordinate in left_coordinates),
right_polyline=" ".join("%.2f,%.2f" % coordinate for coordinate in right_coordinates),
footer_y=height - 18,
)
def fetch_dual_axis_series(refresh: bool = False) -> Tuple[DualAxisSeries, DualAxisSeries]:
"""
功能说明通过 sidecar 获取 A500 和沪深 300 的对齐收盘价序列
参数说明refresh 表示是否强制刷新 sidecar 缓存
返回值说明返回 A500 左轴序列和沪深 300 右轴序列
注意事项横轴范围以 A500 成立日为起点并只保留共同交易日
"""
config = load_config()
tencent = TencentFinanceSource(config.request_timeout_seconds)
service = UnifiedDataService(DuckDBStore(config.db_path), config, tencent, [tencent])
a500_points = filter_close_points(service.get_kline(A500_SYMBOL, period="day", limit=800, refresh=refresh), A500_START_DATE)
hs300_points = filter_close_points(service.get_kline(HS300_SYMBOL, period="day", limit=800, refresh=refresh), A500_START_DATE)
aligned_a500, aligned_hs300 = align_points_by_date(a500_points, hs300_points)
return (
DualAxisSeries("中证 A500", A500_SYMBOL, "#2563eb", aligned_a500),
DualAxisSeries("沪深 300", HS300_SYMBOL, "#dc2626", aligned_hs300),
)
def write_a500_hs300_close_chart(output_path: Path = DEFAULT_OUTPUT_PATH, refresh: bool = False) -> Path:
"""
功能说明生成 A500 与沪深 300 双轴收盘价 SVG
参数说明output_path 为输出 SVG 路径refresh 表示是否强制刷新 sidecar 缓存
返回值说明返回实际写入的图片路径
注意事项输出目录不存在时会自动创建
"""
left, right = fetch_dual_axis_series(refresh=refresh)
svg = build_dual_axis_svg_chart(left, right, "中证 A500 与沪深 300 收盘价对比")
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_hs300_close.svg
"""
parser = argparse.ArgumentParser(description="绘制中证 A500 与沪深 300 双轴收盘价")
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_hs300_close_chart(Path(args.output), refresh=args.refresh)
print("已生成图表: %s" % output_path)
def _shared_ratio_range(left_points: Sequence[ClosePoint], right_points: Sequence[ClosePoint]) -> Tuple[float, float]:
"""
功能说明计算两组序列共享的涨跌比例范围
参数说明left_points 为左轴序列right_points 为右轴序列
返回值说明返回最小涨跌比例和最大涨跌比例例如 0.1 表示上涨 10%
注意事项涨跌比例都以各自首个收盘价为基准
"""
ratios = _ratios_from_base(left_points) + _ratios_from_base(right_points)
return min(ratios), max(ratios)
def _ratios_from_base(points: Sequence[ClosePoint]) -> List[float]:
"""
功能说明计算序列相对首个收盘价的涨跌比例
参数说明points 为收盘价点序列
返回值说明返回涨跌比例列表
注意事项首个点的涨跌比例固定为 0
"""
base = points[0].close
if base == 0:
raise ValueError("基准收盘价不能为 0")
return [(point.close / base) - 1 for point in points]
def _scale_points_by_ratio(points: Sequence[ClosePoint], base_close: float, margin_left: int, margin_top: int, chart_width: int, chart_height: int, ratio_min: float, ratio_range: float) -> List[Tuple[float, float]]:
"""
功能说明按涨跌比例把收盘价点转换为 SVG 坐标
参数说明points 为收盘价点base_close 为首日收盘价margin_left margin_top 为边距chart_width chart_height 为绘图区尺寸ratio_min ratio_range 为共享比例缩放参数
返回值说明返回 SVG 坐标列表
注意事项不同指数相同涨跌比例会得到相同 Y 坐标
"""
if base_close == 0:
raise ValueError("基准收盘价不能为 0")
max_index = max(len(points) - 1, 1)
coordinates = []
for index, point in enumerate(points):
ratio = point.close / base_close - 1
x_position = margin_left + index / max_index * chart_width
y_position = margin_top + chart_height - ((ratio - ratio_min) / ratio_range) * chart_height
coordinates.append((x_position, y_position))
return coordinates
def _price_from_ratio(base_close: float, ratio: float) -> float:
"""
功能说明把涨跌比例转换为某个指数自身价格
参数说明base_close 为指数首日收盘价ratio 为涨跌比例
返回值说明返回该比例对应的价格
注意事项用于左右 Y 轴在同一比例位置显示各自价格刻度
"""
return base_close * (1 + ratio)
def _render_left_axis_by_ratio(ticks: Sequence[float], base_close: float, margin_left: int, margin_top: int, chart_width: int, chart_height: int, ratio_min: float, ratio_range: float, color: str) -> List[str]:
"""
功能说明按共享涨跌比例渲染左侧价格轴
参数说明ticks 为比例刻度base_close 为左轴基准价margin_left margin_top 为边距chart_width chart_height 为绘图区尺寸ratio_min ratio_range 为比例缩放参数color 为文本颜色
返回值说明返回 SVG 片段列表
注意事项左轴显示 A500 价格但刻度位置由共享涨跌比例决定
"""
lines = []
for tick_ratio in ticks:
y_position = margin_top + chart_height - ((tick_ratio - ratio_min) / ratio_range) * chart_height
price = _price_from_ratio(base_close, tick_ratio)
percent = tick_ratio * 100
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="%s">%.2f (%+.1f%%)</text>' % (margin_left - 10, y_position + 4, color, price, percent))
return lines
def _render_right_axis_by_ratio(ticks: Sequence[float], base_close: float, right_axis: int, margin_top: int, chart_height: int, ratio_min: float, ratio_range: float, color: str) -> List[str]:
"""
功能说明按共享涨跌比例渲染右侧价格轴
参数说明ticks 为比例刻度base_close 为右轴基准价right_axis 为右轴横坐标margin_top chart_height 为绘图区参数ratio_min ratio_range 为比例缩放参数color 为文本颜色
返回值说明返回 SVG 片段列表
注意事项右轴显示沪深 300 价格但刻度位置由共享涨跌比例决定
"""
lines = []
for tick_ratio in ticks:
y_position = margin_top + chart_height - ((tick_ratio - ratio_min) / ratio_range) * chart_height
price = _price_from_ratio(base_close, tick_ratio)
percent = tick_ratio * 100
lines.append('<text x="%d" y="%.2f" text-anchor="start" font-size="12" fill="%s">%.2f (%+.1f%%)</text>' % (right_axis + 10, y_position + 4, color, price, percent))
return lines
def _value_range(points: Sequence[ClosePoint]) -> Tuple[float, float]:
"""
功能说明计算收盘价范围
参数说明points 为收盘价点序列
返回值说明返回最低值和最高值
注意事项调用方需保证 points 非空
"""
values = [point.close for point in points]
return min(values), max(values)
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_left_axis(ticks: Sequence[float], margin_left: int, margin_top: int, chart_width: int, chart_height: int, min_close: float, close_range: float, color: str) -> List[str]:
"""
功能说明渲染左侧纵轴网格和刻度
参数说明ticks 为价格刻度margin_left margin_top 为边距chart_width chart_height 为绘图区尺寸min_close close_range 为价格缩放参数color 为轴文本颜色
返回值说明返回 SVG 片段列表
注意事项左轴刻度对应 A500
"""
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="%s">%.2f</text>' % (margin_left - 10, y_position + 4, color, tick_value))
return lines
def _render_right_axis(ticks: Sequence[float], right_axis: int, margin_top: int, chart_height: int, min_close: float, close_range: float, color: str) -> List[str]:
"""
功能说明渲染右侧纵轴刻度
参数说明ticks 为价格刻度right_axis 为右轴横坐标margin_top chart_height 为绘图区参数min_close close_range 为价格缩放参数color 为轴文本颜色
返回值说明返回 SVG 片段列表
注意事项右轴刻度对应沪深 300
"""
lines = []
for tick_value in ticks:
y_position = margin_top + chart_height - ((tick_value - min_close) / close_range) * chart_height
lines.append('<text x="%d" y="%.2f" text-anchor="start" font-size="12" fill="%s">%.2f</text>' % (right_axis + 10, y_position + 4, color, 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,62 @@
# 策略2中证 A500 与沪深 300 双轴收盘价图
## 目标
`sidecar` 模块获取中证 A500 与沪深 300 指数日线数据,将两者自中证 A500 成立日至今日的收盘价绘制在同一张 SVG 图中。由于两个指数价格区间不同,图表使用双 Y 轴:左轴对应中证 A500右轴对应沪深 300。
## 假设
- 中证 A500 指数代码:`sh000510`。
- 沪深 300 指数代码:`sh000300`。
- 横轴起始日:`2024-09-23`。
- 数据周期:日线。
- K 线数据源:`sidecar.sources.tencent.TencentFinanceSource`。
- 输出格式SVG 图片,默认路径为 `strategy/output/a500_hs300_close.svg`
## 双轴缩放规则
- 两个指数都以 `2024-09-23` 的收盘价作为各自基准价。
- Y 坐标按相对基准价的涨跌比例计算,而不是按各自价格区间独立拉伸。
- 因此两个指数起点位于同一水平线;相同涨跌比例对应相同纵向距离。
- 终点不强行对齐,终点高度真实反映各自相对起点的涨跌幅差异。
- 左右 Y 轴仍显示各自指数价格,同时括号展示对应涨跌比例。
## 数据处理
- 两个指数都通过 `sidecar` 统一服务读取并写入 DuckDB 缓存。
- 先过滤 `2024-09-23` 之前的数据。
- 再按共同交易日对齐,避免不同市场日历或缺失数据导致横轴错位。
## 运行方式
```powershell
python -m strategy.a500_hs300_close_chart --refresh
```
可指定输出路径:
```powershell
python -m strategy.a500_hs300_close_chart --output strategy/output/a500_hs300_close.svg --refresh
```
## 输入
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `--output` | string | `strategy/output/a500_hs300_close.svg` | 输出 SVG 文件路径 |
| `--refresh` | bool | `False` | 是否强制刷新 sidecar 缓存 |
## 输出
生成一张 SVG 图片:
- 横轴:共同交易日期,范围为中证 A500 成立日 `2024-09-23` 至当前可获取的最新交易日。
- 左轴:中证 A500 日收盘价。
- 右轴:沪深 300 日收盘价。
- 标题:`中证 A500 与沪深 300 收盘价对比`。
## 测试用例
- `5-1`:按共同交易日对齐两组收盘价。
- `5-2`:生成包含双轴图例和两条折线的 SVG。
- `5-3`:相同涨跌比例映射到相同 Y 坐标。

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,68 @@
"""策略2中证 A500 与沪深 300 双轴图测试。"""
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
def test_5_1_align_points_by_common_date() -> None:
"""
功能说明验证策略2按共同交易日对齐两组收盘价
参数说明
返回值说明无返回值
注意事项用例编号 5-1
"""
left = [ClosePoint(date(2024, 9, 23), 100), ClosePoint(date(2024, 9, 24), 101)]
right = [ClosePoint(date(2024, 9, 24), 201), ClosePoint(date(2024, 9, 25), 202)]
aligned_left, aligned_right = align_points_by_date(left, right)
assert aligned_left == [ClosePoint(date(2024, 9, 24), 101)]
assert aligned_right == [ClosePoint(date(2024, 9, 24), 201)]
def test_5_2_build_dual_axis_svg_chart() -> None:
"""
功能说明验证策略2可生成包含双轴和两条折线的 SVG
参数说明
返回值说明无返回值
注意事项用例编号 5-2
"""
left = DualAxisSeries(
"中证 A500",
"sh000510",
"#2563eb",
[ClosePoint(date(2024, 9, 23), 3700), ClosePoint(date(2024, 9, 24), 3710)],
)
right = DualAxisSeries(
"沪深 300",
"sh000300",
"#dc2626",
[ClosePoint(date(2024, 9, 23), 3300), ClosePoint(date(2024, 9, 24), 3310)],
)
svg = build_dual_axis_svg_chart(left, right, "中证 A500 与沪深 300 收盘价对比")
assert svg.startswith("<svg")
assert "中证 A500 与沪深 300 收盘价对比" in svg
assert "中证 A500左轴" in svg
assert "沪深 300右轴" in svg
assert svg.count("<polyline") == 2
def test_5_3_same_return_ratio_maps_to_same_y() -> None:
"""
功能说明验证策略2相同涨跌比例会映射到相同 Y 坐标
参数说明
返回值说明无返回值
注意事项用例编号 5-3两个指数起点不同但第二天都上涨 10%
"""
left = [ClosePoint(date(2024, 9, 23), 1000), ClosePoint(date(2024, 9, 24), 1100)]
right = [ClosePoint(date(2024, 9, 23), 3000), ClosePoint(date(2024, 9, 24), 3300)]
left_coordinates = _scale_points_by_ratio(left, 1000, 0, 0, 100, 100, 0, 0.1)
right_coordinates = _scale_points_by_ratio(right, 3000, 0, 0, 100, 100, 0, 0.1)
assert left_coordinates[0][1] == right_coordinates[0][1]
assert left_coordinates[1][1] == right_coordinates[1][1]