feat: 回测页加预热期说明徽标与滑点配置

- 回测区间旁加「预热≥120天」徽标,点击弹泡说明开头无交易原因
- 佣金旁新增滑点(万分之)输入,持久化并接入回测请求
- Auth 设密码页加访问密码部署文档链接
This commit is contained in:
shy3130
2026-06-29 22:45:16 +08:00
parent 5d4d643bd8
commit b315e531df
4 changed files with 135 additions and 4 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* 回测预热期徽标 — 点击弹出说明气泡。
*
* 解释「回测开头几个月没有交易」这一高频疑问: 技术指标需要历史数据预热,
* 系统会自动在回测起点之前多取约 120 天 (≈4 个月) 数据; 若本地数据恰好从
* 起点才开始, 开头几个月指标算不出、信号不触发, 属正常现象。
*
* 实现要点:
* - 点击触发 (非 hover), 移动端友好
* - 用 createPortal 渲染到 body, 绕开父容器 overflow 裁剪 (回测配置面板有 overflow-y-auto)
* - 全屏透明遮罩点击关闭 + ESC 关闭
* - 气泡位置 = 锚点 rect 实时计算, 自动判断向左/向右展开避免溢出屏幕
*/
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
interface Pos { top: number; left: number }
export function WarmupBadge() {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)
const [pos, setPos] = useState<Pos>({ top: 0, left: 0 })
// 打开时根据锚点 rect 计算气泡位置 (向下方弹出)
useLayoutEffect(() => {
if (!open || !anchorRef.current) return
const rect = anchorRef.current.getBoundingClientRect()
const POPUP_W = 272
const GAP = 8
// 优先左对齐锚点; 右侧不够则右对齐; 兜底贴左边
let left = rect.left
if (left + POPUP_W > window.innerWidth - 8) {
left = rect.right - POPUP_W
}
left = Math.max(8, left)
setPos({ top: rect.bottom + GAP, left })
}, [open])
// ESC 关闭
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
return (
<>
<button
ref={anchorRef}
type="button"
onClick={() => setOpen(o => !o)}
className="inline-flex items-center gap-0.5 rounded-full px-1.5 text-[10px] text-amber-500/70 transition-colors hover:bg-amber-400/10 hover:text-amber-500"
title="为什么开头可能没交易?"
>
<Info className="h-3 w-3" strokeWidth={1.5} />
120
</button>
{createPortal(
<AnimatePresence>
{open && (
<>
{/* 全屏透明遮罩: 点击关闭 */}
<div
className="fixed inset-0 z-[60]"
onClick={() => setOpen(false)}
/>
{/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */}
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={{ duration: 0.15 }}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: 272 }}
className="z-[70] rounded-btn border border-border bg-surface p-3 text-[11px] leading-relaxed text-secondary shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="mb-1.5 font-medium text-foreground">?</div>
<p className="text-muted">
(MA / MACD / RSI )
<span className="font-medium text-amber-300"> 120 (4 )</span>
</p>
<p className="mt-1.5 text-muted">
, ,
<span className="text-secondary"> , bug</span>
</p>
<div className="mt-2 border-t border-border/60 pt-2 text-muted">
<span className="text-secondary">:</span> ,
</div>
{/* 小箭头指向锚点 */}
<div
className="absolute -top-1 h-2 w-2 rotate-45 border-l border-t border-border bg-surface"
style={{ left: 12 }}
/>
</motion.div>
</>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}
+1
View File
@@ -91,6 +91,7 @@ export const storage = {
entryFill: 'close_t' | 'open_t+1'
exitFill: 'close_t' | 'open_t+1'
fees: string
slippage: string
maxPositions: string
maxExposure: string
initialCapital: string
+16 -3
View File
@@ -166,9 +166,22 @@ export function Auth() {
{/* 提示: 设密码模式告知本机限制 */}
{isSetup && (
<p className="mt-3 text-[10px] leading-relaxed text-muted/70">
, 访
</p>
<div className="mt-3 space-y-1.5 text-[10px] leading-relaxed text-muted/70">
<p>
, 访
</p>
<p>
{' '}
<a
href="https://github.com/shy3130/tickflow-stock-panel/blob/main/docs/deploy-password.md"
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-2 hover:underline"
>
访
</a>
</p>
</div>
)}
</div>
@@ -19,6 +19,7 @@ import { SignalPicker } from '@/components/screener/SignalPicker'
import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask'
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
import { EmptyState } from '@/components/EmptyState'
import { WarmupBadge } from '@/components/WarmupBadge'
import { DatePicker } from '@/components/DatePicker'
import { StrategyNavChart } from './charts/StrategyNavChart'
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
@@ -660,6 +661,7 @@ export function StrategyBacktest() {
const [entryFill, setEntryFill] = useState<'close_t' | 'open_t+1'>(saved?.entryFill ?? saved?.matching ?? 'open_t+1')
const [exitFill, setExitFill] = useState<'close_t' | 'open_t+1'>(saved?.exitFill ?? saved?.matching ?? 'close_t')
const [fees, setFees] = useState(saved?.fees ?? '2')
const [slippage, setSlippage] = useState(saved?.slippage ?? '5')
const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10')
const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100')
const [initialCapital, setInitialCapital] = useState(saved?.initialCapital ?? '1000000')
@@ -765,6 +767,7 @@ export function StrategyBacktest() {
entryFill,
exitFill,
fees,
slippage,
maxPositions,
maxExposure,
initialCapital,
@@ -789,6 +792,7 @@ export function StrategyBacktest() {
entry_fill: entryFill,
exit_fill: exitFill,
fees_pct: Number(fees) / 10000,
slippage_bps: Number(slippage),
max_positions: Number(maxPositions),
max_exposure_pct: Number(maxExposure) / 100,
initial_capital: Number(initialCapital),
@@ -1141,7 +1145,10 @@ export function StrategyBacktest() {
<div className="rounded-btn border border-border bg-surface p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-foreground"></div>
<div className="flex items-center gap-1.5">
<div className="text-xs font-medium text-foreground"></div>
<WarmupBadge />
</div>
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
{rangeTitle}
</span>
@@ -1292,6 +1299,10 @@ export function StrategyBacktest() {
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" value={fees} onChange={e => setFees(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" min={0} value={slippage} onChange={e => setSlippage(e.target.value)} className={INPUT_CLS} />
</div>
</div>
)}
{simMode === 'position' && (