227 lines
11 KiB
Python
227 lines
11 KiB
Python
"""策略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() |