feat(frontend): 终端化重构+策略管理(代码→实例→4运行)+撮合设计+回测参数 [nas]
- 终端化: 科幻色板(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:
@@ -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 Dark</span> 工具感,信息密度低、无命令面板、无归因深度。本提案把它重做成一台 <span class="amber">科幻终端</span>:荧光青 × 琥珀数据 × 多面板高密度,对标 Bloomberg / Wind 的终端 UX,QuantConnect 的分析深度。</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"><GO></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">月度收益热力图 · 2023–2024</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">1–2 周</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">1–2 月</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"><GO></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 Table:tabular-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">~ 4–6 周</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 |
@@ -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 CTA(ashare_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/P5:vnpy 只适配不改源码;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 Editor(VSCode 同款)+ 文件树 + 语法校验(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 / 全市场 / 自定义成分),不手输
|
||||
Generated
+41
-35
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface LiveAccount {
|
||||
latest_date?: string | null
|
||||
total_return?: number | null
|
||||
position_count?: number
|
||||
instance?: string
|
||||
}
|
||||
|
||||
export interface LiveCreateRequest {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PaperAccount {
|
||||
latest_equity?: number | null
|
||||
latest_date?: string | null
|
||||
total_return?: number | null
|
||||
instance?: string
|
||||
}
|
||||
|
||||
export interface BalancePoint {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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' }
|
||||
@@ -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 configs(CRUD)
|
||||
{ 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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') },
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 涨跌色工具 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+354
-57
@@ -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>
|
||||
<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="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>
|
||||
<div class="user-area">
|
||||
<span class="user-avatar">{{ (auth.username ?? '?').slice(0, 1).toUpperCase() }}</span>
|
||||
<span class="user-name">{{ auth.username }}</span>
|
||||
<span class="sep">|</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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">
|
||||
提交回测
|
||||
|
||||
@@ -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,66 +177,73 @@ 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>
|
||||
<!-- 主图:净值 + 基准对比 -->
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 风险分析 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 ?? []"
|
||||
@@ -271,120 +251,177 @@ function fmtDate(d: string): string {
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 交易详情 -->
|
||||
<!-- 详情 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-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="120" align="right">
|
||||
<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>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 日志输出 -->
|
||||
<el-tab-pane label="日志输出">
|
||||
<el-card>
|
||||
<template #header><span class="section-title">系统日志</span></template>
|
||||
<el-tab-pane label="系统日志">
|
||||
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
|
||||
</el-card>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('请填写实例名')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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.broker(order_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>
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user