mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
将可选数据源改为插件化架构: 插件代码放 backend/app/plugins/<name>/, 用户在设置页点击安装依赖, 主仓库不背运行时依赖(nodejs 等)。 架构: - loader.py 新增 _load_builtin_plugins() 扫描 plugins/ 目录, 通过 plugin.yaml 清单动态发现并注册插件(委托自检 + 优雅降级) - runtime 字段支持 node/python, 安装/卸载分别用 npm/pip - 现有 tickflow + YAML 自定义源逻辑 100% 保留, 插件是叠加层 stock-sdk 插件 (plugins/stocksdk/): - 原始实现来自 @forrany 的 PR #57, 迁移到插件化架构, 署名保留 - Node 桥接 (bridge.py/mjs) + Python provider, 覆盖日K/除权/分钟/实时 - 设置页主列表直接显示: 未装灰显+安装按钮, 装完可切换/卸载 配套: - instrument_sync: 通用增强, 任何 provider 都能提供标的维表 - install/uninstall: uv 优先回退 pip, 容错坏 uv.toml + 国内镜像 - 10 例单测全过, tsc 零错误
153 lines
4.8 KiB
Python
153 lines
4.8 KiB
Python
"""标的维表同步服务。
|
||
|
||
盘前 9:10 调用 tf.exchanges.get_instruments("SH"/"SZ"/"BJ", type="stock")
|
||
获取全量标的元数据,flatten ext 字段,写入 instruments.parquet。
|
||
|
||
Starter+ 盘后可用 quotes.get(universes) 顺便补充 name。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import polars as pl
|
||
|
||
from app.tickflow.client import get_client
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_EXCHANGES = ["SH", "SZ", "BJ"]
|
||
|
||
|
||
def _flatten_instruments(items: list[dict]) -> list[dict]:
|
||
"""把 SDK 返回的 Instrument 列表 flatten 成扁平行。"""
|
||
rows = []
|
||
for item in items:
|
||
row = {
|
||
"symbol": item.get("symbol"),
|
||
"name": item.get("name"),
|
||
"code": item.get("code"),
|
||
"exchange": item.get("exchange"),
|
||
"region": item.get("region"),
|
||
"type": item.get("type"),
|
||
}
|
||
ext = item.get("ext") or {}
|
||
row["listing_date"] = ext.get("listing_date")
|
||
row["total_shares"] = ext.get("total_shares")
|
||
row["float_shares"] = ext.get("float_shares")
|
||
row["tick_size"] = ext.get("tick_size")
|
||
row["limit_up"] = ext.get("limit_up")
|
||
row["limit_down"] = ext.get("limit_down")
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
def _fetch_instruments_via_provider() -> list[dict] | None:
|
||
"""若当前日K数据源不是 tickflow 且该 provider 提供 get_instruments, 用它拉标的维表。
|
||
|
||
返回 flatten 行列表; 未命中(仍应走 tickflow)时返回 None。
|
||
标的维表跟随日K数据源(二者天然耦合, 无独立偏好项)。
|
||
"""
|
||
from app.services import preferences
|
||
|
||
provider_name = preferences.get_daily_data_provider()
|
||
if provider_name == "tickflow":
|
||
return None
|
||
from app.data_providers import custom as custom_sources
|
||
|
||
if not custom_sources.is_custom_provider(provider_name):
|
||
return None
|
||
provider = custom_sources.get_provider(provider_name)
|
||
if not hasattr(provider, "get_instruments"):
|
||
return None
|
||
try:
|
||
items = provider.get_instruments("stock") or []
|
||
except Exception as e: # noqa: BLE001
|
||
logger.warning("provider %s get_instruments 失败: %s", provider_name, e)
|
||
return None
|
||
rows = _flatten_instruments(items)
|
||
logger.info("instruments via %s: %d stocks", provider_name, len(rows))
|
||
return rows
|
||
|
||
|
||
def sync_instruments(data_dir: Path) -> int:
|
||
"""全量同步标的维表 → data/instruments/instruments.parquet。
|
||
|
||
返回写入的行数。
|
||
"""
|
||
all_rows = _fetch_instruments_via_provider()
|
||
if all_rows is None:
|
||
# 未命中非 tickflow provider → 走 tickflow 直连
|
||
tf = get_client()
|
||
all_rows = []
|
||
for ex in _EXCHANGES:
|
||
try:
|
||
items = tf.exchanges.get_instruments(ex, instrument_type="stock")
|
||
if items:
|
||
all_rows.extend(_flatten_instruments(items))
|
||
logger.info("instruments %s: %d stocks", ex, len(items))
|
||
except Exception as e:
|
||
logger.warning("get_instruments(%s) failed: %s", ex, e)
|
||
|
||
if not all_rows:
|
||
return 0
|
||
|
||
df = pl.DataFrame(all_rows)
|
||
df = df.with_columns(pl.lit(date.today()).alias("as_of"))
|
||
|
||
out = data_dir / "instruments" / "instruments.parquet"
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
df.write_parquet(out)
|
||
|
||
logger.info("instruments synced: %d rows → %s", df.height, out)
|
||
return df.height
|
||
|
||
|
||
def enrich_names_from_quotes(
|
||
data_dir: Path,
|
||
quotes_data: list[dict],
|
||
) -> int:
|
||
"""从 quotes 响应中提取 name,更新 instruments 维表(兜底补充)。
|
||
|
||
盘后 quotes.get(universes) 返回的数据中包含 ext.name,
|
||
用来补充 instruments 中可能缺失的 name。
|
||
"""
|
||
if not quotes_data:
|
||
return 0
|
||
|
||
# 构建 symbol → name 映射
|
||
name_map: dict[str, str] = {}
|
||
for q in quotes_data:
|
||
symbol = q.get("symbol", "")
|
||
ext = q.get("ext") or {}
|
||
name = ext.get("name") or q.get("name", "")
|
||
if symbol and name:
|
||
name_map[symbol] = name
|
||
|
||
if not name_map:
|
||
return 0
|
||
|
||
inst_path = data_dir / "instruments" / "instruments.parquet"
|
||
if not inst_path.exists():
|
||
return 0
|
||
|
||
df = pl.read_parquet(inst_path)
|
||
|
||
# 只更新空 name 的行
|
||
updates = pl.DataFrame({
|
||
"symbol": list(name_map.keys()),
|
||
"_new_name": list(name_map.values()),
|
||
})
|
||
df = df.join(updates, on="symbol", how="left")
|
||
df = df.with_columns(
|
||
pl.when(pl.col("name").is_null() | (pl.col("name") == ""))
|
||
.then(pl.col("_new_name"))
|
||
.otherwise(pl.col("name"))
|
||
.alias("name"),
|
||
).drop("_new_name")
|
||
|
||
df.write_parquet(inst_path)
|
||
logger.info("instruments name enriched from quotes: %d names", len(name_map))
|
||
return len(name_map)
|