Files
Veyra/web/admin/src/components/BacktestsPanel.tsx
T
Blizzard 92329ddce2
Checks / verify (push) Failing after 9m52s
feat: 完成模拟交易闭环、App 与 A 股行情接入
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
2026-10-08 22:58:52 +08:00

21 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {useEffect,useState} from "react"
import {ApiError,request} from "@/lib/auth"
import {Button} from "@/components/ui/button"
import {Card,CardContent,CardHeader,CardTitle} from "@/components/ui/card"
import type {Strategy,StrategyDetail} from "./StrategiesPanel"
type Result={engine_version:string;final_equity:string;cash:string;remaining_quantity:string;total_return_bps:number;max_drawdown_bps:number;trades:{date:string;side:string;quantity:string;price:string;fee:string;realized_pnl:string|null}[];equity_curve:{date:string;equity:string}[]}
type Run={id:string;strategy_version_id:string;status:string;input_hash:string;failure:string;created_at:string;finished_at:string|null;result:Result|null;input?:{dataset_name:string;symbol:string;initial_cash:string;parameters:unknown;bars:unknown[]}}
const labels:Record<string,string>={PENDING:"排队中",RUNNING:"执行中",COMPLETED:"已完成",FAILED:"失败"}
const field="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm"
export function BacktestsPanel({onSessionExpired}:{onSessionExpired:()=>void}){
const [strategies,setStrategies]=useState<Strategy[]>([]),[strategy,setStrategy]=useState<StrategyDetail|null>(null),[version,setVersion]=useState(""),[list,setList]=useState<Run[]>([]),[detail,setDetail]=useState<Run|null>(null),[error,setError]=useState(""),[busy,setBusy]=useState(false),[form,setForm]=useState({dataset_name:"",symbol:"",initial_cash:"",csv:"",exchange:"SSE",start:"",end:""}),[mode,setMode]=useState("market")
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"请求失败")}
useEffect(()=>{const c=new AbortController();request<Strategy[]>("/api/admin/v1/strategies",{signal:c.signal}).then(setStrategies).catch(e=>{if(!c.signal.aborted)fail(e)});return()=>c.abort()},[])
useEffect(()=>{const c=new AbortController();let running=false;async function load(){if(running)return;running=true;try{const runs=await request<Run[]>("/api/admin/v1/backtests",{signal:c.signal});if(!c.signal.aborted)setList(runs);if(detail&&(detail.status==="PENDING"||detail.status==="RUNNING")){const r=await request<Run>(`/api/admin/v1/backtests/${detail.id}`,{signal:c.signal});if(!c.signal.aborted)setDetail(r)}}catch(e){if(!c.signal.aborted)fail(e)}finally{running=false}}void load();const timer=setInterval(()=>void load(),3000);return()=>{c.abort();clearInterval(timer)}},[detail?.id,detail?.status])
async function select(id:string){setVersion("");setStrategy(null);if(!id)return;try{const s=await request<StrategyDetail>(`/api/admin/v1/strategies/${id}`);setStrategy(s)}catch(e){fail(e)}}
async function submit(){setBusy(true);setError("");try{if(mode==="market"){const result=await request<Run>("/api/admin/v1/backtests/from-market",{method:"POST",body:JSON.stringify({strategy_version_id:version,symbol:form.symbol,exchange:form.exchange,start:form.start,end:form.end,initial_cash:form.initial_cash})});setDetail(result);setList(await request<Run[]>("/api/admin/v1/backtests"));return} const lines=form.csv.trim().split(/\r?\n/).filter(v=>v.trim());if(lines[0]?.trim()!=="date,open,high,low,close")throw new Error("CSV 首行须为 date,open,high,low,close。");const bars=lines.slice(1).map((line,index)=>{const v=line.split(",").map(s=>s.trim());if(v.length!==5)throw new Error(`CSV 第 ${index+2} 行应有五列。`);return{date:v[0],open:v[1],high:v[2],low:v[3],close:v[4]}});const result=await request<Run>("/api/admin/v1/backtests",{method:"POST",body:JSON.stringify({strategy_version_id:version,dataset_name:form.dataset_name,symbol:form.symbol,initial_cash:form.initial_cash,bars})});setDetail(result);setList(await request<Run[]>("/api/admin/v1/backtests"))}catch(e){fail(e)}finally{setBusy(false)}}
async function open(id:string){setError("");try{setDetail(await request<Run>(`/api/admin/v1/backtests/${id}`))}catch(e){fail(e)}}
const result=detail?.result
return <div className="space-y-6"><p className="text-sm leading-7 text-muted-foreground">自动获取所选股票的日线行情,执行均线交叉历史模拟。也保留手工 CSV 用于研究复验。信号使用之前已完成的日线,次日开盘模拟成交;计入费率与滑点。数据来源、复权、停牌及涨跌停须由输入数据说明,当前引擎没有模拟这些成交限制,结果不能代表可实现收益。</p>{error&&<p role="alert" className="text-destructive text-sm">{error}</p>}<Card><CardHeader><CardTitle>创建研究任务</CardTitle></CardHeader><CardContent><div className="mb-5 flex gap-3"><Button variant={mode==="market"?"default":"outline"} onClick={()=>setMode("market")}>自动获取 A 股日线</Button><Button variant={mode==="manual"?"default":"outline"} onClick={()=>setMode("manual")}>导入研究 CSV</Button></div><form onSubmit={e=>{e.preventDefault();void submit()}} className="space-y-4"><div className="grid gap-4 md:grid-cols-2"><label className="text-sm">策略<select className={field} required value={strategy?.id??""} onChange={e=>void select(e.target.value)}><option value="">请选择研究策略</option>{strategies.map(s=><option key={s.id} value={s.id}>{s.name} · {s.code}</option>)}</select></label><label className="text-sm">参数版本<select className={field} required value={version} onChange={e=>setVersion(e.target.value)}><option value="">请选择不可变版本</option>{strategy?.versions.map(v=><option key={v.id} value={v.id}>修订 {v.revision} · 均线 {v.parameters.short_window}/{v.parameters.long_window}</option>)}</select></label>{([["dataset_name","数据来源与处理说明"],["symbol","六位股票代码"],["initial_cash","初始模拟现金(元)"]] as const).map(([key,label])=><label key={key} className="text-sm">{label}<input className={field} required={key!=="dataset_name"||mode==="manual"} disabled={key==="dataset_name"&&mode==="market"} value={form[key]} onChange={e=>setForm({...form,[key]:e.target.value})}/></label>)}</div>{mode==="market"?<div className="grid gap-4 md:grid-cols-3"><label className="text-sm">交易所<select className={field} value={form.exchange} onChange={e=>setForm({...form,exchange:e.target.value})}><option value="SSE">上海证券交易所</option><option value="SZSE">深圳证券交易所</option></select></label>{(["start","end"] as const).map(k=><label className="text-sm" key={k}>{k==="start"?"开始日期":"结束日期"}<input className={field} required type="date" value={form[k]} onChange={e=>setForm({...form,[k]:e.target.value})}/></label>)}</div>:<label className="block text-sm">历史数据(4–1000 行,至少长窗口 + 2 行)<textarea className={field+" font-mono"} rows={8} required value={form.csv} placeholder="date,open,high,low,close" onChange={e=>setForm({...form,csv:e.target.value})}/></label>}<p className="text-xs text-muted-foreground">日期 YYYY-MM-DD,严格递增、不重复;价格最多两位小数。任务保存输入哈希与参数快照,已完成结果不可覆盖。</p><Button disabled={busy} type="submit">{busy?"正在提交":"提交 Python 回测"}</Button></form></CardContent></Card><div className="grid gap-6 xl:grid-cols-[1fr_1.6fr]"><Card><CardHeader><CardTitle>任务列表</CardTitle></CardHeader><CardContent className="space-y-3">{list.length===0&&<p className="text-sm text-muted-foreground">暂无任务。</p>}{list.map(r=><button key={r.id} className="w-full rounded-lg border p-3 text-left hover:bg-muted" onClick={()=>void open(r.id)}><strong className="text-sm">{labels[r.status]}</strong><p className="mt-2 text-xs text-muted-foreground">{new Date(r.created_at).toLocaleString("zh-CN")}<br/>{r.id}</p></button>)}</CardContent></Card><Card><CardHeader><CardTitle>结果与可追溯输入</CardTitle></CardHeader><CardContent>{detail?<div className="space-y-5"><p className="text-sm">{detail.input?.dataset_name} · {detail.input?.symbol} · {labels[detail.status]}</p><p className="break-all text-xs text-muted-foreground">输入 SHA-256:{detail.input_hash}</p>{detail.failure&&<p className="text-destructive text-sm">{detail.failure}</p>}{result?<><div className="grid gap-4 sm:grid-cols-2">{[["期末模拟权益",result.final_equity+" 元"],["总收益",(result.total_return_bps/100).toFixed(2)+"%"],["最大回撤",(result.max_drawdown_bps/100).toFixed(2)+"%"],["期末持股",result.remaining_quantity+" 股"]].map(([label,value])=><div key={label} className="rounded-lg bg-muted p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-2 text-lg font-semibold">{value}</p></div>)}</div><p className="text-xs leading-6 text-muted-foreground">引擎 {result.engine_version};未平仓持股按最后收盘价估值,不强制卖出。结果仅基于此数据集,不提供年化收益或市场胜率。</p><div className="max-h-80 overflow-auto"><table className="w-full text-left text-xs"><thead><tr>{["日期","方向","股数","价格","费用","卖出盈亏"].map(v=><th key={v} className="p-2">{v}</th>)}</tr></thead><tbody>{result.trades.map((t,i)=><tr key={i} className="border-t"><td className="p-2">{t.date}</td><td>{t.side==="BUY"?"买入":"卖出"}</td><td>{t.quantity}</td><td>{t.price}</td><td>{t.fee}</td><td>{t.realized_pnl??"—"}</td></tr>)}</tbody></table>{result.trades.length===0&&<p className="p-3 text-muted-foreground">该参数和数据未产生模拟成交。</p>}</div><details><summary className="cursor-pointer text-sm">每日权益序列({result.equity_curve.length} 条)</summary><div className="mt-3 max-h-60 overflow-auto font-mono text-xs">{result.equity_curve.map(p=><div key={p.date} className="flex justify-between border-b py-2"><span>{p.date}</span><span>{p.equity} 元</span></div>)}</div></details></>:<p className="text-sm text-muted-foreground">{detail.status==="FAILED"?"任务失败,未生成结果。":"任务等待 Worker 执行,状态自动刷新。"}</p>}</div>:<p className="text-sm text-muted-foreground">选择任务查看计算结果。</p>}</CardContent></Card></div></div>
}