From c4db32374e3b0103905e8a0432464cc368581cb2 Mon Sep 17 00:00:00 2001 From: cheney Date: Tue, 23 Jun 2026 10:57:20 +0800 Subject: [PATCH] =?UTF-8?q?a500=20=E7=BB=98=E5=9B=BE=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + README.md | 12 +- setup.py | 2 +- sidecar/sources/tencent.py | 70 ++++++- sidecar/storage.py | 55 +++-- strategy/__init__.py | 1 + strategy/a500_close_chart.py | 227 +++++++++++++++++++++ strategy/doc/strategy1_a500_close_chart.md | 50 +++++ strategy/output/a500_close.svg | 33 +++ tests/test_strategy_a500.py | 45 ++++ tests/test_tencent.py | 21 +- 11 files changed, 482 insertions(+), 39 deletions(-) create mode 100644 strategy/__init__.py create mode 100644 strategy/a500_close_chart.py create mode 100644 strategy/doc/strategy1_a500_close_chart.md create mode 100644 strategy/output/a500_close.svg create mode 100644 tests/test_strategy_a500.py 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 """ + + {title} + {subtitle} + {y_axis} + + + {x_axis} + + + 最新收盘 {last_close:.2f} + 数据来源:sidecar / 腾讯财经,指数代码 sh000510 + +""".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 @@ + + + 中证 A500 指数成立以来收盘价 + 2024-09-23 至 2026-06-23,共 421 个交易日 + + 3725.78 + + 4387.73 + + 5049.67 + + 5711.61 + + 6373.56 + + + + 2024-09-23 + + 2025-01-27 + + 2025-06-09 + + 2025-10-13 + + 2026-02-10 + + 2026-06-23 + + + 最新收盘 6319.15 + 数据来源:sidecar / 腾讯财经,指数代码 sh000510 + 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(" 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" \ No newline at end of file + 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" \ No newline at end of file