mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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 时序图 */}
|
||||
|
||||
Reference in New Issue
Block a user