feat(frontend): 终端化重构+策略管理(代码→实例→4运行)+撮合设计+回测参数 [nas]
CI/CD / test (push) Successful in 18s
CI/CD / nas-deploy (push) Failing after 17s
CI/CD / nas-verify (push) Has been skipped

- 终端化: 科幻色板(tokens/EP/reset/chips/echarts)+Layout(Cmd+K/时钟/状态灯)+mock全接口+Dashboard/Result/Monitor样板+6图表配色
- 策略管理: 策略库三层(代码→实例→4运行)+新建策略(组合/CTA模板+Monaco)+在线代码编辑(Monaco)+实例CRUD(参数反射/标的池type切/interval/match_session)
- 实例→运行关联: backtest/paper/live New页读instance预填
- 撮合设计: match_session(下一根K线开盘/收盘/集合竞价)+interval定频率+实走限日线/分钟走miniQMT
- 回测参数: 基准下拉+日期默认1年localStorage记忆+滑点/手续费UI
- 账户管理页+命名重整(模拟盘/实盘,消除撞名)
- spec §11-13(菜单/策略管理/撮合设计) + Monaco编辑器 + 策略实例删除/编辑入口 + 列表实例列
This commit is contained in:
2026-08-12 23:23:23 +08:00
parent 58b5d08280
commit 5c8a7e12c6
45 changed files with 3935 additions and 459 deletions
@@ -0,0 +1,544 @@
<title>三国量化 · 终端化重构提案</title>
<style>
:root{
--void:#05080d; --panel:#0a0f17; --panel-2:#0d141f; --panel-3:#111a27;
--grid:#15212f; --grid-2:#1d2c3d;
--cyan:#00e5ff; --cyan-dim:#0093a8; --cyan-glow:rgba(0,229,255,.14);
--amber:#ffb000; --amber-dim:#8a5f00; --amber-glow:rgba(255,176,0,.12);
--ink:#d4e4f0; --ink-2:#8aa0b5; --ink-3:#56697c;
--up:#ff4d5e; --down:#2ee68a;
--danger:#ff4d5e; --warn:#ffb000; --ok:#2ee68a;
--mono:ui-monospace,'SF Mono','JetBrains Mono',Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--void);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
body{
background-image:
linear-gradient(var(--grid) 1px,transparent 1px),
linear-gradient(90deg,var(--grid) 1px,transparent 1px);
background-size:48px 48px;
background-position:-1px -1px;
}
a{color:var(--cyan);text-decoration:none}
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
.wrap{max-width:1320px;margin:0 auto;padding:0 28px 80px}
/* ── top status bar ── */
.topbar{position:sticky;top:0;z-index:50;background:rgba(5,8,13,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--grid-2);box-shadow:0 0 0 1px var(--void),0 1px 24px rgba(0,229,255,.05)}
.topbar-in{max-width:1320px;margin:0 auto;padding:0 28px;height:46px;display:flex;align-items:center;gap:18px}
.brand{display:flex;align-items:center;gap:10px}
.brand-mark{width:24px;height:24px;display:grid;place-items:center;background:var(--cyan);color:var(--void);font-family:var(--mono);font-weight:800;font-size:11px;letter-spacing:.5px}
.brand-name{font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.6px;color:var(--ink)}
.brand-sub{font-family:var(--mono);font-size:11px;color:var(--cyan);letter-spacing:1.5px;text-transform:uppercase}
.topbar-sep{width:1px;height:18px;background:var(--grid-2)}
.crumb{font-family:var(--mono);font-size:12px;color:var(--ink-3)}
.crumb b{color:var(--ink-2);font-weight:500}
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:16px;font-family:var(--mono);font-size:11px;color:var(--ink-3)}
.lamp{display:inline-flex;align-items:center;gap:6px}
.dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok)}
.dot.pulse{animation:pulse 1.8s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 8px var(--ok)}50%{opacity:.35;box-shadow:0 0 2px var(--ok)}}
.cmdk{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border:1px solid var(--grid-2);color:var(--ink-2)}
.cmdk kbd{font-family:var(--mono);font-size:10px;color:var(--cyan);border:1px solid var(--grid-2);padding:0 4px}
/* ── section frame ── */
.sec{margin-top:56px}
.sec-head{display:flex;align-items:baseline;gap:14px;margin-bottom:20px;border-bottom:1px solid var(--grid);padding-bottom:12px}
.sec-no{font-family:var(--mono);font-size:12px;color:var(--cyan);letter-spacing:2px}
.sec-title{font-family:var(--mono);font-size:20px;font-weight:700;color:var(--ink);letter-spacing:.5px;margin:0}
.sec-desc{margin-left:auto;font-size:12px;color:var(--ink-3);font-family:var(--mono)}
/* ── hero ── */
.hero{padding:64px 0 8px}
.hero-tag{font-family:var(--mono);font-size:11px;color:var(--cyan);letter-spacing:3px;text-transform:uppercase;margin-bottom:18px}
.hero h1{font-family:var(--mono);font-weight:800;font-size:54px;line-height:1.05;letter-spacing:-1px;margin:0 0 18px;color:var(--ink);text-wrap:balance}
.hero h1 .accent{color:var(--cyan)}
.hero h1 .amber{color:var(--amber)}
.hero p.lead{font-size:16px;color:var(--ink-2);max-width:62ch;margin:0 0 28px;line-height:1.7}
.hero-meta{display:flex;gap:0;border:1px solid var(--grid-2);background:var(--panel);max-width:780px}
.hero-meta>div{flex:1;padding:16px 20px;border-right:1px solid var(--grid)}
.hero-meta>div:last-child{border-right:none}
.hm-k{font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:8px}
.hm-v{font-family:var(--mono);font-size:15px;color:var(--ink)}
.hm-v .arr{color:var(--cyan)}
/* ── generic panels ── */
.panel{background:var(--panel);border:1px solid var(--grid-2);position:relative}
.panel-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--grid);background:var(--panel-2)}
.panel-head .corner{color:var(--cyan);font-family:var(--mono);font-size:10px}
.panel-head .pt{font-family:var(--mono);font-size:12px;color:var(--ink);letter-spacing:.5px;font-weight:600}
.panel-head .pid{margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:1px}
/* ── §1 diagnosis ── */
.diag-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
.diag{padding:18px 20px;background:var(--panel);border:1px solid var(--grid-2);border-left:2px solid var(--danger)}
.diag.k{border-left-color:var(--warn)}
.diag.g{border-left-color:var(--cyan)}
.diag h4{margin:0 0 6px;font-family:var(--mono);font-size:13px;color:var(--ink);letter-spacing:.3px}
.diag p{margin:0;font-size:13px;color:var(--ink-2);line-height:1.6}
.diag .tag{display:inline-block;font-family:var(--mono);font-size:10px;padding:1px 6px;margin-bottom:8px;letter-spacing:1px;border:1px solid currentColor}
.tag.dg{color:var(--danger)} .tag.wn{color:var(--warn)} .tag.cy{color:var(--cyan)}
/* ── §2 visual language ── */
.lang-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:14px}
.swatches{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--grid-2);border:1px solid var(--grid-2)}
.sw{padding:16px;display:flex;flex-direction:column;gap:8px;min-height:96px;justify-content:flex-end}
.sw .hex{font-family:var(--mono);font-size:11px;letter-spacing:.5px}
.sw .nm{font-family:var(--mono);font-size:11px;opacity:.8}
.type-card{padding:20px}
.type-row{display:flex;align-items:baseline;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px dashed var(--grid)}
.type-row:last-child{border-bottom:none}
.type-l{font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:1.5px;text-transform:uppercase}
.t-disp{font-family:var(--mono);font-size:30px;font-weight:800;color:var(--ink);letter-spacing:-.5px}
.t-body{font-size:15px;color:var(--ink)}
.t-data{font-family:var(--mono);font-size:22px;color:var(--amber);font-variant-numeric:tabular-nums}
.lamprow{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--grid-2);border:1px solid var(--grid-2);margin-top:14px}
.lampcell{background:var(--panel);padding:14px;display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:11px;color:var(--ink-2)}
.lampcell .d{width:8px;height:8px;border-radius:50%}
/* ── §3 mockup (hero moment) ── */
.mockup{border:1px solid var(--grid-2);background:var(--panel);box-shadow:0 0 0 1px var(--void),0 30px 80px -20px rgba(0,229,255,.12)}
.mu-top{display:flex;align-items:center;gap:14px;padding:9px 14px;background:var(--panel-2);border-bottom:1px solid var(--grid-2);font-family:var(--mono);font-size:11px;color:var(--ink-2);overflow-x:auto;white-space:nowrap}
.mu-top .tid{color:var(--cyan)}
.mu-top .sep{color:var(--grid-2)}
.mu-top .name{color:var(--ink)}
.mu-top .right{margin-left:auto;display:flex;align-items:center;gap:14px}
.chip{font-family:var(--mono);font-size:10px;padding:2px 7px;border:1px solid var(--grid-2);color:var(--ink-2);letter-spacing:.5px}
.chip.ok{color:var(--ok);border-color:rgba(46,230,138,.4)}
.mu-body{display:grid;grid-template-columns:200px 1fr 220px;gap:1px;background:var(--grid)}
.mu-col{background:var(--panel)}
.mu-subhead{font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:1.5px;text-transform:uppercase;padding:8px 14px;border-bottom:1px solid var(--grid);background:var(--panel-2)}
/* metric tower */
.mt{padding:6px 0}
.mi{display:flex;align-items:baseline;justify-content:space-between;padding:9px 14px;border-bottom:1px solid var(--grid)}
.mi:last-child{border-bottom:none}
.mi-k{font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:.5px}
.mi-v{font-family:var(--mono);font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--ink)}
.mi-v.up{color:var(--up)} .mi-v.down{color:var(--down)} .mi-v.amber{color:var(--amber)}
/* main chart area */
.chart-block{padding:0;border-bottom:1px solid var(--grid)}
.chart-head{display:flex;align-items:center;gap:10px;padding:9px 14px}
.chart-head .ct{font-family:var(--mono);font-size:11px;color:var(--ink);font-weight:600}
.legend{display:flex;gap:12px;margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--ink-2)}
.legend span{display:inline-flex;align-items:center;gap:5px}
.legend i{width:12px;height:2px;display:inline-block}
.scanwrap{position:relative;height:188px;padding:0 8px;overflow:hidden}
.scanline{position:absolute;left:0;right:0;top:0;height:60px;background:linear-gradient(180deg,transparent,rgba(0,229,255,.06),transparent);pointer-events:none;animation:scan 5.5s linear infinite}
@keyframes scan{0%{transform:translateY(-60px)}100%{transform:translateY(188px)}}
/* right rail */
.pos{padding:12px 14px;border-bottom:1px solid var(--grid)}
.pos .sym{font-family:var(--mono);font-size:14px;color:var(--ink);font-weight:700}
.pos .nm{font-family:var(--mono);font-size:10px;color:var(--ink-3);margin-bottom:8px}
.pos-row{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11px;color:var(--ink-2);padding:3px 0}
.pos-row b{color:var(--ink);font-weight:600;font-variant-numeric:tabular-nums}
.pos-row b.up{color:var(--up)} .pos-row b.down{color:var(--down)}
.signal{display:flex;align-items:center;gap:8px;padding:8px 14px;font-family:var(--mono);font-size:11px;border-bottom:1px solid var(--grid)}
.signal .arr{color:var(--up);font-weight:700}
.signal.sell .arr{color:var(--down)}
/* monthly heatmap */
.heat{padding:12px 14px}
.heat-grid{display:grid;grid-template-columns:34px repeat(12,1fr);gap:2px}
.heat-grid .hlab{font-family:var(--mono);font-size:9px;color:var(--ink-3);display:grid;place-items:center}
.hc{height:18px;display:grid;place-items:center;font-family:var(--mono);font-size:8px;color:rgba(255,255,255,.7)}
/* ticker */
.ticker{display:flex;align-items:center;gap:0;border-top:1px solid var(--grid-2);background:var(--panel-2);overflow:hidden}
.tk-label{font-family:var(--mono);font-size:10px;color:var(--cyan);letter-spacing:1px;padding:0 12px;border-right:1px solid var(--grid);height:34px;display:grid;place-items:center}
.tk-track{display:flex;gap:28px;padding:0 14px;font-family:var(--mono);font-size:11px;white-space:nowrap;animation:tk 32s linear infinite}
@keyframes tk{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
.tk-item span.k{color:var(--ink-3)} .tk-item span.v{color:var(--ink)} .tk-item .up{color:var(--up)} .tk-item .down{color:var(--down)}
/* ── §4 priority table ── */
.pri-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.pri{background:var(--panel);border:1px solid var(--grid-2)}
.pri-head{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--grid)}
.pri-head .pl{font-family:var(--mono);font-size:10px;letter-spacing:1.5px;padding:2px 7px}
.pl.p0{color:var(--ok);border:1px solid rgba(46,230,138,.4)}
.pl.p1{color:var(--amber);border:1px solid rgba(255,176,0,.4)}
.pl.p2{color:var(--cyan);border:1px solid rgba(0,229,255,.4)}
.pri-head .pt{font-family:var(--mono);font-size:13px;color:var(--ink);font-weight:600}
.pri-head .pw{margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--ink-3)}
.pri ul{list-style:none;margin:0;padding:6px 0}
.pri li{display:flex;gap:10px;padding:8px 16px;font-size:12.5px;color:var(--ink-2);border-bottom:1px dashed var(--grid)}
.pri li:last-child{border-bottom:none}
.pri li .mk{color:var(--cyan);font-family:var(--mono);font-size:11px;flex-shrink:0;margin-top:1px}
.pri li b{color:var(--ink);font-weight:600}
/* ── §5 ux essence ── */
.ux-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
.ux{padding:18px 20px;background:var(--panel);border:1px solid var(--grid-2);position:relative}
.ux .uno{position:absolute;top:14px;right:16px;font-family:var(--mono);font-size:11px;color:var(--cyan-dim);letter-spacing:1px}
.ux h4{margin:0 0 6px;font-family:var(--mono);font-size:13px;color:var(--ink);letter-spacing:.3px;padding-right:36px}
.ux p{margin:0 0 8px;font-size:12.5px;color:var(--ink-2);line-height:1.6}
.ux .src{font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:.5px}
/* ── §6 roadmap ── */
.road{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--grid-2);border:1px solid var(--grid-2)}
.sp{background:var(--panel);padding:18px}
.sp-no{font-family:var(--mono);font-size:11px;color:var(--cyan);letter-spacing:2px;margin-bottom:6px}
.sp h4{margin:0 0 4px;font-family:var(--mono);font-size:14px;color:var(--ink)}
.sp .dur{font-family:var(--mono);font-size:10px;color:var(--amber);letter-spacing:.5px;margin-bottom:12px}
.sp ul{list-style:none;margin:0;padding:0}
.sp li{font-size:12px;color:var(--ink-2);padding:5px 0;padding-left:16px;position:relative;border-bottom:1px dashed var(--grid);line-height:1.5}
.sp li:last-child{border-bottom:none}
.sp li:before{content:'▸';position:absolute;left:0;color:var(--cyan-dim);font-family:var(--mono)}
/* footer */
.foot{margin-top:72px;padding-top:24px;border-top:1px solid var(--grid);display:flex;align-items:center;gap:14px;font-family:var(--mono);font-size:11px;color:var(--ink-3)}
.foot .rt{margin-left:auto}
@media (max-width:980px){
.hero h1{font-size:38px}
.mu-body{grid-template-columns:1fr}
.diag-grid,.lang-grid,.pri-grid,.ux-grid{grid-template-columns:1fr}
.road{grid-template-columns:repeat(2,1fr)}
.hero-meta{flex-direction:column}
.hero-meta>div{border-right:none;border-bottom:1px solid var(--grid)}
}
@media (prefers-reduced-motion:reduce){
.dot.pulse,.scanline,.tk-track{animation:none}
}
.scanwrap svg,.chart-block svg{display:block;width:100%;height:100%}
</style>
<div class="wrap">
<!-- HERO -->
<section class="hero">
<div class="hero-tag">// frontend redesign proposal · v0.1</div>
<h1>从「研究工具」<br><span class="accent"> 量化终端</span></h1>
<p class="lead">当前前端是合格的暗色后台——功能齐全,但视觉停留在 <span class="mono" style="color:var(--ink)">GitHub&nbsp;Dark</span> 工具感,信息密度低、无命令面板、无归因深度。本提案把它重做成一台 <span class="amber">科幻终端</span>:荧光青 × 琥珀数据 × 多面板高密度,对标 Bloomberg / Wind 的终端 UXQuantConnect 的分析深度。</p>
<div class="hero-meta">
<div><div class="hm-k">现状风格</div><div class="hm-v">GitHub Dark · 单列低密度</div></div>
<div><div class="hm-k">目标风格</div><div class="hm-v"><span class="arr"></span> 深空黑 · 荧光青 · 琥珀</div></div>
<div><div class="hm-k">设计系统</div><div class="hm-v">已变量化 · CSS 换肤友好</div></div>
<div><div class="hm-k">调研覆盖</div><div class="hm-v">10+ 开源项目 · 11 平台</div></div>
</div>
</section>
<!-- §1 DIAGNOSIS -->
<section class="sec">
<div class="sec-head">
<span class="sec-no">[ 01 ]</span>
<h2 class="sec-title">现状诊断</h2>
<span class="sec-desc">cta_7a92d1f6 截图 · 功能骨架已齐 · 视觉与密度待升级</span>
</div>
<div class="diag-grid">
<div class="diag"><span class="tag dg">PAIN</span><h4>单列堆叠 · 信息密度低</h4><p>11 个指标卡横排 + 6 张图纵向 el-card 堆叠,一屏只塞得下 1-2 张图。tokens.css 注释写着"高密度",实现却是低密度——设计意图与实现的落差。</p></div>
<div class="diag"><span class="tag dg">PAIN</span><h4>无命令面板 · 无快捷键</h4><p>Bloomberg <span class="mono">&lt;GO&gt;</span>、Wind 键盘精灵、iFinD 代码简拼——三家顶级终端无一例外都有命令行式交互。当前全靠侧栏点选,power-user 效率为零。</p></div>
<div class="diag k"><span class="tag wn">GAP</span><h4>分析深度停在 P0 基线</h4><p>缺月度收益热力图、Rolling Sharpe/Beta、回撤 Top5 标注、每日收益柱状图;指标卡缺胜率/盈亏比/换手率(11 项里只有 8 项)。归因(Brinson/Barra)完全空白。</p></div>
<div class="diag k"><span class="tag wn">GAP</span><h4>无导出 · 无对比 · 无选股闭环</h4><p>所有数据表无 Excel/CSV 导出;无多回测对比页;无选股器(EQS→回测→归因是专业终端的产品灵魂,4 家终端都做闭环)。</p></div>
<div class="diag g"><span class="tag cy">BASE</span><h4>设计系统已变量化 ✅</h4><p><span class="mono">--bg / --brand / --mono / --r-sm / --ease</span> + 统一 <span class="mono">echartsDark</span> 主题(红涨绿跌)。科幻风改造主要落在 CSS 变量层 + 视觉叠加层,不用重写 Element Plus 组件。</p></div>
<div class="diag g"><span class="tag cy">BASE</span><h4>功能框架齐全 ✅</h4><p>单标的 CTA 全链路(新建→进度→结果→历史)+ 组合回测 + 参数优化 + 因子分析 + 模拟盘 + 实盘模拟 + Dashboard。美化为主,功能查漏为辅。</p></div>
</div>
</section>
<!-- §2 VISUAL LANGUAGE -->
<section class="sec">
<div class="sec-head">
<span class="sec-no">[ 02 ]</span>
<h2 class="sec-title">视觉语言</h2>
<span class="sec-desc">深空黑 · 荧光青 · 琥珀 · 等宽数据</span>
</div>
<div class="lang-grid">
<div>
<div class="swatches">
<div class="sw" style="background:#05080d"><span class="nm">VOID</span><span class="hex" style="color:#56697c">#05080d</span></div>
<div class="sw" style="background:#0d141f"><span class="nm">PANEL</span><span class="hex" style="color:#8aa0b5">#0d141f</span></div>
<div class="sw" style="background:#00e5ff"><span class="nm" style="color:#05080d">CYAN · 主强调</span><span class="hex" style="color:#05080d">#00e5ff</span></div>
<div class="sw" style="background:#ffb000"><span class="nm" style="color:#05080d">AMBER · 数据高亮</span><span class="hex" style="color:#05080d">#ffb000</span></div>
<div class="sw" style="background:#ff4d5e"><span class="nm" style="color:#05080d">UP · 红涨(A股)</span><span class="hex" style="color:#05080d">#ff4d5e</span></div>
<div class="sw" style="background:#2ee68a"><span class="nm" style="color:#05080d">DOWN · 绿跌</span><span class="hex" style="color:#05080d">#2ee68a</span></div>
</div>
<div class="lamprow">
<div class="lampcell"><span class="d" style="background:var(--ok);box-shadow:0 0 6px var(--ok)"></span>RUNNING</div>
<div class="lampcell"><span class="d" style="background:var(--warn);box-shadow:0 0 6px var(--warn)"></span>WARN</div>
<div class="lampcell"><span class="d" style="background:var(--danger);box-shadow:0 0 6px var(--danger)"></span>CRITICAL</div>
</div>
</div>
<div class="panel type-card">
<div class="type-row"><span class="type-l">DISPLAY · 终端标题</span><span class="t-disp">终端化</span></div>
<div class="type-row"><span class="type-l">BODY · 正文叙述</span><span class="t-body">量化研究台正文阅读</span></div>
<div class="type-row"><span class="type-l">DATA · 等宽 tabular-nums</span><span class="t-data">+18.42%</span></div>
<div class="type-row"><span class="type-l">规则</span><span class="mono" style="font-size:11px;color:var(--ink-2)">数据全右对齐 · 等宽列对齐 · 直角/2px 圆角 · 几何角标代替 emoji · 1px 网格底 · 克制动效(扫描线/呼吸灯)</span></div>
</div>
</div>
</section>
<!-- §3 MOCKUP -->
<section class="sec">
<div class="sec-head">
<span class="sec-no">[ 03 ]</span>
<h2 class="sec-title">改造后 · 回测结果页</h2>
<span class="sec-desc">高保真 mockup · 多面板终端布局 · 非实现,视觉目标</span>
</div>
<div class="mockup">
<!-- top strip -->
<div class="mu-top">
<span class="tid">cta_7a92d1f6</span><span class="sep"></span>
<span class="name">DoubleMa</span><span class="sep">·</span><span>600519 贵州茅台</span><span class="sep">·</span><span>2023-01-01 ~ 2024-06-30</span><span class="sep">·</span><span>日线</span><span class="sep">·</span><span>基准 沪深300</span>
<span class="right">
<span class="chip ok">● DONE · 1.8s</span>
<span class="chip">⌘K</span>
</span>
</div>
<div class="mu-body">
<!-- LEFT: metric tower -->
<div class="mu-col">
<div class="mu-subhead">核心指标</div>
<div class="mt">
<div class="mi"><span class="mi-k">年化收益</span><span class="mi-v up">+18.42%</span></div>
<div class="mi"><span class="mi-k">Sharpe</span><span class="mi-v">1.34</span></div>
<div class="mi"><span class="mi-k">Sortino</span><span class="mi-v">1.71</span></div>
<div class="mi"><span class="mi-k">最大回撤</span><span class="mi-v down">-9.80%</span></div>
<div class="mi"><span class="mi-k">Alpha</span><span class="mi-v up">6.20%</span></div>
<div class="mi"><span class="mi-k">Beta</span><span class="mi-v">0.78</span></div>
<div class="mi"><span class="mi-k">胜率</span><span class="mi-v">54%</span></div>
<div class="mi"><span class="mi-k">盈亏比</span><span class="mi-v amber">1.82</span></div>
<div class="mi"><span class="mi-k">换手率</span><span class="mi-v">3.2x</span></div>
<div class="mi"><span class="mi-k">PSR</span><span class="mi-v amber">0.92</span></div>
</div>
</div>
<!-- CENTER: charts -->
<div class="mu-col">
<!-- equity -->
<div class="chart-block">
<div class="chart-head">
<span class="ct">净值曲线 · 策略 / 基准 / 超额</span>
<span class="legend">
<span><i style="background:var(--cyan)"></i>策略</span>
<span><i style="background:var(--ink-3)"></i>沪深300</span>
<span><i style="background:var(--amber)"></i>超额</span>
</span>
</div>
<div class="scanwrap">
<div class="scanline"></div>
<svg viewBox="0 0 600 180" preserveAspectRatio="none">
<defs>
<linearGradient id="g1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#00e5ff" stop-opacity=".28"/><stop offset="1" stop-color="#00e5ff" stop-opacity="0"/></linearGradient>
</defs>
<!-- grid -->
<g stroke="#15212f" stroke-width="1">
<line x1="0" y1="45" x2="600" y2="45"/><line x1="0" y1="90" x2="600" y2="90"/><line x1="0" y1="135" x2="600" y2="135"/>
</g>
<!-- benchmark (dull) -->
<polyline fill="none" stroke="#56697c" stroke-width="1.4" points="0,120 40,118 80,125 120,122 160,128 200,124 240,130 280,126 320,132 360,128 400,122 440,118 480,124 520,120 560,116 600,114"/>
<!-- strategy -->
<path d="M0,120 L40,110 L80,115 L120,98 L160,104 L200,82 L240,90 L280,68 L320,74 L360,55 L400,62 L440,40 L480,48 L520,32 L560,38 L600,22 L600,180 L0,180 Z" fill="url(#g1)"/>
<polyline fill="none" stroke="#00e5ff" stroke-width="2" points="0,120 40,110 80,115 120,98 160,104 200,82 240,90 280,68 320,74 360,55 400,62 440,40 480,48 520,32 560,38 600,22"/>
<!-- excess (amber dashed) -->
<polyline fill="none" stroke="#ffb000" stroke-width="1.4" stroke-dasharray="4 3" points="0,150 40,148 80,146 120,140 160,138 200,130 240,128 280,118 320,116 360,104 400,102 440,88 480,86 520,76 560,74 600,62"/>
<!-- end dot -->
<circle cx="600" cy="22" r="3.5" fill="#00e5ff"/>
<circle cx="600" cy="22" r="7" fill="#00e5ff" opacity=".25"/>
</svg>
</div>
</div>
<!-- drawdown underwater -->
<div class="chart-block">
<div class="chart-head">
<span class="ct">回撤水下图 · Top5 回撤期标注</span>
<span class="legend"><span><i style="background:var(--up)"></i>回撤</span><span><i style="background:var(--warn)"></i>Top5</span></span>
</div>
<div class="scanwrap" style="height:96px">
<svg viewBox="0 0 600 90" preserveAspectRatio="none">
<line x1="0" y1="0" x2="600" y2="0" stroke="#1d2c3d"/>
<path d="M0,0 L30,0 L60,18 L90,12 L120,28 L150,22 L180,0 L220,0 L260,40 L300,32 L330,48 L370,20 L400,0 L440,0 L480,55 L520,38 L560,14 L600,0 L600,0 L0,0 Z" fill="#ff4d5e" fill-opacity=".22" stroke="#ff4d5e" stroke-width="1.2"/>
<!-- top5 markers -->
<g fill="#ffb000"><circle cx="270" cy="40" r="3"/><circle cx="330" cy="48" r="3"/><circle cx="135" cy="28" r="3"/><circle cx="490" cy="55" r="3"/><circle cx="300" cy="36" r="3"/></g>
<g font-family="monospace" font-size="8" fill="#ffb000"><text x="272" y="52">1</text><text x="332" y="60">2</text></g>
</svg>
</div>
</div>
<!-- monthly heatmap -->
<div class="chart-block" style="border-bottom:none">
<div class="chart-head"><span class="ct">月度收益热力图 · 20232024</span><span class="legend"><span><i style="background:var(--up)"></i></span><span><i style="background:var(--down)"></i></span></span></div>
<div class="heat">
<div class="heat-grid">
<div class="hlab"></div><div class="hlab">1</div><div class="hlab">2</div><div class="hlab">3</div><div class="hlab">4</div><div class="hlab">5</div><div class="hlab">6</div><div class="hlab">7</div><div class="hlab">8</div><div class="hlab">9</div><div class="hlab">10</div><div class="hlab">11</div><div class="hlab">12</div>
<div class="hlab">23</div>
<div class="hc" style="background:#ff4d5e;opacity:.5">+3</div><div class="hc" style="background:#2ee68a;opacity:.4">-2</div><div class="hc" style="background:#ff4d5e;opacity:.8">+6</div><div class="hc" style="background:#ff4d5e;opacity:.3">+1</div><div class="hc" style="background:#2ee68a;opacity:.6">-3</div><div class="hc" style="background:#ff4d5e;opacity:.6">+4</div><div class="hc" style="background:#ff4d5e;opacity:.9">+7</div><div class="hc" style="background:#2ee68a;opacity:.3">-1</div><div class="hc" style="background:#ff4d5e;opacity:.4">+2</div><div class="hc" style="background:#2ee68a;opacity:.7">-4</div><div class="hc" style="background:#ff4d5e;opacity:.5">+3</div><div class="hc" style="background:#ff4d5e;opacity:.7">+5</div>
<div class="hlab">24</div>
<div class="hc" style="background:#2ee68a;opacity:.5">-3</div><div class="hc" style="background:#ff4d5e;opacity:.7">+5</div><div class="hc" style="background:#ff4d5e;opacity:.4">+2</div><div class="hc" style="background:#2ee68a;opacity:.4">-2</div><div class="hc" style="background:#ff4d5e;opacity:.6">+4</div><div class="hc" style="background:#ff4d5e;opacity:.85">+6</div><div class="hc" style="background:rgba(214,228,240,.08)"></div><div class="hc" style="background:rgba(214,228,240,.08)"></div><div class="hc" style="background:rgba(214,228,240,.08)"></div><div class="hc" style="background:rgba(214,228,240,.08)"></div><div class="hc" style="background:rgba(214,228,240,.08)"></div><div class="hc" style="background:rgba(214,214,214,.08)"></div>
</div>
</div>
</div>
</div>
<!-- RIGHT rail -->
<div class="mu-col">
<div class="mu-subhead">持仓 · 信号</div>
<div class="pos">
<div class="sym">600519</div>
<div class="nm">贵州茅台 · 多头</div>
<div class="pos-row"><span>持仓</span><b>400 股</b></div>
<div class="pos-row"><span>成本</span><b>1,685.20</b></div>
<div class="pos-row"><span>现价</span><b>1,722.50</b></div>
<div class="pos-row"><span>浮盈</span><b class="up">+2.21%</b></div>
<div class="pos-row"><span>暴露</span><b>68.9%</b></div>
</div>
<div class="signal"><span class="arr">▲ BUY</span><span>2024-05-16 · 1,680</span></div>
<div class="signal sell"><span class="arr">▼ SELL</span><span>2024-06-20 · 1,722</span></div>
<div class="signal"><span class="arr">▲ BUY</span><span>2024-06-28 · 1,705</span></div>
<div class="mu-subhead" style="margin-top:0">风险</div>
<div class="pos">
<div class="pos-row"><span>VaR(95%)</span><b class="down">-2.1%</b></div>
<div class="pos-row"><span>行业暴露</span><b>食饮 69%</b></div>
<div class="pos-row"><span>风格</span><b>大盘·价值</b></div>
</div>
</div>
</div>
<!-- ticker -->
<div class="ticker">
<div class="tk-label">TRADES ▸</div>
<div class="tk-track">
<span class="tk-item"><span class="k">BUY</span> <span class="v">600519</span> <span class="up">@1685</span> ×400</span>
<span class="tk-item"><span class="k">SELL</span> <span class="v">600519</span> <span class="down">@1702</span> ×400</span>
<span class="tk-item"><span class="k">BUY</span> <span class="v">600519</span> <span class="up">@1660</span> ×400</span>
<span class="tk-item"><span class="k">SELL</span> <span class="v">600519</span> <span class="down">@1688</span> ×400</span>
<span class="tk-item"><span class="k">BUY</span> <span class="v">600519</span> <span class="up">@1672</span> ×400</span>
<span class="tk-item"><span class="k">BUY</span> <span class="v">600519</span> <span class="up">@1685</span> ×400</span>
<span class="tk-item"><span class="k">SELL</span> <span class="v">600519</span> <span class="down">@1702</span> ×400</span>
<span class="tk-item"><span class="k">BUY</span> <span class="v">600519</span> <span class="up">@1660</span> ×400</span>
<span class="tk-item"><span class="k">SELL</span> <span class="v">600519</span> <span class="down">@1688</span> ×400</span>
<span class="tk-item"><span class="k">BUY</span> <span class="v">600519</span> <span class="up">@1672</span> ×400</span>
</div>
</div>
</div>
<p style="font-family:var(--mono);font-size:11px;color:var(--ink-3);margin-top:12px">// 上图为视觉目标 mockup(假数据)。一屏承载:10 指标 + 净值/基准/超额 + 回撤Top5 + 月度热力图 + 持仓信号 + 风险 + 交易滚动条 —— 信息密度对标 Wind/Choice 综合屏。</p>
</section>
<!-- §4 PRIORITY -->
<section class="sec">
<div class="sec-head">
<span class="sec-no">[ 04 ]</span>
<h2 class="sec-title">功能优先级</h2>
<span class="sec-desc">调研对照 · P0 立即补齐 / P1 差异化 / P2 长期</span>
</div>
<div class="pri-grid">
<div class="pri">
<div class="pri-head"><span class="pl p0">P0</span><span class="pt">立即补齐</span><span class="pw">12 周</span></div>
<ul>
<li><span class="mk"></span><b>科幻视觉层</b>:CSS 变量换肤(深空黑/荧光青/琥珀)+ 网格底 + 信号灯 + tabular-nums,全站换肤不重构组件</li>
<li><span class="mk"></span><b>Cmd+K 命令面板</b>:代码/拼音/命令三模式,全局快捷入口</li>
<li><span class="mk"></span><b>月度收益热力图</b>QuantConnect 标杆,全头部都有</li>
<li><span class="mk"></span><b>回撤 Top5 标注</b> + 每日收益柱状图</li>
<li><span class="mk"></span><b>补 3 指标</b>:胜率 / 盈亏比 / 换手率</li>
<li><span class="mk"></span><b>导出</b>:所有表加 Excel/CSV 按钮</li>
</ul>
</div>
<div class="pri">
<div class="pri-head"><span class="pl p1">P1</span><span class="pt">差异化体验</span><span class="pw">12 月</span></div>
<ul>
<li><span class="mk"></span><b>多面板可拖拽布局</b>vue-grid-layout + 模板切换 + 联动</li>
<li><span class="mk"></span><b>股票代码智能补全</b>:代码/中文/拼音 fuzzy(米筐独占级体验)</li>
<li><span class="mk"></span><b>选股→回测→归因闭环</b>:选股器 + universe 透传</li>
<li><span class="mk"></span><b>Crisis Events</b>2015股灾/2018贸易战/2020新冠/2024微盘崩期间表现</li>
<li><span class="mk"></span><b>Rolling Sharpe/Beta</b>6M/12M 滚动)+ <b>PSR</b> 概率化夏普</li>
</ul>
</div>
<div class="pri">
<div class="pri-head"><span class="pl p2">P2</span><span class="pt">长期 / 选型</span><span class="pw">视定位</span></div>
<ul>
<li><span class="mk"></span><b>Brinson / Barra 归因</b>:国内聚宽/优矿/Choice 杀手锏</li>
<li><span class="mk"></span><b>无代码策略生成器</b>:指标 + 风控可视化搭建</li>
<li><span class="mk"></span><b>AI 策略助手</b>:对话式开发 + 研报总结</li>
<li><span class="mk"></span><b>社区 / 策略广场</b>:热度榜 + 实体引用</li>
<li><span class="mk"></span><b>Bar Replay</b> 历史回放(TradingView 独占级)</li>
</ul>
</div>
</div>
</section>
<!-- §5 UX ESSENCE -->
<section class="sec">
<div class="sec-head">
<span class="sec-no">[ 05 ]</span>
<h2 class="sec-title">终端 UX 精华落地</h2>
<span class="sec-desc">Bloomberg / Wind / iFinD / Choice 共识 · 7 条</span>
</div>
<div class="ux-grid">
<div class="ux"><span class="uno">01</span><h4>全局命令面板 ⌘K</h4><p>Bloomberg <span class="mono">&lt;GO&gt;</span> / Wind 键盘精灵 / iFinD 简拼——三终端无一例外。三模式:<span class="mono">600519</span>切标的 · <span class="mono">gzmt</span>拼音 · <span class="mono">bt/fac/opt</span>路由。配合 vim 风格单键跳转。</p><span class="src">SRC: Bloomberg · Wind · iFinD</span></div>
<div class="ux"><span class="uno">02</span><h4>多面板可拖拽工作区</h4><p>四面板 + Tabbed Panel + 布局保存(4 家终端都做)。预设模板:单策略深挖 / 多策略 2×2 对比 / 选股+回测 / 组合归因。<b style="color:var(--cyan)">面板间联动</b>——点持仓高亮净值图买卖点。</p><span class="src">SRC: Bloomberg · Wind · iFinD · Choice</span></div>
<div class="ux"><span class="uno">03</span><h4>语义化色彩 · 颜色承载信息</h4><p>严格 token:涨/跌/中性/预警/信息分层(项目已有红涨绿跌,补预警/信息色)。数字右对齐 + <span class="mono">tabular-nums</span>(Bloomberg 做得最彻底)。涨跌色三档可切换。</p><span class="src">SRC: Bloomberg amber-on-black</span></div>
<div class="ux"><span class="uno">04</span><h4>选股 → 回测 → 归因闭环</h4><p>Bloomberg <span class="mono">EQS→BTST→PORT</span> / Wind <span class="mono">EQS→EQBT→PMS</span>。产品流灵魂:选股结果"送入回测"→结果页"因子归因"tab→统一 portfolio schema 跨模块透传。</p><span class="src">SRC: 4 家专业终端共识</span></div>
<div class="ux"><span class="uno">05</span><h4>数据密度 + 零摩擦导出</h4><p>ag-Grid / TanStack Tabletabular-nums + 冻结表头 + 内联 sparkline(持仓行迷你净值)。所有表右上角"导出 Excel"exceljs 前端生成多 sheet)。数字格式全平台统一。</p><span class="src">SRC: Bloomberg XLTP · Wind =WSD() · iFinD</span></div>
<div class="ux"><span class="uno">06</span><h4>实体引用 · 符号触发</h4><p>编辑器/备注/讨论区:<span class="mono">$SH600519$</span>→股票卡片 · <span class="mono">#DoubleMa#</span>→策略聚合 · <span class="mono">@因子:momentum_20d</span>→因子详情。从股票页进新建回测自动填 universe。</p><span class="src">SRC: 雪球</span></div>
</div>
</section>
<!-- §6 ROADMAP -->
<section class="sec">
<div class="sec-head">
<span class="sec-no">[ 06 ]</span>
<h2 class="sec-title">落地路线图</h2>
<span class="sec-desc">4 Sprint · 视觉先行 · 每步可验收</span>
</div>
<div class="road">
<div class="sp">
<div class="sp-no">SPRINT 01</div>
<h4>视觉层 · 设计系统</h4>
<div class="dur">~ 1 周 · 低风险</div>
<ul>
<li>CSS 变量层换肤(深空黑/青/琥珀)</li>
<li>等宽数据字体 + 全局 tabular-nums</li>
<li>网格底 / 信号灯 / 扫描线 / 角标</li>
<li>不改组件结构,全站换肤</li>
</ul>
</div>
<div class="sp">
<div class="sp-no">SPRINT 02</div>
<h4>结果页重构 + P0 功能</h4>
<div class="dur">~ 2 周</div>
<ul>
<li>结果页转多面板终端布局</li>
<li>月度热力图 / 回撤 Top5 / 每日收益</li>
<li>补胜率/盈亏比/换手率指标</li>
<li>Cmd+K 命令面板 + 表导出</li>
</ul>
</div>
<div class="sp">
<div class="sp-no">SPRINT 03</div>
<h4>P1 差异化</h4>
<div class="dur">~ 46 周</div>
<ul>
<li>多面板可拖拽 + 布局保存</li>
<li>股票智能补全(代码/中文/拼音)</li>
<li>选股→回测→归因闭环</li>
<li>Crisis Events / Rolling / PSR</li>
</ul>
</div>
<div class="sp">
<div class="sp-no">SPRINT 04</div>
<h4>P2 选型</h4>
<div class="dur">视定位</div>
<ul>
<li>Brinson / Barra 因子归因</li>
<li>无代码策略生成器</li>
<li>AI 策略助手</li>
<li>社区 / 策略广场 / Bar Replay</li>
</ul>
</div>
</div>
</section>
<div class="foot">
<span class="brand-mark" style="width:18px;height:18px;font-size:9px">SG</span>
<span>三国量化研究台 · 终端化重构提案</span>
<span style="color:var(--grid-2)"></span>
<span>调研:10+ 开源项目 · 11 专业平台</span>
<span class="rt">sanguo_vnpy_v2 · 前后端 session</span>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+308
View File
@@ -0,0 +1,308 @@
# 三国量化研究台 · 终端化重构 Spec
> 范围:前端视觉/功能改造 + 后端引擎适配 + 模拟实盘功能改造
> 制定:前后端 session · 2026-08-12 · 状态:**待评审**
---
## 1. 背景与目标
**现状**:功能骨架齐全(回测/组合/优化/因子/模拟/实盘/Dashboard),视觉停留在 GitHub Dark 工具风——信息密度低、无命令面板、分析深度停在 P0 基线、组合策略只回测得了跑不了模拟实盘。
**目标**:重做为科幻量化终端(深空黑+荧光青+琥珀),对标 Bloomberg/Wind 终端 UX + QuantConnect 分析深度,组合策略贯通 backtest→paper→live。
**三大改造**
1. 前端终端化(视觉 + 功能补齐 + 多面板)
2. 组合策略引擎贯通模拟/实盘(适配层接入)
3. 模拟实盘功能完善(组合/多账户/多策略同账户)
---
## 2. 核心原则(不可违背)
| # | 原则 | 说明 |
|---|------|------|
| **P1** | **vnpy 是适配不是改造** | `vnpy_v4.4.0/` 为上游源码,**零改动**;CTA 侧通过子类化适配层(`ashare_engine.py` 模式)扩展。验收:`git diff vnpy_v4.4.0/` 恒为空 |
| **P2** | **组合策略为主线** | 选股+择时+调仓+风控一体(bullet_trade 聚宽风格)是产品重心;未来加 AI 辅助、多策略同账户 |
| **P3** | **CTA 不淡化** | 所有功能 CTA/组合都做,CTA 优先级靠后。关注同功能下两者实现分歧,一套代码覆盖优先 |
| **P4** | **miniQMT 只管交易** | 下单通道与策略引擎解耦,CTA/组合共用 QMT;策略差异在引擎层不在 QMT 层 |
| **P5** | **自研可改、上游只适配** | `bullet_trade`/`sanguo_trader`/`sanguo_live`/`sanguo_qmt_bridge` 是自研可改;vnpy 是上游只适配 |
---
## 3. 现状基线(改造起点)
### 3.1 引擎层
| 引擎 | 归属 | backtest | paper 模拟 | live 实盘 |
|------|------|:---:|:---:|:---:|
| vnpy CTAashare_engine 适配) | 上游·只适配 | ✅ | ✅ per-symbol | ✅ 单标的 |
| bullet_trade 组合 | 自研·可改 | ✅ | ❌ | runner_live 有 / **web 接入层缺** |
| sanguo_trader 自研撮合 | 自研·可改 | — | paper 用 | — |
| sanguo_qmt_bridge | 自研·可改 | — | — | QMT 下单通道(共用) |
### 3.2 前端层
- 回测:New/Progress/Result/History/Optimize/PortfolioBacktest
- 模拟:paper(New/List/Result/Live) · live(New/List/Monitor)
- 设计系统已变量化(CSS tokens + `echartsDark`)→ 换肤友好
### 3.3 模拟实盘 3 种运行方式
| 方式 | 引擎 | 数据源 | 撮合 | 策略范式 |
|------|------|--------|------|---------|
| ① paper-replay 回放 | 自研撮合 | 历史 parquet | 快速重放·立即出结果 | CTA per-symbol+组合待加)|
| ② paper-live 实走 | 自研撮合 | 日终 parquet | 每日定时 step·checkpoint 续跑 | CTA+组合待加)|
| ③ live-miniQMT 实盘模拟 | QMT 直连 | QMT 实时行情 | 券商模拟账户真实下单 | CTA 单标的(+组合 runner_live 待接入)|
---
## 4. 前端改造
### 4.1 视觉语言(科幻终端)
- **色板**:深空黑 `#05080d` + 荧光青 `#00e5ff`(主强调)+ 琥珀 `#ffb000`(数据高亮)+ 红涨 `#ff4d5e`/绿跌 `#2ee68a`
- **字体**:等宽数据字体(全局 `tabular-nums`+ 无衬线正文
- **元素**:1px 网格底 / 信号灯(绿运行·琥警告·红 critical)/ 扫描线 / 几何角标(无 emoji)/ 直角·2px 圆角
- **落地**CSS 变量层换肤,不改 Element Plus 组件结构,全站一次性换肤
- **参考**:提案页 `docs/frontend_terminal_redesign_proposal.html`(已验证视觉)
### 4.2 功能补齐(回测结果页,CTA+组合共用一套)
| 功能 | 优先级 | 说明 |
|------|:---:|------|
| 月度收益热力图 / 每日收益柱状 / 回撤 Top5 / 收益分布 | P0 | QuantConnect 标杆 |
| 指标卡补齐:胜率/盈亏比/换手率/PSR | P0 | 现 11 项缺这 4 个 |
| 净值图超额线 + brush 缩放 | P0 | 现仅策略+基准两线 |
| 克隆重跑 / 导出报告(Excel/PDF) | P0 | 效率闭环 |
| Cmd+K 命令面板 / 所有表导出 | P0 | 终端 UX 共识 |
| 多回测对比页 / Rolling Sharpe·Beta | P1 | 差异化 |
| 参数优化热力图·3D 曲面 / 选股→回测→归因闭环 | P1 | vnpy 只有表格,超越 |
| Crisis Events 压力测试 | P1 | QuantConnect 独占 |
| Brinson/Barra 归因 / 多面板可拖拽 / 股票智能补全 | P1 | 专业化 |
| AI 策略助手 / 无代码策略生成器 / 社区广场 / Bar Replay | P2 | 长期 |
### 4.3 页面改造
- **结果页**:单列堆叠 → 多面板终端布局(指标塔 + 主图 + 回撤 + 月度热力 + 持仓信号 + 交易 ticker),一屏高密度
- **模拟盘/实盘监控**:科幻风监控台(状态灯 + 实时净值 + 持仓 + 信号 + 告警 + 下次运行时刻)
- **多账户/多策略**:多账户管理列表 + 一账户多策略分策略展示
- **组合策略创建入口**:新建回测/模拟/实盘时能选组合策略 + 股票池(不只是 CTA 类名 + 单 symbol
---
## 5. 后端改造(引擎适配层)
> 原则 P1/P5vnpy 只适配不改源码;bullet_trade/sanguo_trader/sanguo_live/sanguo_qmt_bridge 自研可改
### 5.1 任务 A — 组合 paper 模拟
- **目标**:组合策略能跑模拟盘(replay 回放 + live 实走)
- **方案**bullet_trade 接入 `sanguo_trader/matcher`(自研撮合),复用 paper 的 checkpoint / 跨日 pending 机制
- **vnpy**:不涉及(纯自研引擎链路)
### 5.2 任务 B — 组合 live 接入 web
- **现状**`sanguo_portfolio/runner_live.py` 引擎就绪(bullet_trade `LiveEngine` + `QmtBroker` + AllWeather),但未接 web
- **方案**
- `sanguo_live/supervisor` 识别"组合账户"类型 → 起 bullet_trade `LiveEngine`(非 vnpy engine
- 新建 `sanguo_api/routes_portfolio_live`(创建/启停/监控组合实盘)
- 前端组合实盘入口 + 监控台
- **下单**:复用 `QmtBroker`/miniQMT**不碰 QMT 适配层**
- **vnpy**:不涉及
### 5.3 任务 C — 多策略同账户 + 多账户
- **多账户**:架构已支持(`live_accounts` 表每行一账户 + per-account `mini_path`)→ 多行记录 + 多 miniQMT 客户端实例;**待 VPS 实测**单机并行实例数
- **多策略同账户**supervisor + engine 支持一账户多策略路由 + 资金/持仓隔离
- **前端**:多账户管理 + 一账户多策略视图
### 5.4 CTA 侧(适配层,不改造 vnpy)
- CTA paper/live 继续走 `ashare_engine` / vnpy 适配层
- 功能补齐(同组合)通过适配层扩展,**vnpy_v4.4.0 源码零改动**
### 5.5 CTA vs 组合实现分歧(评估)
| 功能 | 分歧 | 处理 |
|------|:---:|------|
| backtest | 小 | 各有引擎,已就绪 |
| paper 撮合 | 中 | 组合接 matcher |
| live web 接入 | 中 | 组合接 supervisor+routes |
| 下单通道 | 无 | 共用 miniQMT |
| 结果分析/监控/导出 | 无 | **一套组件 CTA+组合共用**P5 一套优先)|
---
## 6. 模拟实盘功能改造目标
| 目标 | 现状 | 改造后 |
|------|------|--------|
| 组合策略跑模拟 | ❌ | ✅ paper-replay + paper-live |
| 组合策略跑实盘 | runner_live 有、未接 web | ✅ web 创建/启停/监控 |
| 多账户 | 架构支持、未暴露 | ✅ 多账户管理 UI |
| 多策略同账户 | ❌ | ✅ 一账户多策略 + 隔离 |
| 监控视觉 | 朴素 | 科幻风监控台 |
| 结果分析 | 基础 | 复用回测深度(月度/归因/Top5)|
---
## 7. 路线图
| Sprint | 内容 | 风险 | 优先级 |
|--------|------|------|--------|
| **S1** 视觉层 | CSS 变量换肤全站 + 结果页科幻样板 | 低 | 即时 |
| **S2** 结果页重构 + P0 功能 | 多面板布局 + 月度/Top5/每日收益 + 指标补齐 + 克隆/导出/Cmd+K | 中 | 高 |
| **S2.5** 策略管理 | 菜单重构(策略+账户两组) + 策略配置CRUD(类+参数+标的 命名预设) + 策略在线代码编辑(Monaco+热重载) | 中 | 高·已启动 |
| **S3** 组合贯通模拟实盘 | 任务 A(组合 paper+ B(组合 live 接 web)+ C(多账户/多策略前端)| 中高 | 高(组合主线)|
| **S4** P1 差异化 | 多回测对比 + Rolling + 优化可视化 + 选股闭环 + Crisis + 归因 + 多面板拖拽 | 中 | 中 |
| **S5** P2 选型 | AI 助手 + 无代码策略 + 社区 + Bar Replay | 高 | 长期 |
| CTA 并行 | 各 Sprint CTA 侧同步(适配层),优先级靠后 | — | P3 靠后 |
---
## 8. 职责与依赖
| Session | 职责 |
|---------|------|
| **前后端**(本 session)| 前端全部 + 后端引擎适配(A/B/C)+ CTA 适配层 + Spec/路线图 |
| 数据 | 组合策略所需数据/因子(归因数据、风格因子)|
| 策略 | 策略逻辑(all_weather 等 bullet_trade 策略本身)|
| 基建 | CI/CD/部署(commit 标 [vps]/[nas]NAS 自动/VPS dispatch|
> 引擎改造归属已记 memory`portfolio-engine-paper-live-ownership.md`(防职责混淆)
---
## 9. 验收标准
- **视觉**:全站科幻终端风;结果页一屏多面板(对标提案 mockup)
- **功能**:P0 全齐;CTA 与组合共用一套结果分析组件
- **组合贯通**:组合策略能 backtest → paper(replay+live) → live(miniQMT) 全链路
- **模拟实盘**:3 种方式都支持组合 + 多账户 + 多策略同账户
- **原则 P1**`git diff vnpy_v4.4.0/` 恒空(vnpy 零改动)
- **CTA**:功能不退化,同组合功能,优先级靠后
---
## 10. 待评审决策点
1. spec 整体方向认可吗? ——>认可
3. S1 先做(视觉换肤 + 结果页样板)作为第一步? -> 没问题,所有页面都给我一个样板,假的数据带页面跳转即可
4. 多账户多 miniQMT 实例数 → 安排 VPS 实测时机?-> 作为一个长期todu就行,现在不需要
5. 归因(Brinson/Barra)放 S4,还是要提前(依赖数据 session 的风格因子数据)? -> 可以放s4
---
## 11. 菜单结构(2026-08-12 定稿)
> 用户决策:在现有 5 组基础上加「策略」「账户」两组,最小破坏现有分组。
```
工作台
策略 ← 新增
├ 策略库 策略配置 CRUD(列表/新建/编辑/删除)
├ 新建策略
└ 代码编辑 策略在线代码编辑(Monaco)
回测
├ 新建回测 / ├ 组合回测 / ├ 参数优化 / └ 历史任务
投研
└ 因子分析
模拟
├ 模拟盘列表 / └ 新建模拟盘
实盘模拟
├ 实盘列表 / └ 新建实盘
账户 ← 新增(多账户/多 miniQMT 统一视图)
```
## 12. 策略管理(2026-08-12 增补)
两层管理,对象与工作量不同:
### 12.1 策略配置 CRUD(参数层)
- **对象**:策略类 + 参数 + 标的/池 + 基准 的命名预设(如"茅台双均线(10,30)"
- **痛点**:当前每次回测/模拟现选现填、跑完即丢;CRUD 让配置可命名/复用/一键发起回测·模拟·实盘
- **后端**`strategies` 表(id/name/strategy_class/params JSON/symbol_or_pool/benchmark/interval/remark/created_at) + 5 路由(list/create/update/delete/duplicate),复用 strategy_registry 校验类名。**纯自研,不碰 vnpy**
- **前端**:策略库列表(卡片) + 新建/编辑表单(选类自动载入参数) + 各 New 页"从已存策略载入"
- **工作量**~1-2 天 **状态**:✅ 前端完成(mock) / ⏳ 后端待做(task16)
### 12.2 策略在线代码编辑(代码层)
- **对象**:策略 Python 代码本身(.py 文件)
- **边界(P1 原则)**:只编辑**自研策略**(`sanguo_trader/strategy``sanguo_portfolio/strategies``sanguo_live/strategies`);**不编辑 vnpy 上游包**`vnpy_ctastrategy/strategies` 属适配不改造红线)
- **方案**Monaco EditorVSCode 同款)+ 文件树 + 语法校验(py_compile) + 保存 + 模板新建
- **架构利好**:回测走 ProcessPool spawn(每次新进程)→ 改完代码下次回测自动加载最新,**天然热重载**,无需处理 Python reload 缓存
- **安全**:单用户内部研究台,`py_compile` 校验 + 进程超时 + ProcessPool 隔离崩溃即可,不需沙箱
- **层次**:①只读查看(~0.5天) / ②**在线编辑**⭐(~2.5-3天) / ③无代码生成(~1-2周·P2)
- **工作量(②)**Monaco 集成 ~1.5天 + 后端文件 CRUD ~0.5天 + registry 扩展扫描自研目录 + 模板 ~1天
- **状态**:📋 本轮先做前端原型(Monaco mock)供体验,后端待体验后定
### 12.3 决策记录
- 策略 CRUD 对象 = **策略配置实例**(非代码类)✅
- 菜单 = 加「策略」「账户」两组(保留现有分组)✅
- 在线编辑 = 先前端原型体验,再定是否上后端
- 多账户多 miniQMT 实例数实测 = 长期 todo(不阻塞)
### 12.4 业界调研结论(2026-08-12 · 7 平台 agent 调研)
调研聚宽/米筐/掘金/QuantConnect/Quantopian/Alpaca/VnPy,核心结论:
1. **策略 = 代码**(6/7 一致,Alpaca 无策略层)。本轮"策略=代码"重做方向正确。
2. **"先建策略 → 再发起回测/模拟/实盘"是唯一主流模式**(6/6)。回测/模拟/实盘是策略的运行模式,不是独立顶层对象。没有一家用"回测页直接填配置"。
3. **🔑 两层模型(VnPy 范式,推荐采纳)**:策略类(代码)+ 策略实例(`setting` 参数 dict)。
- 顶层 = 策略类列表(从 strategies/ 自动发现)
- 二级 = 该类下的实例(每组参数 + 标的/池 = 一个实例,VnPy `strategy_name`
- 每实例一键:回测 / 模拟 / 实盘
- **这正是"编辑策略=编辑参数"纠结的正解**:参数属于"实例",不属于"策略代码",也不该独立叫"策略"
4. **新建策略分流**:业界按**资产类别**(股票/期货/加密)分流,不按组合/CTA。本项目按"组合/CTA 范式"分流(A股组合 vs CTA 差异大),是合理的本地化,保留。
5. **参数归属**:策略专用参数(快慢线/持仓数)绑策略类(`parameters=[]` 反射生成 UI 输入框);回测运行配置(日期/资金/基准/频率/滑点/手续费)独立,每次可改。
**组合策略 UI 配置项清单**(业界共识):
- 实例参数层:选股池/Universe、调仓周期(日/周/月)、持仓数 Top-N、仓位算法(等权/风险平价)、风控(止损%/止盈%/单标的最大仓位%)、策略专用参数(反射 `parameters`
- 运行配置层:初始资金、起止日期、基准、频率、滑点、手续费
- 不配置化(留代码):选股逻辑、因子计算、下单触发条件
**修正**:当前重做(S2.5)只做了"策略类(代码)"第一层;应补**"策略实例"第二层**(策略类下创建多组参数实例,每实例一键回测/模拟/实盘)。待用户确认后补。
### 12.5 最终落地设计(2026-08-12 定稿 · 已实现前端 mock)
策略管理「三层 + 4 运行全生命周期」:
```
策略代码(.py · 编辑代码)
└ 策略实例(参数变体 + 标的/池)
├ [回测] 历史全量 · 秒出 · 验证逻辑
├ [模拟·回放] 历史 parquet · 快速重放 · 验证撮合/滑点
├ [模拟·实走] 日终 parquet · 每日推进+断点续跑 · 模拟真实节奏
└ [实盘] miniQMT · 真下单(券商模拟/真账户)
```
**命名定稿**(消除撞名):
- 回测(不变)
- 模拟盘 = 原"模拟"(自研撮合,**不走 QMT**),内含 回放/实走 两 mode
- 实盘 = 原"实盘模拟"**miniQMT 真下单**"实盘模拟"撞名已删)
**菜单最终**:工作台 / 策略(策略库·新建策略·代码编辑) / 回测 / 投研 / 模拟盘 / 实盘 / 账户
**策略库 UI**(已实现):按策略代码分组,每组下列实例;每实例一行 = 参数摘要 + 标的/池 + 最近收益 + 4 运行状态灯(已测/运行/已停/—) + 一键发起按钮。代码无实例时提示"编辑代码后可创建"。
业界「策略=代码+实例」两层 + 「先建策略→再发起运行」模式,全部落地。后端待补:策略代码文件 CRUD + 实例 CRUD + 各运行入口接实例参数。
## 13. 撮合机制与回放设计(2026-08-12 定稿)
### 13.1 撮合频率原则(理论硬约束)
**撮合频率 = 策略决策频率**。撮合 < 决策频率 → 丢信号 + 价格失真。业界(聚宽 run_minute / vnpy on_bar / backtrader cerebro)均逐 bar 撮合,**没有"统一日级"的设计**。
用户选 3 样,撮合频率由它们推导,**不独立选**(避免矛盾配置如"15m策略+日终撮合"):
- **interval**(数据频率 d/15m)— 决定决策频率
- **match_session**(撮合时点:开盘价/收盘价/集合竞价)
- **运行模式**(回测/回放/实走/实盘)
### 13.2 撮合频率 = interval × 运行模式
| 运行模式 | 撮合频率 | 适合策略 |
|---------|---------|---------|
| 回测 / 回放 | = interval(逐历史 bar| 全频率 |
| paper 实走 | **固定日级**(日终一次)| **只日线** |
| 实盘(miniQMT| **实时**(行情驱动)| 全频率(含分钟盘中)|
**两路分工**:日线策略 → paper 实走(日终低成本模拟);分钟策略 → miniQMT 实盘(盘中实时)。paper 实走为日线设计,分钟策略不该迁就它。**UI 校验**:分钟策略(interval=15m) + paper 实走 = 不匹配,提示引导到 实盘/回放。
### 13.3 回放设计
回放 = 历史数据按选定 interval **逐根 K 线快速重放 + 自研 A 股撮合**
- **精度 = interval**:日线 = 每天 1 撮合点;15m = 每天 ~16 点(每根 15m bar 撮合)。**选 interval 即选撮合频率**。
- **逐 bar 流程**:撮合上根 pending → 喂 `on_bar` 算信号 → 新单按 match_session 当根/下根撮合 → 记账 + 涨跌停封板/最小手数/滑点/费率/T+1 校验。
- **速度**:本地历史毫秒级/根,一段历史秒~分钟跑完,立即出结果。
- **vs 回测**:回放用自研 A 股精细撮合(封板/集合竞价/滑点/T+1),比 vnpy 回测更贴近实盘真实发生。
- **数据精度**:当前支持 **d / 15m**baostock 5.5 年 15m);**1m / 5m 待数据源补齐后扩展**。
### 13.4 标的 / 池控件(按策略 type)
- **CTA(单标的)**:股票代码输入框 + 智能补全
- **组合**:**池选择框**(沪深300 / 中证500 / 中证1000 / 中证2000 / 全市场 / 自定义成分),不手输
+41 -35
View File
@@ -11,6 +11,7 @@
"axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.2",
"monaco-editor": "^0.56.0",
"pinia": "^3.0.4",
"vue": "^3.5.39",
"vue-router": "^5.1.0"
@@ -362,7 +363,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -374,7 +374,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -385,7 +384,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -502,7 +500,6 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -563,7 +560,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -580,7 +576,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -597,7 +592,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -614,7 +608,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -631,7 +624,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -648,7 +640,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -665,7 +656,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -682,7 +672,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -699,7 +688,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -716,7 +704,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -733,7 +720,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -750,7 +736,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -767,7 +752,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -786,7 +770,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -803,7 +786,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -831,7 +813,6 @@
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -888,12 +869,19 @@
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -1710,6 +1698,15 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1959,7 +1956,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2311,7 +2307,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2332,7 +2327,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2353,7 +2347,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2374,7 +2367,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2395,7 +2387,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2416,7 +2407,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2437,7 +2427,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2458,7 +2447,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2479,7 +2467,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2500,7 +2487,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2521,7 +2507,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2609,6 +2594,18 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2713,6 +2710,16 @@
"pathe": "^2.0.1"
}
},
"node_modules/monaco-editor": {
"version": "0.56.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
"integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
"license": "MIT",
"dependencies": {
"dompurify": "3.4.8",
"marked": "14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3371,7 +3378,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
@@ -3409,7 +3415,7 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
+1
View File
@@ -13,6 +13,7 @@
"axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.2",
"monaco-editor": "^0.56.0",
"pinia": "^3.0.4",
"vue": "^3.5.39",
"vue-router": "^5.1.0"
+4
View File
@@ -8,6 +8,9 @@ export interface CtaSubmit {
end: string
benchmark?: string
interval?: string
slippage?: number
commission_rate?: number
stamp_duty_rate?: number
}
export interface TaskStatus {
@@ -101,6 +104,7 @@ export interface TaskListItem {
symbol: string
start: string
end: string
instance?: string
}
export async function getTasks(type?: string): Promise<TaskListItem[]> {
+21 -1
View File
@@ -1,6 +1,7 @@
import axios, { AxiosError } from 'axios'
import axios, { AxiosError, type AxiosResponse } from 'axios'
import { useAuthStore } from '@/stores/auth'
import { router } from '@/router'
import { matchMock, MOCK_ON } from '@/mock'
export const apiClient = axios.create({
baseURL: '/api/v1',
@@ -12,6 +13,25 @@ apiClient.interceptors.request.use((config) => {
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
// 样板 mock:命中假数据表的请求直接短路(自定义 adapter),不发网络。
// 接真实后端联调时,把 src/mock/index.ts 的 MOCK_ON 置 false 即可恢复全部真实请求。
if (MOCK_ON) {
const url = config.url ?? ''
const method = config.method ?? 'get'
const mock = matchMock(method, url)
if (mock !== undefined) {
const cfg = config
cfg.adapter = (): Promise<AxiosResponse> =>
Promise.resolve({
data: mock,
status: 200,
statusText: 'MOCK_OK',
headers: {},
config: cfg,
} as AxiosResponse)
}
}
return config
})
+1
View File
@@ -24,6 +24,7 @@ export interface LiveAccount {
latest_date?: string | null
total_return?: number | null
position_count?: number
instance?: string
}
export interface LiveCreateRequest {
+1
View File
@@ -36,6 +36,7 @@ export interface PaperAccount {
latest_equity?: number | null
latest_date?: string | null
total_return?: number | null
instance?: string
}
export interface BalancePoint {
+34
View File
@@ -19,3 +19,37 @@ export async function getParams(name: string): Promise<StrategyParams> {
const { data } = await apiClient.get<StrategyParams>(`/strategy/${name}/params`)
return data
}
// ----- 策略配置 CRUD(策略类+参数+标的 的可命名持久化配置)-----
export interface StrategyConfig {
id: number
name: string
strategy_class: string
params: Record<string, unknown>
symbol_or_pool: string
benchmark: string
interval: string
remark: string
created_at: string
updated_at?: string
}
export type StrategyConfigInput = Omit<StrategyConfig, 'id' | 'created_at' | 'updated_at'>
export async function getStrategyConfigs(): Promise<StrategyConfig[]> {
const { data } = await apiClient.get<{ strategies: StrategyConfig[] }>('/strategy/configs')
return data.strategies
}
export async function createStrategyConfig(req: StrategyConfigInput): Promise<number> {
const { data } = await apiClient.post<{ id: number }>('/strategy/configs', req)
return data.id
}
export async function updateStrategyConfig(id: number, req: StrategyConfigInput): Promise<void> {
await apiClient.put(`/strategy/configs/${id}`, req)
}
export async function deleteStrategyConfig(id: number): Promise<void> {
await apiClient.delete(`/strategy/configs/${id}`)
}
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as monaco from 'monaco-editor'
// Python 语法高亮是主线程 tokenization,不依赖语言服务 worker(仅 json/ts/css/html 才需)。
// 配 noop worker 规避 monaco-editor 子路径 worker 在 vite 的解析问题。
;(self as unknown as { MonacoEnvironment: unknown }).MonacoEnvironment = {
getWorker: () =>
({
postMessage() {},
terminate() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => true,
}) as unknown as Worker,
}
const props = withDefaults(defineProps<{
modelValue: string
language?: string
readonly?: boolean
}>(), {
language: 'python',
readonly: false,
})
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
const el = ref<HTMLDivElement>()
let editor: monaco.editor.IStandaloneCodeEditor | null = null
onMounted(() => {
if (!el.value) return
monaco.editor.defineTheme('sanguo-dark', {
name: 'sanguo-dark',
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '4a5868', fontStyle: 'italic' },
{ token: 'keyword', foreground: '00e5ff' },
{ token: 'string', foreground: 'ffb000' },
{ token: 'number', foreground: '2ee68a' },
{ token: 'type', foreground: 'b48cff' },
{ token: 'function', foreground: '7adfff' },
{ token: 'delimiter', foreground: '7a8a9a' },
],
colors: {
'editor.background': '#05080d',
'editor.foreground': '#d4e4f0',
'editorLineNumber.foreground': '#3a4654',
'editorLineNumber.activeForeground': '#00e5ff',
'editor.lineHighlightBackground': '#0a1018',
'editorCursor.foreground': '#00e5ff',
'editor.selectionBackground': '#1c5a6a',
'editorIndentGuide.background': '#0f1822',
'editorIndentGuide.activeBackground': '#16202e',
'editorGutter.background': '#070b12',
'editorWidget.background': '#0a0f17',
'editorWidget.border': '#16202e',
'scrollbarSlider.background': '#16202e80',
},
})
editor = monaco.editor.create(el.value, {
value: props.modelValue,
language: props.language,
theme: 'sanguo-dark',
readOnly: props.readonly,
automaticLayout: true,
fontSize: 13,
lineHeight: 20,
fontFamily: "ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace",
fontLigatures: true,
minimap: { enabled: true, scale: 1, renderCharacters: false },
lineNumbers: 'on',
roundedSelection: false,
scrollBeyondLastLine: false,
renderLineHighlight: 'all',
cursorBlinking: 'smooth',
cursorWidth: 2,
padding: { top: 12, bottom: 12 },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
})
editor.onDidChangeModelContent(() => {
emit('update:modelValue', editor!.getValue())
})
})
watch(() => props.modelValue, (v) => {
if (editor && v !== editor.getValue()) editor.setValue(v)
})
onBeforeUnmount(() => {
editor?.dispose()
})
</script>
<template>
<div ref="el" class="monaco-host" />
</template>
<style scoped>
.monaco-host {
width: 100%;
height: 100%;
}
</style>
@@ -4,7 +4,7 @@ import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const ALPHA = '#61a0a8' // Alpha 绿
const ALPHA = '#00e5ff' // Alpha · 荧光青
const props = defineProps<{ dates: string[]; alpha: number[] }>()
const el = ref<HTMLDivElement>()
@@ -4,8 +4,8 @@ import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const STRATEGY = '#c23531' // 策略
const BENCHMARK = '#2f4554' // 基准
const STRATEGY = '#00e5ff' // 策略 · 荧光青
const BENCHMARK = '#ffb000' // 基准 · 琥珀
const props = defineProps<{ dates: string[]; strategy: number[]; benchmark: number[] }>()
const el = ref<HTMLDivElement>()
@@ -4,7 +4,7 @@ import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const BETA = '#61a0a8' // Beta 绿
const BETA = '#ffb000' // Beta · 琥珀
const props = defineProps<{ dates: string[]; beta: number[] }>()
const el = ref<HTMLDivElement>()
@@ -4,7 +4,7 @@ import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const DRAWDOWN = '#d48265' // 回撤
const DRAWDOWN = '#ff4d5e' // 回撤 · 风险红
const props = defineProps<{ dates: string[]; drawdown: number[] }>()
const el = ref<HTMLDivElement>()
@@ -29,8 +29,8 @@ function render(): void {
name: '净值',
smooth: true,
showSymbol: false,
lineStyle: { color: '#c23531', width: 1.6 },
areaStyle: { color: '#c23531', opacity: 0.12 },
lineStyle: { color: '#00e5ff', width: 1.6 },
areaStyle: { color: '#00e5ff', opacity: 0.12 },
data: props.equity,
},
],
@@ -4,8 +4,8 @@ import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const STRATEGY = '#c23531' // 策略
const BENCHMARK = '#2f4554' // 基准
const STRATEGY = '#00e5ff' // 策略 · 荧光青
const BENCHMARK = '#ffb000' // 基准 · 琥珀
const props = defineProps<{ dates: string[]; strategy: number[]; benchmark: number[] }>()
const el = ref<HTMLDivElement>()
+425
View File
@@ -0,0 +1,425 @@
/* 业务假数据 —— 覆盖各接口的核心展示数据(样板用,连真实后端时不触发) */
import { genEquity, genBenchmark, genRisk, genDailyPnl, genPaperEquity, genDates } from './series'
const N = 117
/* ========== 策略 ========== */
export const strategiesMock = {
strategies: [
{ name: '双均线', class_name: 'AShareDoubleMaStrategy' },
{ name: '布林通道', class_name: 'AShareBollStrategy' },
{ name: 'ATR 突破', class_name: 'AShareAtrStrategy' },
{ name: '全天候(组合)', class_name: 'AllWeatherStrategy' },
],
}
export const strategyParamsMock: Record<string, { parameters: string[]; defaults: Record<string, number> }> = {
AShareDoubleMaStrategy: { parameters: ['fast_window', 'slow_window', 'size'], defaults: { fast_window: 10, slow_window: 30, size: 100 } },
AShareBollStrategy: { parameters: ['boll_window', 'boll_dev', 'size'], defaults: { boll_window: 20, boll_dev: 2.0, size: 100 } },
AShareAtrStrategy: { parameters: ['atr_window', 'atr_multiplier', 'size'], defaults: { atr_window: 14, atr_multiplier: 3.0, size: 100 } },
AllWeatherStrategy: { parameters: ['rebalance_day', 'top_n'], defaults: { rebalance_day: 1, top_n: 5 } },
}
/* ========== 回测历史任务列表 ========== */
export const taskListMock = {
tasks: [
{ id: 101, task_id: 'cta_7a92d1f6', type: 'cta', status: 'done', strategy: 'AShareDoubleMaStrategy', symbol: '600519.SH', start: '2024-01-02', end: '2024-06-28', instance: '茅台双均线(10,30)·15m' },
{ id: 100, task_id: 'cta_c287f4b5', type: 'cta', status: 'done', strategy: 'AShareBollStrategy', symbol: '000858.SZ', start: '2023-06-01', end: '2024-06-01', instance: '五粮液双均线(5,20)·日线' },
{ id: 99, task_id: 'opt_3f8a2c', type: 'optimize', status: 'done', strategy: 'AShareDoubleMaStrategy', symbol: '600519.SH', start: '2023-01-03', end: '2024-06-28', instance: '茅台双均线(10,30)·15m' },
{ id: 98, task_id: 'pf_a1b2c3', type: 'portfolio', status: 'done', strategy: 'AllWeatherStrategy', symbol: '中证1000成份', start: '2022-01-04', end: '2024-06-28', instance: '全天候·中证1000·周调仓' },
{ id: 97, task_id: 'cta_e5d4c3', type: 'cta', status: 'failed', strategy: 'AShareAtrStrategy', symbol: '300750.SZ', start: '2024-03-01', end: '2024-06-28', instance: '宁德布林(20,2)·15m' },
{ id: 96, task_id: 'fc_88aabb', type: 'factor', status: 'done', strategy: '动量因子MOM20', symbol: '全市场', start: '2020-01-02', end: '2024-06-28', instance: '' },
{ id: 95, task_id: 'cta_112233', type: 'cta', status: 'running', strategy: 'AShareDoubleMaStrategy', symbol: '510300.SH', start: '2024-01-02', end: '2024-08-12', instance: '茅台双均线(10,30)·15m' },
],
}
/* ========== CTA 回测结果 ========== */
export const ctaMetricsMock = {
relative_metrics: {
total_return: 0.3842,
annual_return: 0.3125,
alpha: 0.0821,
beta: 0.7128,
sharpe_ratio: 1.4203,
sortino_ratio: 1.8841,
information_ratio: 0.9635,
annual_volatility: 0.1822,
max_drawdown: -0.1278,
benchmark_return: 0.0721,
benchmark_volatility: 0.1513,
},
statistics: {
total_return: '0.3842',
annual_return: '0.3125',
sharpe_ratio: '1.4203',
sortino_ratio: '1.8841',
max_drawdown: '-0.1278',
total_trade_count: '48',
win_rate: '0.5417',
avg_return: '0.0080',
max_dd_duration: '42',
daily_net_pnl: '0',
total_net_pnl: '384200',
total_commission: '4128',
total_slippage: '1820',
total_turnover: '0.84',
},
task_id: 'cta_7a92d1f6',
symbol: '600519.SH',
start: '2024-01-02',
end: '2024-06-28',
strategy: 'AShareDoubleMaStrategy',
params: { fast_window: 10, slow_window: 30, size: 100 },
status: 'done',
}
export const equityCurveMock = { equity_curve: genEquity(N) }
export const benchmarkCurveMock = (() => {
const e = genEquity(N)
const b = genBenchmark(N)
return { dates: e.map((p) => p.date), strategy: e.map((p) => p.balance), benchmark: b.map((p) => p.balance) }
})()
export const riskSeriesMock = genRisk(N)
export const dailyPnlMock = { daily_pnl: genDailyPnl(N) }
export const tradesMock = {
trades: [
{ datetime: '2024-01-05 09:30:00', direction: 'LONG', offset: 'OPEN', price: 1685.2, volume: 100, vt_symbol: '600519.SH' },
{ datetime: '2024-01-18 14:50:00', direction: 'LONG', offset: 'OPEN', price: 1702.5, volume: 100, vt_symbol: '600519.SH' },
{ datetime: '2024-02-08 09:35:00', direction: 'SHORT', offset: 'CLOSE', price: 1748.0, volume: 200, vt_symbol: '600519.SH' },
{ datetime: '2024-03-04 09:30:00', direction: 'LONG', offset: 'OPEN', price: 1710.6, volume: 100, vt_symbol: '600519.SH' },
{ datetime: '2024-04-01 10:15:00', direction: 'SHORT', offset: 'CLOSE', price: 1756.3, volume: 100, vt_symbol: '600519.SH' },
{ datetime: '2024-04-22 09:30:00', direction: 'LONG', offset: 'OPEN', price: 1632.8, volume: 100, vt_symbol: '600519.SH' },
{ datetime: '2024-05-20 13:40:00', direction: 'SHORT', offset: 'CLOSE', price: 1718.9, volume: 100, vt_symbol: '600519.SH' },
{ datetime: '2024-06-12 09:30:00', direction: 'LONG', offset: 'OPEN', price: 1508.2, volume: 100, vt_symbol: '600519.SH' },
],
}
export const klineMock = {
kline: genDates('2024-01-02', 60).map((date, i) => ({
datetime: date,
open: 1680 + Math.sin(i / 5) * 30 + i * 0.4,
high: 1690 + Math.sin(i / 5) * 30 + i * 0.4,
low: 1670 + Math.sin(i / 5) * 30 + i * 0.4,
close: 1685 + Math.sin(i / 5) * 30 + i * 0.4,
volume: 1200000 + Math.sin(i / 3) * 400000,
})),
}
export const optResultsMock = {
results: [
{ params: { fast_window: 5, slow_window: 20 }, statistics: { sharpe_ratio: 1.12, total_return: 0.28, max_drawdown: -0.15 } },
{ params: { fast_window: 5, slow_window: 30 }, statistics: { sharpe_ratio: 1.35, total_return: 0.33, max_drawdown: -0.14 } },
{ params: { fast_window: 10, slow_window: 20 }, statistics: { sharpe_ratio: 1.48, total_return: 0.37, max_drawdown: -0.12 } },
{ params: { fast_window: 10, slow_window: 30 }, statistics: { sharpe_ratio: 1.42, total_return: 0.38, max_drawdown: -0.13 } },
{ params: { fast_window: 10, slow_window: 40 }, statistics: { sharpe_ratio: 1.51, total_return: 0.41, max_drawdown: -0.11 } },
{ params: { fast_window: 15, slow_window: 30 }, statistics: { sharpe_ratio: 1.36, total_return: 0.35, max_drawdown: -0.14 } },
{ params: { fast_window: 20, slow_window: 60 }, statistics: { sharpe_ratio: 1.23, total_return: 0.30, max_drawdown: -0.16 } },
],
}
export const logMock = { log: '[2024-06-28 09:30:00] load_data bars=117\n[09:30:01] run_backtesting start\n[09:30:04] run_backtesting done\n[09:30:04] calculate_result shape=(117,25)\n[09:30:04] compute_metrics 27ms\n[09:30:04] write metrics.json done\n' }
/* ========== 模拟盘 paper ========== */
export const paperListMock = {
accounts: [
{ id: 1, name: '茅台双均线·回放', mode: 'replay', interval: 'd', status: 'running', symbols: '600519.SH', initial_capital: 1000000, start_date: '2024-01-02', end_date: '2024-06-28', last_run_date: '2024-06-28', next_run_at: null, checkpoint_date: '2024-06-27', error_msg: null, latest_equity: 1384200, latest_date: '2024-06-28', total_return: 0.3842 },
{ id: 2, name: '15分钟动量·实走', mode: 'live', interval: '15m', status: 'running', symbols: '000858.SZ,002594.SZ', initial_capital: 500000, start_date: '2024-05-01', end_date: null, last_run_date: '2024-08-12', next_run_at: '2024-08-13 15:05', checkpoint_date: '2024-08-12', error_msg: null, latest_equity: 521800, latest_date: '2024-08-12', total_return: 0.0436 },
{ id: 3, name: '全天候组合·实走', mode: 'live', interval: 'd', status: 'pending', symbols: '中证1000池', initial_capital: 1000000, start_date: '2024-03-01', end_date: null, last_run_date: '2024-08-11', next_run_at: '2024-08-13 15:05', checkpoint_date: '2024-08-11', error_msg: null, latest_equity: 1046200, latest_date: '2024-08-11', total_return: 0.0462 },
{ id: 4, name: '布林通道测试', mode: 'replay', interval: 'd', status: 'failed', symbols: '300750.SZ', initial_capital: 300000, start_date: '2024-06-01', end_date: '2024-08-01', last_run_date: '2024-08-01', next_run_at: null, checkpoint_date: null, error_msg: 'load_data bars=0:区间无日线', latest_equity: null, latest_date: null, total_return: null },
],
}
export const paperDetailMock = paperListMock.accounts[0]
export const paperEquityMock = genPaperEquity(120)
export const paperTradesMock = tradesMock.trades.map((t, i) => ({
strategy_id: 'doublema_' + (i % 2),
symbol: '600519.SH',
direction: t.direction,
price: t.price,
volume: t.volume,
commission: Math.round(t.price * t.volume * 0.0003),
rejected: 0,
reject_reason: '',
bar_date: t.datetime.slice(0, 10),
}))
export const paperStrategiesMock = [
{ strategy_id: 'doublema_0', total_orders: 32, filled: 28, rejected: 4, commission: 1280 },
{ strategy_id: 'boll_1', total_orders: 16, filled: 16, rejected: 0, commission: 720 },
]
export const paperPositionsMock = [
{ symbol: '600519.SH', volume: 200, frozen: 0, avg_price: 1689.3 },
{ symbol: '000858.SZ', volume: 500, frozen: 0, avg_price: 168.2 },
]
export const paperPendingMock = [
{ strategy_id: 'doublema_0', symbol: '002594.SZ', side: 'BUY', price: 0, volume: 300, is_market: true, match_session: 'next_open', listing_days: 1200 },
]
/* ========== 实盘 live ========== */
export const liveListMock = {
accounts: [
{ id: 11, name: '茅台 miniQMT', account: '8888001100', vt_symbol: '600519.SH', strategy_class: 'AShareDoubleMaStrategy', strategy_name: '双均线', setting: '{"fast_window":10,"slow_window":30,"size":100}', status: 'running', interval: 'd', initial_capital: 1000000, connect_wait_sec: 15, init_wait_sec: 8, mini_path: 'D:\\miniQMTuserdata', error_msg: null, created_at: '2024-07-01 10:00:00', updated_at: '2024-08-12 15:05:00', latest_equity: 1384200, latest_date: '2024-08-12', total_return: 0.3842, position_count: 2 },
{ id: 12, name: '宁德 miniQMT', account: '8888001101', vt_symbol: '300750.SZ', strategy_class: 'AShareBollStrategy', strategy_name: '布林通道', setting: '{"boll_window":20,"boll_dev":2.0,"size":100}', status: 'stopped', interval: 'd', initial_capital: 500000, connect_wait_sec: 15, init_wait_sec: 8, mini_path: 'D:\\miniQMTuserdata', error_msg: null, created_at: '2024-07-10 14:00:00', updated_at: '2024-08-10 15:05:00', latest_equity: 485200, latest_date: '2024-08-10', total_return: -0.0296, position_count: 1 },
{ id: 13, name: '五粮液 miniQMT', account: '8888001102', vt_symbol: '000858.SZ', strategy_class: 'AShareAtrStrategy', strategy_name: 'ATR突破', setting: '{"atr_window":14,"atr_multiplier":3.0,"size":100}', status: 'error', interval: 'd', initial_capital: 300000, connect_wait_sec: 15, init_wait_sec: 8, mini_path: 'D:\\miniQMTuserdata', error_msg: 'xtquant connect timeout (mini_path 未登录)', created_at: '2024-08-01 09:00:00', updated_at: '2024-08-12 09:32:00', latest_equity: null, latest_date: null, total_return: null, position_count: 0 },
],
}
export const liveDetailMock = liveListMock.accounts[0]
export const livePositionsMock = [
{ symbol: '600519.SH', volume: 200, frozen: 0, avg_price: 1689.3, updated_at: '2024-08-12 15:00:00' },
{ symbol: '000858.SZ', volume: 500, frozen: 0, avg_price: 168.2, updated_at: '2024-08-12 15:00:00' },
]
export const liveTradesMock = tradesMock.trades.slice(0, 5).map((t, i) => ({
account_id: 11, strategy_name: '双均线', symbol: '600519.SH', direction: t.direction, offset: t.offset, price: t.price, volume: t.volume, traded_at: t.datetime, vt_tradeid: 'xt_' + i,
}))
export const liveAccountMock = { account_id: 11, date: '2024-08-12', cash: 612400, market_value: 771800, total: 1384200 }
export const liveStatusMock = { account_id: 11, status: 'running', name: '茅台 miniQMT', account: '8888001100', vt_symbol: '600519.SH', strategy_name: '双均线', updated_at: '2024-08-12 15:05:00', error_msg: null }
/* ========== 组合回测 portfolio ========== */
export const portfolioResultMock = {
strategy: 'AllWeatherStrategy',
period: { start: '2022-01-04', end: '2024-06-28', trading_days: 596 },
stocks_selected: [
{ code: '002594.SZ', name: '比亚迪', amount: 200, avg_cost: 245.3, price: 268.5, value: 53700 },
{ code: '300750.SZ', name: '宁德时代', amount: 50, avg_cost: 198.4, price: 212.6, value: 10630 },
{ code: '600519.SH', name: '贵州茅台', amount: 10, avg_cost: 1689.3, price: 1718.9, value: 17189 },
{ code: '000858.SZ', name: '五粮液', amount: 300, avg_cost: 168.2, price: 158.4, value: 47520 },
{ code: '601012.SH', name: '隆基绿能', amount: 800, avg_cost: 32.1, price: 28.6, value: 22880 },
],
trades: tradesMock.trades.slice(0, 6).map((t, i) => ({ datetime: t.datetime, code: '600519.SH', side: i % 2 === 0 ? 'BUY' : 'SELL', amount: 100, filled_amount: 100, price: t.price, filled_price: t.price, commission: 18.5, status: 'FILLED' })),
equity_curve: genEquity(180, { from: '2022-01-04' }).map((p) => ({ date: p.date, equity: p.balance })),
metrics: {
total_return: 0.1523, annual_return: 0.0582, max_drawdown: -0.168, sharpe: 0.78,
win_rate_daily: 0.521, win_rate_trade: 0.483, trading_days: 596,
},
}
/* ========== 策略配置(CRUD ========== */
export const strategyConfigsMock = {
strategies: [
{ id: 1, name: '茅台双均线(10,30)', strategy_class: 'AShareDoubleMaStrategy', params: { fast_window: 10, slow_window: 30, size: 100 }, symbol_or_pool: '600519.SH', benchmark: 'hs300', interval: 'd', remark: '茅台日线双均线', created_at: '2024-07-01', updated_at: '2024-08-10' },
{ id: 2, name: '宁德布林(20,2)', strategy_class: 'AShareBollStrategy', params: { boll_window: 20, boll_dev: 2, size: 100 }, symbol_or_pool: '300750.SZ', benchmark: 'zz500', interval: 'd', remark: '宁德时代布林通道', created_at: '2024-07-15', updated_at: '2024-08-09' },
{ id: 3, name: '全天候组合', strategy_class: 'AllWeatherStrategy', params: { rebalance_day: 1, top_n: 5 }, symbol_or_pool: '中证1000池', benchmark: 'zz500', interval: 'd', remark: '组合主线·月度调仓', created_at: '2024-06-01', updated_at: '2024-08-12' },
{ id: 4, name: '五粮液ATR突破', strategy_class: 'AShareAtrStrategy', params: { atr_window: 14, atr_multiplier: 3, size: 100 }, symbol_or_pool: '000858.SZ', benchmark: 'hs300', interval: 'd', remark: '', created_at: '2024-08-01', updated_at: '2024-08-11' },
],
}
/* ========== 策略代码文件(在线编辑 mock ========== */
const DOUBLE_MA_CODE = `"""双均线策略(A股适配 · 基于 vnpy StrategyTemplate)。
fast_window 周期均线上穿 slow_window 周期均线 → 买入;
下穿 → 卖出。定寸 size 股下单(A股 100 股一手)。
"""
from typing import List
from vnpy_ctastrategy import StrategyTemplate
from vnpy.trader.object import BarData
class DoubleMaStrategy(StrategyTemplate):
"""双均线 CTA 策略。"""
author = "sanguo"
fast_window = 10
slow_window = 30
size = 100
parameters = ["fast_window", "slow_window", "size"]
variables = ["fast_ma", "slow_ma"]
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
self.fast_ma = 0.0
self.slow_ma = 0.0
def on_init(self) -> None:
self.write_log("策略初始化")
self.load_bar(self.slow_window)
def on_bar(self, bar: BarData) -> None:
am = self.am
am.update_bar(bar)
if not am.inited:
return
self.fast_ma = am.sma(self.fast_window, array=True)[-1]
self.slow_ma = am.sma(self.slow_window, array=True)[-1]
cross_over = self.fast_ma > self.slow_ma
cross_below = self.fast_ma < self.slow_ma
if cross_over and self.pos == 0:
self.buy(bar.close_price + 0.01, self.size)
elif cross_below and self.pos > 0:
self.sell(bar.close_price - 0.01, self.size)
self.put_event()
`
const ALL_WEATHER_CODE = `"""聚宽"全天候轮动"策略 · BulletTrade 框架移植。
选股 + 大小盘轮动 + 海外 ETF 兜底 + 涨停盯盘,月度调仓。
数据/下单全部走注入的 provider 和 broker_facade(聚宽风格 API)。
"""
import logging
import numpy as np
from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class AllWeatherConfig:
stock_num: int = 3 # 持仓数
trend_window: int = 10 # 趋势涨幅窗口
trend_threshold: float = 10.0 # "无敌好行情"阈值
stop_loss_pct: float = 0.92 # 止损线
benchmark: str = "000300.XSHG"
class AllWeatherStrategy:
"""全天候轮动(组合主线策略)。"""
def __init__(self, provider: Any, broker: Any = None, config: Optional[AllWeatherConfig] = None):
self.provider = provider
self.broker = broker
self.config = config or AllWeatherConfig()
self.hold_list: List[str] = []
def initialize(self, context: Any) -> None:
b = self.broker
b.set_benchmark(self.config.benchmark)
b.run_daily(self.prepare_stock_list, "9:05")
b.run_monthly(self.monthly_adjustment, 1, "9:30")
b.run_daily(self.stop_loss, "14:00")
def monthly_adjustment(self, context: Any) -> None:
"""月度调仓:大小盘轮动 + 选股 + 调仓下单(详见完整源码)。"""
cfg = self.config
b_stocks = self._stock_pool("000300.XSHG", context.previous_date)
target = self._pick_big_universe(b_stocks, context)
for code in target:
self.broker.order_target_value(code, 0) # 占位:实际按 target 等权
logger.info("monthly_adjustment target=%s", target)
def stop_loss(self, context: Any) -> None:
"""持仓跌幅超 stop_loss_pct 止损。"""
pass # 详见完整实现
`
const BOLL_CODE = `"""布林带通道策略(A股)。价格突破上轨买入,跌破下轨卖出。"""
from vnpy_ctastrategy import StrategyTemplate
class BollChannelStrategy(StrategyTemplate):
author = "sanguo"
boll_window = 20
boll_dev = 2.0
size = 100
parameters = ["boll_window", "boll_dev", "size"]
variables = ["boll_up", "boll_down"]
def on_init(self):
self.load_bar(self.boll_window)
def on_bar(self, bar):
am = self.am
am.update_bar(bar)
if not am.inited:
return
self.boll_up, self.boll_down = am.boll(self.boll_window, self.boll_dev)
if am.close > self.boll_up and self.pos == 0:
self.buy(bar.close_price, self.size)
elif am.close < self.boll_down and self.pos > 0:
self.sell(bar.close_price, self.size)
`
const ATR_CODE = `"""ATR 通道突破策略(A股)。基于 ATR 的动态通道突破。"""
from vnpy_ctastrategy import StrategyTemplate
class AtrRsiStrategy(StrategyTemplate):
author = "sanguo"
atr_window = 14
atr_multiplier = 3.0
size = 100
parameters = ["atr_window", "atr_multiplier", "size"]
variables = ["atr_value", "entry_price"]
def on_init(self):
self.load_bar(self.atr_window + 5)
def on_bar(self, bar):
am = self.am
am.update_bar(bar)
if not am.inited:
return
self.atr_value = am.atr(self.atr_window)
entry = am.close[-2] + self.atr_value * self.atr_multiplier
if am.close > entry and self.pos == 0:
self.buy(entry, self.size)
self.entry_price = entry
`
export interface StrategyFile {
name: string
dir: string
class_name: string
type: 'portfolio' | 'cta'
lines: number
modified: string
code: string
}
export const strategyFilesMock: { files: StrategyFile[] } = {
files: [
{ name: 'all_weather.py', dir: 'sanguo_portfolio/strategies/', class_name: 'AllWeatherStrategy', type: 'portfolio', lines: 575, modified: '2024-08-12 14:30', code: ALL_WEATHER_CODE },
{ name: 'momentum_timing.py', dir: 'sanguo_portfolio/strategies/', class_name: 'MomentumTimingStrategy', type: 'portfolio', lines: 412, modified: '2024-08-10 10:20', code: ALL_WEATHER_CODE },
{ name: 'double_ma.py', dir: 'sanguo_live/strategies/', class_name: 'AShareDoubleMaStrategy', type: 'cta', lines: 68, modified: '2024-07-20 09:15', code: DOUBLE_MA_CODE },
{ name: 'boll_channel.py', dir: 'sanguo_live/strategies/', class_name: 'AShareBollStrategy', type: 'cta', lines: 32, modified: '2024-07-22 10:00', code: BOLL_CODE },
{ name: 'atr_breakout.py', dir: 'sanguo_live/strategies/', class_name: 'AShareAtrStrategy', type: 'cta', lines: 30, modified: '2024-08-01 11:20', code: ATR_CODE },
],
}
/* ========== 策略实例(实例层:代码下的参数变体,每实例一键 4 运行) ========== */
export interface StrategyInstance {
id: number
code_file: string
name: string
type: 'portfolio' | 'cta'
params: Record<string, unknown>
symbol_or_pool: string
interval: string
match_session: string
status: { backtest: string; replay: string; paper_live: string; live: string }
last_return: number | null
updated_at: string
}
export const strategyInstancesMock: { instances: StrategyInstance[] } = {
instances: [
{ id: 1, code_file: 'all_weather.py', name: '全天候·HS300·月调仓', type: 'portfolio', params: { stock_num: 3, trend_window: 10 }, symbol_or_pool: 'HS300', interval: 'd', match_session: 'next_open', status: { backtest: 'done', replay: 'done', paper_live: 'running', live: '-' }, last_return: 0.1523, updated_at: '2024-08-12' },
{ id: 2, code_file: 'all_weather.py', name: '全天候·中证1000·周调仓', type: 'portfolio', params: { stock_num: 5, trend_window: 5 }, symbol_or_pool: '中证1000', interval: 'd', match_session: 'next_open', status: { backtest: 'done', replay: '-', paper_live: '-', live: '-' }, last_return: 0.082, updated_at: '2024-08-10' },
{ id: 3, code_file: 'double_ma.py', name: '茅台双均线(10,30)·15m', type: 'cta', params: { fast_window: 10, slow_window: 30, size: 100 }, symbol_or_pool: '600519.SH', interval: '15m', match_session: 'next_open', status: { backtest: 'done', replay: 'done', paper_live: 'running', live: 'running' }, last_return: 0.3842, updated_at: '2024-08-12' },
{ id: 4, code_file: 'double_ma.py', name: '五粮液双均线(5,20)·日线', type: 'cta', params: { fast_window: 5, slow_window: 20, size: 100 }, symbol_or_pool: '000858.SZ', interval: 'd', match_session: 'current_close', status: { backtest: 'done', replay: '-', paper_live: '-', live: '-' }, last_return: -0.052, updated_at: '2024-08-08' },
{ id: 5, code_file: 'boll_channel.py', name: '宁德布林(20,2)·15m', type: 'cta', params: { boll_window: 20, boll_dev: 2, size: 100 }, symbol_or_pool: '300750.SZ', interval: '15m', match_session: 'next_open', status: { backtest: 'done', replay: '-', paper_live: '-', live: 'stopped' }, last_return: -0.0296, updated_at: '2024-08-10' },
],
}
/* ========== 因子 factor ========== */
export const factorListMock = {
factors: [
{ name: '动量因子 MOM20', category: '动量' },
{ name: '换手率 TURN5', category: '量价' },
{ name: '波动率 VOL20', category: '波动' },
{ name: '市值 SIZE', category: '风格' },
{ name: 'ROE_TTM', category: '质量' },
{ name: '毛利率 GROSS', category: '质量' },
],
}
export const icSummaryMock = {
ic_summary: {
MOM20: { IC_mean: 0.042, ICIR: 0.51, t_stat: 3.21, positive_ratio: 0.58 },
TURN5: { IC_mean: -0.028, ICIR: -0.33, t_stat: -2.05, positive_ratio: 0.41 },
VOL20: { IC_mean: -0.035, ICIR: -0.42, t_stat: -2.6, positive_ratio: 0.38 },
},
}
/* ========== 任务状态(progress 轮询) ========== */
export const taskStatusDoneMock = { task_id: 'cta_7a92d1f6', status: 'done', stage: 'compute_metrics' }
export const taskStatusRunningMock = { task_id: 'cta_running01', status: 'running', stage: 'run_backtesting' }
+97
View File
@@ -0,0 +1,97 @@
/* mock 路由匹配器 —— 按 method + URL 正则返回假数据
* 仅在后端完全不可达(无 HTTP response)时由 client 兜底调用;
* 连真实后端时(含 4xx/5xx)不触发,真实错误照常暴露。
* 样板阶段用于让每个页面都有内容可看。接真实后端时无需改这里。 */
import * as D from './data'
export const MOCK_ON = true
type Builder = (url: string) => unknown
interface Route {
m: string
re: RegExp
build: Builder
}
const routes: Route[] = [
// strategy
{ m: 'get', re: /^\/strategy\/list$/, build: () => D.strategiesMock },
// strategy instances(实例层 · 代码下参数变体)
{ m: 'get', re: /^\/strategy\/instances$/, build: () => D.strategyInstancesMock },
{ m: 'get', re: /^\/strategy\/instances\/[^/]+$/, build: (url) => { const id = Number(url.split('/')[3]); const inst = D.strategyInstancesMock.instances.find((i) => i.id === id); return { instance: inst ?? D.strategyInstancesMock.instances[0] } } },
{ m: 'post', re: /^\/strategy\/instances$/, build: () => ({ id: 99 }) },
{ m: 'delete', re: /^\/strategy\/instances\/[^/]+$/, build: () => ({ ok: true }) },
// strategy code files(在线编辑)
{ m: 'get', re: /^\/strategy\/files$/, build: () => D.strategyFilesMock },
{ m: 'post', re: /^\/strategy\/file\/[^/]+$/, build: () => ({ ok: true }) },
// strategy configsCRUD
{ m: 'get', re: /^\/strategy\/configs$/, build: () => D.strategyConfigsMock },
{ m: 'post', re: /^\/strategy\/configs$/, build: () => ({ id: 99 }) },
{ m: 'put', re: /^\/strategy\/configs\/[^/]+$/, build: () => ({ ok: true }) },
{ m: 'delete', re: /^\/strategy\/configs\/[^/]+$/, build: () => ({ ok: true }) },
// factor
{ m: 'get', re: /^\/factor\/list$/, build: () => D.factorListMock },
{ m: 'get', re: /^\/task\/[^/]+\/ic-summary$/, build: () => D.icSummaryMock },
{ m: 'post', re: /^\/factor\/analyze$/, build: () => ({ task_id: 'fc_mock_submit' }) },
{
m: 'get',
re: /^\/strategy\/[^/]+\/params$/,
build: (url) => D.strategyParamsMock[decodeURIComponent(url.split('/')[2])] ?? { parameters: [], defaults: {} },
},
// task
{ m: 'get', re: /^\/task$/, build: () => D.taskListMock },
{ m: 'get', re: /^\/task\/[^/]+$/, build: () => D.taskStatusDoneMock },
{ m: 'get', re: /^\/task\/[^/]+\/result$/, build: () => D.ctaMetricsMock },
{ m: 'get', re: /^\/task\/[^/]+\/equity-curve$/, build: () => D.equityCurveMock },
{ m: 'get', re: /^\/task\/[^/]+\/benchmark-curve$/, build: () => D.benchmarkCurveMock },
{ m: 'get', re: /^\/task\/[^/]+\/risk-series$/, build: () => D.riskSeriesMock },
{ m: 'get', re: /^\/task\/[^/]+\/daily-pnl$/, build: () => D.dailyPnlMock },
{ m: 'get', re: /^\/task\/[^/]+\/trades$/, build: () => D.tradesMock },
{ m: 'get', re: /^\/task\/[^/]+\/log$/, build: () => D.logMock },
{ m: 'get', re: /^\/task\/[^/]+\/optimization-results$/, build: () => D.optResultsMock },
// kline
{ m: 'get', re: /^\/kline$/, build: () => D.klineMock },
// paper
{ m: 'get', re: /^\/paper$/, build: () => D.paperListMock },
{ m: 'get', re: /^\/paper\/[^/]+$/, build: () => D.paperDetailMock },
{ m: 'get', re: /^\/paper\/[^/]+\/equity$/, build: () => D.paperEquityMock },
{ m: 'get', re: /^\/paper\/[^/]+\/trades$/, build: () => D.paperTradesMock },
{ m: 'get', re: /^\/paper\/[^/]+\/strategies$/, build: () => D.paperStrategiesMock },
{ m: 'get', re: /^\/paper\/[^/]+\/positions$/, build: () => D.paperPositionsMock },
{ m: 'get', re: /^\/paper\/[^/]+\/pending$/, build: () => D.paperPendingMock },
// live
{ m: 'get', re: /^\/live$/, build: () => D.liveListMock },
{ m: 'get', re: /^\/live\/[^/]+$/, build: () => D.liveDetailMock },
{ m: 'get', re: /^\/live\/[^/]+\/positions$/, build: () => D.livePositionsMock },
{ m: 'get', re: /^\/live\/[^/]+\/trades$/, build: () => D.liveTradesMock },
{ m: 'get', re: /^\/live\/[^/]+\/account$/, build: () => D.liveAccountMock },
{ m: 'get', re: /^\/live\/[^/]+\/status$/, build: () => D.liveStatusMock },
// portfolio
{ m: 'get', re: /^\/portfolio\/task\/[^/]+\/result$/, build: () => D.portfolioResultMock },
// POST 写操作 → 成功响应(样板)
{ m: 'post', re: /^\/auth\/login$/, build: () => ({ token: 'mock-token-sanguo-terminal' }) },
{ m: 'post', re: /^\/backtest\/cta$/, build: () => ({ task_id: 'cta_mock_submit' }) },
{ m: 'post', re: /^\/backtest\/optimize$/, build: () => ({ task_id: 'opt_mock_submit' }) },
{ m: 'post', re: /^\/portfolio\/backtest$/, build: () => ({ task_id: 'pf_mock_submit' }) },
{ m: 'post', re: /^\/paper\/create$/, build: () => ({ account_id: 99 }) },
{ m: 'post', re: /^\/live\/create$/, build: () => ({ account_id: 99, status: 'stopped' }) },
{
m: 'post',
re: /^\/live\/[^/]+\/start$/,
build: (url) => ({ account_id: Number(url.split('/')[2]), status: 'running' }),
},
{
m: 'post',
re: /^\/live\/[^/]+\/stop$/,
build: (url) => ({ account_id: Number(url.split('/')[2]), status: 'stopped' }),
},
]
export function matchMock(method: string, url: string): unknown | undefined {
if (!MOCK_ON) return undefined
const m = method.toLowerCase()
for (const r of routes) {
if (r.m === m && r.re.test(url)) return r.build(url)
}
return undefined
}
+86
View File
@@ -0,0 +1,86 @@
/* 时序假数据生成器 —— 确定性(基于 index 的函数),刷新不变,图表稳定 */
/** 从 start 起生成 n 个交易日(跳周末) */
export function genDates(start: string, n: number): string[] {
const d = new Date(start + 'T00:00:00')
const out: string[] = []
let guard = 0
while (out.length < n && guard < n * 2 + 20) {
const day = d.getDay()
if (day !== 0 && day !== 6) out.push(d.toISOString().slice(0, 10))
d.setDate(d.getDate() + 1)
guard++
}
return out
}
/** 策略净值:趋势 + 双频波动 + 周期回撤 */
export function genEquity(n: number, opts?: { start?: number; drift?: number; from?: string }):
{ date: string; balance: number }[] {
const start = opts?.start ?? 1
const drift = opts?.drift ?? 0.0009
const dates = genDates(opts?.from ?? '2024-01-02', n)
let v = start
return dates.map((date, i) => {
const wave = Math.sin(i / 8) * 0.006 + Math.sin(i / 21) * 0.004
const dd = i % 55 < 14 ? -0.004 * Math.sin(((i % 55) / 14) * Math.PI) : 0
v = v * (1 + drift + wave + dd)
return { date, balance: Number(v.toFixed(4)) }
})
}
/** 基准:弱趋势(跑输策略) */
export function genBenchmark(n: number, from = '2024-01-02'): { date: string; balance: number }[] {
const dates = genDates(from, n)
let v = 1
return dates.map((date, i) => {
v = v * (1 + 0.0003 + Math.sin(i / 13) * 0.004)
return { date, balance: Number(v.toFixed(4)) }
})
}
/** 每日盈亏(基点,偏正) */
export function genDailyPnl(n: number): { date: string; pnl: number }[] {
const eq = genEquity(n)
return eq.map((p, i) => {
const prev = i === 0 ? 1 : eq[i - 1].balance
return { date: p.date, pnl: Number(((p.balance - prev) * 10000).toFixed(0)) }
})
}
/** alpha/beta/drawdown/vol 时间序列 */
export function genRisk(n: number, from = '2024-01-02') {
const dates = genDates(from, n)
const eq = genEquity(n, { from }).map((p) => p.balance)
let runMax = 0
const drawdown = eq.map((v) => {
runMax = Math.max(runMax, v)
return Number((((v - runMax) / runMax) * 100).toFixed(2))
})
return {
dates,
alpha: dates.map((_, i) => Number((0.08 + Math.sin(i / 17) * 0.05).toFixed(3))),
beta: dates.map((_, i) => Number((0.72 + Math.sin(i / 25) * 0.12).toFixed(3))),
drawdown,
strategy_vol: dates.map((_, i) => Number((0.18 + Math.sin(i / 11) * 0.03).toFixed(3))),
benchmark_vol: dates.map((_, i) => Number((0.15 + Math.sin(i / 13) * 0.02).toFixed(3))),
}
}
/** 模拟盘/实盘净值(含 cash/market_value/总权益,带 checkpoint 标记) */
export function genPaperEquity(n: number): {
date: string; cash: number; market_value: number; total_equity: number; is_checkpoint: number
}[] {
const eq = genEquity(n, { start: 1000000, drift: 0.0006 })
return eq.map((p, i) => {
const total = p.balance * 1000000
const mv = total * (0.55 + Math.sin(i / 9) * 0.12)
return {
date: p.date,
cash: Number((total - mv).toFixed(0)),
market_value: Number(mv.toFixed(0)),
total_equity: Number(total.toFixed(0)),
is_checkpoint: i % 15 === 0 ? 1 : 0,
}
})
}
+6
View File
@@ -9,6 +9,12 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/Dashboard.vue') },
{ path: 'strategy', name: 'strategy-list', component: () => import('@/views/strategy/List.vue') },
{ path: 'strategy/new', name: 'strategy-new', component: () => import('@/views/strategy/Edit.vue') },
{ path: 'strategy/code', name: 'strategy-code', component: () => import('@/views/strategy/Code.vue') },
{ path: 'strategy/instance/new', name: 'instance-new', component: () => import('@/views/strategy/InstanceEdit.vue') },
{ path: 'strategy/instance/:id', name: 'instance-edit', component: () => import('@/views/strategy/InstanceEdit.vue') },
{ path: 'account', name: 'account', component: () => import('@/views/account/List.vue') },
{ path: 'backtest/new', name: 'bt-new', component: () => import('@/views/backtest/New.vue') },
{ path: 'backtest/portfolio', name: 'bt-portfolio', component: () => import('@/views/backtest/PortfolioBacktest.vue') },
{ path: 'backtest/progress/:id', name: 'bt-progress', component: () => import('@/views/backtest/Progress.vue') },
+83 -30
View File
@@ -1,63 +1,77 @@
/* 全局 chip / 状态色 —— 所有页面复用(红涨绿跌 A 股配色,深色主题) */
/* 全局 chip / 状态色 —— 科幻终端(荧光青+琥珀+红涨绿跌,深色主题) */
.chip {
display: inline-block;
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
border-radius: var(--r-sm);
font-size: 11.5px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.3px;
white-space: nowrap;
font-family: var(--mono);
border: 1px solid transparent;
}
/* 任务类型 */
.chip-cta {
color: var(--brand);
background: rgba(24, 144, 255, 0.14);
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
}
.chip-optimize {
color: #13c2c2;
background: rgba(19, 194, 194, 0.14);
color: var(--brand);
background: rgba(0, 229, 255, 0.06);
border-color: rgba(0, 229, 255, 0.2);
}
.chip-factor {
color: #b37feb;
background: rgba(179, 127, 235, 0.14);
color: #b48cff;
background: rgba(180, 140, 255, 0.1);
border-color: rgba(180, 140, 255, 0.3);
}
.chip-paper {
color: #d29922;
background: rgba(210, 153, 34, 0.14);
color: var(--amber);
background: rgba(255, 176, 0, 0.1);
border-color: rgba(255, 176, 0, 0.3);
}
.chip-portfolio {
color: var(--amber);
background: rgba(255, 176, 0, 0.08);
border-color: rgba(255, 176, 0, 0.25);
}
/* 状态 */
/* 状态(终端信号灯配色) */
.st-done {
color: var(--down);
background: rgba(63, 185, 80, 0.14);
color: var(--lamp-ok);
background: rgba(46, 230, 138, 0.1);
border-color: rgba(46, 230, 138, 0.3);
}
.st-failed {
color: var(--danger);
background: rgba(248, 81, 73, 0.14);
color: var(--lamp-crit);
background: rgba(255, 77, 94, 0.1);
border-color: rgba(255, 77, 94, 0.3);
}
.st-running {
color: var(--brand);
background: rgba(24, 144, 255, 0.14);
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
}
.st-pending {
color: var(--warn);
background: rgba(210, 153, 34, 0.14);
color: var(--lamp-warn);
background: rgba(255, 176, 0, 0.1);
border-color: rgba(255, 176, 0, 0.3);
}
/* 涨跌(红涨绿跌) */
.up {
color: var(--up);
}
.down {
color: var(--down);
}
.up { color: var(--up); }
.down { color: var(--down); }
/* 通用文字层级 */
.mono {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.muted {
color: var(--text-2);
@@ -70,13 +84,52 @@
margin-left: 8px;
padding: 0 8px;
min-width: 24px;
height: 22px;
line-height: 22px;
height: 20px;
line-height: 20px;
text-align: center;
font-size: 12px;
font-size: 11px;
font-weight: 600;
font-family: var(--mono);
color: var(--text-2);
background: var(--bg-hover);
border-radius: 11px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
vertical-align: middle;
}
/* 终端按钮 · 描边发光风(全站复用) */
.term-btn {
padding: 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-2);
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: 0.3px;
cursor: pointer;
transition: border-color 0.15s var(--ease), color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.term-btn:hover {
border-color: rgba(0, 229, 255, 0.5);
color: var(--brand);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.18);
}
.term-btn.primary {
background: var(--cyan-soft);
border-color: rgba(0, 229, 255, 0.5);
color: var(--brand);
font-weight: 600;
}
.term-btn.primary:hover {
background: rgba(0, 229, 255, 0.22);
border-color: var(--brand);
}
.term-btn.sm { padding: 3px 9px; font-size: 10.5px; }
.term-btn.danger { color: var(--lamp-crit); }
.term-btn.danger:hover {
border-color: rgba(255, 77, 94, 0.5);
color: var(--lamp-crit);
box-shadow: 0 0 12px rgba(255, 77, 94, 0.18);
}
.term-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+72 -42
View File
@@ -1,13 +1,13 @@
/* Element Plus 深色覆盖 —— html.dark 已启用 EP 官方深色,此处精修到量化主题 */
/* Element Plus 科幻终端覆盖 —— html.dark 下精修到荧光青 / 深空黑主题 */
html.dark {
/* 主色 → 品牌 */
--el-color-primary: #1890ff;
--el-color-primary-light-3: #2f7fce;
--el-color-primary-light-5: #1f5e9c;
--el-color-primary-light-7: #164670;
--el-color-primary-light-8: #113a5e;
--el-color-primary-light-9: #0e2f4d;
--el-color-primary-dark-2: #3a9eff;
/* 主色 → 荧光青(深色主题色阶越 light 越深,作 tint 底) */
--el-color-primary: #00e5ff;
--el-color-primary-light-3: #2a8095;
--el-color-primary-light-5: #1c5a6a;
--el-color-primary-light-7: #143f4a;
--el-color-primary-light-8: #0f3038;
--el-color-primary-light-9: #0a2228;
--el-color-primary-dark-2: #33eaff;
/* 背景层级 */
--el-bg-color: var(--bg-card);
@@ -19,7 +19,7 @@ html.dark {
--el-text-color-regular: var(--text-2);
--el-text-color-secondary: var(--text-3);
--el-text-color-placeholder: var(--text-3);
--el-text-color-disabled: #4b5158;
--el-text-color-disabled: #3a4654;
/* 边框 */
--el-border-color: var(--border);
@@ -39,39 +39,51 @@ html.dark {
--el-fill-color-blank: transparent;
/* 遮罩/禁用 */
--el-mask-color: rgba(1, 4, 9, 0.72);
--el-mask-color: rgba(2, 4, 9, 0.82);
--el-disabled-bg-color: var(--bg-card);
--el-disabled-text-color: var(--text-3);
--el-disabled-border-color: var(--border-2);
/* 语义色danger 用红、success 用绿,契合涨跌但保持语义) */
--el-color-success: #3fb950;
--el-color-warning: #d29922;
--el-color-danger: #f85149;
--el-color-error: #f85149;
--el-color-info: #8b949e;
/* 语义色 */
--el-color-success: #2ee68a;
--el-color-success-light-9: rgba(46, 230, 138, 0.12);
--el-color-warning: #ffb000;
--el-color-warning-light-9: rgba(255, 176, 0, 0.12);
--el-color-danger: #ff4d5e;
--el-color-danger-light-9: rgba(255, 77, 94, 0.12);
--el-color-error: #ff4d5e;
--el-color-info: #7a8a9a;
}
/* 组件细节精修 */
/* 组件细节精修 —— 科幻终端 */
html.dark {
/* 卡片更紧凑、去多余阴影 */
/* 卡片:去阴影,加顶部荧光高光线 */
.el-card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: none;
position: relative;
}
.el-card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 229, 255, 0.4), transparent);
opacity: 0.6;
}
.el-card__header {
padding: 12px 16px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 13px;
color: var(--text);
letter-spacing: 0.4px;
}
.el-card__body {
padding: 16px;
}
.el-card__body { padding: 16px; }
/* 表格密度 */
/* 表格密度 + 等宽数字 */
.el-table {
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
@@ -81,17 +93,17 @@ html.dark {
--el-table-border: 1px solid var(--border-2);
--el-table-text-color: var(--text);
--el-table-header-text-color: var(--text-2);
font-size: 13px;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
.el-table th.el-table__cell {
background-color: var(--bg-hover) !important;
font-weight: 600;
letter-spacing: 0.3px;
}
.el-table .cell {
line-height: 1.5;
}
.el-table .cell { line-height: 1.5; }
/* 菜单 */
/* 菜单:激活项荧光青 + 左侧发光指示条 */
.el-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--text-2);
@@ -99,38 +111,56 @@ html.dark {
--el-menu-active-color: var(--brand);
border-right: none;
}
.el-menu-item.is-active,
.el-sub-menu.is-active > .el-sub-menu__title {
.el-menu-item.is-active {
color: var(--brand);
background: var(--cyan-soft) !important;
box-shadow: inset 2px 0 0 var(--brand);
}
.el-sub-menu.is-active > .el-sub-menu__title { color: var(--brand); }
/* 输入/选择器贴主题 */
/* 输入/选择器:聚焦荧光发光 */
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
background-color: var(--bg) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
border-radius: var(--r-sm) !important;
}
.el-input__wrapper:hover,
.el-select__wrapper:hover {
box-shadow: 0 0 0 1px var(--brand) inset !important;
box-shadow: 0 0 0 1px var(--cyan-dim) inset !important;
}
.el-input.is-focus .el-input__wrapper,
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--brand) inset !important;
box-shadow: 0 0 0 1px var(--brand) inset, 0 0 12px rgba(0, 229, 255, 0.18) !important;
}
/* 描述列表/标签页 边框 */
.el-descriptions__border {
background-color: transparent;
/* 主按钮:荧光青描边发光风 */
.el-button--primary {
background: var(--cyan-soft);
border-color: rgba(0, 229, 255, 0.5);
color: var(--brand);
font-weight: 600;
letter-spacing: 0.3px;
}
.el-button--primary:hover {
background: rgba(0, 229, 255, 0.22);
border-color: var(--brand);
box-shadow: 0 0 14px rgba(0, 229, 255, 0.25);
}
/* 滚动条贴深色 */
.el-scrollbar__bar {
opacity: 0.5;
}
/* 标签页激活下划线荧光青 */
.el-tabs__active-bar { background-color: var(--brand); }
.el-tabs__item.is-active { color: var(--brand); }
.el-tabs__item:hover { color: var(--cyan-dim); }
/* 数字用等宽(涨跌色由业务层控制) */
/* 描述列表边框 */
.el-descriptions__border { background-color: transparent; }
/* 滚动条 */
.el-scrollbar__bar { opacity: 0.5; }
/* 数字等宽 */
.num {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
+135 -6
View File
@@ -1,4 +1,4 @@
/* 全局 reset —— 后台基础:深 / 紧凑 / 高数据密度 */
/* 全局 reset —— 科幻终端基础:深空黑 / 紧凑 / 高数据密度 */
*,
*::before,
*::after {
@@ -13,11 +13,12 @@ body,
body {
margin: 0;
background: var(--bg);
background-color: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
@@ -35,15 +36,139 @@ a {
color: var(--brand);
text-decoration: none;
}
a:hover { color: var(--cyan-dim); }
/* 深色滚动条 */
/* 深色滚动条 · 青调 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { background: #424a53; }
::-webkit-scrollbar-thumb:hover { background: rgba(0, 229, 255, 0.4); }
/* 选区荧光青 */
::selection {
background: rgba(0, 229, 255, 0.25);
color: var(--text);
}
/* ====== 科幻装饰工具类(页面复用) ====== */
/* 终端网格底 —— 给主内容区/面板叠一层 1px 青网格 */
.term-grid {
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
/* 信号灯 · 圆点 + 脉冲(配合 .lamp-ok/.lamp-warn/.lamp-crit/.lamp-idle */
.lamp {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
position: relative;
vertical-align: middle;
}
.lamp::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: inherit;
opacity: 0.35;
filter: blur(2px);
}
.lamp-ok { background: var(--lamp-ok); }
.lamp-warn { background: var(--lamp-warn); }
.lamp-crit { background: var(--lamp-crit); }
.lamp-idle { background: var(--lamp-idle); }
.lamp-pulse { animation: lamp-pulse 1.8s var(--ease) infinite; }
@keyframes lamp-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.lamp-pulse { animation: none; }
}
/* 几何角标 —— 面板四角的 L 形装饰 */
.term-corners {
position: relative;
}
.term-corners::before,
.term-corners::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
border-color: rgba(0, 229, 255, 0.5);
border-style: solid;
pointer-events: none;
}
.term-corners::before {
top: 0; left: 0;
border-width: 1px 0 0 1px;
}
.term-corners::after {
bottom: 0; right: 0;
border-width: 0 1px 1px 0;
}
/* 扫描线 —— 叠在监控/实时面板顶部,缓慢下扫 */
.term-scan {
position: relative;
overflow: hidden;
}
.term-scan::after {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 40%;
background: linear-gradient(180deg, rgba(0, 229, 255, 0.06), transparent);
animation: term-scan-move 4s linear infinite;
pointer-events: none;
}
@keyframes term-scan-move {
0% { transform: translateY(-100%); }
100% { transform: translateY(250%); }
}
@media (prefers-reduced-motion: reduce) {
.term-scan::after { animation: none; display: none; }
}
/* 终端面板基类 —— 带 1px 青色顶高光 + 角标 */
.term-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
position: relative;
}
.term-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px;
border-bottom: 1px solid var(--border);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--text-2);
text-transform: uppercase;
font-family: var(--mono);
}
.term-panel-head::before {
content: '';
display: inline-block;
width: 3px;
height: 12px;
background: var(--brand);
margin-right: 8px;
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
.term-panel-body { padding: 14px; }
/* ====== 布局工具类 ====== */
.page {
@@ -65,18 +190,22 @@ a {
font-size: 16px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.3px;
}
.page-subtitle {
font-size: 12px;
color: var(--text-3);
font-family: var(--mono);
}
.section-title {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.3px;
letter-spacing: 0.5px;
text-transform: uppercase;
font-family: var(--mono);
}
/* 涨跌色工具 */
+56 -31
View File
@@ -1,28 +1,53 @@
/* 量化后台设计 token —— 深色 / 高密度 / 红涨绿跌(A股惯例) */
/* 三国量化研究台 · 科幻终端设计 token
深空黑 + 荧光青 + 琥珀 · 高密度数据终端
换肤原则:只改值不改名,全站页面零改动即刻生效 */
:root {
/* 背景层级 */
--bg: #0d1117; /* 主背景 */
--bg-card: #161b22; /* 卡片/面板 */
--bg-hover: #1c2128; /* 悬停/表头 */
--bg-overlay: #1c2128; /* 弹层 */
/* —— 背景层级(深空黑,蓝调冷底)—— */
--bg: #05080d; /* 主背景 · 深空黑 */
--bg-card: #0a0f17; /* 卡片/面板 · 略亮冷蓝 */
--bg-hover: #0e1622; /* 悬停/表头 */
--bg-overlay: #0d1320; /* 弹层 */
--panel: #070b12; /* 嵌套面板 · 比主背景略深,多层叠 */
/* 文字 */
--text: #e6edf3; /* 主 */
--text-2: #8b949e; /* 次 */
--text-3: #6e7681; /* 弱/占位 */
/* —— 文字(冷调)—— */
--text: #d4e4f0; /* 主 · 冷白 */
--text-2: #7a8a9a; /* 次 */
--text-3: #4a5868; /* 弱/占位 */
/* 语义色:红涨绿跌 */
--up: #f85149; /* 涨/红 */
--down: #3fb950; /* 跌/绿 */
--brand: #1890ff; /* 品牌/链接/激活 */
--warn: #d29922;
--danger: #f85149;
/* —— 主强调:荧光青 —— */
--brand: #00e5ff; /* 品牌/链接/激活 · 荧光青 */
--cyan: #00e5ff; /* 语义别名 */
--cyan-dim: #00b8cc; /* 暗青(hover/次要激活 */
--cyan-soft: rgba(0, 229, 255, 0.14); /* 青色填充底 */
/* 边框 */
--border: #30363d;
--border-2: #21262d; /* 更弱的分隔线 */
/* —— 数据高亮:琥珀 —— */
--amber: #ffb000; /* 数据高亮/警告 */
--warn: #ffb000; /* 语义别名 */
--amber-soft: rgba(255, 176, 0, 0.14);
/* 间距 */
/* —— 涨跌(A股:红涨绿跌)—— */
--up: #ff4d5e; /* 涨 · 红 */
--down: #2ee68a; /* 跌 · 绿 */
--danger: #ff4d5e;
/* —— 信号灯(终端状态)—— */
--lamp-ok: #2ee68a; /* 运行/正常 · 绿 */
--lamp-warn: #ffb000; /* 告警 · 琥珀 */
--lamp-crit: #ff4d5e; /* 临界 · 红 */
--lamp-idle: #4a5868; /* 空闲 · 灰 */
/* —— 边框(冷调,低明度)—— */
--border: #16202e; /* 主边框 */
--border-2: #0f1822; /* 更弱分隔线 */
/* —— 科幻装饰 —— */
--grid-line: rgba(0, 229, 255, 0.035); /* 1px 网格底 */
--grid-size: 32px;
--scan: rgba(0, 229, 255, 0.05); /* 扫描线 */
--glow-cyan: 0 0 0 1px rgba(0, 229, 255, 0.22), 0 0 18px rgba(0, 229, 255, 0.08);
--glow-amber: 0 0 0 1px rgba(255, 176, 0, 0.22), 0 0 18px rgba(255, 176, 0, 0.08);
/* —— 间距 —— */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
@@ -30,22 +55,22 @@
--sp-5: 24px;
--sp-6: 32px;
/* 圆角 */
--r-sm: 4px;
--r-md: 6px;
--r-lg: 8px;
/* —— 圆角(终端:近直角)—— */
--r-sm: 2px;
--r-md: 3px;
--r-lg: 4px;
/* 阴影 */
--shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
/* —— 阴影 —— */
--shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
/* 字体 */
/* —— 字体 —— */
--mono: ui-monospace, 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
/* 布局 */
--sidebar-w: 208px;
--header-h: 52px;
/* —— 布局 —— */
--sidebar-w: 200px;
--header-h: 48px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
}
+16 -12
View File
@@ -1,32 +1,36 @@
/* ECharts 深色量化主题 —— 全站统一:深色轴线/文字/网格,红涨绿跌,净值品牌色 */
/* ECharts 科幻终端主题 —— 深空黑底 / 荧光青主轴 / 琥珀高亮 / 红涨绿跌 */
export const UP = '#f85149' // 红涨(A股惯例)
export const DOWN = '#3fb950' // 绿跌
export const BRAND = '#1890ff'
export const TEXT = '#e6edf3'
export const TEXT_DIM = '#8b949e'
export const AXIS = '#30363d'
export const SPLIT = '#21262d'
export const TOOLTIP_BG = '#1c2128'
export const UP = '#ff4d5e' // 红涨(A股惯例)
export const DOWN = '#2ee68a' // 绿跌
export const BRAND = '#00e5ff' // 荧光青(净值/主序列)
export const AMBER = '#ffb000' // 琥珀(高亮/超额)
export const TEXT = '#d4e4f0'
export const TEXT_DIM = '#7a8a9a'
export const AXIS = '#16202e'
export const SPLIT = '#0f1822'
export const TOOLTIP_BG = '#0a0f17'
/** 通用深色坐标轴片段(合并进 xAxis/yAxis */
export function darkAxis(): Record<string, unknown> {
return {
axisLine: { lineStyle: { color: AXIS } },
axisTick: { lineStyle: { color: AXIS } },
axisLabel: { color: TEXT_DIM, fontSize: 11 },
axisLabel: { color: TEXT_DIM, fontSize: 11, fontFamily: 'var(--mono)' },
splitLine: { lineStyle: { color: SPLIT } },
}
}
/** 通用深色 tooltip */
/** 通用深色 tooltip —— 荧光青边框 + 深空底 */
export function darkTooltip(): Record<string, unknown> {
return {
trigger: 'axis',
backgroundColor: TOOLTIP_BG,
borderColor: AXIS,
borderColor: 'rgba(0, 229, 255, 0.4)',
borderWidth: 1,
textStyle: { color: TEXT, fontSize: 12 },
axisPointer: {
lineStyle: { color: 'rgba(0, 229, 255, 0.5)', type: 'dashed' },
},
}
}
+4 -2
View File
@@ -47,7 +47,7 @@ function open(row: TaskListItem): void {
router.push(path)
}
const typeLabelMap: Record<string, string> = { cta: '回测', optimize: '优化', factor: '因子' }
const typeLabelMap: Record<string, string> = { cta: '回测', optimize: '优化', factor: '因子', portfolio: '组合' }
function typeLabel(t: string): string {
return typeLabelMap[t] || t
}
@@ -197,7 +197,9 @@ function statusLabel(s: string): string {
width: 36px;
height: 36px;
border-radius: var(--r-sm);
background: rgba(24, 144, 255, 0.14);
background: var(--cyan-soft);
border: 1px solid rgba(0, 229, 255, 0.3);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.15);
color: var(--brand);
font-weight: 700;
font-size: 13px;
+358 -61
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
@@ -7,16 +7,21 @@ const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
// 实走监控入口需要 aid,从模拟结果页进入;侧栏保持分组激活即可
const activeMenu = computed(() => {
const p = route.path
if (p.startsWith('/paper')) return p
return p
})
// 顶栏动态页名(按路由推断,替代静态口号占位)
const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/dashboard$/, group: '工作台', title: '工作台' },
{ match: /^\/strategy\/new$/, group: '策略', title: '新建策略' },
{ match: /^\/strategy\/code$/, group: '策略', title: '代码编辑' },
{ match: /^\/strategy\/instance\/new$/, group: '策略', title: '新建实例' },
{ match: /^\/strategy\/instance\//, group: '策略', title: '编辑实例' },
{ match: /^\/strategy\/edit\//, group: '策略', title: '编辑策略' },
{ match: /^\/strategy$/, group: '策略', title: '策略库' },
{ match: /^\/account$/, group: '账户', title: '账户管理' },
{ match: /^\/backtest\/new$/, group: '回测', title: '新建回测' },
{ match: /^\/backtest\/portfolio$/, group: '回测', title: '组合回测' },
{ match: /^\/backtest\/optimize$/, group: '回测', title: '参数优化' },
@@ -26,23 +31,104 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/factor\/new$/, group: '投研', title: '因子分析' },
{ match: /^\/factor\/progress\//, group: '投研', title: '因子分析进度' },
{ match: /^\/factor\/result\//, group: '投研', title: '因子分析结果' },
{ match: /^\/paper\/new$/, group: '模拟', title: '新建模拟盘' },
{ match: /^\/paper\/result\//, group: '模拟', title: '模拟盘结果' },
{ match: /^\/paper\/live\//, group: '模拟', title: '实走监控' },
{ match: /^\/paper$/, group: '模拟', title: '模拟交易' },
{ match: /^\/live\/new$/, group: '实盘模拟', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘模拟', title: '实盘监控' },
{ match: /^\/live$/, group: '实盘模拟', title: '实盘模拟' },
{ match: /^\/paper\/new$/, group: '模拟', title: '新建模拟盘' },
{ match: /^\/paper\/result\//, group: '模拟', title: '模拟盘结果' },
{ match: /^\/paper\/live\//, group: '模拟', title: '实走监控' },
{ match: /^\/paper$/, group: '模拟', title: '模拟' },
{ match: /^\/live\/new$/, group: '实盘', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘', title: '实盘监控' },
{ match: /^\/live$/, group: '实盘', title: '实盘' },
]
const pageMeta = computed(() => {
const p = route.path
return PAGE_TITLE.find((r) => r.match.test(p)) ?? { group: '', title: '控制台' }
})
/* —— 实时行情时钟 —— */
const clock = ref('--:--:--')
let timer = 0
const pad = (n: number): string => String(n).padStart(2, '0')
const tick = (): void => {
const d = new Date()
clock.value = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/* —— Cmd+K 命令面板 —— */
interface CmdItem { label: string; group: string; path: string }
const CMD_ITEMS: CmdItem[] = [
{ label: '工作台', group: '总览', path: '/dashboard' },
{ label: '策略库', group: '策略', path: '/strategy' },
{ label: '新建策略', group: '策略', path: '/strategy/new' },
{ label: '策略代码编辑', group: '策略', path: '/strategy/code' },
{ label: '账户管理', group: '账户', path: '/account' },
{ label: '新建回测', group: '回测', path: '/backtest/new' },
{ label: '组合回测', group: '回测', path: '/backtest/portfolio' },
{ label: '参数优化', group: '回测', path: '/backtest/optimize' },
{ label: '历史任务', group: '回测', path: '/backtest/history' },
{ label: '因子分析', group: '投研', path: '/factor/new' },
{ label: '模拟盘列表', group: '模拟', path: '/paper' },
{ label: '新建模拟盘', group: '模拟', path: '/paper/new' },
{ label: '实盘列表', group: '实盘', path: '/live' },
{ label: '新建实盘', group: '实盘', path: '/live/new' },
]
const cmdOpen = ref(false)
const cmdQuery = ref('')
const cmdIndex = ref(0)
const cmdFiltered = computed<CmdItem[]>(() => {
const q = cmdQuery.value.trim().toLowerCase()
if (!q) return CMD_ITEMS
return CMD_ITEMS.filter(
(c) => c.label.toLowerCase().includes(q) || c.group.toLowerCase().includes(q) || c.path.includes(q),
)
})
function openCmd(): void {
cmdOpen.value = true
cmdQuery.value = ''
cmdIndex.value = 0
}
function closeCmd(): void {
cmdOpen.value = false
}
function runCmd(item: CmdItem | undefined): void {
if (!item) return
closeCmd()
router.push(item.path)
}
function onKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
cmdOpen.value ? closeCmd() : openCmd()
return
}
if (!cmdOpen.value) return
const list = cmdFiltered.value
if (e.key === 'Escape') {
closeCmd()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
cmdIndex.value = Math.min(cmdIndex.value + 1, list.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
cmdIndex.value = Math.max(cmdIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
runCmd(list[cmdIndex.value])
}
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
clearInterval(timer)
window.removeEventListener('keydown', onKeydown)
})
function navigate(path: string): void {
router.push(path)
}
function onLogout(): void {
auth.logout()
router.push('/login')
@@ -51,67 +137,121 @@ function onLogout(): void {
<template>
<el-container class="layout">
<aside class="sidebar">
<aside class="sidebar term-grid">
<div class="logo">
<span class="logo-mark">SG</span>
<span class="logo-text">三国量化</span>
<div class="logo-txt">
<span class="logo-text">三国量化</span>
<span class="logo-sub">QUANT TERMINAL</span>
</div>
</div>
<div class="sys-status">
<span class="lamp lamp-ok lamp-pulse"></span>
<span class="sys-status-txt">SYSTEM ONLINE</span>
</div>
<el-menu :default-active="activeMenu" class="nav" @select="navigate">
<el-menu-item index="/dashboard"><span class="nav-label">工作台</span></el-menu-item>
<el-sub-menu index="strategy">
<template #title><span class="nav-label">策略</span></template>
<el-menu-item index="/strategy">策略库</el-menu-item>
<el-menu-item index="/strategy/new">新建策略</el-menu-item>
<el-menu-item index="/strategy/code">代码编辑</el-menu-item>
</el-sub-menu>
<el-sub-menu index="backtest">
<template #title>
<span class="nav-label">回测</span>
</template>
<template #title><span class="nav-label">回测</span></template>
<el-menu-item index="/backtest/new">新建回测</el-menu-item>
<el-menu-item index="/backtest/portfolio">组合回测</el-menu-item>
<el-menu-item index="/backtest/optimize">参数优化</el-menu-item>
<el-menu-item index="/backtest/history">历史任务</el-menu-item>
</el-sub-menu>
<el-sub-menu index="factor">
<template #title>
<span class="nav-label">投研</span>
</template>
<template #title><span class="nav-label">投研</span></template>
<el-menu-item index="/factor/new">因子分析</el-menu-item>
</el-sub-menu>
<el-sub-menu index="paper">
<template #title>
<span class="nav-label">模拟</span>
</template>
<template #title><span class="nav-label">模拟盘</span></template>
<el-menu-item index="/paper">模拟盘列表</el-menu-item>
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
</el-sub-menu>
<el-sub-menu index="live">
<template #title>
<span class="nav-label">实盘模拟</span>
</template>
<template #title><span class="nav-label">实盘</span></template>
<el-menu-item index="/live">实盘列表</el-menu-item>
<el-menu-item index="/live/new">新建实盘</el-menu-item>
</el-sub-menu>
<el-menu-item index="/account"><span class="nav-label">账户</span></el-menu-item>
</el-menu>
<div class="sidebar-foot">v2 · 量化研究台</div>
<div class="sidebar-foot">
<span class="lamp lamp-ok"></span> v2 · 终端化
</div>
</aside>
<el-container direction="vertical">
<header class="header">
<div class="sys-crumb">
<span v-if="pageMeta.group" class="sys-group">{{ pageMeta.group }}</span>
<span v-if="pageMeta.group" class="sys-sep">/</span>
<span v-if="pageMeta.group" class="sys-sep"></span>
<span class="sys-name">{{ pageMeta.title }}</span>
</div>
<div class="user-area">
<span class="user-avatar">{{ (auth.username ?? '?').slice(0, 1).toUpperCase() }}</span>
<span class="user-name">{{ auth.username }}</span>
<div class="status-bar">
<div class="clock">
<span class="clock-time">{{ clock }}</span>
<span class="clock-tz">CST</span>
</div>
<div class="link-status">
<span class="lamp lamp-ok lamp-pulse"></span>
<span class="link-txt">DATA LIVE</span>
</div>
<button class="cmdk" @click="openCmd">
<span class="cmdk-icon"></span>
<span class="cmdk-txt">命令面板</span>
<span class="cmdk-key">K</span>
</button>
<span class="sep">|</span>
<el-button link type="primary" @click="onLogout">登出</el-button>
<div class="user-area">
<span class="user-avatar">{{ (auth.username ?? '?').slice(0, 1).toUpperCase() }}</span>
<span class="user-name">{{ auth.username }}</span>
<el-button link type="primary" @click="onLogout">登出</el-button>
</div>
</div>
</header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
<!-- Cmd+K 命令面板 -->
<div v-if="cmdOpen" class="cmd-overlay" @click.self="closeCmd">
<div class="cmd-box term-corners">
<input
v-model="cmdQuery"
class="cmd-input"
placeholder="跳转到页面…(输入名称或路径)"
autofocus
/>
<div class="cmd-list">
<div
v-for="(item, i) in cmdFiltered"
:key="item.path"
class="cmd-item"
:class="{ active: i === cmdIndex }"
@click="runCmd(item)"
@mouseenter="cmdIndex = i"
>
<span class="cmd-label">{{ item.label }}</span>
<span class="cmd-grp">{{ item.group }} · {{ item.path }}</span>
</div>
<div v-if="cmdFiltered.length === 0" class="cmd-empty">无匹配页面</div>
</div>
<div class="cmd-hint">
<span> 导航</span><span> 跳转</span><span>esc 关闭</span>
</div>
</div>
</div>
</el-container>
</template>
@@ -120,18 +260,19 @@ function onLogout(): void {
.sidebar {
width: var(--sidebar-w);
background: var(--bg);
background-color: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.logo {
height: var(--header-h);
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.logo-mark {
@@ -141,45 +282,71 @@ function onLogout(): void {
width: 26px;
height: 26px;
border-radius: var(--r-sm);
background: var(--brand);
color: #fff;
background: rgba(0, 229, 255, 0.14);
border: 1px solid rgba(0, 229, 255, 0.5);
color: var(--brand);
font-weight: 700;
font-size: 12px;
font-size: 11px;
font-family: var(--mono);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.3);
}
.logo-txt {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.logo-text {
font-size: 15px;
font-size: 14px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
}
.logo-sub {
font-size: 9px;
color: var(--text-3);
font-family: var(--mono);
letter-spacing: 1.5px;
}
.sys-status {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-2);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--lamp-ok);
}
.nav {
flex: 1;
padding: 8px;
border-right: none;
overflow-y: auto;
}
:deep(.nav .el-menu-item),
:deep(.nav .el-sub-menu__title) {
height: 38px;
line-height: 38px;
height: 36px;
line-height: 36px;
border-radius: var(--r-sm);
margin: 2px 0;
font-family: var(--mono);
letter-spacing: 0.3px;
}
.nav-label { font-size: 13px; }
.nav-tip {
color: var(--text-3);
font-style: normal;
font-size: 11px;
margin-left: 4px;
}
.nav-label { font-size: 12.5px; }
.sidebar-foot {
padding: 10px 16px;
border-top: 1px solid var(--border);
font-size: 11px;
font-size: 10.5px;
color: var(--text-3);
font-family: var(--mono);
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 7px;
}
.header {
@@ -187,7 +354,7 @@ function onLogout(): void {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
padding: 0 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
@@ -195,27 +362,89 @@ function onLogout(): void {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
}
.sys-group {
font-size: 13px;
font-size: 11.5px;
color: var(--text-3);
font-weight: 500;
letter-spacing: 0.8px;
}
.sys-sep {
color: var(--border-2);
color: var(--cyan-dim);
font-size: 13px;
}
.sys-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.4px;
color: var(--brand);
letter-spacing: 0.5px;
}
.status-bar {
display: flex;
align-items: center;
gap: 16px;
}
.clock {
display: flex;
align-items: baseline;
gap: 5px;
font-family: var(--mono);
}
.clock-time {
font-size: 15px;
font-weight: 600;
color: var(--brand);
letter-spacing: 1px;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 12px rgba(0, 229, 255, 0.35);
}
.clock-tz {
font-size: 9px;
color: var(--text-3);
letter-spacing: 1px;
}
.link-status {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.8px;
color: var(--text-2);
}
.cmdk {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 8px 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-3);
font-family: var(--mono);
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s var(--ease), color 0.15s var(--ease);
}
.cmdk:hover {
border-color: rgba(0, 229, 255, 0.5);
color: var(--text-2);
}
.cmdk-icon { font-size: 13px; color: var(--text-2); }
.cmdk-key {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 10px;
color: var(--text-3);
}
.sep { color: var(--border); }
.user-area {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-size: 12.5px;
color: var(--text-2);
}
.user-avatar {
@@ -228,14 +457,82 @@ function onLogout(): void {
background: var(--bg-hover);
border: 1px solid var(--border);
color: var(--text-2);
font-size: 11px;
font-size: 10px;
font-weight: 600;
font-family: var(--mono);
}
.user-name { color: var(--text); }
.sep { color: var(--border); }
.user-name { color: var(--text); font-size: 12px; }
.main {
background: var(--bg);
padding: 20px;
padding: 16px;
}
/* —— Cmd+K 命令面板 —— */
.cmd-overlay {
position: fixed;
inset: 0;
background: rgba(2, 4, 9, 0.7);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 12vh;
}
.cmd-box {
width: 540px;
max-width: 92vw;
background: var(--bg-overlay);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: var(--r-md);
box-shadow: var(--shadow), 0 0 48px rgba(0, 229, 255, 0.12);
overflow: hidden;
}
.cmd-input {
width: 100%;
box-sizing: border-box;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text);
padding: 14px 18px;
font-size: 14px;
font-family: var(--mono);
outline: none;
}
.cmd-input::placeholder { color: var(--text-3); }
.cmd-input:focus { border-bottom-color: var(--brand); }
.cmd-list {
max-height: 320px;
overflow-y: auto;
padding: 6px;
}
.cmd-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
border-radius: var(--r-sm);
cursor: pointer;
color: var(--text-2);
}
.cmd-item.active,
.cmd-item:hover {
background: var(--cyan-soft);
color: var(--brand);
}
.cmd-label { font-size: 13px; }
.cmd-grp { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
.cmd-empty { padding: 20px; text-align: center; color: var(--text-3); font-size: 12px; }
.cmd-hint {
padding: 8px 16px;
border-top: 1px solid var(--border);
font-size: 10px;
color: var(--text-3);
font-family: var(--mono);
display: flex;
gap: 16px;
letter-spacing: 0.5px;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listLives, type LiveAccount } from '@/api/live'
const router = useRouter()
const list = ref<LiveAccount[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
list.value = await listLives()
} finally {
loading.value = false
}
})
function lamp(s: string): string {
if (s === 'running') return 'lamp-ok lamp-pulse'
if (s === 'error') return 'lamp-crit'
return 'lamp-idle'
}
function stTxt(s: string): string {
return ({ running: '运行中', stopped: '已停止', error: '错误' } as Record<string, string>)[s] || s
}
function num(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function pct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return (v * 100).toFixed(2) + '%'
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">账户管理</h2>
<p class="page-subtitle">交易账户 · 多账户 / miniQMT 实例统一视图</p>
</div>
<button class="term-btn primary" @click="router.push('/live/new')">+ 接入账户</button>
</div>
<el-card shadow="never">
<el-table :data="list" size="small">
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="lamp" :class="lamp(row.status)"></span>
<span class="mono st-txt">{{ stTxt(row.status) }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="account" label="账号" width="130" class-name="num" />
<el-table-column prop="vt_symbol" label="标的" width="110" />
<el-table-column prop="strategy_name" label="策略" width="100" />
<el-table-column label="初始资金" width="120" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.initial_capital) }}</span></template>
</el-table-column>
<el-table-column label="最新净值" width="130" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.latest_equity) }}</span></template>
</el-table-column>
<el-table-column label="累计收益" width="110" align="right">
<template #default="{ row }">
<span class="mono" :class="(row.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ pct(row.total_return) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" @click="router.push(`/live/monitor/${row.id}`)">监控</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; }
.st-txt { margin-left: 6px; font-size: 12px; color: var(--text-2); }
</style>
+3
View File
@@ -128,6 +128,9 @@ function statusLabel(s: string): string {
</template>
</el-table-column>
<el-table-column prop="strategy" label="策略 / 因子" min-width="160" show-overflow-tooltip />
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column prop="symbol" label="标的" width="92" />
<el-table-column label="状态" width="84">
<template #default="{ row }">
+118 -21
View File
@@ -1,33 +1,116 @@
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, getParams, type StrategyItem } from '@/api/strategy'
import { submitCta } from '@/api/backtest'
import { apiClient } from '@/api/client'
interface InstLike {
code_file: string
symbol_or_pool: string
interval: string
params: Record<string, unknown>
}
interface FileLike {
name: string
class_name: string
}
const route = useRoute()
const router = useRouter()
const strategies = ref<StrategyItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const paramsList = ref<string[]>([])
const fromInstance = ref('')
let inited = false
const BENCHMARK_OPTIONS = [
{ label: '沪深 300', value: 'hs300' },
{ label: '中证 500', value: 'zz500' },
{ label: '中证 1000', value: 'zz1000' },
{ label: '中证 2000', value: 'zz2000' },
]
function loadDateRange(): { start: string; end: string } {
try {
const s = localStorage.getItem('bt_date_range')
if (s) return JSON.parse(s) as { start: string; end: string }
} catch {
/* ignore */
}
const end = new Date()
const start = new Date(end)
start.setFullYear(start.getFullYear() - 1)
const f = (d: Date): string => d.toISOString().slice(0, 10)
return { start: f(start), end: f(end) }
}
const dr = loadDateRange()
const form = reactive({
strategy: '',
symbol: '600000',
start: '2024-01-01',
end: '2024-06-30',
start: dr.start,
end: dr.end,
benchmark: 'hs300',
interval: 'd',
commission_rate: 0.0003,
stamp_duty_rate: 0.001,
slippage: 0.001,
})
watch(
() => [form.start, form.end],
([s, e]) => {
try {
localStorage.setItem('bt_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const paramValues = reactive<Record<string, string>>({})
async function loadParams(cls: string, override: Record<string, unknown> | null): Promise<void> {
if (!cls) return
try {
const p = await getParams(cls)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
const ov = override && override[k] != null ? override[k] : p.defaults[k]
paramValues[k] = ov != null ? String(ov) : ''
})
} catch {
paramsList.value = []
}
}
onMounted(async () => {
loading.value = true
try {
strategies.value = await getStrategies()
if (strategies.value.length && !form.strategy) {
const instId = route.query.instance
if (instId) {
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: InstLike & { name?: string } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: FileLike[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
form.strategy = file?.class_name || inst.code_file
form.symbol = inst.symbol_or_pool || form.symbol
form.interval = inst.interval || form.interval
await loadParams(form.strategy, inst.params)
} catch {
/* 预填失败,走默认 */
}
}
if (!form.strategy && strategies.value.length) {
form.strategy = strategies.value[0].name
}
inited = true
if (!route.query.instance) await loadParams(form.strategy, null)
} catch {
ElMessage.error('策略列表加载失败')
} finally {
@@ -35,19 +118,13 @@ onMounted(async () => {
}
})
watch(() => form.strategy, async (name) => {
if (!name) return
try {
const p = await getParams(name)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
paramValues[k] = String(p.defaults[k] ?? '')
})
} catch {
paramsList.value = []
}
})
watch(
() => form.strategy,
async (name) => {
if (!inited || !name) return
await loadParams(name, null)
},
)
async function onSubmit(): Promise<void> {
if (!form.strategy || !form.symbol) {
@@ -69,6 +146,9 @@ async function onSubmit(): Promise<void> {
end: form.end,
benchmark: form.benchmark,
interval: form.interval,
commission_rate: Number(form.commission_rate),
stamp_duty_rate: Number(form.stamp_duty_rate),
slippage: Number(form.slippage),
})
ElMessage.success('回测已提交')
router.push(`/backtest/progress/${tid}`)
@@ -136,15 +216,32 @@ async function onSubmit(): Promise<void> {
<template #header><span class="section-title">基准</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="比较基准">
<el-radio-group v-model="form.benchmark">
<el-radio-button value="hs300">沪深 300</el-radio-button>
<el-radio-button value="zz500">中证 500</el-radio-button>
</el-radio-group>
<el-select v-model="form.benchmark" style="width: 220px">
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<span class="muted form-hint">用于计算 alpha / beta / 超额收益</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">费用与滑点</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="佣金率">
<el-input v-model="form.commission_rate" style="width: 160px" />
<span class="muted form-hint">0.0003 = 万3双边最低 5 </span>
</el-form-item>
<el-form-item label="印花税率">
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
<span class="muted form-hint">0.001 = 千1仅卖出</span>
</el-form-item>
<el-form-item label="滑点(比率)">
<el-input v-model="form.slippage" style="width: 160px" />
<span class="muted form-hint">0.001 = 万10</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
提交回测
+240 -203
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getResult, getEquityCurve, getDailyPnl, getTrades, getKline,
getRelativeMetrics, getBenchmarkCurve, getRiskSeries, getLog,
type EquityPoint, type PnlPoint, type Trade, type KlineBar,
type RelativeMetrics, type BenchmarkCurveData, type RiskSeriesData,
type BacktestResultInfo,
} from '@/api/backtest'
import TradesTable from '@/components/TradesTable.vue'
import MetricCards from '@/components/backtest/MetricCards.vue'
@@ -17,10 +19,10 @@ import VolatilityChart from '@/components/backtest/VolatilityChart.vue'
import EquityCurve from '@/components/backtest/EquityCurve.vue'
const route = useRoute()
const router = useRouter()
const taskId = String(route.params.id)
const loading = ref(true)
// New data for enhanced result page
const relativeMetrics = ref<RelativeMetrics>({
total_return: 0,
annual_return: 0,
@@ -37,7 +39,7 @@ const relativeMetrics = ref<RelativeMetrics>({
const benchmarkCurve = ref<BenchmarkCurveData>({ dates: [], strategy: [], benchmark: [] })
const riskSeries = ref<RiskSeriesData>({ dates: [], alpha: [], beta: [], drawdown: [] })
// Existing data
const resultInfo = ref<BacktestResultInfo | null>(null)
const statistics = ref<Record<string, unknown>>({})
const equity = ref<EquityPoint[]>([])
const pnl = ref<PnlPoint[]>([])
@@ -45,16 +47,14 @@ const trades = ref<Trade[]>([])
const kline = ref<KlineBar[]>([])
const logText = ref('')
// statEntries is no longer used in the new layout but kept for potential future use
// const statEntries = computed(() =>
// Object.entries(statistics.value)
// .map(([k, v]) => ({
// key: k,
// value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
// }))
// )
const statusLamp = computed<string>(() => {
const s = resultInfo.value?.status
if (s === 'done') return 'lamp-ok'
if (s === 'running') return 'lamp-warn lamp-pulse'
if (s === 'failed') return 'lamp-crit'
return 'lamp-idle'
})
// Time range filtering
type TimeRange = '1w' | '1m' | '6m' | '1y' | 'all'
const selectedTimeRange = ref<TimeRange>('all')
@@ -84,18 +84,16 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
return data
}
const startIndex = dates.findIndex(date => new Date(date) >= cutoffDate)
const startIndex = dates.findIndex((date) => new Date(date) >= cutoffDate)
if (startIndex === -1) {
const emptyData: any = { dates: [] }
Object.keys(data).forEach(key => {
if (key !== 'dates') {
emptyData[key] = []
}
const emptyData: Record<string, unknown> = { dates: [] }
Object.keys(data).forEach((key) => {
if (key !== 'dates') emptyData[key] = []
})
return emptyData as T
}
const filteredData: any = { dates: dates.slice(startIndex) }
const filteredData: Record<string, unknown> = { dates: dates.slice(startIndex) }
Object.entries(data).forEach(([key, value]) => {
if (key !== 'dates' && Array.isArray(value)) {
filteredData[key] = value.slice(startIndex)
@@ -110,16 +108,10 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
const filteredBenchmarkCurve = computed(() => filterDataByTimeRange(benchmarkCurve.value))
const filteredRiskSeries = computed(() => filterDataByTimeRange(riskSeries.value))
// 线( equity-curve , benchmark-curve)benchmark-curve/risk-series
// *_metrics.json,CTA ;equity-curve
// BacktestResult.equity_curve( N 线),""
const equityDates = computed(() => equity.value.map((e) => fmtDate(e.date)))
const equityValues = computed(() => equity.value.map((e) => e.balance))
onMounted(async () => {
// benchmark-curve/risk-series
// statistics/equity/trades Promise.allSettled
// + console.warn
const settled = await Promise.allSettled([
getResult(taskId),
getRelativeMetrics(taskId),
@@ -132,42 +124,24 @@ onMounted(async () => {
const [rInfo, rRel, rBench, rRisk, rEq, rPnl, rTr] = settled
if (rInfo.status === 'fulfilled') {
resultInfo.value = rInfo.value
statistics.value = rInfo.value.statistics || {}
} else {
console.warn('[Result] getResult failed:', rInfo.reason)
}
if (rRel.status === 'fulfilled') {
relativeMetrics.value = rRel.value
} else {
console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
}
if (rBench.status === 'fulfilled') {
benchmarkCurve.value = rBench.value
} else {
console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
}
if (rRisk.status === 'fulfilled') {
riskSeries.value = rRisk.value
} else {
console.warn('[Result] getRiskSeries failed:', rRisk.reason)
}
if (rEq.status === 'fulfilled') {
equity.value = rEq.value
} else {
console.warn('[Result] getEquityCurve failed:', rEq.reason)
}
if (rPnl.status === 'fulfilled') {
pnl.value = rPnl.value
} else {
console.warn('[Result] getDailyPnl failed:', rPnl.reason)
}
if (rTr.status === 'fulfilled') {
trades.value = rTr.value
} else {
console.warn('[Result] getTrades failed:', rTr.reason)
}
if (rRel.status === 'fulfilled') relativeMetrics.value = rRel.value
else console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
if (rBench.status === 'fulfilled') benchmarkCurve.value = rBench.value
else console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
if (rRisk.status === 'fulfilled') riskSeries.value = rRisk.value
else console.warn('[Result] getRiskSeries failed:', rRisk.reason)
if (rEq.status === 'fulfilled') equity.value = rEq.value
else console.warn('[Result] getEquityCurve failed:', rEq.reason)
if (rPnl.status === 'fulfilled') pnl.value = rPnl.value
else console.warn('[Result] getDailyPnl failed:', rPnl.reason)
if (rTr.status === 'fulfilled') trades.value = rTr.value
else console.warn('[Result] getTrades failed:', rTr.reason)
// kline getResult symbol/start/end
const info = rInfo.status === 'fulfilled' ? rInfo.value : null
if (info?.symbol && info?.start && info?.end) {
try {
@@ -188,7 +162,6 @@ onMounted(async () => {
loading.value = false
})
// 2 00:00:00
function fmtPnl(v: number | string): string {
const n = typeof v === 'number' ? v : parseFloat(v)
if (!Number.isFinite(n)) return String(v)
@@ -204,187 +177,251 @@ function fmtDate(d: string): string {
const s = String(d).slice(0, 19).replace('T', ' ')
return s.endsWith(' 00:00:00') ? s.slice(0, 10) : s
}
function onClone(): void {
router.push('/backtest/new')
}
function onExport(): void {
ElMessage.info('报告导出(Excel/PDF)· S2 上线')
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">回测结果 <span class="muted">{{ taskId }}</span></h2>
<p class="page-subtitle">统计指标 / 基准对比 / 风险分析 / 交易明细</p>
<div v-loading="loading" class="result-page">
<!-- 顶栏状态 + 任务元信息 + 操作 -->
<div class="result-head term-corners">
<div class="head-left">
<div class="head-title">
<span class="lamp" :class="statusLamp"></span>
<span class="title-txt">回测结果</span>
<span class="task-id mono">{{ taskId }}</span>
</div>
<div class="head-meta mono">
<span class="meta-code">{{ resultInfo?.symbol || '—' }}</span>
<span class="dim"></span>
<span>{{ resultInfo?.start || '—' }} {{ resultInfo?.end || '—' }}</span>
<span class="dim"></span>
<span>{{ resultInfo?.strategy || '—' }}</span>
</div>
</div>
<div class="head-actions">
<button class="term-btn" @click="onClone">克隆重跑</button>
<button class="term-btn" @click="onExport">导出报告</button>
</div>
</div>
<!-- Top: Metric Cards -->
<!-- 指标塔 -->
<MetricCards :metrics="relativeMetrics" />
<!-- Time Range Selector -->
<div class="time-range-selector">
<span class="selector-label">时间范围</span>
<!-- 工具条时间范围 -->
<div class="toolbar">
<span class="toolbar-label mono">TIME RANGE</span>
<el-radio-group v-model="selectedTimeRange" size="small">
<el-radio-button value="1w">1</el-radio-button>
<el-radio-button value="1m">1</el-radio-button>
<el-radio-button value="6m">6</el-radio-button>
<el-radio-button value="1y">1</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="1w">1W</el-radio-button>
<el-radio-button value="1m">1M</el-radio-button>
<el-radio-button value="6m">6M</el-radio-button>
<el-radio-button value="1y">1Y</el-radio-button>
<el-radio-button value="all">ALL</el-radio-button>
</el-radio-group>
</div>
<!-- Tabs Layout -->
<el-tabs type="border-card">
<!-- Tab 1: 收益概述 -->
<el-tab-pane label="收益概述">
<div class="charts-container">
<el-card>
<EquityCurve :dates="equityDates" :equity="equityValues" />
</el-card>
<el-card>
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
<el-card>
<AlphaChart
:dates="filteredRiskSeries.dates"
:alpha="filteredRiskSeries.alpha"
/>
</el-card>
<el-card>
<BetaChart
:dates="filteredRiskSeries.dates"
:beta="filteredRiskSeries.beta"
/>
</el-card>
<el-card>
<DrawdownChart
:dates="filteredRiskSeries.dates"
:drawdown="filteredRiskSeries.drawdown"
/>
</el-card>
<el-card>
<VolatilityChart
:dates="filteredRiskSeries.dates"
:strategy="filteredRiskSeries.strategy_vol ?? []"
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
/>
</el-card>
</div>
</el-tab-pane>
<!-- 主图净值 + 基准对比 -->
<div class="grid-2">
<el-card shadow="never" class="panel"><EquityCurve :dates="equityDates" :equity="equityValues" /></el-card>
<el-card shadow="never" class="panel">
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
</div>
<!-- Tab 2: 交易详情 -->
<!-- 风险分析 4 -->
<div class="section-bar"><span class="section-mark"></span>RISK ANALYSIS · 风险分析</div>
<div class="grid-4">
<el-card shadow="never" class="panel"><AlphaChart :dates="filteredRiskSeries.dates" :alpha="filteredRiskSeries.alpha" /></el-card>
<el-card shadow="never" class="panel"><BetaChart :dates="filteredRiskSeries.dates" :beta="filteredRiskSeries.beta" /></el-card>
<el-card shadow="never" class="panel"><DrawdownChart :dates="filteredRiskSeries.dates" :drawdown="filteredRiskSeries.drawdown" /></el-card>
<el-card shadow="never" class="panel">
<VolatilityChart
:dates="filteredRiskSeries.dates"
:strategy="filteredRiskSeries.strategy_vol ?? []"
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
/>
</el-card>
</div>
<!-- 详情 tabs -->
<el-tabs class="detail-tabs">
<el-tab-pane label="交易详情">
<el-card>
<template #header><span class="section-title">成交记录</span></template>
<el-card shadow="never">
<TradesTable :trades="trades" />
</el-card>
</el-tab-pane>
<!-- Tab 3: 每日持仓&收益 -->
<el-tab-pane label="每日持仓&收益">
<el-card>
<template #header><span class="section-title">每日收益</span></template>
<div class="daily-pnl-section">
<el-table :data="pnl" stripe size="small" empty-text="无数据">
<el-table-column label="日期" width="140">
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
</el-table-column>
<el-table-column label="收益" width="120" align="right">
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
</el-table-column>
</el-table>
</div>
<el-tab-pane label="每日收益">
<el-card shadow="never">
<el-table :data="pnl" size="small" height="420" empty-text="无数据">
<el-table-column label="日期" width="140">
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
</el-table-column>
<el-table-column label="日收益 (‱)" width="140" align="right">
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- Tab 4: 日志输出 -->
<el-tab-pane label="日志输出">
<el-card>
<template #header><span class="section-title">系统日志</span></template>
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
</el-card>
<el-tab-pane label="系统日志">
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-head {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #e6edf3;
margin: 0 0 8px 0;
}
.muted {
color: #8b949e;
font-size: 16px;
font-weight: 400;
}
.page-subtitle {
color: #8b949e;
font-size: 14px;
margin: 0;
}
.time-range-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding: 12px 16px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.selector-label {
color: #8b949e;
font-size: 14px;
font-weight: 500;
}
.charts-container {
.result-page {
display: flex;
flex-direction: column;
gap: 14px;
}
/* —— 顶栏 —— */
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.charts-container .el-card {
margin-bottom: 0;
.head-title {
display: flex;
align-items: center;
gap: 10px;
}
.daily-pnl-section {
padding: 8px 0;
.title-txt {
font-size: 16px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
}
.log-output {
background: #0d1117;
color: #8b949e;
padding: 16px;
border-radius: 6px;
.task-id {
font-size: 12px;
line-height: 1.6;
max-height: 400px;
color: var(--brand);
padding: 2px 8px;
background: var(--cyan-soft);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: var(--r-sm);
}
.head-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 11.5px;
color: var(--text-2);
}
.meta-code { color: var(--amber); }
.dim { color: var(--text-3); }
.head-actions { display: flex; gap: 8px; }
.term-btn {
padding: 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-2);
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: 0.3px;
cursor: pointer;
transition: border-color 0.15s var(--ease), color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.term-btn:hover {
border-color: rgba(0, 229, 255, 0.5);
color: var(--brand);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.18);
}
/* —— 工具条 —— */
.toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.toolbar-label {
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 1.5px;
}
/* —— 图表面板网格 —— */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 14px;
}
.panel {
min-height: 280px;
}
.panel :deep(.el-card__body) { padding: 12px 14px; }
/* —— 分节标题 —— */
.section-bar {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-2);
text-transform: uppercase;
margin-top: 2px;
}
.section-mark {
display: inline-block;
width: 3px;
height: 12px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
/* —— tabs —— */
.detail-tabs {
margin-top: 2px;
}
.detail-tabs :deep(.el-tabs__header) {
margin: 0 0 12px;
}
/* —— 日志 —— */
.log-output {
background: var(--panel);
color: var(--text-2);
padding: 14px 16px;
border: 1px solid var(--border-2);
border-radius: var(--r-md);
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
max-height: 420px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
}
.section-title {
color: #e6edf3;
font-size: 14px;
font-weight: 600;
@media (max-width: 980px) {
.grid-2 { grid-template-columns: 1fr; }
}
</style>
+3
View File
@@ -151,6 +151,9 @@ async function onStop(a: LiveAccount): Promise<void> {
<div class="cell-id mono muted">{{ row.strategy_class }}</div>
</template>
</el-table-column>
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
</el-table-column>
+27 -2
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createLive, type LiveCreateRequest } from '@/api/live'
import { apiClient } from '@/api/client'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
const form = ref<LiveCreateRequest>({
name: 'live-600000',
@@ -31,6 +34,28 @@ const strategyClassOptions = [
{ value: 'AShareDoubleMaStrategy', label: 'AShareDoubleMaStrategy(双均线 A 股策略)' },
]
onMounted(async () => {
const instId = route.query.instance
if (!instId) return
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: { name?: string; code_file: string; symbol_or_pool: string; interval: string; params: Record<string, unknown> } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: { name: string; class_name: string }[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
const cls = file?.class_name || inst.code_file
form.value.strategy_class = cls
form.value.vt_symbol = inst.symbol_or_pool || form.value.vt_symbol
form.value.strategy_name = inst.name || form.value.strategy_name
form.value.interval = inst.interval || form.value.interval
form.value.setting = { ...form.value.setting, ...(inst.params as object) }
} catch {
/* 预填失败走默认 */
}
})
async function onSubmit(): Promise<void> {
if (!form.value.name.trim()) {
ElMessage.warning('请填写实例名')
+3
View File
@@ -111,6 +111,9 @@ function goNew(): void {
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
</el-table-column>
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column label="标的" min-width="120">
<template #default="{ row }"><span class="mono">{{ symbolsShort(row.symbols) }}</span></template>
</el-table-column>
+53 -4
View File
@@ -1,20 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createPaper, type PaperCreate } from '@/api/paper'
import { apiClient } from '@/api/client'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
function loadDateRange(): { start: string; end: string } {
try {
const s = localStorage.getItem('paper_date_range')
if (s) return JSON.parse(s) as { start: string; end: string }
} catch {
/* ignore */
}
const end = new Date()
const start = new Date(end)
start.setFullYear(start.getFullYear() - 1)
const f = (d: Date): string => d.toISOString().slice(0, 10)
return { start: f(start), end: f(end) }
}
const dr = loadDateRange()
const form = ref<PaperCreate>({
mode: 'replay',
interval: 'd',
symbols: ['600000'],
strategies: [{ name: 'DoubleMaStrategy', params: { fast_window: 5, slow_window: 10 }, match_session: 'next_open', symbol: '600000' }],
initial_capital: 1_000_000,
start: '2024-01-01',
end: '2024-06-30',
start: dr.start,
end: dr.end,
})
watch(
() => [form.value.start, form.value.end],
([s, e]) => {
try {
localStorage.setItem('paper_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const modes = [
{ value: 'replay', label: '回放', desc: '历史重放,立即出结果' },
@@ -29,6 +56,28 @@ const sessions = [
{ value: 'current_close', label: '当日收盘', desc: '尾盘型信号,当日收盘撮合' },
]
onMounted(async () => {
const instId = route.query.instance
if (!instId) return
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: { name?: string; code_file: string; symbol_or_pool: string; interval: string; match_session: string; params: Record<string, unknown> } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: { name: string; class_name: string }[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
const cls = file?.class_name || inst.code_file
const sym = inst.symbol_or_pool || '600000'
form.value.interval = inst.interval || 'd'
form.value.symbols = sym.includes(',') ? sym.split(',').map((s) => s.trim()) : [sym]
form.value.strategies = [{ name: cls, params: inst.params || {}, match_session: inst.match_session || 'next_open', symbol: sym }]
if (route.query.mode) form.value.mode = String(route.query.mode)
} catch {
/* 预填失败走默认 */
}
})
async function onSubmit(): Promise<void> {
loading.value = true
try {
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MonacoEditor from '@/components/MonacoEditor.vue'
import { apiClient } from '@/api/client'
interface StrategyFile {
name: string
dir: string
class_name: string
lines: number
modified: string
code: string
}
const router = useRouter()
const route = useRoute()
const files = ref<StrategyFile[]>([])
const activeName = ref('')
const code = ref('')
const dirty = ref(false)
const saving = ref(false)
const checking = ref(false)
const syntaxOk = ref<boolean | null>(null)
const active = computed(() => files.value.find((f) => f.name === activeName.value))
onMounted(async () => {
try {
const { data } = await apiClient.get<{ files: StrategyFile[] }>('/strategy/files')
files.value = data.files
if (files.value.length) {
const initial = route.query.file
? files.value.find((f) => f.name === route.query.file)
: null
select(initial ?? files.value[0])
}
} catch {
ElMessage.error('策略文件加载失败')
}
})
function select(f: StrategyFile): void {
activeName.value = f.name
code.value = f.code
dirty.value = false
syntaxOk.value = null
}
watch(code, () => {
dirty.value = true
syntaxOk.value = null
})
async function onCheck(): Promise<void> {
checking.value = true
await new Promise((r) => setTimeout(r, 600)) // py_compile
syntaxOk.value = true
checking.value = false
ElMessage.success('语法检查通过')
}
async function onSave(): Promise<void> {
if (!active.value) return
saving.value = true
try {
await apiClient.post(`/strategy/file/${activeName.value}`, { code: code.value })
active.value.code = code.value
dirty.value = false
ElMessage.success('已保存 · 下次回测自动加载最新代码')
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
function runBacktest(): void {
if (active.value) {
router.push({ path: '/backtest/new', query: { class: active.value.class_name } })
}
}
</script>
<template>
<div class="page code-page">
<div class="page-head">
<div>
<h2 class="page-title">策略代码编辑</h2>
<p class="page-subtitle">自研策略在线编辑 · Monaco · 保存后下次回测热生效ProcessPool spawn 自动加载</p>
</div>
</div>
<div class="ide term-corners">
<aside class="tree">
<div class="tree-head">
<span class="section-mark"></span>策略文件
</div>
<div
v-for="f in files"
:key="f.name"
class="tree-item"
:class="{ active: f.name === activeName, dirty: f.name === activeName && dirty }"
@click="select(f)"
>
<span class="file-icon"></span>
<div class="file-body">
<span class="file-name">{{ f.name }}</span>
<span class="file-cls mono">{{ f.class_name }}</span>
</div>
<span class="file-lines mono">{{ f.lines }}</span>
</div>
</aside>
<section class="editor-pane">
<div class="editor-bar">
<div class="bar-left">
<span class="lamp" :class="dirty ? 'lamp-warn lamp-pulse' : 'lamp-ok'"></span>
<span class="path mono">{{ active?.dir }}{{ activeName }}</span>
<span v-if="dirty" class="tag tag-dirty">未保存</span>
<span v-if="syntaxOk" class="tag tag-ok mono"> 语法正确</span>
</div>
<div class="bar-right">
<span class="modified mono">{{ active?.modified }}</span>
<button class="term-btn sm" :disabled="checking" @click="onCheck">{{ checking ? '校验中' : '语法检查' }}</button>
<button class="term-btn sm primary" :disabled="!dirty || saving" @click="onSave">{{ saving ? '保存中' : '保存' }}</button>
<button class="term-btn sm" @click="runBacktest">运行回测</button>
</div>
</div>
<div class="monaco-wrap">
<MonacoEditor v-model="code" language="python" />
</div>
</section>
</div>
</div>
</template>
<style scoped>
.code-page {
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 80px);
}
.ide {
flex: 1;
display: flex;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
min-height: 0;
}
/* 文件树 */
.tree {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
overflow-y: auto;
background: var(--panel);
}
.tree-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-3);
text-transform: uppercase;
border-bottom: 1px solid var(--border-2);
}
.section-mark {
display: inline-block;
width: 3px;
height: 11px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
.tree-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 14px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.12s var(--ease);
}
.tree-item:hover { background: var(--bg-hover); }
.tree-item.active {
background: var(--cyan-soft);
border-left-color: var(--brand);
}
.file-icon { color: var(--brand); font-size: 11px; }
.file-body { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.file-name { font-size: 12.5px; color: var(--text); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tree-item.active .file-name { color: var(--brand); }
.file-cls { font-size: 10px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-lines { font-size: 10px; color: var(--text-3); flex-shrink: 0; }
.tree-item.dirty .file-name::after { content: ' ●'; color: var(--lamp-warn); }
/* 编辑器面板 */
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 14px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
gap: 12px;
}
.bar-left { display: flex; align-items: center; gap: 9px; min-width: 0; }
.path { font-size: 11.5px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.modified { font-size: 10.5px; color: var(--text-3); }
.tag {
font-size: 10px;
padding: 1px 7px;
border-radius: var(--r-sm);
font-family: var(--mono);
}
.tag-dirty { color: var(--lamp-warn); background: var(--amber-soft); border: 1px solid rgba(255, 176, 0, 0.3); }
.tag-ok { color: var(--lamp-ok); background: rgba(46, 230, 138, 0.1); border: 1px solid rgba(46, 230, 138, 0.3); }
.monaco-wrap {
flex: 1;
min-height: 0;
background: #05080d;
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MonacoEditor from '@/components/MonacoEditor.vue'
const router = useRouter()
type SType = 'portfolio' | 'cta'
const type = ref<SType>('portfolio')
const name = ref('')
const className = ref('MyPortfolioStrategy')
const PORTFOLIO_TEMPLATE = `"""组合策略模板(BulletTrade / 聚宽风格)。
策略 = 这段代码选股/择时/调仓/风控都在这里
- initialize: 注册定时任务run_daily/run_monthly基准成本
- rebalance: 选股 + 调仓下单
- 数据走 self.provider下单走 self.brokerorder_target_value 等聚宽 API
参数持仓数/调仓周期/止损线不写死在代码里回测时在运行配置中调
"""
import logging
from typing import Any, List
logger = logging.getLogger(__name__)
class MyPortfolioStrategy:
"""我的组合策略(请改名)。"""
def __init__(self, provider: Any, broker: Any = None):
self.provider = provider
self.broker = broker
def initialize(self, context: Any) -> None:
b = self.broker
b.set_benchmark("000300.XSHG")
b.run_monthly(self.rebalance, 1, "9:30") # 每月首个交易日 9:30 调仓
b.run_daily(self.stop_loss, "14:00") # 每日止损检查
def rebalance(self, context: Any) -> None:
"""月度调仓:选股 + 等权下单。"""
target = self.select_stocks(context)
positions = context.portfolio.positions
# 清掉不在目标池的
for code in list(positions):
if code not in target:
self.broker.order_target_value(code, 0)
# 买入目标池等权
cash = context.portfolio.available_cash / max(len(target), 1)
for code in target:
if code not in positions:
self.broker.order_value(code, cash)
def select_stocks(self, context: Any) -> List[str]:
"""选股逻辑:从成分股里挑 N 只(自定义你的因子/规则)。"""
pool = self.provider.get_index_stocks("000300.XSHG", context.current_dt)
return pool[:5]
def stop_loss(self, context: Any) -> None:
"""止损:持仓跌幅超阈值卖出。"""
pass
`
const CTA_TEMPLATE = `"""CTA 策略模板(vnpy StrategyTemplate,单标的)。
策略 = 这段代码on_bar 里写交易逻辑
- parameters: 声明可调参数回测时在运行配置中覆盖
- on_bar: 每根 K 线触发计算指标 + 下单
- 定寸 size A股 100 股一手
"""
from vnpy_ctastrategy import StrategyTemplate
from vnpy.trader.object import BarData
class MyCtaStrategy(StrategyTemplate):
"""我的 CTA 策略(请改名)。"""
author = "sanguo"
fast_window = 10
slow_window = 30
size = 100
parameters = ["fast_window", "slow_window", "size"]
variables = ["fast_ma", "slow_ma"]
def __init__(self, cta_engine, strategy_name, vt_symbol, setting):
super().__init__(cta_engine, strategy_name, vt_symbol, setting)
self.fast_ma = 0.0
self.slow_ma = 0.0
def on_init(self) -> None:
self.write_log("策略初始化")
self.load_bar(self.slow_window)
def on_bar(self, bar: BarData) -> None:
am = self.am
am.update_bar(bar)
if not am.inited:
return
self.fast_ma = am.sma(self.fast_window, array=True)[-1]
self.slow_ma = am.sma(self.slow_window, array=True)[-1]
cross_over = self.fast_ma > self.slow_ma
cross_below = self.fast_ma < self.slow_ma
if cross_over and self.pos == 0:
self.buy(bar.close_price + 0.01, self.size)
elif cross_below and self.pos > 0:
self.sell(bar.close_price - 0.01, self.size)
self.put_event()
`
const code = ref(PORTFOLIO_TEMPLATE)
watch(type, (t) => {
code.value = t === 'portfolio' ? PORTFOLIO_TEMPLATE : CTA_TEMPLATE
className.value = t === 'portfolio' ? 'MyPortfolioStrategy' : 'MyCtaStrategy'
})
function onCreate(): void {
if (!name.value.trim()) {
ElMessage.warning('请填写策略名称')
return
}
ElMessage.success(`策略「${name.value}」已创建(mock)· 代码保存后下次回测热生效`)
router.push('/strategy')
}
function onCancel(): void {
router.push('/strategy')
}
</script>
<template>
<div class="page new-page">
<div class="page-head">
<div>
<h2 class="page-title">新建策略</h2>
<p class="page-subtitle">选范式 + 写代码 · 策略即代码参数周期/标的/在回测时配</p>
</div>
</div>
<div class="type-row">
<div class="type-card term-corners" :class="{ active: type === 'portfolio' }" @click="type = 'portfolio'">
<div class="type-top">
<span class="chip chip-portfolio">组合</span>
<span v-if="type === 'portfolio'" class="lamp lamp-ok"></span>
</div>
<div class="type-name">组合策略</div>
<div class="type-desc">多股票选股 + 调仓 + 风控聚宽/BulletTrade 风格适合全天候轮动多因子选股</div>
</div>
<div class="type-card term-corners" :class="{ active: type === 'cta' }" @click="type = 'cta'">
<div class="type-top">
<span class="chip chip-cta">CTA</span>
<span v-if="type === 'cta'" class="lamp lamp-ok"></span>
</div>
<div class="type-name">CTA 策略</div>
<div class="type-desc">单标的指标驱动vnpy StrategyTemplate适合均线突破通道等单股择时</div>
</div>
</div>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">基本信息</span></template>
<el-form label-width="100px">
<el-form-item label="策略名称">
<el-input v-model="name" placeholder="如 我的小市值轮动" style="width: 280px" />
</el-form-item>
<el-form-item label="策略类名">
<el-input v-model="className" style="width: 280px" />
</el-form-item>
</el-form>
</el-card>
<div class="section-bar"><span class="section-mark"></span>策略代码 · {{ type === 'portfolio' ? '组合' : 'CTA' }} 模板已载入按需编辑</div>
<div class="monaco-wrap">
<MonacoEditor v-model="code" language="python" />
</div>
<div class="submit-bar">
<button class="term-btn primary" @click="onCreate">创建策略</button>
<button class="term-btn" @click="onCancel">取消</button>
</div>
</div>
</template>
<style scoped>
.new-page { display: flex; flex-direction: column; gap: 14px; height: calc(100vh - 80px); }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; }
.type-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.type-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 14px 16px;
cursor: pointer;
transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.type-card:hover { border-color: rgba(0, 229, 255, 0.4); }
.type-card.active {
border-color: var(--brand);
box-shadow: var(--glow-cyan);
}
.type-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.type-name { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.type-desc { font-size: 12px; color: var(--text-2); line-height: 1.6; }
.blk { border: 1px solid var(--border-2); }
.section-bar {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-2);
text-transform: uppercase;
}
.section-mark {
display: inline-block;
width: 3px;
height: 12px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
.monaco-wrap {
flex: 1;
min-height: 280px;
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.submit-bar { display: flex; gap: 10px; }
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getParams } from '@/api/strategy'
import { apiClient } from '@/api/client'
interface CodeFile {
name: string
type: 'portfolio' | 'cta'
class_name: string
}
interface Instance {
id: number
code_file: string
name: string
params: Record<string, unknown>
symbol_or_pool: string
interval: string
match_session: string
}
const route = useRoute()
const router = useRouter()
const editId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEdit = computed(() => editId.value !== null)
const codes = ref<CodeFile[]>([])
const paramsList = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const form = reactive({
name: '',
code_file: '',
symbol_or_pool: '',
interval: 'd',
match_session: 'next_open',
})
const paramValues = reactive<Record<string, string>>({})
const selectedCode = computed(() => codes.value.find((c) => c.name === form.code_file))
const isPortfolio = computed(() => selectedCode.value?.type === 'portfolio')
const POOL_OPTIONS = [
{ label: '沪深300', value: 'HS300' },
{ label: '中证500', value: 'ZZ500' },
{ label: '中证1000', value: 'ZZ1000' },
{ label: '中证2000', value: 'ZZ2000' },
{ label: '全市场', value: 'ALL' },
]
const INTERVAL_OPTIONS = [
{ label: '日线', value: 'd', matchHint: '每日 1 个撮合点' },
{ label: '15 分钟', value: '15m', matchHint: '每日约 16 个撮合点(每根 15m K线撮合)' },
]
const SESSION_OPTIONS = [
{ label: '下一根 K线 · 开盘价(防未来函数,推荐)', value: 'next_open', dayOnly: false },
{ label: '当前 K线 · 收盘价', value: 'current_close', dayOnly: false },
{ label: '集合竞价开盘价(仅日线)', value: 'call_auction', dayOnly: true },
]
const matchHint = computed(() => INTERVAL_OPTIONS.find((o) => o.value === form.interval)?.matchHint || '')
const sessionsByInterval = computed(() =>
SESSION_OPTIONS.filter((s) => !s.dayOnly || form.interval === 'd'),
)
watch(
() => form.interval,
() => {
if (!sessionsByInterval.value.some((s) => s.value === form.match_session)) {
form.match_session = 'next_open'
}
},
)
async function loadParams(codeFile: string, override: Record<string, unknown> | null): Promise<void> {
const code = codes.value.find((c) => c.name === codeFile)
if (!code) return
try {
const p = await getParams(code.class_name)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
const ov = override && override[k] != null ? override[k] : p.defaults[k]
paramValues[k] = ov != null ? String(ov) : ''
})
} catch {
paramsList.value = []
}
}
onMounted(async () => {
loading.value = true
try {
const { data } = await apiClient.get<{ files: CodeFile[] }>('/strategy/files')
codes.value = data.files
let inst: Instance | undefined
if (isEdit.value) {
const { data: ins } = await apiClient.get<{ instances: Instance[] }>('/strategy/instances')
inst = ins.instances.find((i) => i.id === editId.value)
if (inst) {
form.name = inst.name
form.code_file = inst.code_file
form.symbol_or_pool = inst.symbol_or_pool
form.interval = inst.interval
form.match_session = inst.match_session
}
} else {
if (route.query.code) form.code_file = String(route.query.code)
else if (codes.value.length) form.code_file = codes.value[0].name
}
await loadParams(form.code_file, inst ? inst.params : null)
} finally {
loading.value = false
}
})
async function onSave(): Promise<void> {
if (!form.name.trim()) {
ElMessage.warning('请填写实例名称')
return
}
const params: Record<string, unknown> = {}
paramsList.value.forEach((k) => {
const raw = paramValues[k]
params[k] = raw !== '' && !isNaN(Number(raw)) ? Number(raw) : raw
})
saving.value = true
try {
await apiClient.post('/strategy/instances', {
id: editId.value,
code_file: form.code_file,
name: form.name,
params,
symbol_or_pool: form.symbol_or_pool,
interval: form.interval,
match_session: form.match_session,
})
ElMessage.success(isEdit.value ? '实例已更新(mock' : '实例已创建(mock')
router.push('/strategy')
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function onDelete(): Promise<void> {
if (!editId.value) return
try {
await ElMessageBox.confirm('确认删除该实例?删除后不可恢复。', '删除实例', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await apiClient.delete(`/strategy/instances/${editId.value}`)
ElMessage.success('已删除')
router.push('/strategy')
} catch {
ElMessage.error('删除失败')
}
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">{{ isEdit ? '编辑策略实例' : '新建策略实例' }}</h2>
<p class="page-subtitle">实例 = 策略代码的一组参数变体 · 参数/标的/精度属实例可建多个一键运行</p>
</div>
</div>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">归属与命名</span></template>
<el-form label-width="100px">
<el-form-item label="策略代码">
<el-select v-model="form.code_file" style="width: 320px" :disabled="isEdit" @change="(v: string) => loadParams(v, null)">
<el-option v-for="c in codes" :key="c.name" :label="`${c.name} · ${c.type === 'portfolio' ? '组合' : 'CTA'}`" :value="c.name" />
</el-select>
<span v-if="selectedCode" class="chip" :class="selectedCode.type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'" style="margin-left: 10px">
{{ selectedCode.type === 'portfolio' ? '组合' : 'CTA' }}
</span>
</el-form-item>
<el-form-item label="实例名称">
<el-input v-model="form.name" placeholder="如 茅台双均线(10,30)·15m" style="width: 320px" />
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="blk">
<template #header>
<span class="section-title">参数 · 从代码 {{ selectedCode?.class_name }} 反射</span>
</template>
<div class="param-grid">
<div v-for="k in paramsList" :key="k" class="param-item">
<span class="param-key mono">{{ k }}</span>
<el-input v-model="paramValues[k]" style="width: 140px" />
</div>
<div v-if="!paramsList.length" class="muted">该策略无参数</div>
</div>
</el-card>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">标的 / </span></template>
<el-form label-width="100px">
<el-form-item label="标的/股票池">
<el-select v-if="isPortfolio" v-model="form.symbol_or_pool" style="width: 280px" placeholder="选择股票池">
<el-option v-for="p in POOL_OPTIONS" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
<el-input v-else v-model="form.symbol_or_pool" placeholder="如 600519.SH(不需交易所后缀也可)" style="width: 280px" />
<span class="muted form-hint">{{ isPortfolio ? '组合策略选预设池(成分股由数据层展开)' : 'CTA 输入单标的代码' }}</span>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="blk">
<template #header><span class="section-title">撮合 · 精度与时点</span></template>
<el-form label-width="100px">
<el-form-item label="数据精度">
<el-radio-group v-model="form.interval">
<el-radio-button v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value">{{ o.label }}</el-radio-button>
</el-radio-group>
<span class="muted form-hint">撮合频率 = 精度 · {{ matchHint }}</span>
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.match_session" style="width: 320px">
<el-option v-for="s in sessionsByInterval" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<span class="muted form-hint">每根 K 线按此时点的价格撮合</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<button class="term-btn primary" :disabled="saving" @click="onSave">{{ saving ? '保存中' : '保存实例' }}</button>
<button class="term-btn" @click="router.push('/strategy')">取消</button>
<button v-if="isEdit" class="term-btn danger" style="margin-left:auto" @click="onDelete">删除实例</button>
</div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 14px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; }
.blk { border: 1px solid var(--border-2); }
.param-grid { display: flex; flex-wrap: wrap; gap: 12px 20px; }
.param-item { display: flex; align-items: center; gap: 8px; }
.param-key { font-size: 12px; color: var(--text-2); min-width: 120px; }
.form-hint { margin-left: 10px; font-size: 12px; }
.submit-bar { display: flex; gap: 10px; }
</style>
+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiClient } from '@/api/client'
interface CodeFile {
name: string
type: 'portfolio' | 'cta'
class_name: string
lines: number
}
interface Instance {
id: number
code_file: string
name: string
type: 'portfolio' | 'cta'
params: Record<string, unknown>
symbol_or_pool: string
interval: string
match_session: string
status: { backtest: string; replay: string; paper_live: string; live: string }
last_return: number | null
updated_at: string
}
const router = useRouter()
const files = ref<CodeFile[]>([])
const instances = ref<Instance[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const [f, ins] = await Promise.all([
apiClient.get<{ files: CodeFile[] }>('/strategy/files'),
apiClient.get<{ instances: Instance[] }>('/strategy/instances'),
])
files.value = f.data.files
instances.value = ins.data.instances
} finally {
loading.value = false
}
})
const grouped = computed(() =>
files.value.map((f) => ({
file: f,
instances: instances.value.filter((i) => i.code_file === f.name),
})),
)
function editCode(name: string): void {
router.push({ path: '/strategy/code', query: { file: name } })
}
function newStrategy(): void {
router.push('/strategy/new')
}
function newInstance(f: CodeFile): void {
router.push({ path: '/strategy/instance/new', query: { code: f.name } })
}
function editInstance(i: Instance): void {
router.push(`/strategy/instance/${i.id}`)
}
function runBacktest(i: Instance): void {
router.push({
path: i.type === 'portfolio' ? '/backtest/portfolio' : '/backtest/new',
query: { class: i.code_file, instance: String(i.id) },
})
}
function runReplay(i: Instance): void {
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
}
function runPaperLive(i: Instance): void {
router.push({ path: '/paper/new', query: { mode: 'live', instance: String(i.id) } })
}
function runLive(i: Instance): void {
router.push({ path: '/live/new', query: { instance: String(i.id) } })
}
async function onDelInst(i: Instance): Promise<void> {
try {
await ElMessageBox.confirm(`确认删除实例「${i.name}」?`, '删除实例', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await apiClient.delete(`/strategy/instances/${i.id}`)
instances.value = instances.value.filter((x) => x.id !== i.id)
ElMessage.success('已删除')
} catch {
ElMessage.error('删除失败')
}
}
const typeChip = (t: string): string => (t === 'portfolio' ? 'chip-portfolio' : 'chip-cta')
const typeLabel = (t: string): string => (t === 'portfolio' ? '组合' : 'CTA')
const lampOf = (s: string): string => {
if (s === 'running') return 'lamp-ok lamp-pulse'
if (s === 'done') return 'lamp-ok'
if (s === 'stopped') return 'lamp-idle'
if (s === 'failed') return 'lamp-crit'
return 'lamp-idle'
}
const stTxt = (s: string): string => {
if (s === '-') return '—'
return ({ running: '运行', done: '已测', stopped: '已停', failed: '失败' } as Record<string, string>)[s] || s
}
const pct = (v: number | null): string => (v == null ? '—' : (v * 100).toFixed(2) + '%')
const retClass = (v: number | null): string => (v == null ? '' : v >= 0 ? 'up' : 'down')
const paramSummary = (p: Record<string, unknown>): string =>
Object.entries(p)
.map(([k, v]) => `${k}=${v}`)
.join(' · ')
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">策略库</h2>
<p class="page-subtitle">策略代码 实例参数变体 回测 / 模拟盘 / 实盘 全生命周期</p>
</div>
<button class="term-btn primary" @click="newStrategy">+ 新建策略</button>
</div>
<div class="groups">
<div v-for="g in grouped" :key="g.file.name" class="code-group">
<div class="code-head">
<div class="code-title">
<span class="code-name mono">{{ g.file.name }}</span>
<span class="chip" :class="typeChip(g.file.type)">{{ typeLabel(g.file.type) }}</span>
<span class="code-cls mono muted">{{ g.file.class_name }}</span>
</div>
<div class="code-actions">
<button class="term-btn sm" @click="editCode(g.file.name)">编辑代码</button>
<button class="term-btn sm primary" @click="newInstance(g.file)">+ 新实例</button>
</div>
</div>
<div class="inst-table">
<div class="inst-row inst-head-row">
<span class="c-name">实例</span>
<span class="c-params">参数</span>
<span class="c-sym">标的/</span>
<span class="c-ret">最近收益</span>
<span class="c-run">回测</span>
<span class="c-run">回放</span>
<span class="c-run">实走</span>
<span class="c-run">实盘</span>
<span class="c-ops">操作</span>
</div>
<div v-for="i in g.instances" :key="i.id" class="inst-row">
<span class="c-name inst-name clickable" @click="editInstance(i)">{{ i.name }}</span>
<span class="c-params mono muted">{{ i.interval }} · {{ paramSummary(i.params) }}</span>
<span class="c-sym mono">{{ i.symbol_or_pool }}</span>
<span class="c-ret mono" :class="retClass(i.last_return)">{{ pct(i.last_return) }}</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.backtest !== '-' }" @click="runBacktest(i)">
<span class="lamp" :class="lampOf(i.status.backtest)"></span>{{ stTxt(i.status.backtest) }}
</button>
</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.replay !== '-' }" @click="runReplay(i)">
<span class="lamp" :class="lampOf(i.status.replay)"></span>{{ stTxt(i.status.replay) }}
</button>
</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.paper_live !== '-' }" @click="runPaperLive(i)">
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>{{ stTxt(i.status.paper_live) }}
</button>
</span>
<span class="c-run">
<button class="run-btn" :class="{ active: i.status.live !== '-' }" @click="runLive(i)">
<span class="lamp" :class="lampOf(i.status.live)"></span>{{ stTxt(i.status.live) }}
</button>
</span>
<span class="c-ops">
<button class="term-btn sm" @click="editInstance(i)">编辑</button>
<button class="term-btn sm danger" @click="onDelInst(i)">删除</button>
</span>
</div>
<div v-if="!g.instances.length" class="inst-empty muted">暂无实例编辑代码后可创建参数实例</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 16px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.groups { display: flex; flex-direction: column; gap: 14px; }
.code-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.code-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.code-title { display: flex; align-items: center; gap: 10px; }
.code-name { font-size: 13px; font-weight: 600; color: var(--text); }
.code-cls { font-size: 11px; }
.code-actions { display: flex; gap: 6px; }
.inst-table { display: flex; flex-direction: column; }
.inst-row {
display: grid;
grid-template-columns: 1.4fr 2fr 1fr 0.9fr repeat(4, 0.8fr) 0.9fr;
align-items: center;
gap: 8px;
padding: 9px 14px;
border-bottom: 1px solid var(--border-2);
font-size: 12px;
}
.inst-row:last-child { border-bottom: none; }
.inst-head-row {
background: var(--panel);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 6px 14px;
}
.inst-name { color: var(--text); font-weight: 500; }
.inst-name.clickable { cursor: pointer; }
.inst-name.clickable:hover { color: var(--brand); }
.c-params { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.c-sym { color: var(--amber); font-size: 11.5px; }
.c-ret { font-weight: 600; }
.c-run { text-align: center; }
.c-ops { display: flex; gap: 4px; justify-content: center; }
.run-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
background: transparent;
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
color: var(--text-3);
font-family: var(--mono);
font-size: 10px;
cursor: pointer;
transition: all 0.12s var(--ease);
}
.run-btn:hover { border-color: rgba(0,229,255,0.5); color: var(--brand); }
.run-btn.active { color: var(--text-2); border-color: var(--border); }
.run-btn .lamp { width: 5px; height: 5px; }
.inst-empty { padding: 16px 14px; font-size: 12px; }
</style>
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig({
'/ws': { target: 'ws://192.168.2.154:8000', ws: true, changeOrigin: true },
},
},
optimizeDeps: { exclude: ['monaco-editor'] },
build: { outDir: 'dist', emptyOutDir: true },
test: { environment: 'jsdom', globals: true },
})