mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(custom): 自定义数据源支持五档盘口与财务数据接入
后端拉取/路由/能力增广链路此前已就绪, 本次补齐缺口: - 编辑器新增「五档盘口」「财务数据」数据集 tab: 五档预填 symbol/asks/bids; 财务预填骨架三列, 下拉可选五表全部 canonical 列(带中文标注); 两者均为逐批代码参数快照型, 隐藏起止时间参数 - test_dataset 财务分支注入 table=metrics, 与生产同口径 - validate: depth5/financial 豁免起止时间重名检查; 新增映射目标 重复拦截(运行时 rename DuplicateError 会整表拉空) - mapper.map_rows 改全量推断: 默认 100 行采样把稀疏财务列推断成 Null 列, 第 101 行数值 append ComputeError → 整表拉空(batch=100 必现, 与 fuyao 插件同因) - 契约文档: 五档/financial 字段契约与五表 canonical 列清单, 映射一对一限制、缺失列跨批合并补 null 的降级语义 - 回归 6 条: table/latest 注入、validate 豁免与拦截、metrics 探测、稀疏列推断修复 验证: 后端全量 2798 passed + 前端 155 passed + tsc 通过; mock 接口 E2E 真连 + 浏览器 UI 用户旅程实测。
This commit is contained in:
@@ -31,7 +31,10 @@ def extract_rows(payload: Any, response_path: str = "") -> list[dict]:
|
||||
def map_rows(rows: list[dict], field_map: dict[str, str]) -> pl.DataFrame:
|
||||
if not rows:
|
||||
return pl.DataFrame()
|
||||
df = pl.DataFrame(rows)
|
||||
# 全量扫描推断: 默认 100 行采样会把稀疏列(前 100 行全 null, 如仅银行股
|
||||
# 有值的财务费用)推断成 Null 列, 后续数值行 append 直接 ComputeError —
|
||||
# 财务批 batch=100 时整表拉空 (与 fuyao 插件 infer_schema_length=None 同因)。
|
||||
df = pl.DataFrame(rows, infer_schema_length=None)
|
||||
rename = {src: dst for src, dst in field_map.items() if src in df.columns and src != dst}
|
||||
if rename:
|
||||
df = df.rename(rename)
|
||||
|
||||
@@ -117,12 +117,19 @@ class GenericHTTPProvider:
|
||||
missing = sorted(required - mapped)
|
||||
if missing:
|
||||
errors.append(f"{dataset}: missing mapped fields: {', '.join(missing)}")
|
||||
# 两个外部字段映射同一内部列: rename 阶段 DuplicateError → 整表拉空,
|
||||
# 保存时拦截给明确提示 (前端映射行允许选重, 不拦会静默失败难排查)。
|
||||
targets = list(cfg.field_map.values())
|
||||
dup_targets = sorted({t for t in targets if targets.count(t) > 1})
|
||||
if dup_targets:
|
||||
errors.append(f"{dataset}: duplicate mapped target fields: {', '.join(dup_targets)}")
|
||||
if cfg.pct_unit is not None:
|
||||
if dataset != "realtime":
|
||||
errors.append(f"{dataset}: pct_unit 仅用于 realtime 数据集")
|
||||
elif cfg.pct_unit not in ("percent", "decimal"):
|
||||
errors.append(f"{dataset}: pct_unit 必须是 percent 或 decimal")
|
||||
if dataset not in {"realtime", "depth5"}:
|
||||
# depth5/financial 逐批只注入代码参数, 起止时间不参与请求, 不纳入重名检查
|
||||
if dataset not in {"realtime", "depth5", "financial"}:
|
||||
request_params = [cfg.symbols_param, cfg.start_param, cfg.end_param]
|
||||
if dataset in {"minute", "full_minute"}:
|
||||
request_params.extend(
|
||||
@@ -436,6 +443,15 @@ class GenericHTTPProvider:
|
||||
rows = self._request_rows(cfg)
|
||||
elif dataset == "depth5":
|
||||
rows = self._request_rows(cfg, symbols=test_symbols)
|
||||
elif dataset == "financial":
|
||||
# 财务测试取 metrics 表: 与生产 get_financials("metrics") 同口径
|
||||
override = {**cfg.params, "table": "metrics"}
|
||||
rows = self._request_rows(
|
||||
cfg,
|
||||
symbols=test_symbols,
|
||||
override_params=override,
|
||||
override_body={**cfg.body, "table": "metrics"},
|
||||
)
|
||||
elif dataset in {"minute", "full_minute"}:
|
||||
override: dict[str, Any] = {}
|
||||
if cfg.asset_type_param:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""自定义源 financial 数据集回归。
|
||||
|
||||
一个 financial 配置覆盖全部五表(metrics/income/balance_sheet/cash_flow/
|
||||
shares): 请求时注入 table 参数由上游分发; shares 表额外注入 latest。
|
||||
字段只要求映射出 symbol, 指标列按 canonical 口径宽表透传, 无
|
||||
period_end 的帧由同步层合并链路自然过滤。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.data_providers.custom.config import config_from_dict
|
||||
from app.data_providers.custom.provider import GenericHTTPProvider
|
||||
|
||||
|
||||
def _provider(**overrides) -> GenericHTTPProvider:
|
||||
cfg = {
|
||||
"url": "http://example.test/financial",
|
||||
"response_path": "data",
|
||||
"field_map": {
|
||||
"symbol": "symbol", "period_end": "period_end",
|
||||
"announce_date": "announce_date",
|
||||
"revenue": "revenue", "net_income": "net_income",
|
||||
},
|
||||
**overrides,
|
||||
}
|
||||
return GenericHTTPProvider(config_from_dict({
|
||||
"name": "eltdx", "datasets": {"financial": cfg},
|
||||
}))
|
||||
|
||||
|
||||
def test_financial_request_injects_table_parameter(monkeypatch):
|
||||
provider = _provider()
|
||||
captured: dict = {}
|
||||
|
||||
def fake_request(cfg, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return [{"symbol": "600000.SH", "period_end": "2026-06-30",
|
||||
"announce_date": "2026-08-30",
|
||||
"revenue": 1.0, "net_income": 0.1}]
|
||||
|
||||
monkeypatch.setattr(provider, "_request_rows", fake_request)
|
||||
df = provider.get_financials("income", ["600000.SH"])
|
||||
|
||||
assert captured["symbols"] == ["600000.SH"]
|
||||
assert captured["override_params"]["table"] == "income"
|
||||
assert captured["override_body"]["table"] == "income"
|
||||
assert df.columns == ["symbol", "period_end", "announce_date", "revenue", "net_income"]
|
||||
assert df["symbol"][0] == "600000.SH"
|
||||
|
||||
|
||||
def test_financial_shares_request_injects_latest_flag(monkeypatch):
|
||||
provider = _provider()
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(
|
||||
provider, "_request_rows",
|
||||
lambda cfg, **kwargs: (captured.update(kwargs), [])[1],
|
||||
)
|
||||
|
||||
provider.get_financials("shares", ["600000.SH"], latest_only=True)
|
||||
assert captured["override_params"]["table"] == "shares"
|
||||
assert captured["override_params"]["latest"] is True
|
||||
|
||||
provider.get_financials("shares", ["600000.SH"], latest_only=False)
|
||||
assert captured["override_params"]["latest"] is False
|
||||
|
||||
provider.get_financials("metrics", ["600000.SH"])
|
||||
# 非 shares 表不注入 latest
|
||||
assert "latest" not in captured["override_params"]
|
||||
|
||||
|
||||
def test_financial_validate_skips_date_parameter_conflicts():
|
||||
# financial 逐批只注入代码参数: 起止时间不参与请求,
|
||||
# 其与 symbols_param 重名不应被判 duplicate
|
||||
provider = _provider(symbols_param="range", start_param="range")
|
||||
assert provider.validate() == []
|
||||
|
||||
assert _provider().validate() == []
|
||||
|
||||
|
||||
def test_financial_test_dataset_probes_metrics_table(monkeypatch):
|
||||
provider = _provider()
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(
|
||||
provider, "_request_rows",
|
||||
lambda cfg, **kwargs: (captured.update(kwargs), [
|
||||
{"symbol": "600000.SH", "period_end": "2026-06-30"},
|
||||
])[1],
|
||||
)
|
||||
|
||||
out = provider.test_dataset("financial", ["600000.SH"])
|
||||
assert out["dataset"] == "financial"
|
||||
assert out["rows"] == 1
|
||||
# 测试与生产同口径: 注入 table=metrics
|
||||
assert captured["override_params"]["table"] == "metrics"
|
||||
|
||||
|
||||
def test_financial_sparse_column_survives_schema_inference(monkeypatch):
|
||||
"""稀疏列回归: batch=100 时前 100 行全 null 的列不得被采样推断成 Null 列。
|
||||
|
||||
旧行为: pl.DataFrame(rows) 默认 100 行采样 → Null dtype → 第 101 行数值
|
||||
append ComputeError → 整表拉空 (fuyao 插件同因踩坑)。
|
||||
"""
|
||||
provider = _provider()
|
||||
rows = [
|
||||
{"symbol": f"00000{i}.SZ", "period_end": "2026-06-30",
|
||||
"announce_date": "2026-08-30", "revenue": None}
|
||||
for i in range(100)
|
||||
]
|
||||
rows.append({"symbol": "600519.SH", "period_end": "2026-06-30",
|
||||
"announce_date": "2026-08-30", "revenue": 5.0e10})
|
||||
monkeypatch.setattr(provider, "_request_rows", lambda cfg, **kwargs: rows)
|
||||
|
||||
df = provider.get_financials("metrics", ["600519.SH"])
|
||||
assert df["revenue"].dtype == pl.Float64
|
||||
assert df["revenue"].drop_nulls().to_list() == [5.0e10]
|
||||
|
||||
|
||||
def test_duplicate_mapped_targets_are_rejected():
|
||||
"""两个外部字段映射同一内部列: rename 阶段 DuplicateError → 整表拉空,
|
||||
validate 必须在保存时拦截 (前端映射行允许选重)。"""
|
||||
provider = GenericHTTPProvider(config_from_dict({
|
||||
"name": "eltdx",
|
||||
"datasets": {"financial": {
|
||||
"url": "http://example.test/financial",
|
||||
"field_map": {"symbol": "symbol", "eps": "eps_basic",
|
||||
"eps_dil": "eps_basic"}, # eps_dil 与 eps 同目标
|
||||
}},
|
||||
}))
|
||||
errors = provider.validate()
|
||||
assert "financial: duplicate mapped target fields: eps_basic" in errors
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 支持范围
|
||||
|
||||
当前自定义源支持六类数据:
|
||||
当前自定义源支持七类数据:
|
||||
|
||||
| 数据集 | 配置名 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -12,10 +12,9 @@
|
||||
| 除权因子 | `adj_factor` | 批量返回一组股票的复权因子 |
|
||||
| 实时行情 | `realtime` | 返回全市场快照,用于盘中 enriched 增量计算 |
|
||||
| 分钟K | `minute` | 返回 1m 分钟K(需映射出 symbol / datetime / OHLC / 量; amount 成交额可缺省, 缺省时均价线降级显示 —) |
|
||||
| 五档盘口 | `depth5` | 批量返回一组股票的买卖五档快照(逐批注入代码参数,无起止时间) |
|
||||
| 全量分钟 | `full_minute` | 与 `minute` 同形;声明后可被路由为「全量分钟」生效源,内置服务盘中按当日窗口全市场批量落盘(仅修复轮语义,节奏下限 60s) |
|
||||
| 财务数据 | `financial` | 一个配置覆盖全部财务表,请求时把表名作为参数传给上游;字段由数据源决定,仅需映射出 symbol |
|
||||
|
||||
深度盘口(depth5)暂无数据集契约,仍由 TickFlow 提供。
|
||||
| 财务数据 | `financial` | 一个配置覆盖全部五张财务表;请求时注入 `table` 参数由上游分发,五表按报告期累积落库(见下 `financial` 字段契约) |
|
||||
|
||||
`full_minute` 声明式源只提供修复轮(当日窗口批量);廉价增量端点
|
||||
(`get_intraday_latest`)是 Python 插件契约,见
|
||||
@@ -152,12 +151,87 @@ datasets:
|
||||
| 未声明 | `change_pct` 按截面中位数归一(A 股涨跌停 30% 上限使两种单位物理可分);`amplitude` / `turnover_rate` **置 `None`** 交由 pipeline 按价格与股本口径重算,并记录 WARNING |
|
||||
| 列已配置 `transforms` | 视为用户已接管该列单位,原样透传 |
|
||||
|
||||
### depth5 必填
|
||||
|
||||
| 内部字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `symbol` | 标准代码 |
|
||||
| `asks` | 卖五档,嵌套数组 `[[价,量], ...]`,按卖一至卖五顺序 |
|
||||
| `bids` | 买五档,嵌套数组 `[[价,量], ...]`,按买一至买五顺序 |
|
||||
|
||||
`asks` / `bids` 每档取前两个元素按数值解析为 `[价格, 手数]`,无法解析的档位被跳过;数量单位沿用现有封单量口径(手),前端计算封单额时再乘 100 换算为股。示例响应行:
|
||||
|
||||
```json
|
||||
{ "code": "000001.SZ", "sell": [[10.01, 1200], [10.02, 800], [10.03, 500], [10.04, 300], [10.05, 200]],
|
||||
"buy": [[10.00, 900], [9.99, 700], [9.98, 400], [9.97, 350], [9.96, 150]] }
|
||||
```
|
||||
|
||||
对应映射 `code→symbol`、`sell→asks`、`buy→bids`。声明 depth5 后即可在设置 → 数据源 → 能力路由把五档生效源切到该自定义源;五档独立路由,失败不跨源回退。
|
||||
|
||||
### financial 必填
|
||||
|
||||
| 内部字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `symbol` | 标准代码(唯一硬性要求,缺 symbol 的帧被丢弃) |
|
||||
| `period_end` | 报告期 `YYYY-MM-DD`;缺此列的帧不参与报告期合并,页面按时间排序失效 |
|
||||
| `announce_date` | 公告日期 `YYYY-MM-DD`;缺省时按报告期排序,换手率用公告日判业务可用性 |
|
||||
|
||||
一个 `financial` 配置覆盖全部五张表,每次请求自动注入 `table`
|
||||
参数(`metrics` / `income` / `balance_sheet` / `cash_flow` / `shares`)
|
||||
由上游分发返回对应表数据;`shares` 表额外注入 `latest`(当期 `True` /
|
||||
全历史 `False`)。映射为「外部字段 → 内部列」一对一:同一外部字段名只能
|
||||
映射到一个内部列 — 若两张表存在同名字段且含义不同(如 `total` 在
|
||||
balance_sheet 是资产、在 shares 是股本),无法分别映射,需要上游区分字段名
|
||||
或只映射其一。两个外部字段映射同一内部列会在保存时被校验拒绝。某批响应
|
||||
里完全缺失(而非为 null)的字段该批不落该列,跨批/跨轮合并会自动补 null。
|
||||
|
||||
每表 canonical 列(前端财务页与回测基本面因子按此消费;不映射的列在
|
||||
页面显示为 —,不影响其他列):
|
||||
|
||||
- `metrics` 核心指标: `eps_basic` / `eps_diluted` / `bps`(每股净资产) /
|
||||
`ocfps`(每股经营现金流) / `roe` / `roe_diluted` / `roa` /
|
||||
`gross_margin`(毛利率) / `net_margin`(净利率) / `debt_to_asset_ratio`(资产负债率) /
|
||||
`revenue_yoy`(营收同比) / `net_income_yoy`(净利同比) /
|
||||
`operating_cash_to_revenue`(经营现金/营收) / `inventory_turnover`(存货周转率)
|
||||
—— 注意: 百分比列存**百分点**(12.3 表示 12.3%),与 realtime 的小数制不同
|
||||
- `income` 利润表: `revenue`(营收) / `operating_cost`(营业成本) /
|
||||
`operating_profit`(营业利润) / `selling_expense`(销售费用) /
|
||||
`admin_expense`(管理费用) / `rd_expense`(研发费用) /
|
||||
`financial_expense`(财务费用) / `non_operating_income`(营业外收入) /
|
||||
`non_operating_expense`(营业外支出) / `total_profit`(利润总额) /
|
||||
`income_tax`(所得税) / `net_income`(净利润) /
|
||||
`net_income_attributable`(归母净利) / `net_income_deducted`(扣非净利) /
|
||||
`basic_eps` / `diluted_eps` —— 金额单位元
|
||||
- `balance_sheet` 资产负债表: `total_assets`(资产总计) /
|
||||
`total_current_assets`(流动资产) / `total_non_current_assets`(非流动资产) /
|
||||
`cash_and_equivalents`(货币资金) / `accounts_receivable`(应收账款) /
|
||||
`inventory`(存货) / `fixed_assets`(固定资产) / `intangible_assets`(无形资产) /
|
||||
`goodwill`(商誉) / `total_liabilities`(负债合计) /
|
||||
`total_current_liabilities`(流动负债) / `total_non_current_liabilities`(非流动负债) /
|
||||
`short_term_borrowing`(短期借款) / `long_term_borrowing`(长期借款) /
|
||||
`accounts_payable`(应付账款) / `total_equity`(所有者权益) /
|
||||
`equity_attributable`(归母权益) / `retained_earnings`(未分配利润) /
|
||||
`minority_interest`(少数股东权益) —— 金额单位元
|
||||
- `cash_flow` 现金流量表: `net_operating_cash_flow`(经营净额) /
|
||||
`net_investing_cash_flow`(投资净额) / `net_financing_cash_flow`(筹资净额) /
|
||||
`capex`(资本开支) / `net_cash_change`(现金净增加) —— 金额单位元
|
||||
- `shares` 股本: `total_shares`(总股本,股) / `float_shares`(流通股本,股) —
|
||||
历史换手率按公告日(缺则报告期)取有效股本,无 `float_shares` 时回退维表最新股本
|
||||
|
||||
被切为财务生效源后,财务同步走独立调度(每周 metrics + 手动全表),
|
||||
报告期合并按 `(symbol, period_end)` 逐列取最新非空值:多源字段可并集
|
||||
共存互不覆盖(如 TickFlow 补齐 metrics 历史各期,自定义源提供当期报表),
|
||||
但切换生效源后**不再拉取的表不会自动清空**,旧数据会保留到被新数据覆盖。
|
||||
声明了 financial 但上游某表无数据时返回空即可,已有存量靠合并写入保留。
|
||||
|
||||
## 请求约定
|
||||
|
||||
- `daily` / `adj_factor` 会按 `batch` 切分 symbols。
|
||||
- POST 请求会发送 JSON body: `symbols`、`start_time`、`end_time`。
|
||||
- GET 请求会发送 query 参数: `symbols=000001.SZ,600000.SH`。
|
||||
- `realtime` 必须是全市场快照接口,不支持逐个 symbol 拉实时行情。
|
||||
- `depth5` 会按 `batch` 切分 symbols 逐批请求,只注入代码参数(无 `start_time` / `end_time`)。
|
||||
- `financial` 同样按 `batch` 切分逐批请求,只注入代码参数 + `table` 表名(无起止时间);`shares` 表额外注入 `latest`。
|
||||
|
||||
可通过这些字段改参数名:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { toast } from '@/components/Toast'
|
||||
const INPUT_CLS =
|
||||
'w-full h-9 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/40 text-xs text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/40 transition-shadow'
|
||||
|
||||
const DATASETS = ['daily', 'adj_factor', 'realtime', 'minute', 'full_minute'] as const
|
||||
const DATASETS = ['daily', 'adj_factor', 'realtime', 'minute', 'depth5', 'full_minute', 'financial'] as const
|
||||
type DatasetKey = typeof DATASETS[number]
|
||||
|
||||
const DATASET_LABEL: Record<DatasetKey, string> = {
|
||||
@@ -17,17 +17,61 @@ const DATASET_LABEL: Record<DatasetKey, string> = {
|
||||
adj_factor: '除权因子',
|
||||
realtime: '实时行情',
|
||||
minute: '分钟K',
|
||||
depth5: '五档盘口',
|
||||
full_minute: '全量分钟',
|
||||
financial: '财务数据',
|
||||
}
|
||||
|
||||
// 财务 canonical 全列 (与前端财务页 FIELD_DEFS / fuyao provider 映射对齐)。
|
||||
// 一个 financial 配置覆盖全部五表 (metrics/income/balance_sheet/cash_flow/shares),
|
||||
// 上游每个外部字段分别映射到其所属表的 canonical 列。
|
||||
const FINANCIAL_ALL_FIELDS = [
|
||||
// 报告期骨架 (五表通用)
|
||||
'symbol', 'period_end', 'announce_date',
|
||||
// metrics: 核心指标 (pct 百分点制: 12.3 表示 12.3%)
|
||||
'eps_basic', 'eps_diluted', 'bps', 'ocfps', 'roe', 'roe_diluted', 'roa',
|
||||
'gross_margin', 'net_margin', 'debt_to_asset_ratio', 'revenue_yoy',
|
||||
'net_income_yoy', 'operating_cash_to_revenue', 'inventory_turnover',
|
||||
// income: 利润表
|
||||
'revenue', 'operating_cost', 'operating_profit', 'selling_expense',
|
||||
'admin_expense', 'rd_expense', 'financial_expense', 'non_operating_income',
|
||||
'non_operating_expense', 'total_profit', 'income_tax', 'net_income',
|
||||
'net_income_attributable', 'net_income_deducted', 'basic_eps', 'diluted_eps',
|
||||
// balance_sheet: 资产负债表
|
||||
'total_assets', 'total_current_assets', 'total_non_current_assets',
|
||||
'cash_and_equivalents', 'accounts_receivable', 'inventory', 'fixed_assets',
|
||||
'intangible_assets', 'goodwill', 'total_liabilities', 'total_current_liabilities',
|
||||
'total_non_current_liabilities', 'short_term_borrowing', 'long_term_borrowing',
|
||||
'accounts_payable', 'total_equity', 'equity_attributable', 'retained_earnings',
|
||||
'minority_interest',
|
||||
// cash_flow: 现金流量表
|
||||
'net_operating_cash_flow', 'net_investing_cash_flow', 'net_financing_cash_flow',
|
||||
'capex', 'net_cash_change',
|
||||
// shares: 股本 (换手率历史股本用)
|
||||
'total_shares', 'float_shares',
|
||||
] as const
|
||||
|
||||
const TARGET_FIELDS: Record<DatasetKey, string[]> = {
|
||||
daily: ['symbol', 'date', 'open', 'high', 'low', 'close', 'volume', 'amount'],
|
||||
adj_factor: ['symbol', 'trade_date', 'ex_factor'],
|
||||
realtime: ['symbol', 'name', 'last_price', 'prev_close', 'open', 'high', 'low', 'volume', 'amount', 'change_pct', 'change_amount', 'amplitude', 'turnover_rate', 'timestamp', 'session'],
|
||||
minute: ['symbol', 'datetime', 'open', 'high', 'low', 'close', 'volume', 'amount'],
|
||||
depth5: ['symbol', 'asks', 'bids'],
|
||||
full_minute: ['symbol', 'datetime', 'open', 'high', 'low', 'close', 'volume', 'amount'],
|
||||
// 财务只预填骨架三列, 具体指标从"添加映射"下拉按表选择 (全预填 60 行太长)
|
||||
financial: ['symbol', 'period_end', 'announce_date'],
|
||||
}
|
||||
|
||||
// 财务的映射下拉选项 = 五表 canonical 全列 (预填只给骨架, 下拉覆盖全部)
|
||||
const TARGET_OPTIONS: Record<DatasetKey, string[]> = {
|
||||
...Object.fromEntries(
|
||||
(['daily', 'adj_factor', 'realtime', 'minute', 'depth5', 'full_minute'] as DatasetKey[]).map(
|
||||
k => [k, TARGET_FIELDS[k]],
|
||||
),
|
||||
),
|
||||
financial: [...FINANCIAL_ALL_FIELDS],
|
||||
} as Record<DatasetKey, string[]>
|
||||
|
||||
// 内部字段的中文说明 (下拉选项展示用)
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
symbol: '股票代码 (如 000001.SZ / 600000.SH)',
|
||||
@@ -50,6 +94,72 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
turnover_rate: '换手率 (小数 0.05=5%)',
|
||||
timestamp: '时间戳',
|
||||
session: '交易时段',
|
||||
asks: '卖五档 [[价,量]×5] (量:手)',
|
||||
bids: '买五档 [[价,量]×5] (量:手)',
|
||||
// 财务骨架
|
||||
period_end: '报告期 (YYYY-MM-DD)',
|
||||
announce_date: '公告日期 (YYYY-MM-DD)',
|
||||
// 财务 metrics
|
||||
eps_basic: '基本每股收益 EPS',
|
||||
eps_diluted: '稀释每股收益 EPS',
|
||||
bps: '每股净资产 BPS',
|
||||
ocfps: '每股经营现金流',
|
||||
roe: '净资产收益率 ROE (%)',
|
||||
roe_diluted: '稀释 ROE (%)',
|
||||
roa: '总资产收益率 ROA (%)',
|
||||
gross_margin: '销售毛利率 (%)',
|
||||
net_margin: '销售净利率 (%)',
|
||||
debt_to_asset_ratio: '资产负债率 (%)',
|
||||
revenue_yoy: '营收同比增长 (%)',
|
||||
net_income_yoy: '净利润同比增长 (%)',
|
||||
operating_cash_to_revenue: '经营现金/营收 (%)',
|
||||
inventory_turnover: '存货周转率',
|
||||
// 财务 income (金额:元)
|
||||
revenue: '营业收入 (元)',
|
||||
operating_cost: '营业成本 (元)',
|
||||
operating_profit: '营业利润 (元)',
|
||||
selling_expense: '销售费用 (元)',
|
||||
admin_expense: '管理费用 (元)',
|
||||
rd_expense: '研发费用 (元)',
|
||||
financial_expense: '财务费用 (元)',
|
||||
non_operating_income: '营业外收入 (元)',
|
||||
non_operating_expense: '营业外支出 (元)',
|
||||
total_profit: '利润总额 (元)',
|
||||
income_tax: '所得税 (元)',
|
||||
net_income: '净利润 (元)',
|
||||
net_income_attributable: '归母净利润 (元)',
|
||||
net_income_deducted: '扣非净利润 (元)',
|
||||
basic_eps: '基本每股收益 (元)',
|
||||
diluted_eps: '稀释每股收益 (元)',
|
||||
// 财务 balance_sheet (金额:元)
|
||||
total_assets: '资产总计 (元)',
|
||||
total_current_assets: '流动资产合计 (元)',
|
||||
total_non_current_assets: '非流动资产合计 (元)',
|
||||
cash_and_equivalents: '货币资金 (元)',
|
||||
accounts_receivable: '应收账款 (元)',
|
||||
inventory: '存货 (元)',
|
||||
fixed_assets: '固定资产 (元)',
|
||||
intangible_assets: '无形资产 (元)',
|
||||
goodwill: '商誉 (元)',
|
||||
total_liabilities: '负债合计 (元)',
|
||||
total_current_liabilities: '流动负债合计 (元)',
|
||||
total_non_current_liabilities: '非流动负债合计 (元)',
|
||||
short_term_borrowing: '短期借款 (元)',
|
||||
long_term_borrowing: '长期借款 (元)',
|
||||
accounts_payable: '应付账款 (元)',
|
||||
total_equity: '所有者权益合计 (元)',
|
||||
equity_attributable: '归母所有者权益 (元)',
|
||||
retained_earnings: '未分配利润 (元)',
|
||||
minority_interest: '少数股东权益 (元)',
|
||||
// 财务 cash_flow (金额:元)
|
||||
net_operating_cash_flow: '经营现金流净额 (元)',
|
||||
net_investing_cash_flow: '投资现金流净额 (元)',
|
||||
net_financing_cash_flow: '筹资现金流净额 (元)',
|
||||
capex: '固定资产/无形资产投资 (元)',
|
||||
net_cash_change: '现金净增加额 (元)',
|
||||
// 财务 shares (股)
|
||||
total_shares: '总股本 (股)',
|
||||
float_shares: '流通股本 (股)',
|
||||
}
|
||||
|
||||
function normalizeConfig(config: CustomSourceConfig): CustomSourceConfig {
|
||||
@@ -65,6 +175,11 @@ function normalizeConfig(config: CustomSourceConfig): CustomSourceConfig {
|
||||
delete normalized.start_param
|
||||
delete normalized.end_param
|
||||
}
|
||||
// 五档/财务为逐批快照: 只注入代码参数(财务另注入 table 表名), 起止时间不参与请求
|
||||
if (key === 'depth5' || key === 'financial') {
|
||||
delete normalized.start_param
|
||||
delete normalized.end_param
|
||||
}
|
||||
return [
|
||||
key,
|
||||
{
|
||||
@@ -343,7 +458,9 @@ function DatasetDetail({
|
||||
const enabled = !!cfg
|
||||
const [testSymbols, setTestSymbols] = useState('000001.SZ,600000.SH')
|
||||
const [showParams, setShowParams] = useState(false)
|
||||
const showTimeParams = datasetKey !== 'realtime'
|
||||
// realtime 为全市场快照不注入任何参数; depth5/financial 逐批注入代码参数但无起止时间
|
||||
const showSymbolsParam = datasetKey !== 'realtime'
|
||||
const showTimeParams = datasetKey !== 'realtime' && datasetKey !== 'depth5' && datasetKey !== 'financial'
|
||||
const test = useMutation({
|
||||
mutationFn: () => api.testDataSource(
|
||||
providerName,
|
||||
@@ -450,6 +567,13 @@ function DatasetDetail({
|
||||
<div className="text-[10px] text-muted/50 mb-1.5">
|
||||
改外部接口的参数名(留空用默认)
|
||||
</div>
|
||||
{datasetKey === 'financial' && (
|
||||
<div className="text-[10px] text-muted/50 mb-1.5">
|
||||
一个财务配置覆盖全部五表;请求时自动附带 <span className="font-mono">table</span> 参数
|
||||
(metrics/income/balance_sheet/cash_flow/shares),上游据此返回对应表数据。
|
||||
shares 表额外附带 <span className="font-mono">latest</span> 参数(当期/历史)。
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{showParams && (
|
||||
<motion.div
|
||||
@@ -460,16 +584,18 @@ function DatasetDetail({
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pt-1">
|
||||
{showSymbolsParam && (
|
||||
<Field label="代码参数">
|
||||
<input
|
||||
value={cfg.symbols_param ?? ''}
|
||||
onChange={e => onUpdate({ symbols_param: e.target.value || undefined })}
|
||||
placeholder="symbols"
|
||||
className={`${INPUT_CLS} w-full`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{showTimeParams && (
|
||||
<>
|
||||
<Field label="代码参数">
|
||||
<input
|
||||
value={cfg.symbols_param ?? ''}
|
||||
onChange={e => onUpdate({ symbols_param: e.target.value || undefined })}
|
||||
placeholder="symbols"
|
||||
className={`${INPUT_CLS} w-full`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="起始时间参数">
|
||||
<input
|
||||
value={cfg.start_param ?? ''}
|
||||
@@ -538,6 +664,7 @@ function DatasetDetail({
|
||||
key={datasetKey}
|
||||
fieldMap={cfg.field_map}
|
||||
targets={TARGET_FIELDS[datasetKey]}
|
||||
options={TARGET_OPTIONS[datasetKey]}
|
||||
onChange={onFieldMap}
|
||||
/>
|
||||
</div>
|
||||
@@ -600,12 +727,16 @@ function DatasetDetail({
|
||||
function FieldMapEditor({
|
||||
fieldMap,
|
||||
targets,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
fieldMap: Record<string, string>
|
||||
targets: string[]
|
||||
// 下拉可选列; 缺省 = targets。财务预填仅骨架三列, 下拉给五表全列。
|
||||
options?: string[]
|
||||
onChange: (fm: Record<string, string>) => void
|
||||
}) {
|
||||
const choices = options ?? targets
|
||||
// 内部用数组维护行的稳定身份, 避免 Record 在编辑空行时 key 漂移导致输入框失焦
|
||||
const [rows, setRows] = useState<Array<{ src: string; target: string; id: number }>>(() => {
|
||||
const entries = Object.entries(fieldMap)
|
||||
@@ -675,12 +806,12 @@ function FieldMapEditor({
|
||||
/>
|
||||
<span className="text-muted/50 text-[10px]">→</span>
|
||||
<select
|
||||
value={targets.includes(row.target) ? row.target : ''}
|
||||
value={choices.includes(row.target) ? row.target : ''}
|
||||
onChange={e => updateRow(row.id, { target: e.target.value })}
|
||||
className={`${INPUT_CLS} text-xs ${row.target && !targets.includes(row.target) ? 'text-warning' : ''}`}
|
||||
className={`${INPUT_CLS} text-xs ${row.target && !choices.includes(row.target) ? 'text-warning' : ''}`}
|
||||
>
|
||||
<option value="">{row.target || '(选择)'}</option>
|
||||
{targets.map(t => (
|
||||
{choices.map(t => (
|
||||
<option key={t} value={t}>
|
||||
{t}({FIELD_LABELS[t] || t})
|
||||
</option>
|
||||
|
||||
Reference in New Issue
Block a user