Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
21 lines
9.8 KiB
TypeScript
21 lines
9.8 KiB
TypeScript
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>
|
||
}
|