feat(ext-data): 新增扩展数据支持 URL 创建并调整设置弹窗 tab (#64)

- 新建扩展数据弹窗加入 URL/文件/手动 三种接入方式, URL 模式支持探测字段、预览、保存拉取配置并可选立即导入或定时拉取
- 后端新增 /api/ext-data/detect-url 探测接口, 自动识别 JSON 行数组路径、字段类型与 symbol/code 候选列
- 抽出 infer_fields_from_df / detect_symbol_candidates / apply_config_mapping 公共逻辑, 文件上传与 URL 探测复用
- 拉取写入支持仅含 code 的数据, 按 code_map 自动生成标准 symbol
- 设置弹窗 tab 顺序调整为 拉取/推送/上传, 默认显示拉取
This commit is contained in:
wshy
2026-07-07 18:04:49 +08:00
committed by GitHub
parent f8914c9140
commit 5b2282f8cb
5 changed files with 723 additions and 210 deletions
+87
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import re
from datetime import date, datetime
from pathlib import Path
from typing import Literal
@@ -266,6 +267,54 @@ _POLARS_DTYPE_MAP = {
"bool": pl.Boolean,
}
_POLARS_TYPE_MAP = {
"Int64": "int", "Int32": "int", "Int16": "int", "Int8": "int",
"UInt64": "int", "UInt32": "int", "UInt16": "int", "UInt8": "int",
"Float64": "float", "Float32": "float",
"Boolean": "bool",
"Utf8": "string", "String": "string",
"Date": "string", "Datetime": "string", "Duration": "string",
"Categorical": "string",
}
_CODE_PAT = re.compile(r"^\d{6}$")
_SYMBOL_PAT = re.compile(r"^\d{6}\.[A-Z]{2}$")
def infer_fields_from_df(df: pl.DataFrame) -> list[dict]:
"""从 DataFrame 推断扩展字段定义。"""
fields = []
for col_name in df.columns:
pl_type = df[col_name].dtype
dtype = _POLARS_TYPE_MAP.get(str(pl_type.base_type()), "string")
fields.append({"name": col_name, "dtype": dtype, "label": col_name})
return fields
def detect_symbol_candidates(df: pl.DataFrame) -> tuple[list[str], list[str]]:
"""识别 symbol/code 候选列。"""
symbol_candidates: list[str] = []
code_candidates: list[str] = []
for col in df.columns:
try:
col_data = df[col].cast(pl.Utf8).drop_nulls()
except Exception:
continue
if len(col_data) == 0:
continue
sample = col_data.head(200).to_list()
sym_hits = sum(1 for v in sample if _SYMBOL_PAT.match(str(v).strip()))
code_hits = sum(1 for v in sample if _CODE_PAT.match(str(v).strip()))
total = len(sample)
if total > 0:
if sym_hits / total > 0.5:
symbol_candidates.append(col)
elif code_hits / total > 0.5:
code_candidates.append(col)
return symbol_candidates, code_candidates
def build_code_lookup(data_dir: Path) -> dict[str, str]:
"""从 instruments 维表构建 code → symbol 映射。"""
@@ -309,6 +358,43 @@ def normalize_symbol(series: pl.Series, lookup: dict[str, str] | None = None) ->
return series.map_elements(_fix_one, return_dtype=pl.Utf8)
def apply_config_mapping(df: pl.DataFrame, config: ExtConfig, data_dir: Path) -> pl.DataFrame:
"""根据 config 的 symbol_map / code_map 自动生成 symbol 和 code 列。"""
sm = config.symbol_map or {}
cm = config.code_map or {}
if sm.get("type") == "mapped" and sm["col"] in df.columns:
df = df.with_columns(df[sm["col"]].cast(pl.Utf8).alias("symbol"))
if cm.get("type") == "mapped" and cm["col"] in df.columns:
df = df.with_columns(df[cm["col"]].cast(pl.Utf8).alias("code"))
if "symbol" not in df.columns and sm.get("type") == "computed":
if sm.get("from") == "code" and "code" in df.columns:
lookup = build_code_lookup(data_dir)
df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol"))
if "code" not in df.columns and cm.get("type") == "computed":
if cm.get("from") == "symbol" and "symbol" in df.columns:
df = df.with_columns(
df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code")
)
if "symbol" in df.columns and "code" not in df.columns:
df = df.with_columns(
df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code")
)
elif "code" in df.columns and "symbol" not in df.columns:
lookup = build_code_lookup(data_dir)
df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol"))
if "symbol" in df.columns:
lookup = build_code_lookup(data_dir)
df = df.with_columns(normalize_symbol(df["symbol"].cast(pl.Utf8), lookup))
return df
def ensure_utf8_csv(file_path: Path) -> Path:
"""确保 CSV 文件以 UTF-8 编码可读,非 UTF-8(如 GBK/GB18030)则转换。
@@ -516,6 +602,7 @@ def rows_to_parquet(
写入行数。
"""
df = pl.DataFrame(rows)
df = apply_config_mapping(df, config, data_dir)
if "symbol" in df.columns:
df = df.with_columns(pl.col("symbol").cast(pl.Utf8))
return write_ext_parquet(df, config, data_dir, snapshot_date=snapshot_date)