feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):
1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
- 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
- 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
- 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
- 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
- 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)
2) 策略库与前端统一
- strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
- 任何出现股票代码处都成对显示名称且可点击进个股页
- 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上
3) 回测存档完整化(可往复查看)
- 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
- data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
- 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
- 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
- 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
**交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
非回测归档不套用回测口径
- 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)
门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
This commit is contained in:
@@ -1,200 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* K 线图(v3 §20 Chart):蜡烛图 + 成交量 + MA 指标 + 事件标记。
|
||||
*
|
||||
* 标记语义(v3 §20.3 Signal↔Fill 严格区分):
|
||||
* - fills(实际成交):实心标记 —— fill_buy 红色 ▲ / fill_sell 绿色 ▼
|
||||
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
|
||||
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
|
||||
*
|
||||
* 基于 ECharts candlestick(ECharts 实现);另一套实现见 CandleChartLW.tsx(TradingView
|
||||
* Lightweight Charts),两套共用同一 props 接口(chartTypes.ts)供对比。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts/core";
|
||||
import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/charts";
|
||||
import { GridComponent, TooltipComponent } from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import type { ChartMarker } from "@/lib/types";
|
||||
import type { CandleChartProps } from "./chartTypes";
|
||||
export type { CandleDatum } from "./chartTypes";
|
||||
|
||||
echarts.use([
|
||||
CandlestickChart,
|
||||
LineChart,
|
||||
BarChart,
|
||||
ScatterChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const C = {
|
||||
faint: "#8b98b2",
|
||||
line: "rgba(150,165,195,0.13)",
|
||||
tooltipBg: "#141d30",
|
||||
tooltipBorder: "rgba(150,165,195,0.28)",
|
||||
up: "#e5484d",
|
||||
down: "#2fb36b",
|
||||
ma20: "#3fb6ff",
|
||||
ma60: "#d4a72c",
|
||||
};
|
||||
|
||||
export function CandleChart({
|
||||
data,
|
||||
volume = [],
|
||||
indicators = {},
|
||||
fills = [],
|
||||
signals = [],
|
||||
height = 480,
|
||||
name = "",
|
||||
}: CandleChartProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || data.length === 0) return;
|
||||
const chart = echarts.init(ref.current);
|
||||
const dates = data.map((d) => d.time);
|
||||
const vol = volume ?? [];
|
||||
|
||||
// echarts option 结构较宽,series 用宽松类型(组件层实现细节)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const series: any[] = [
|
||||
{
|
||||
name: "K线",
|
||||
type: "candlestick",
|
||||
data: data.map((d) => [d.open, d.close, d.low, d.high]),
|
||||
itemStyle: {
|
||||
color: C.up, // 阳线(收盘>开盘)红涨
|
||||
color0: C.down,
|
||||
borderColor: C.up,
|
||||
borderColor0: C.down,
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const [name, pts] of Object.entries(indicators)) {
|
||||
const vals = dates.map((t) => {
|
||||
const p = pts.find((x) => x.time === t);
|
||||
return p?.value ?? null;
|
||||
});
|
||||
series.push({
|
||||
name,
|
||||
type: "line",
|
||||
data: vals,
|
||||
smooth: false,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1.2 },
|
||||
itemStyle: { color: C[name as keyof typeof C] ?? "#b98bff" },
|
||||
connectNulls: true,
|
||||
});
|
||||
}
|
||||
// 成交标记(实心)与未成交信号(空心):以散点叠加在价格坐标
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mk = (m: ChartMarker, filled: boolean): any => {
|
||||
const buy = m.kind.endsWith("buy");
|
||||
return {
|
||||
value: [m.time, m.price ?? 0],
|
||||
symbol: filled ? (buy ? "triangle" : "triangle") : buy ? "triangle" : "triangle",
|
||||
symbolRotate: buy ? 0 : 180,
|
||||
symbolSize: 13,
|
||||
itemStyle: {
|
||||
color: filled ? (buy ? C.up : C.down) : "transparent",
|
||||
borderColor: buy ? C.up : C.down,
|
||||
borderWidth: 1.6,
|
||||
},
|
||||
tooltip: { formatter: m.text.join("<br/>") },
|
||||
};
|
||||
};
|
||||
series.push(
|
||||
{ name: "成交", type: "scatter", data: fills.map((m) => mk(m, true)), z: 6 },
|
||||
{ name: "信号(未成交)", type: "scatter", data: signals.map((m) => mk(m, false)), z: 5 }
|
||||
);
|
||||
|
||||
const hasVol = vol.length > 0;
|
||||
const mainGridBottom = hasVol ? 58 : 14;
|
||||
series.push({
|
||||
name: "成交量",
|
||||
type: "bar",
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: dates.map((t) => {
|
||||
const p = vol.find((x) => x.time === t);
|
||||
return p?.value ?? 0;
|
||||
}),
|
||||
itemStyle: { color: "rgba(150,165,195,0.35)" },
|
||||
});
|
||||
|
||||
chart.setOption({
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "cross" },
|
||||
backgroundColor: C.tooltipBg,
|
||||
borderColor: C.tooltipBorder,
|
||||
textStyle: { color: "#e9eef7", fontSize: 12 },
|
||||
},
|
||||
axisPointer: { link: [{ xAxisIndex: "all" }] },
|
||||
grid: [
|
||||
{ left: 8, right: 18, top: 16, height: hasVol ? "62%" : "82%", containLabel: true },
|
||||
...(hasVol
|
||||
? [{ left: 8, right: 18, top: hasVol ? "76%" : undefined, height: "14%", containLabel: true }]
|
||||
: []),
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: "category",
|
||||
data: dates,
|
||||
boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: C.line } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: C.faint, fontSize: 10 },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
...(hasVol
|
||||
? [{
|
||||
type: "category",
|
||||
gridIndex: 1,
|
||||
data: dates,
|
||||
boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: C.line } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
}]
|
||||
: []),
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
splitNumber: 4,
|
||||
axisLabel: { color: C.faint, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: C.line } },
|
||||
},
|
||||
...(hasVol
|
||||
? [{
|
||||
type: "value",
|
||||
gridIndex: 1,
|
||||
scale: true,
|
||||
axisLabel: { color: C.faint, fontSize: 9 },
|
||||
splitLine: { show: false },
|
||||
}]
|
||||
: []),
|
||||
],
|
||||
series,
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
});
|
||||
const onResize = () => chart.resize();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
chart.dispose();
|
||||
};
|
||||
}, [data, volume, indicators, fills, signals, height]);
|
||||
|
||||
if (data.length === 0) return <div className="hint">暂无 K 线数据</div>;
|
||||
return <div ref={ref} style={{ height, width: "100%" }} aria-label={`${name} K 线图`} />;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
/** K 线图统一入口:可切换 ECharts / TradingView Lightweight Charts 两套实现(对比期)。 */
|
||||
/**
|
||||
* K 线图统一入口(TradingView Lightweight Charts)。
|
||||
*
|
||||
* 为什么去掉 `library` 参数:ECharts 版(CandleChart.tsx)已随「图表统一到
|
||||
* lightweight-charts」下线并删除;如果继续保留切换参数,调用方会以为还有第二套实现可用
|
||||
* (AGENT §24:未实现必须如实标注,不许假装)。props 仍沿用 chartTypes.ts 的 CandleChartProps,
|
||||
* 单只股票页与后续调用方无需改接口。
|
||||
*/
|
||||
import type { CandleChartProps } from "./chartTypes";
|
||||
import { CandleChart } from "./CandleChart";
|
||||
import { CandleChartLW } from "./CandleChartLW";
|
||||
|
||||
export type ChartLibrary = "echarts" | "lightweight";
|
||||
|
||||
export function StockChart({ library = "lightweight", ...props }: CandleChartProps & { library?: ChartLibrary }) {
|
||||
return library === "echarts" ? <CandleChart {...props} /> : <CandleChartLW {...props} />;
|
||||
export function StockChart(props: CandleChartProps) {
|
||||
return <CandleChartLW {...props} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user