fix: 修复 6 个 issue (#225/#226/#201/#188/#200/#196)

后端:
- #225 自定义源分钟K字符串 datetime 不再被 cast 成 null:
  _normalize_minute 对 Utf8 列按常见格式链式解析 (参照 kline_sync 口径)
- #226 自定义源日K/除权因子单批失败只隔离该批 (重试 1 次 + 跳过 +
  warning 汇总), 不再丢弃已成功批次的全部进度
- #201 旧信号回测 _load_panel 加指标 warmup 窗口 (120 交易日保守日历日),
  计算后裁回 [start,end]; 数据不足时自然退化

前端:
- #188 因子回测单标的不再整面板空白: 外层条件改 !error, IC 卡片
  单独守卫并给出需 >=2 只的提示
- #200 因子回测支持调仓频率 (日/周/月) 与滑点 (bp) 配置
- #196 自选页板块筛选新增 ETF 分类, 旧偏好加载时补 ETF 键保持默认可见
This commit is contained in:
shy3130
2026-09-03 13:20:59 +08:00
parent e89ea9becf
commit ef59df4a67
6 changed files with 362 additions and 36 deletions
+12 -6
View File
@@ -57,6 +57,10 @@ import {
const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const
type BoardType = typeof BOARDS[number]
// 板块筛选选项 = 股票板块 + ETF(ETF 无 symbol 板块语义,按 asset_type 匹配)
const ETF_BOARD = 'ETF'
const BOARD_OPTIONS = [...BOARDS, ETF_BOARD]
function getBoardType(symbol: string): BoardType | null {
if (/^(300|301)/.test(symbol)) return '创业板'
if (/^688/.test(symbol)) return '科创板'
@@ -1112,9 +1116,10 @@ export function Watchlist() {
const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({})
// 板块筛选(持久化)
// 兼容: 旧存储不含 ETF 键 → 加载时补上,保持 ETF 行默认可见
const [boardFilter, setBoardFilter] = useState<Set<string>>(() => {
const saved = storage.watchlistBoardFilter.get([])
return saved.length > 0 ? new Set(saved) : new Set(BOARDS) // 默认全选
return saved.length > 0 ? new Set([...saved, ETF_BOARD]) : new Set(BOARD_OPTIONS) // 默认全选
})
const persistBoardFilter = useCallback((next: Set<string>) => {
setBoardFilter(next)
@@ -1156,7 +1161,7 @@ export function Watchlist() {
const resetAllFilters = useCallback(() => {
setFilters({})
persistBoardFilter(new Set(BOARDS))
persistBoardFilter(new Set(BOARD_OPTIONS))
setExcludeST(false)
storage.watchlistExcludeST.set(false)
}, [persistBoardFilter])
@@ -1184,9 +1189,10 @@ export function Watchlist() {
const filteredRows = useMemo(() => {
// 板块筛选(全选时跳过)
let result = rowsInSelectedGroup
if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) {
if (boardFilter.size > 0 && boardFilter.size < BOARD_OPTIONS.length) {
result = result.filter(r => {
// 非股票 (指数/ETF) 无板块语义, 不受板块筛选影响 (顺带修复 ETF 行被误过滤)
if (r.asset_type === 'etf') return boardFilter.has(ETF_BOARD)
// 其他非股票 (指数等) 无板块语义, 不受板块筛选影响
if (r.asset_type && r.asset_type !== 'stock') return true
const board = getBoardType(r.symbol)
return board != null && boardFilter.has(board)
@@ -1219,7 +1225,7 @@ export function Watchlist() {
}, [rowsInSelectedGroup, filters, columns, boardFilter, excludeST])
const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length
const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARDS.length
const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARD_OPTIONS.length
const hasActiveFilters = activeFilterCount > 0 || hasBoardFilter || excludeST
// 排序(复用共享三态排序 hook)。分时列按「最新分钟收盘 vs 昨收」排序(分时图最后一点同口径),
@@ -1527,7 +1533,7 @@ export function Watchlist() {
<div className="mb-2">
<div className="text-[10px] text-muted uppercase tracking-wider mb-0.5"></div>
<div className="flex flex-wrap gap-1">
{BOARDS.map(board => {
{BOARD_OPTIONS.map(board => {
const active = boardFilter.has(board)
return (
<button
+44 -24
View File
@@ -92,7 +92,9 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
const [end, setEnd] = useState(TODAY)
const [nGroups, setNGroups] = useState(5)
const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
const [rebalance, setRebalance] = useState<'daily' | 'weekly' | 'monthly'>('daily')
const [fees, setFees] = useState('2')
const [slippage, setSlippage] = useState('5')
const [result, setResult] = useState<FactorBacktestResult | null>(null)
const columns = useQuery({
@@ -129,9 +131,10 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
start: start || null,
end: end || undefined,
n_groups: nGroups,
rebalance: 'daily',
rebalance,
weight,
fees_pct: Number(fees) / 10000,
slippage_bps: Number(slippage),
}),
onSuccess: (data) => {
if (data.error) {
@@ -302,8 +305,21 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" value={fees} onChange={e => setFees(e.target.value)}
<label className="text-[11px] text-secondary block mb-1"></label>
<select value={rebalance} onChange={e => setRebalance(e.target.value as any)} className={INPUT_CLS}>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
</div>
<div>
<label className="text-[11px] text-secondary block mb-1">()</label>
<input type="number" min="0" value={fees} onChange={e => setFees(e.target.value)}
className={INPUT_CLS} />
</div>
<div>
<label className="text-[11px] text-secondary block mb-1">(bp)</label>
<input type="number" min="0" value={slippage} onChange={e => setSlippage(e.target.value)}
className={INPUT_CLS} />
</div>
</div>
@@ -322,7 +338,7 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
{/* 结果面板 */}
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
{result?.error && !result.ic_mean && (
{result?.error && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{result.error}
</div>
@@ -352,7 +368,7 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
<LoadingPanel symbolsText={symbols ? `${symbols.split(',').length} 只标的` : '全市场 · 当前区间'} />
)}
{result && result.ic_mean != null && (
{result && !result.error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
@@ -374,7 +390,7 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
{saveCandidate.isPending ? '保存中' : '保存候选'}
</button>
<span className="text-[11px] text-muted">
Rank IC ·
Rank IC · {rebalance === 'daily' ? '日度' : rebalance === 'weekly' ? '周度' : '月度'}
</span>
{result.elapsed_ms > 0 && (
<span className="flex items-center gap-1 text-[11px] text-muted">
@@ -384,24 +400,28 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
)}
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard
label="IC 均值"
value={result.ic_mean != null ? fmtPct(result.ic_mean) : null}
highlight={result.ic_mean != null
? result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
: undefined}
/>
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
<StatCard
label="ICIR"
value={result.ir != null ? result.ir.toFixed(2) : null}
highlight={result.ir != null
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
: undefined}
/>
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
</div>
{result.ic_mean != null ? (
<div className="grid grid-cols-4 gap-4">
<StatCard
label="IC 均值"
value={fmtPct(result.ic_mean)}
highlight={result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'}
/>
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
<StatCard
label="ICIR"
value={result.ir != null ? result.ir.toFixed(2) : null}
highlight={result.ir != null
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
: undefined}
/>
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
</div>
) : (
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
IC/IR 2 使
</div>
)}
</div>
{/* IC 时序图 */}