From e54213158be0d21f0c3bf608c2045ef15807cc40 Mon Sep 17 00:00:00 2001 From: feifeinotfly Date: Tue, 7 Jul 2026 02:39:00 +0800 Subject: [PATCH] feat: add TDX kline provider with minute-level K-line support - Add Go binary (tools/tdx-kline) based on injoyai/tdx library for A-share data - Support daily, 5min, 15min, 30min, and 60min K-line intervals - Register TdxKlineProvider in KlineOrchestrator for datasource-based provider priority - Refactor klines API to try orchestrator first, fall back to built-in collector - Use orchestrator for technical indicator generation in SignalPack - Refresh orchestrator instances on datasource CRUD operations - Add comprehensive tests for TDX provider --- .dockerignore | 1 - Dockerfile | 24 +--- server.py | 33 ++++- src/collectors/kline_collector.py | 19 ++- src/core/providers/kline/tdx.py | 212 +++++++++++++++++++++++++++++ src/core/providers/orchestrator.py | 2 + src/core/signals/signal_pack.py | 48 +++---- src/web/api/datasources.py | 22 +++ src/web/api/klines.py | 120 ++++++++++++++-- tests/test_tdx_provider.py | 65 +++++++++ tools/tdx-kline/go.mod | 39 ++++++ tools/tdx-kline/go.sum | 145 ++++++++++++++++++++ tools/tdx-kline/main.go | 187 +++++++++++++++++++++++++ 13 files changed, 855 insertions(+), 62 deletions(-) create mode 100644 src/core/providers/kline/tdx.py create mode 100644 tests/test_tdx_provider.py create mode 100644 tools/tdx-kline/go.mod create mode 100644 tools/tdx-kline/go.sum create mode 100644 tools/tdx-kline/main.go diff --git a/.dockerignore b/.dockerignore index 0c96096a..e48c1fa4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,7 +26,6 @@ data/ *.sqlite # Build -frontend/dist/ static/ # Docs diff --git a/Dockerfile b/Dockerfile index db655e8e..5f8280e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,9 @@ # PanWatch Dockerfile # 多阶段构建,减小最终镜像大小 -# ===== Stage 1: 前端构建 ===== -FROM node:20-alpine AS frontend-builder - -WORKDIR /app/frontend - -# 安装 pnpm -RUN npm install -g pnpm - -# 复制依赖文件 -COPY frontend/package.json frontend/pnpm-lock.yaml ./ - -# 安装依赖 -RUN pnpm install --frozen-lockfile - -# 复制源码并构建 -COPY frontend/ ./ -RUN pnpm build +# ===== Stage 1: 前端静态文件 ===== +# 前端已在本机构建(frontend/dist/),直接复制到运行阶段 +# 如需在 Docker 内重建,可回退至:使用 node:20-alpine 执行 pnpm install && pnpm build # ===== Stage 2: Python 运行环境 ===== @@ -93,8 +79,8 @@ COPY prompts/ ./prompts/ # 写入版本号 RUN echo "${VERSION}" > VERSION -# 从前端构建阶段复制静态文件 -COPY --from=frontend-builder /app/frontend/dist ./static/ +# 复制本地已构建的前端静态文件 +COPY frontend/dist ./static/ # 创建数据目录 RUN mkdir -p /app/data diff --git a/server.py b/server.py index a833df4e..ab50443d 100644 --- a/server.py +++ b/server.py @@ -360,7 +360,8 @@ def seed_data_sources(): "provider": "xueqiu", "config": { "cookies": "", - "description": "雪球个股新闻聚合,需要登录 cookie", + "auto_refresh_waf": True, + "description": "雪球个股新闻聚合,需要登录 cookie;超时/风控时自动重新过 WAF", }, "enabled": False, "priority": 0, @@ -398,6 +399,21 @@ def seed_data_sources(): "supports_batch": False, "test_symbols": ["601127", "600519", "300750"], }, + { + "name": "通达信K线", + "type": "kline", + "provider": "tdx", + "config": { + "host": "124.71.187.122:7709", + "binary": "/app/tools/tdx-kline/tdx-kline", + "timeout_sec": 15, + "description": "基于 injoyai/tdx 的通达信日K数据。仅 A 股;需编译 helper 二进制。", + }, + "enabled": False, + "priority": 5, + "supports_batch": False, + "test_symbols": ["600519", "000001"], + }, { "name": "Tushare K线", "type": "kline", @@ -1120,8 +1136,21 @@ async def trigger_agent_for_stock( market=market, ) + # 从数据库获取 Stock 记录(可能不在自选表中) + stock_id = 0 + db_lookup = SessionLocal() + try: + db_stock = db_lookup.query(Stock).filter( + Stock.symbol == stock.symbol, + Stock.market == stock.market, + ).first() + if db_stock: + stock_id = db_stock.id + finally: + db_lookup.close() + # 加载该股票的持仓信息 - portfolio = load_portfolio_for_stock(stock.id) + portfolio = load_portfolio_for_stock(stock_id) model, service = resolve_ai_model(agent_name, stock_agent_id) channels = [] if suppress_notify else resolve_notify_channels(agent_name, stock_agent_id) diff --git a/src/collectors/kline_collector.py b/src/collectors/kline_collector.py index da9e8129..fe49878b 100644 --- a/src/collectors/kline_collector.py +++ b/src/collectors/kline_collector.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) # 腾讯日K线 API -TENCENT_KLINE_URL = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get" +TENCENT_KLINE_URL = "http://ifzq.gtimg.cn/appstock/app/fqkline/get" EASTMONEY_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get" @@ -661,7 +661,10 @@ def _fetch_tencent_klines( klines = _parse_tencent_kline_text(text, tencent_sym) if klines: break - last_err = "空响应" # gtimg 突发限流常回空 body,退避后重试 + if resp.status_code != 200: + last_err = f"HTTP {resp.status_code}: {text[:100]}" + else: + last_err = "空响应" except Exception as e: last_err = e if attempt < 2: @@ -958,9 +961,15 @@ def get_technical_indicators( kline_pattern=kline_pattern, ) - def get_kline_summary(self, symbol: str) -> dict: - """获取 K 线摘要(用于 prompt 和前端展示)""" - klines = self.get_klines(symbol, days=120) + def get_kline_summary(self, symbol: str = "", klines: list[KlineData] | None = None) -> dict: + """获取 K 线摘要(用于 prompt 和前端展示) + + Args: + symbol: 股票代码(仅当 klines 为空时用于联网获取) + klines: 外部传入的 K 线数据(非空则跳过联网获取) + """ + if klines is None: + klines = self.get_klines(symbol, days=120) if not klines: return {"error": "无K线数据"} indicators = self.get_technical_indicators(klines=klines) diff --git a/src/core/providers/kline/tdx.py b/src/core/providers/kline/tdx.py new file mode 100644 index 00000000..2ce56250 --- /dev/null +++ b/src/core/providers/kline/tdx.py @@ -0,0 +1,212 @@ +"""通达信 K 线 Provider — 通过 Go helper 调用 injoyai/tdx。 + +通过编译后的 Go 二进制(tools/tdx-kline/tdx-kline)直连通达信行情服务器。 + +约束: +- 仅 A 股(CN 市场),港股/美股不支持 +- 软依赖:未编译 Go binary 时,fetch 返回明确错误 +- 默认使用公开行情服务器 124.71.187.122:7709,可通过 config 自定义 host +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import subprocess + +from src.collectors.kline_collector import KlineData +from src.core.providers.base import KlineProvider, ProviderRequest, ProviderResponse +from src.core.cn_symbol import get_cn_exchange + +logger = logging.getLogger(__name__) + +# 默认二进制路径(容器内路径) +_DEFAULT_BINARY = "tools/tdx-kline/tdx-kline" + +# 默认通达信行情服务器 +_DEFAULT_HOST = "124.71.187.122:7709" + +# 单次最大拉取条数 +_MAX_DAYS = 800 + +# Go binary 默认超时(秒) +_DEFAULT_TIMEOUT = 5 + + +def _tdx_symbol(symbol: str) -> str: + """将纯 A 股代码转为通达信前缀格式。 + + sh600519 / sz000001 / bj... + """ + exchange = get_cn_exchange(symbol) + return f"{exchange.lower()}{symbol}" + + +class TdxKlineProvider(KlineProvider): + name = "tdx" + supports_markets = {"CN"} # 仅 A 股 + + def __init__(self, config: dict | None = None): + super().__init__(config=config) + self._binary = (self.config or {}).get("binary", _DEFAULT_BINARY) + self._host = (self.config or {}).get("host", _DEFAULT_HOST) + self._timeout = int((self.config or {}).get("timeout_sec", _DEFAULT_TIMEOUT)) + self._init_error = "" + + # 检查二进制是否存在 + if not os.path.isfile(self._binary): + self._init_error = ( + f"通达信 helper 不存在({self._binary})," + f"请先编译 tools/tdx-kline: " + f"cd tools/tdx-kline && GOOS=linux GOARCH=amd64 go build -o tdx-kline ." + ) + + def _days(self, req: ProviderRequest) -> int: + for k, v in req.extra: + if k == "days": + try: + return max(1, min(int(v), _MAX_DAYS)) + except Exception: + return 60 + return 60 + + def _ktype(self, req: ProviderRequest) -> str: + """从 extra 中提取 K 线类型,默认 day""" + for k, v in req.extra: + if k == "type": + if v in ("5m", "15m", "30m", "60m"): + return v + return "day" + + async def fetch(self, req: ProviderRequest) -> ProviderResponse: + if self._init_error: + return ProviderResponse(success=False, error=self._init_error) + if not req.symbols: + return ProviderResponse(success=True, data=[]) + if len(req.symbols) > 1: + return ProviderResponse( + success=False, error="TdxKlineProvider 仅支持单 symbol" + ) + if req.market != "CN": + return ProviderResponse( + success=False, error="TdxKlineProvider 仅支持 CN 市场" + ) + + symbol = req.symbols[0] + days = self._days(req) + ktype = self._ktype(req) + tdx_sym = _tdx_symbol(symbol) + logger.info( + "TdxKlineProvider 调用 helper: binary=%s symbol=%s tdx_sym=%s host=%s days=%d type=%s", + self._binary, symbol, tdx_sym, self._host, days, ktype, + ) + + def _run_helper() -> ProviderResponse: + """同步函数: 在 asyncio.to_thread 中执行。""" + try: + proc = subprocess.run( + [ + self._binary, + "--symbol", tdx_sym, + "--days", str(days), + "--host", self._host, + "--timeout", str(self._timeout), + "--type", ktype, + ], + capture_output=True, + text=True, + timeout=self._timeout + 2, # 额外给 2s 缓冲 + ) + except FileNotFoundError: + return ProviderResponse( + success=False, + error=f"通达信 helper 不存在({self._binary})", + ) + except subprocess.TimeoutExpired: + return ProviderResponse( + success=False, + error=f"通达信接口超时({self._host}, {self._timeout}s)", + ) + except Exception as e: + return ProviderResponse( + success=False, error=f"通达信 helper 执行失败: {e}" + ) + + if proc.returncode != 0: + err_msg = proc.stderr.strip() or "未知错误" + return ProviderResponse( + success=False, error=f"通达信接口调用失败: {err_msg}" + ) + + # 解析 JSON: Go helper 内部日志也输出到 stdout(含 ANSI 颜色码) + stdout = proc.stdout + # 找内容为 "[{" 开始的 JSON 数组(可能前有 ANSI 码) + idx = stdout.find('[{"date"') + if idx < 0: + idx = stdout.rfind("[") + if idx >= 0: + # 只取 JSON 部分(从 [ 到匹配的 ]),避免尾部日志干扰 + decoder = json.JSONDecoder() + try: + raw, _ = decoder.raw_decode(stdout, idx) + except json.JSONDecodeError as e: + return ProviderResponse( + success=False, + error=f"通达信返回数据解析失败: {e}", + ) + else: + return ProviderResponse( + success=False, + error=f"通达信未返回有效 JSON: {stdout[:200]}", + ) + + if not raw: + return ProviderResponse( + success=False, + error=f"通达信未返回数据(symbol={symbol})", + ) + + klines: list[KlineData] = [] + for item in raw: + try: + klines.append( + KlineData( + date=str(item["date"]), + open=float(item["open"]), + close=float(item["close"]), + high=float(item["high"]), + low=float(item["low"]), + volume=float(item["volume"]), + ) + ) + except (KeyError, ValueError, TypeError) as e: + logger.debug(f"通达信 JSON 条目解析失败: {e}, item={item}") + continue + + if not klines: + return ProviderResponse( + success=False, + error=f"通达信数据解析后为空(symbol={symbol})", + ) + + # 按日期升序排列 + klines.sort(key=lambda k: k.date) + + return ProviderResponse(success=True, data=klines[-days:]) + + return await asyncio.to_thread(_run_helper) + + async def health_check(self) -> bool: + if self._init_error: + return False + try: + resp = await self.fetch( + ProviderRequest( + symbols=("600519",), market="CN", extra=(("days", 5),) + ) + ) + return resp.success and not resp.is_empty + except Exception: + return False \ No newline at end of file diff --git a/src/core/providers/orchestrator.py b/src/core/providers/orchestrator.py index cfba1198..faafbc3d 100644 --- a/src/core/providers/orchestrator.py +++ b/src/core/providers/orchestrator.py @@ -299,9 +299,11 @@ def get_kline_orchestrator() -> KlineOrchestrator: orch = KlineOrchestrator() from src.core.providers.kline.tencent import TencentKlineProvider from src.core.providers.kline.tushare import TushareKlineProvider + from src.core.providers.kline.tdx import TdxKlineProvider from src.core.providers.kline.yfinance import YFinanceKlineProvider orch.register("tencent", lambda cfg: TencentKlineProvider(config=cfg)) + orch.register("tdx", lambda cfg: TdxKlineProvider(config=cfg)) orch.register("tushare", lambda cfg: TushareKlineProvider(config=cfg)) orch.register("yfinance", lambda cfg: YFinanceKlineProvider(config=cfg)) _kline_orchestrator = orch diff --git a/src/core/signals/signal_pack.py b/src/core/signals/signal_pack.py index 0dcc0028..9978191e 100644 --- a/src/core/signals/signal_pack.py +++ b/src/core/signals/signal_pack.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from src.collectors.akshare_collector import AkshareCollector -from src.collectors.kline_collector import KlineCollector +from src.collectors.kline_collector import KlineCollector, KlineData from src.collectors.news_collector import NewsCollector, NewsItem from src.models.market import MarketCode from src.models.market import StockData @@ -219,28 +219,30 @@ async def build_for_symbols( self._tech_cache[key] = {"error": "K线数据源已禁用"} self._tech_source_cache[key] = "disabled" else: - last_err = None - for provider, cfg in kline_providers: - try: - if provider == "tencent": - collector = KlineCollector(market) - else: - logger.info( - f"SignalPack kline 未支持 provider={provider},跳过" - ) - continue - self._tech_cache[key] = collector.get_kline_summary(sym) - self._tech_source_cache[key] = provider - last_err = None - break - except Exception as e: - last_err = e - continue - if key not in self._tech_cache: - self._tech_cache[key] = { - "error": str(last_err) if last_err else "获取K线失败" - } - self._tech_source_cache.setdefault(key, "unavailable") + try: + from src.core.providers.orchestrator import get_kline_orchestrator + from src.core.providers.base import ProviderRequest + + req = ProviderRequest( + symbols=(sym,), + market=market.value, + extra=(("days", 120),), + ) + resp = await get_kline_orchestrator().fetch(req) + if resp.success and resp.data: + collector = KlineCollector(market) + self._tech_cache[key] = collector.get_kline_summary( + sym, klines=list(resp.data) + ) + self._tech_source_cache[key] = resp.provider or "orchestrator" + else: + self._tech_cache[key] = { + "error": resp.error or "获取K线失败" + } + self._tech_source_cache[key] = resp.provider or "unavailable" + except Exception as e: + self._tech_cache[key] = {"error": str(e)} + self._tech_source_cache[key] = "error" tech_map[sym] = self._tech_cache[key] if key in self._tech_cache and key not in self._tech_source_cache: self._tech_source_cache[key] = "cache" diff --git a/src/web/api/datasources.py b/src/web/api/datasources.py index 0284eb68..9f76a464 100644 --- a/src/web/api/datasources.py +++ b/src/web/api/datasources.py @@ -13,6 +13,25 @@ router = APIRouter() +def _clear_orchestrator_instances(source_type: str) -> None: + """数据源变更后刷新对应 orchestrator 的 provider 实例缓存。""" + try: + if source_type == "kline": + from src.core.providers.orchestrator import get_kline_orchestrator + get_kline_orchestrator().clear_instances() + elif source_type == "quote": + from src.core.providers.orchestrator import get_quote_orchestrator + get_quote_orchestrator().clear_instances() + elif source_type == "capital_flow": + from src.core.providers.orchestrator import get_capital_flow_orchestrator + get_capital_flow_orchestrator().clear_instances() + elif source_type == "events": + from src.core.providers.orchestrator import get_events_orchestrator + get_events_orchestrator().clear_instances() + except Exception as e: + logger.warning(f"刷新数据源 provider 实例失败: {e}") + + # 数据源类型说明 TYPE_LABELS = { "news": "新闻资讯", @@ -119,6 +138,7 @@ def create_datasource(data: DataSourceCreate, db: Session = Depends(get_db)): db.add(source) db.commit() db.refresh(source) + _clear_orchestrator_instances(source.type) logger.info(f"创建数据源: {source.name} ({source.provider})") return _to_response(source) @@ -137,6 +157,7 @@ def update_datasource( db.commit() db.refresh(source) + _clear_orchestrator_instances(source.type) logger.info(f"更新数据源: {source.name}") return _to_response(source) @@ -150,6 +171,7 @@ def delete_datasource(source_id: int, db: Session = Depends(get_db)): db.delete(source) db.commit() + _clear_orchestrator_instances(source.type) logger.info(f"删除数据源: {source.name}") return {"ok": True, "message": f"已删除 {source.name}"} diff --git a/src/web/api/klines.py b/src/web/api/klines.py index e02b7d3e..e5667790 100644 --- a/src/web/api/klines.py +++ b/src/web/api/klines.py @@ -1,3 +1,12 @@ +"""K 线 API — 通过 KlineOrchestrator 按 datasource 优先级调用各 provider。 + +调用链: +1. 先尝试 KlineOrchestrator (走 DB 中 enabled 的 datasource,按 priority 排序) +2. 失败后 fallback 到内置 KlineCollector(硬编码腾讯→Stooq→东财) + +这样启用了 通达信K线 且优先级更高时,业务 K 线会优先通过 injoyai/tdx 获取。 +""" + from fastapi import APIRouter, HTTPException from datetime import datetime @@ -13,7 +22,7 @@ class KlineItem(BaseModel): symbol: str = Field(..., description="股票代码") market: str = Field(..., description="市场: CN/HK/US") days: int | None = Field(default=60, description="K线天数") - interval: str | None = Field(default="1d", description="周期: 1d/1w/1m") + interval: str | None = Field(default="1d", description="周期: 1d/1w/1m/5m/15m/30m/60m") class KlineBatchRequest(BaseModel): @@ -99,19 +108,82 @@ def _aggregate_klines(klines, interval: str) -> list: return out +def _interval_to_ktype(interval: str) -> str: + """将 KlineItem 的 interval 转换为 provider 的 ktype。""" + mapping = { + "1d": "day", "day": "day", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "60m": "60m", + } + return mapping.get(interval, "day") + + +def _is_minute_interval(interval: str) -> bool: + return interval in ("5m", "15m", "30m", "60m") + + +def _try_orchestrator_first( + symbol: str, market: str, days: int, ktype: str = "day" +) -> list | None: + """尝试通过 KlineOrchestrator 获取 K 线(按 datasource 优先级调用 provider)。 + + 返回 list[KlineData] 或 None(全部 provider 失败时)。 + """ + import logging + + logger = logging.getLogger(__name__) + try: + from src.core.providers.base import ProviderRequest + from src.core.providers.orchestrator import get_kline_orchestrator + + req = ProviderRequest( + symbols=(symbol,), + market=market, + extra=(("days", str(days)), ("type", ktype)), + ) + resp = get_kline_orchestrator().fetch_sync(req, cache_ttl_sec=60) + if resp.success and resp.data: + logger.info( + "K线 通过 orchestrator 获取成功: provider=%s symbol=%s market=%s type=%s", + resp.provider, symbol, market, ktype, + ) + return resp.data + else: + logger.warning( + "K线 orchestrator 全部失败(%s): %s, 回退内置采集器", + symbol, resp.error, + ) + except Exception as e: + logger.warning( + "K线 orchestrator 异常(%s): %s, 回退内置采集器", symbol, e, + ) + return None + + @router.get("/{symbol}") def get_klines(symbol: str, market: str = "CN", days: int = 60, interval: str = "1d"): """获取单只股票K线数据""" market_code = _parse_market(market) - collector = KlineCollector(market_code) - klines = collector.get_klines(symbol, days=days) - klines = _aggregate_klines(klines, interval) + ktype = _interval_to_ktype(interval) + + # 先尝试 orchestrator(按 datasource 优先级) + data = _try_orchestrator_first(symbol, market_code.value, days, ktype) + if data is None: + # fallback 到内置 KlineCollector + collector = KlineCollector(market_code) + data = collector.get_klines(symbol, days=days) + + # 分钟 K 线跳过周/月聚合 + if not _is_minute_interval(interval): + data = _aggregate_klines(data, interval) return { "symbol": symbol, "market": market_code.value, "days": days, "interval": interval, - "klines": _serialize_klines(klines), + "klines": _serialize_klines(data), } @@ -124,18 +196,26 @@ def get_klines_batch(payload: KlineBatchRequest): results = [] for item in payload.items: market_code = _parse_market(item.market) - collector = KlineCollector(market_code) days = item.days or 60 interval = item.interval or "1d" - klines = collector.get_klines(item.symbol, days=days) - klines = _aggregate_klines(klines, interval) + ktype = _interval_to_ktype(interval) + + # 先尝试 orchestrator(按 datasource 优先级) + data = _try_orchestrator_first(item.symbol, market_code.value, days, ktype) + if data is None: + # fallback 到内置 KlineCollector + collector = KlineCollector(market_code) + data = collector.get_klines(item.symbol, days=days) + + if not _is_minute_interval(interval): + data = _aggregate_klines(data, interval) results.append( { "symbol": item.symbol, "market": market_code.value, "days": days, "interval": interval, - "klines": _serialize_klines(klines), + "klines": _serialize_klines(data), } ) @@ -147,7 +227,15 @@ def get_kline_summary(symbol: str, market: str = "CN"): """获取单只股票K线摘要""" market_code = _parse_market(market) collector = KlineCollector(market_code) - summary = collector.get_kline_summary(symbol) + + # 先尝试 orchestrator 获取 K 线数据,再生成摘要 + klines = _try_orchestrator_first(symbol, market_code.value, days=120) + if klines is not None: + summary = collector.get_kline_summary(klines=klines) + else: + # fallback 到内置采集器 + summary = collector.get_kline_summary(symbol) + return { "symbol": symbol, "market": market_code.value, @@ -165,7 +253,15 @@ def get_kline_summary_batch(payload: KlineSummaryBatchRequest): for item in payload.items: market_code = _parse_market(item.market) collector = KlineCollector(market_code) - summary = collector.get_kline_summary(item.symbol) + + # 先尝试 orchestrator 获取 K 线数据,再生成摘要 + klines = _try_orchestrator_first(item.symbol, market_code.value, days=120) + if klines is not None: + summary = collector.get_kline_summary(klines=klines) + else: + # fallback 到内置采集器 + summary = collector.get_kline_summary(item.symbol) + results.append( { "symbol": item.symbol, @@ -174,4 +270,4 @@ def get_kline_summary_batch(payload: KlineSummaryBatchRequest): } ) - return results + return results \ No newline at end of file diff --git a/tests/test_tdx_provider.py b/tests/test_tdx_provider.py new file mode 100644 index 00000000..50c773bb --- /dev/null +++ b/tests/test_tdx_provider.py @@ -0,0 +1,65 @@ +"""TDX Kline Provider 编译与基本功能测试。""" +from __future__ import annotations +import sys +import asyncio + +sys.path.insert(0, ".") + +from src.core.providers.base import ProviderRequest +from src.core.providers.kline.tdx import TdxKlineProvider + +print("[PASS] 导入 TdxKlineProvider / ProviderRequest 成功") + +# 1) 实例化(无 config) +p = TdxKlineProvider(config={}) +print(f"[PASS] 实例化成功, init_error={p._init_error!r}") +print(f" name={p.name!r}, supports_markets={p.supports_markets}") + +# 2) 空 symbol → 空列表 +req_empty = ProviderRequest(symbols=(), market="CN") +resp_empty = asyncio.run(p.fetch(req_empty)) +assert resp_empty.success and resp_empty.data == [] +print("[PASS] 空 symbol → success=True, data=[]") + +# 3) CN 单 symbol (可能 pytdx 未安装或网络连不上) +req = ProviderRequest(symbols=("600519",), market="CN", extra=(("days", 30),)) +resp = asyncio.run(p.fetch(req)) +if resp.success: + print(f"[PASS] fetch 返回成功, 数据条数={len(resp.data) if resp.data else 0}") +else: + print(f"[INFO] fetch 返回 error={resp.error!r}") + allowed = ("pytdx", "通达信", "tdx", "connect", "refused", "timeout", "网络") + assert any(kw in resp.error.lower() for kw in allowed), f"异常错误: {resp.error}" + print(f"[PASS] fetch 错误信息合理: {resp.error}") + +# 4) 非 CN 市场 → 拒绝 +req_hk = ProviderRequest(symbols=("00700",), market="HK", extra=()) +resp_hk = asyncio.run(p.fetch(req_hk)) +assert not resp_hk.success +assert "仅支持 CN" in resp_hk.error +print(f"[PASS] 非 CN 市场拒绝正确: {resp_hk.error}") + +# 5) 多 symbol → 拒绝 +req_multi = ProviderRequest(symbols=("600519", "000001"), market="CN") +resp_multi = asyncio.run(p.fetch(req_multi)) +assert not resp_multi.success +assert "单 symbol" in resp_multi.error +print(f"[PASS] 多 symbol 拒绝正确: {resp_multi.error}") + +# 6) health_check 不抛异常 +try: + healthy = asyncio.run(p.health_check()) + print(f"[PASS] health_check 执行完毕, result={healthy}") +except Exception as e: + print(f"[FAIL] health_check 抛异常: {e}") + import traceback; traceback.print_exc() + sys.exit(1) + +# 7) 验证 orchestrator 已注册 tdx +from src.core.providers.orchestrator import get_kline_orchestrator +orch = get_kline_orchestrator() +registered = orch.registered_providers() +assert "tdx" in registered, f"tdx 未注册, 已注册: {registered}" +print(f"[PASS] tdx 已在 orchestrator 中注册, 全部 providers: {registered}") + +print("\n=== 全部验证通过 ===") \ No newline at end of file diff --git a/tools/tdx-kline/go.mod b/tools/tdx-kline/go.mod new file mode 100644 index 00000000..f1acc720 --- /dev/null +++ b/tools/tdx-kline/go.mod @@ -0,0 +1,39 @@ +module panwatch-tdx-kline + +go 1.23 + +require github.com/injoyai/tdx v0.0.82 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.8.1 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/injoyai/base v1.2.20 // indirect + github.com/injoyai/conv v1.2.5 // indirect + github.com/injoyai/ios v1.2.2 // indirect + github.com/injoyai/logs v1.0.12 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/syndtr/goleveldb v1.0.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect + xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978 // indirect + xorm.io/core v0.7.3 // indirect + xorm.io/xorm v1.3.9 // indirect +) diff --git a/tools/tdx-kline/go.sum b/tools/tdx-kline/go.sum new file mode 100644 index 00000000..61ea8bb4 --- /dev/null +++ b/tools/tdx-kline/go.sum @@ -0,0 +1,145 @@ +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.8.1 h1:4/Wjm0JIJaTDm8K1KcGrLHJoa8EsJ13YWeX+6Kfq6uI= +github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/injoyai/base v1.2.20 h1:S0y66Cl/VptBsX8SgpQk7o3rMK17rvqP7hI/LQCa9Sc= +github.com/injoyai/base v1.2.20/go.mod h1:NfCQjml3z2pCvQ3J3YcOXtecqXD0xVPKjo4YTsMLhr8= +github.com/injoyai/conv v1.2.5 h1:G4OCyF0NTZul5W1u9IgXDOhW4/zmIigdPKXFHQGmv1M= +github.com/injoyai/conv v1.2.5/go.mod h1:s05l3fQJQ4mT4VX+KIdbvCWQB0YzZHprmUfUu2uxd1k= +github.com/injoyai/ios v1.2.2 h1:fAPWBL6t22DiE2ZEpBgf5bzyVQTcm2ZhLMkM+JFPhZA= +github.com/injoyai/ios v1.2.2/go.mod h1:DJVJGQFQvqF80CeJVabFOm6AKilqc/m8MFvz39Uy5ow= +github.com/injoyai/logs v1.0.12 h1:f7syIGZMTg9ZzhJhdd3tzaPdxkMhdKsncGaxljqIiYE= +github.com/injoyai/logs v1.0.12/go.mod h1:+dKEL6GvaFqqVRatqUBiCicJbZnAgtj7hVs824Src4s= +github.com/injoyai/tdx v0.0.82 h1:NrOWp6LIkl+sSSyMB+gtaLNEc00jk5gwMhkLoRBIHIQ= +github.com/injoyai/tdx v0.0.82/go.mod h1:eeWABq2NWLlZPFsECucZM3K/sy1Li1QXLzAJsYPfwC4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978 h1:bvLlAPW1ZMTWA32LuZMBEGHAUOcATZjzHcotf3SWweM= +xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0= +xorm.io/core v0.7.3/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= +xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU= +xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw= diff --git a/tools/tdx-kline/main.go b/tools/tdx-kline/main.go new file mode 100644 index 00000000..bc5b1060 --- /dev/null +++ b/tools/tdx-kline/main.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/injoyai/tdx" +) + +// KlineJSON represents a single K-line data point in JSON output. +type KlineJSON struct { + Date string `json:"date"` + Open float64 `json:"open"` + Close float64 `json:"close"` + High float64 `json:"high"` + Low float64 `json:"low"` + Volume float64 `json:"volume"` +} + +func main() { + symbol := flag.String("symbol", "sh600519", "Stock code with prefix (e.g. sh600519, sz000001)") + days := flag.Int("days", 60, "Number of trading days to fetch") + host := flag.String("host", "124.71.187.122:7709", "TDX server address (host:port)") + timeout := flag.Int("timeout", 5, "Connection timeout in seconds") + ktype := flag.String("type", "day", "K-line type: day / 5m / 15m / 30m / 60m") + flag.Parse() + + if *days < 1 { + *days = 60 + } + if *days > 800 { + *days = 800 + } + + // Save real stdout fd; redirect os.Stdout to devnull to suppress library logs + realStdout := os.Stdout + devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "open devnull failed: %v\n", err) + os.Exit(1) + } + os.Stdout = devnull + + // Connect to TDX server (library logs go to devnull) + cli, err := tdx.Dial(*host) + if err != nil { + os.Stdout = realStdout + devnull.Close() + fmt.Fprintf(os.Stderr, "connection failed: %v\n", err) + os.Exit(1) + } + defer cli.Close() + + // Set timeout + cli.SetTimeout(time.Duration(*timeout) * time.Second) + + // Fetch K-line data based on type + var klines []KlineJSON + + switch *ktype { + case "5m": + resp, err := cli.GetKline5Minute(*symbol, 0, uint16(*days)) + if err != nil { + os.Stdout = realStdout + devnull.Close() + fmt.Fprintf(os.Stderr, "get 5m kline failed: %v\n", err) + os.Exit(1) + } + if resp != nil { + klines = make([]KlineJSON, 0, len(resp.List)) + for _, k := range resp.List { + klines = append(klines, KlineJSON{ + Date: k.Time.Format("2006-01-02 15:04"), + Open: k.Open.Float64(), + Close: k.Close.Float64(), + High: k.High.Float64(), + Low: k.Low.Float64(), + Volume: float64(k.Volume), + }) + } + } + case "15m": + resp, err := cli.GetKline15Minute(*symbol, 0, uint16(*days)) + if err != nil { + os.Stdout = realStdout + devnull.Close() + fmt.Fprintf(os.Stderr, "get 15m kline failed: %v\n", err) + os.Exit(1) + } + if resp != nil { + klines = make([]KlineJSON, 0, len(resp.List)) + for _, k := range resp.List { + klines = append(klines, KlineJSON{ + Date: k.Time.Format("2006-01-02 15:04"), + Open: k.Open.Float64(), + Close: k.Close.Float64(), + High: k.High.Float64(), + Low: k.Low.Float64(), + Volume: float64(k.Volume), + }) + } + } + case "30m": + resp, err := cli.GetKline30Minute(*symbol, 0, uint16(*days)) + if err != nil { + os.Stdout = realStdout + devnull.Close() + fmt.Fprintf(os.Stderr, "get 30m kline failed: %v\n", err) + os.Exit(1) + } + if resp != nil { + klines = make([]KlineJSON, 0, len(resp.List)) + for _, k := range resp.List { + klines = append(klines, KlineJSON{ + Date: k.Time.Format("2006-01-02 15:04"), + Open: k.Open.Float64(), + Close: k.Close.Float64(), + High: k.High.Float64(), + Low: k.Low.Float64(), + Volume: float64(k.Volume), + }) + } + } + case "60m": + resp, err := cli.GetKline60Minute(*symbol, 0, uint16(*days)) + if err != nil { + os.Stdout = realStdout + devnull.Close() + fmt.Fprintf(os.Stderr, "get 60m kline failed: %v\n", err) + os.Exit(1) + } + if resp != nil { + klines = make([]KlineJSON, 0, len(resp.List)) + for _, k := range resp.List { + klines = append(klines, KlineJSON{ + Date: k.Time.Format("2006-01-02 15:04"), + Open: k.Open.Float64(), + Close: k.Close.Float64(), + High: k.High.Float64(), + Low: k.Low.Float64(), + Volume: float64(k.Volume), + }) + } + } + default: // "day" and fallback + resp, err := cli.GetKlineDay(*symbol, 0, uint16(*days)) + if err != nil { + os.Stdout = realStdout + devnull.Close() + fmt.Fprintf(os.Stderr, "get kline failed: %v\n", err) + os.Exit(1) + } + if resp != nil { + klines = make([]KlineJSON, 0, len(resp.List)) + for _, k := range resp.List { + klines = append(klines, KlineJSON{ + Date: k.Time.Format("2006-01-02"), + Open: k.Open.Float64(), + Close: k.Close.Float64(), + High: k.High.Float64(), + Low: k.Low.Float64(), + Volume: float64(k.Volume), + }) + } + } + } + + // Restore real stdout before printing JSON + os.Stdout = realStdout + devnull.Close() + + if len(klines) == 0 { + fmt.Println("[]") + return + } + + // Output JSON to real stdout + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(klines); err != nil { + fmt.Fprintf(os.Stderr, "json encode failed: %v\n", err) + os.Exit(1) + } +} \ No newline at end of file