tradingSystem/app/web/static/index.html

1910 lines
124 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PMS 持仓管理系统</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css"/>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
:root, html[data-theme=light]{
--plane:#f4f5f2; --surface:#ffffff; --surface-2:#fafaf8; --ink:#141412; --ink-2:#54534f; --muted:#8a8882;
--hair:#e7e6df; --ring:rgba(17,17,16,.10); --accent:#2a78d6; --accent-weak:#ecf3fd;
--up:#d5342f; --up-weak:#fbeceb; --down:#158a4c; --down-weak:#e7f4ec;
--warn:#9a6a00; --warn-weak:#fbf1d6; --stop:#b4552b; --stop-weak:#fbeadf;
--shadow:0 1px 2px rgba(17,17,16,.04),0 2px 8px rgba(17,17,16,.045);
}
[v-cloak]{display:none;}
body{margin:0;background:var(--plane);color:var(--ink);
font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;}
.app{max-width:1280px;margin:0 auto;padding:0 20px 44px;}
.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;}
.muted{color:var(--muted);font-size:12.5px;}
.nowrap{white-space:nowrap;}
.spacer{flex:1;}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.clr-up{color:var(--up);} .clr-down{color:var(--down);}
/* 顶栏 */
.hd{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:9px;flex-wrap:wrap;
margin:0 -20px 6px;padding:10px 20px;background:rgba(244,245,242,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--hair);}
.hd h1{font-size:15px;margin:0;font-weight:680;letter-spacing:.2px;}
.hd .ver{color:var(--muted);font-size:12px;font-weight:400;margin-left:6px;}
.seg{display:inline-flex;border:1px solid var(--hair);border-radius:8px;overflow:hidden;background:var(--surface);}
.seg button{border:0;background:transparent;color:var(--ink-2);padding:5px 12px;font-size:13px;cursor:pointer;}
.seg button.on{background:var(--accent);color:#fff;}
.tbtn{border:1px solid var(--hair);background:var(--surface);color:var(--ink);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer;font-weight:520;}
.tbtn:hover{border-color:var(--accent);color:var(--accent);}
.tbtn.pri{background:var(--accent);border-color:var(--accent);color:#fff;}
.tbtn.pri:hover{color:#fff;}
.tbtn.sm{padding:3px 10px;font-size:12.5px;}
/* 状态胶囊(系统状态:不用红绿) */
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:560;padding:3px 9px;border-radius:999px;white-space:nowrap;}
.chip .dot{width:6px;height:6px;border-radius:50%;flex:none;}
.chip.ok{background:var(--accent-weak);color:var(--accent);} .chip.ok .dot{background:var(--accent);}
.chip.warn{background:var(--warn-weak);color:var(--warn);} .chip.warn .dot{background:var(--warn);}
.chip.idle{background:rgba(138,136,130,.15);color:var(--ink-2);} .chip.idle .dot{background:var(--muted);}
.chip.stop{background:var(--stop-weak);color:var(--stop);} .chip.stop .dot{background:var(--stop);}
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;
border:1px solid var(--muted);color:var(--muted);font-size:10px;cursor:help;position:relative;font-weight:600;margin-left:3px;}
.help:hover::after{content:attr(data-tip);position:absolute;left:0;top:150%;width:250px;background:var(--ink);color:#fff;
font-size:12px;font-weight:400;line-height:1.5;padding:8px 10px;border-radius:8px;z-index:50;box-shadow:0 6px 20px rgba(0,0,0,.22);}
/* KPI 焦点行 */
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin:16px 0;}
.m{background:var(--surface);border:1px solid var(--hair);border-radius:12px;padding:12px 15px;box-shadow:var(--shadow);}
.m .k{color:var(--muted);font-size:12px;}
.m .v{color:var(--ink);font-size:21px;font-weight:680;margin-top:5px;letter-spacing:-.2px;font-variant-numeric:tabular-nums;}
.m .v .muted,.m .v span{font-weight:500;}
.m .v.up{color:var(--up);} .m .v.down{color:var(--down);}
/* 颜色约定图例 */
.legend{display:flex;gap:15px;flex-wrap:wrap;align-items:center;color:var(--muted);font-size:12px;margin:0 2px 16px;}
.legend .sw{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px;vertical-align:middle;}
/* 卡片/面板 */
.panel{background:var(--surface);border:1px solid var(--hair);border-radius:14px;padding:16px 18px;margin-bottom:16px;box-shadow:var(--shadow);}
.panel h3,.tblk h3{margin:0 0 10px;font-size:15px;color:var(--ink);font-weight:660;}
.sub2{color:var(--muted);font-size:12.5px;margin:-4px 0 12px;}
.banner{margin:10px 0;}
.attn{color:var(--warn);padding:4px 0;}
.tcard{background:var(--surface-2);border:1px solid var(--hair);border-radius:11px;padding:12px 14px;margin-bottom:10px;}
.feed-row{padding:5px 0;border-bottom:1px solid var(--hair);font-size:13px;}
.feed-row:last-child{border-bottom:none;}
.tag{display:inline-block;font-size:11.5px;font-weight:560;padding:2px 8px;border-radius:6px;background:rgba(138,136,130,.14);color:var(--ink-2);}
.tier{font-size:12px;color:var(--muted);}
/* 处置胶囊(候选表) */
.disp{display:inline-flex;align-items:center;gap:3px;font-size:12px;font-weight:600;padding:3px 9px;border-radius:7px;}
.disp.done{background:var(--accent-weak);color:var(--accent);}
.disp.queue{background:var(--warn-weak);color:var(--warn);}
.disp.deny{background:var(--stop-weak);color:var(--stop);}
.disp.would{background:var(--surface);color:var(--accent);border:1px solid var(--accent);}
.disp.wait{background:rgba(138,136,130,.14);color:var(--ink-2);}
.why{color:var(--ink-2);font-size:12.5px;margin-top:3px;line-height:1.45;}
.pool-note{margin:0 0 12px;padding:9px 12px;background:var(--surface-2);border:1px solid var(--hair);border-radius:9px;color:var(--ink-2);font-size:12.5px;}
.tabn{color:var(--muted);font-weight:600;font-size:12px;margin-left:3px;}
/* 组合操作 / 设置 */
.op-row{display:flex;align-items:flex-start;gap:12px;padding:9px 0;border-bottom:1px solid var(--hair);}
.op-row:last-child{border-bottom:none;}
.op-btn{flex:none;width:150px;} .op-btn .el-button{width:100%;}
.op-row .d{color:var(--ink-2);font-size:12.5px;line-height:1.55;padding-top:3px;}
.set-card{border:1px solid var(--hair);border-radius:10px;padding:12px 14px;margin-bottom:10px;background:var(--surface-2);}
.set-sum{color:var(--ink);font-size:13px;margin-bottom:8px;line-height:1.6;}
.set-fields{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center;color:var(--ink-2);font-size:12px;}
pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:8px;padding:10px;max-height:420px;overflow:auto;font-size:12px;color:var(--ink-2);}
/* Element Plus 浅色微调 */
.el-table{background:transparent!important;font-size:13px;
--el-table-border-color:var(--hair);--el-table-header-bg-color:var(--surface-2);
--el-table-row-hover-bg-color:var(--surface-2);--el-table-bg-color:transparent;--el-table-tr-bg-color:transparent;}
.el-table th.el-table__cell{background:var(--surface-2)!important;color:var(--muted)!important;font-weight:560;}
.el-table td.el-table__cell,.el-table th.el-table__cell{padding:10px 0;border-color:var(--hair)!important;}
.el-table .cell{color:var(--ink);line-height:1.5;}
.el-tabs__item{font-size:14px;color:var(--muted);font-weight:560;}
.el-tabs__item.is-active{color:var(--ink);}
.el-tabs__active-bar{background:var(--accent);}
.el-tabs__nav-wrap::after{background:var(--hair);height:1px;}
</style>
</head>
<body>
<div id="app" class="app" v-cloak>
<div class="hd">
<h1>PMS 持仓管理<span class="ver">v{{ health.version || '-' }} · {{ health.now || '' }}</span></h1>
<span class="chip" :class="health.trade_day?'ok':'idle'"><span class="dot"></span>{{ health.trade_day ? '交易日':'非交易日' }}</span>
<span class="chip" :class="ov.buy_halt?'warn':'ok'"><span class="dot"></span>买入 {{ ov.buy_halt ? '已暂停':'正常' }}</span>
<span class="chip" :class="ov.exec_halt?'warn':'ok'"><span class="dot"></span>执行 {{ ov.exec_halt ? '已暂停':'正常' }}</span>
<span class="chip" :class="ov.autonomy==='off'?'warn':'ok'"><span class="dot"></span>自主 {{ tx('autonomy', ov.autonomy) }}<span class="help" data-tip="系统自己出手的程度:全自动=闸门通过就执行;只提议=等你在『等我拍板』确认;暂停=不自动动作。">?</span></span>
<span class="chip" :class="planStatus.ok===false?'warn':'ok'"><span class="dot"></span>计划 {{ planStatus.ok===false ? '不可用' : (planStatus.date || '-') }}</span>
<span class="chip" :class="dm.shadow?'idle':'ok'" v-show="mode==='ops'"><span class="dot"></span>下发 {{ dm.mode || '-' }}<span class="help" data-tip="实盘=真发到 QMT 下单;影子=只记录不真下单,用来观察。">?</span></span>
<div class="spacer"></div>
<div class="seg">
<button :class="{on:mode==='trader'}" @click="mode='trader'">交易员</button>
<button :class="{on:mode==='ops'}" @click="mode='ops'">运维</button>
</div>
<button class="tbtn" @click="openPlan">上游计划</button>
<button class="tbtn" @click="openReport">日报</button>
<button class="tbtn" v-show="mode==='ops'" @click="opsDrawer=true">运维</button>
<button class="tbtn pri" :disabled="loading" @click="loadAll">{{ loading ? '刷新中…' : '刷新' }}</button>
</div>
<el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon
@close="err=''"></el-alert>
<el-alert v-if="planStatus.ok===false" class="banner" type="error" effect="dark" show-icon
:closable="false"
:title="'上游选股计划不可用 —— 升仓/建仓类命令将无票可选: ' + (planStatus.hint||'')">
</el-alert>
<el-alert v-if="ov.sector_ready===false" class="banner" type="warning" effect="dark" show-icon
:closable="false"
title="行业划分数据源未配置 —— 行业集中度硬拦截已停用, 行业类命令置灰 (设计 §5)"></el-alert>
<el-alert v-if="ov.price_missing && ov.price_missing.length" class="banner" type="warning"
effect="dark" show-icon :closable="false"
:title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"></el-alert>
<el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon
:closable="false" title="交易日历降级: 未安装 chinesecalendar, 节假日不可辨"></el-alert>
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
:title="'影子运行中 —— ' + (dm.hint || '')"></el-alert>
<el-alert v-if="scaleGap" class="banner" type="warning" effect="dark" show-icon :closable="false"
:title="'总规模与账户不符 —— 参数 ' + money(ov.scale) + ' / 账户总资产 ' + money(ov.total_asset) + ' (' + pct(scaleGap) + ')。仓位上限按参数算、资金校验按账户算, 差得远时方案会过闸却下不出去'"></el-alert>
<!-- ==================== 交易员视图 ==================== -->
<div v-show="mode==='trader'">
<!-- 1) 今日一览 + 需要注意 -->
<div class="metrics">
<div class="m"><div class="k">账户总资产</div><div class="v">{{ money(ov.total_asset) }}</div></div>
<div class="m"><div class="k">可用资金<span class="muted" v-if="ov.cash_source!=='ws'"> (估算)</span></div>
<div class="v" :class="ov.cash_source==='ws'?'':'muted'">{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}</div></div>
<div class="m"><div class="k">持仓市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
<div class="m"><div class="k">仓位</div><div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
<div class="m"><div class="k">浮动盈亏</div>
<div class="v" :class="(ov.float_pnl||0)>=0?'up':'down'">{{ money(ov.float_pnl) }}<span style="font-size:13px"> {{ ov.float_pnl_pct==null?'':pct(ov.float_pnl_pct) }}</span></div></div>
<div class="m"><div class="k">持仓数</div><div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
<div class="m" style="cursor:pointer" @click="scrollTo('t-prop')"><div class="k">等我拍板</div>
<div class="v" :class="proposals.length?'up':''">{{ proposals.length }} 条</div></div>
<div class="m"><div class="k">自主档位</div><div class="v" style="font-size:15px">{{ tx('autonomy', ov.autonomy) }}</div></div>
</div>
<div class="legend">
<span>颜色约定:</span>
<span><i class="sw" style="background:var(--up)"></i><b class="clr-up">红=涨/盈</b></span>
<span><i class="sw" style="background:var(--down)"></i><b class="clr-down">绿=跌/亏</b></span>
<span><i class="sw" style="background:var(--warn)"></i><b style="color:var(--warn)">琥珀=需注意</b></span>
<span><i class="sw" style="background:var(--stop)"></i><b style="color:var(--stop)">橙=被拦下</b></span>
<span><i class="sw" style="background:var(--accent)"></i><b style="color:var(--accent)">蓝=可操作/正常</b></span>
<span class="help" data-tip="红绿只用于价格涨跌和盈亏;系统状态(正常/暂停/过期/否决)改用蓝/琥珀/橙/灰,避免看混。">?</span>
</div>
<div class="panel" v-if="attention.length" style="border-color:#7a5b1e">
<h3 style="color:#e6a23c">需要注意</h3>
<div v-for="(a,i) in attention" :key="i" class="attn">· {{ a }}</div>
</div>
<!-- 2) 等我拍板 -->
<div class="panel tblk" id="t-prop">
<h3>等我拍板 · {{ proposals.length }} 条</h3>
<div class="muted" v-if="!proposals.length">暂无需要你确认的提议。</div>
<div v-for="p in proposals" :key="p.proposal_id" class="tcard">
<div class="row" style="justify-content:space-between">
<div><b style="font-size:15px">{{ nm(p.ts_code) }}</b> <span class="muted mono">{{ p.ts_code }}</span>
<el-tag size="small" effect="dark" type="primary" style="margin-left:8px">{{ tx('action', p.action) }}</el-tag></div>
<div class="muted">{{ p.expire_at }} 前有效</div>
</div>
<div style="margin:6px 0" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ p.judge_reason }}</div>
<div class="muted" style="margin:4px 0">{{ propWhy(p) }}</div>
<div class="row" style="margin-top:6px">
<span class="muted">约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }}
· 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}</span>
<div class="spacer"></div>
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
<el-button type="danger" size="small" @click="decide(p,'DECLINED')">驳回</el-button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">采纳=记一笔待执行指令,由择时在窗口内择机下单;驳回=今日不再提这只。</div>
</div>
</div>
<!-- 3) 我的持仓 -->
<div class="panel tblk" id="t-pos">
<h3>我的持仓 · {{ heldPositions.length }} 只</h3>
<el-table :data="heldPositions" size="small" border max-height="460" @expand-change="onPosExpand">
<el-table-column type="expand">
<template #default="s">
<div style="padding:6px 16px">
<div class="muted" style="margin-bottom:6px">持仓批次 + 账本(展开即载入,无需再点按钮)</div>
<el-table v-if="lotsOf===s.row.ts_code && lots.length" :data="lots" size="small" border>
<el-table-column label="批次" width="100"><template #default="l">{{ tx('lot', l.row.lot_type) }}</template></el-table-column>
<el-table-column prop="qty" label="剩余" width="80"></el-table-column>
<el-table-column prop="open_price" label="开仓价" width="90"></el-table-column>
<el-table-column prop="open_date" label="开仓日" width="110"></el-table-column>
<el-table-column prop="realized_pnl" label="已实现" width="90"></el-table-column>
</el-table>
<div class="muted" v-else-if="lotsOf===s.row.ts_code" style="padding:2px 0">无持仓批次</div>
<div class="muted" v-else style="padding:2px 0">载入中…</div>
<div style="border-top:1px solid #333;margin:8px 0 4px"></div>
<div class="muted" style="margin-bottom:2px">该股账本</div>
<div class="muted" v-if="!ledger.filter(x=>x.ts_code===s.row.ts_code).length">今日暂无记录</div>
<div v-for="(l,i) in ledger.filter(x=>x.ts_code===s.row.ts_code)" :key="i" style="padding:2px 0">
<span class="muted">{{ (l.decided_at||'').slice(5,16) }}</span>
{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> — {{ l.reason }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="股票" width="150">
<template #default="s"><b>{{ nm(s.row.ts_code) }}</b><br><span class="muted mono">{{ s.row.ts_code }}</span></template>
</el-table-column>
<el-table-column label="现价 / 成本" width="120">
<template #default="s">{{ s.row.price }}<span v-if="!s.row.price_ok" class="muted"></span><br><span class="muted">{{ s.row.avg_cost||'—' }}</span></template>
</el-table-column>
<el-table-column label="浮动盈亏" width="110">
<template #default="s">
<span v-if="s.row.price_ok && s.row.avg_cost" :class="(s.row.price/s.row.avg_cost-1)>=0?'clr-up':'clr-down'">{{ pct(s.row.price/s.row.avg_cost-1) }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="市值 / 占比" width="130">
<template #default="s">{{ money(s.row.market_value) }}<br><span class="muted">{{ pct(s.row.pct_of_scale) }}</span></template>
</el-table-column>
<el-table-column label="安全垫" width="120">
<template #default="s"><el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">{{ tx('cushion', s.row.cushion_state) }}</el-tag>
<div class="muted">{{ pct(s.row.cushion_pct) }}<span v-if="s.row.neg_cushion_days>0"> · 连负{{ s.row.neg_cushion_days }}日</span></div></template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="s">{{ tx('posStatus', s.row.status) }}
<div v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" style="color:#f56c6c">{{ tx('frozen', s.row.frozen_reason) }}</div></template>
</el-table-column>
<el-table-column label="下一步" min-width="150"><template #default="s"><span class="muted">{{ nextStep(s.row) }}</span></template></el-table-column>
<el-table-column label="操作" width="220">
<template #default="s">
<el-button size="small" @click="actReduceStock(s.row)">减仓</el-button>
<el-button size="small" type="danger" @click="actExitStock(s.row)">清仓</el-button>
<el-dropdown style="margin-left:6px" @command="c=>rowMore(c,s.row)">
<el-button size="small">更多 ▾</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="stop">设止损价</el-dropdown-item>
<el-dropdown-item command="target">设目标价</el-dropdown-item>
<el-dropdown-item command="freeze">{{ (s.row.frozen_reason && s.row.frozen_reason!=='NONE') ? '解冻' : '冻结(禁增持)' }}</el-dropdown-item>
<el-dropdown-item command="black">加入黑名单</el-dropdown-item>
<el-dropdown-item command="strategy" divided>挂交易方案做T/网格/跟踪止盈)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
<!-- 4) 今日在办与动向 -->
<div class="panel tblk">
<h3>今日在办</h3>
<div class="sub2">今天下出去的买卖单,按状态分三档:还在跑的、已成交的、已经结束的。</div>
<el-tabs v-model="insTab">
<el-tab-pane name="live">
<template #label>在办中 <span class="tabn">{{ insLive.length }}</span></template>
<el-table :data="insLive" size="small" max-height="260" empty-text="没有在办的买卖">
<el-table-column label="股票" min-width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="动作" width="150"><template #default="s">{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}</template></el-table-column>
<el-table-column label="数量 / 已成" width="120" align="right"><template #default="s"><span class="mono">{{ s.row.qty }} / {{ s.row.exec_qty||0 }}</span></template></el-table-column>
<el-table-column label="限价" width="82" align="right"><template #default="s"><span class="mono">{{ s.row.limit_price||'—' }}</span></template></el-table-column>
<el-table-column label="状态" width="118"><template #default="s"><span class="chip idle"><span class="dot"></span>{{ tx('insStatus', s.row.status) }}</span></template></el-table-column>
<el-table-column label="操作" width="76"><template #default="s"><button class="tbtn sm" @click="cancelIns(s.row.instruction_id)">撤单</button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="done">
<template #label>今日成交 <span class="tabn">{{ insDone.length }}</span></template>
<el-table :data="insDone" size="small" max-height="260" empty-text="今天还没有成交">
<el-table-column label="股票" min-width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="动作" width="150"><template #default="s">{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}</template></el-table-column>
<el-table-column label="数量 / 已成" width="120" align="right"><template #default="s"><span class="mono">{{ s.row.qty }} / {{ s.row.exec_qty||0 }}</span></template></el-table-column>
<el-table-column label="状态" width="120"><template #default="s"><span class="chip ok"><span class="dot"></span>{{ tx('insStatus', s.row.status) }}</span></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="end">
<template #label>已了结 <span class="tabn">{{ insEnd.length }}</span></template>
<el-table :data="insEnd" size="small" max-height="260" empty-text="今天没有了结的单">
<el-table-column label="股票" min-width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="动作" width="150"><template #default="s">{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}</template></el-table-column>
<el-table-column label="结束方式" min-width="150"><template #default="s"><span class="chip warn"><span class="dot"></span>{{ tx('insStatus', s.row.status) }}</span></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="deny">
<template #label>今日否决 <span class="tabn">{{ denyToday.length }}</span></template>
<div class="sub2" style="margin:8px 2px 4px">今天考察过、但没建仓的票,连原因一起摆出来。</div>
<el-table :data="denyToday" size="small" max-height="320" empty-text="今天没有被否决的候选">
<el-table-column label="股票" width="180"><template #default="s"><b>{{ s.row.name }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="为什么没建仓" min-width="440"><template #default="s"><div class="why" style="margin:0">{{ s.row.why || '—' }}</div></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<h3 style="margin-top:14px;cursor:pointer" @click="showLedger=!showLedger">今天发生了什么 · {{ ledger.length }} 条 <span class="muted" style="font-size:12px;font-weight:normal">{{ showLedger?'▲ 收起':'▼ 展开' }}</span></h3>
<template v-if="showLedger">
<div class="muted" v-if="!ledger.length">今天还没有记录。</div>
<div v-for="(l,i) in ledger" :key="i" class="feed-row">
<span class="muted mono">{{ (l.decided_at||'').slice(11,16) }}</span>
<b> {{ nm(l.ts_code) }}</b>{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span>
</div>
</template>
</div>
<!-- 4.5) 我的策略 + 操作日志 -->
<div class="panel tblk">
<h3>我的策略 · {{ strategies.length }} 个
<el-tag size="small" :type="stratEnabled?'primary':'info'" effect="dark" style="margin-left:8px">
{{ stratEnabled ? '策略层已启用' : '策略层总开关未开PMS_STRATEGY_ENABLED' }}</el-tag>
</h3>
<el-alert v-if="!stratEnabled" type="info" effect="dark" :closable="false" show-icon style="margin-bottom:8px"
title="策略挂了也不会出手:请在「设置 / 运维」把 PMS_STRATEGY_ENABLED 打开;影子还是实盘由 PMS_DISPATCH_MODE 决定。"></el-alert>
<div class="muted" v-if="!strategies.length">还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。</div>
<el-table v-else :data="strategies" size="small" border max-height="300">
<el-table-column label="股票" width="150"><template #default="s"><b>{{ nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="类型" width="86"><template #default="s"><el-tag size="small" effect="dark">{{ tx('stype', s.row.type) }}</el-tag></template></el-table-column>
<el-table-column label="自主档" width="86"><template #default="s">{{ s.row.autonomy==='auto'?'自动执行':'待我拍板' }}</template></el-table-column>
<el-table-column label="实时状态" min-width="230"><template #default="s"><span class="muted">{{ stratStateText(s.row) }}</span>
<el-tag v-if="s.row.buy_paused" size="small" type="warning" effect="dark" style="margin-left:6px">买入已暂停</el-tag>
<div v-if="s.row.buy_paused" style="color:#9a6a00;font-size:11px">决策系统风控:{{ s.row.buy_paused.reason || '资金异动' }}(卖出/平回不受影响;确认无碍点右侧「恢复买入」)</div>
</template></el-table-column>
<el-table-column label="状态" width="84"><template #default="s"><el-tag size="small" :type="s.row.status==='ACTIVE'?'primary':'info'">{{ tx('stratStatus', s.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="260"><template #default="s">
<el-button v-if="s.row.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.row.strategy_id,'PAUSED')">暂停</el-button>
<el-button v-else-if="s.row.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.row.strategy_id,'ACTIVE')">恢复</el-button>
<el-button v-if="s.row.buy_paused" size="small" type="warning" @click="resumeBuy(s.row)">恢复买入</el-button>
<el-button size="small" type="danger" @click="setStrategyStatus(s.row.strategy_id,'CANCELLED')">撤下</el-button>
</template></el-table-column>
</el-table>
<h3 style="margin-top:14px">操作日志 · 最近 {{ opLog.length }} 条</h3>
<div class="muted" style="margin-bottom:6px">每一次页面写操作都留一行;被仓位 / 盈亏 / T+1 可卖 / 单股上限挡下的,连原因一起记,不静默。</div>
<div class="muted" v-if="!opLog.length">还没有操作记录。</div>
<div v-for="(o,i) in opLog" :key="i" style="padding:3px 0;border-bottom:1px solid #262626">
<span class="muted">{{ (o.at||'').slice(11,16) }}</span>
<el-tag size="small" :type="o.result==='OK'?'primary':(o.result==='BLOCKED'?'warning':'info')" style="margin:0 6px">{{ o.result }}</el-tag>
<b>{{ o.op }}</b><span v-if="o.ts_code" class="muted"> · {{ nm(o.ts_code) }}</span>
<span v-if="o.reason" style="color:#f56c6c"> — {{ o.reason }}</span>
</div>
</div>
<!-- 挂交易方案 对话框 (非盲填: 现价/持仓/可卖/浮盈都摆出来) -->
<el-dialog v-model="stratDlg.visible" :title="'挂交易方案 · ' + nm(stratDlg.ts_code)" width="580px">
<div class="muted" style="margin-bottom:10px">
现价 <b>{{ stratDlg.price }}</b> · 持仓 <b>{{ stratDlg.total_qty }}</b>T+1 可卖 <b>{{ stratDlg.avail_qty }}</b>)· 浮盈 <b>{{ pct(stratDlg.cushion_pct) }}</b>。参数按这些现值给了默认,不用盲填。
</div>
<el-form label-width="112px" size="small">
<el-form-item label="方案类型">
<el-radio-group v-model="stratDlg.type">
<el-radio-button label="T0">做T</el-radio-button>
<el-radio-button label="GRID">网格</el-radio-button>
<el-radio-button label="TRAIL">跟踪止盈</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="stratDlg.type==='T0'">
<el-form-item label="T仓比例">
<el-input v-model="stratDlg.p.t_ratio" style="width:120px"><template #append>×持仓</template></el-input>
<span class="muted" style="margin-left:8px">≤ 1/3每轮约 <b>{{ stratQty }}</b></span>
</el-form-item>
<el-form-item label="怎么做">
<span class="muted">回落近支撑→买、按目标价差高卖正T近压力或滞涨→卖存量、低买回反T。当日 ≤ 3 次14:50 强制平回,单票 / 全局当日T亏熔断。</span>
</el-form-item>
</template>
<template v-else-if="stratDlg.type==='GRID'">
<el-form-item label="下界 / 上界">
<el-input v-model="stratDlg.p.lower" style="width:110px"></el-input>
<span style="margin:0 6px">~</span>
<el-input v-model="stratDlg.p.upper" style="width:110px"></el-input>
</el-form-item>
<el-form-item label="中枢价">
<el-input v-model="stratDlg.p.center" style="width:110px"></el-input>
<span class="muted" style="margin-left:8px">默认取现价</span>
</el-form-item>
<el-form-item label="档距">
<el-input v-model="stratDlg.p.step_pct" style="width:110px"><template #append>×价</template></el-input>
<span class="muted" style="margin-left:8px">0.02=每 2% 一档</span>
</el-form-item>
<el-form-item label="每档股数">
<el-input v-model="stratDlg.p.per_lot" style="width:110px"><template #append></template></el-input>
</el-form-item>
<el-form-item label="最大投入额">
<el-input v-model="stratDlg.p.max_capital" style="width:150px"><template #append></template></el-input>
<span class="muted" style="margin-left:8px">须落在单股上限内(合规检查兜底)</span>
</el-form-item>
<el-form-item label="怎么做">
<span class="muted">跌破未买档→买一份,涨破已买档→卖一份(从 T+1 可卖存量);跌破下界=继续持有、不再买。</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="启动线">
<el-input v-model="stratDlg.p.start_line" style="width:110px"><template #append>浮盈</template></el-input>
<span class="muted" style="margin-left:8px">浮盈达此才开始跟踪</span>
</el-form-item>
<el-form-item label="回撤触发">
<el-input v-model="stratDlg.p.giveback" style="width:110px"><template #append>自高点</template></el-input>
<span class="muted" style="margin-left:8px">0.05=从高点回落 5% 就卖</span>
</el-form-item>
<el-form-item label="卖出比例">
<el-input v-model="stratDlg.p.sell_ratio" style="width:110px"><template #append>×可卖</template></el-input>
<span class="muted" style="margin-left:8px">1全清</span>
</el-form-item>
<el-form-item label="硬止盈(可选)">
<el-input v-model="stratDlg.p.hard_target" style="width:110px"><template #append>浮盈</template></el-input>
<span class="muted" style="margin-left:8px">0不设达此直接全清</span>
</el-form-item>
<el-form-item label="怎么做">
<span class="muted">创新高抬止盈线,从高点回落到设定比例就卖(只卖不买,纯离场保护)。</span>
</el-form-item>
</template>
<el-form-item label="自主档">
<el-radio-group v-model="stratDlg.autonomy">
<el-radio-button label="auto">自动执行</el-radio-button>
<el-radio-button label="confirm">待我拍板</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<el-alert v-if="stratDlg.reasons.length" type="error" effect="dark" :closable="false" show-icon
title="不能挂载(下列约束理论上不允许违反):" style="margin-top:6px">
<div v-for="(r,i) in stratDlg.reasons" :key="i" style="color:#fca5a5">· {{ r }}</div>
</el-alert>
<template #footer>
<el-button size="small" @click="validateStrategy" :loading="stratDlg.busy">先校验</el-button>
<el-button size="small" type="primary" @click="attachStrategy" :loading="stratDlg.busy">挂载并生效</el-button>
</template>
</el-dialog>
<!-- 5) 组合操作 + 设置 -->
<div class="panel tblk">
<h3>组合操作</h3>
<div class="muted" style="margin-bottom:8px">对整个组合的手动动作。数值都基于当前实际持仓算出、并预览影响金额,不用盲填;危险动作要二次确认。</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" :type="ov.buy_halt?'success':'warning'" @click="ov.buy_halt?pResumeBuy():pHaltBuy()">{{ ov.buy_halt?'恢复买入':'暂停买入' }}</el-button></span>
<span class="d">停止一切新增买入并撤销在途买入指令;卖出与止损不受影响。当前:{{ ov.buy_halt?'已暂停买入':'买入正常' }}。</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" :type="ov.exec_halt?'success':''" @click="ov.exec_halt?pResumeAll():pHaltAll()">{{ ov.exec_halt?'结束休假':'休假模式' }}</el-button></span>
<span class="d">所有自动动作暂停,只保留对账与日报。当前:{{ ov.exec_halt?'休假中':'正常运行' }}。</span>
</div>
<div class="op-row" style="align-items:center">
<span class="op-btn"><el-button size="small" type="primary" :disabled="!posMoveValid" @click="doPosMove()">调整仓位</el-button></span>
<span class="d">
当前总仓位 <b>{{ pct(ov.portfolio_pct) }}</b>(市值 {{ money(ov.portfolio_mv) }} / 规模 {{ money(ov.scale) }}),可用资金 {{ money(ov.cash_source==='ws'?ov.cash_avail:ov.cash_est) }}。
目标 <el-input v-model="tgtPos" size="small" style="width:96px"><template #append>%</template></el-input>
<span v-if="posMovePreview" style="margin-left:8px;color:#bbb">{{ posMovePreview }}</span>
</span>
</div>
<div class="op-row" style="align-items:center">
<span class="op-btn"><el-button size="small" :disabled="!secSel" @click="doSectorExit()">清仓行业</el-button></span>
<span class="d">
卖光某个行业的全部持仓,从下面选(不用手打):
<el-select v-model="secSel" size="small" style="width:240px" placeholder="选持仓行业" :disabled="!heldSectors.length" clearable>
<el-option v-for="s in heldSectors" :key="s.sector" :label="s.sector+''+s.count+'只 '+money(s.mv)+''" :value="s.sector"></el-option>
</el-select>
<span v-if="!heldSectors.length" class="muted"> —— {{ ov.sector_ready===false?'行业数据未就绪':'当前无带行业的持仓' }}</span>
</span>
</div>
<div class="op-row">
<span class="op-btn"><el-button size="small" type="danger" @click="pLiquidate()">一键清仓(紧急)</el-button></span>
<span class="d">全部持仓({{ heldPositions.length }} 只,市值 {{ money(ov.portfolio_mv) }})按最快节奏卖出、不做择时优化。紧急离场用,需二次确认。</span>
</div>
</div>
<div class="panel tblk">
<h3>设置</h3>
<div class="muted" style="margin-bottom:8px">每组先给一句「现在是什么状态」的合成说明,再让你改其中的项。改完点保存即时生效。</div>
<div class="set-card">
<div class="set-sum">资金与仓位纪律:{{ sumPosition }}</div>
<div class="set-fields" v-if="pmap['PMS_TOTAL_SCALE']">
<span>总操作规模 <el-input v-model="pmap['PMS_TOTAL_SCALE'].value" size="small" style="width:130px"><template #append></template></el-input></span>
<span>总仓上限 <el-input v-model="pmap['PMS_PORTFOLIO_CAP'].value" size="small" style="width:88px" placeholder="0~1"></el-input></span>
<span>单股上限 <el-input v-model="pmap['PMS_STOCK_CAP'].value" size="small" style="width:88px" placeholder="0~1"></el-input></span>
<span>最多持仓 <el-input v-model="pmap['PMS_MAX_NAMES'].value" size="small" style="width:82px"><template #append></template></el-input></span>
<span>预留现金 <el-input v-model="pmap['PMS_CASH_RESERVE'].value" size="small" style="width:88px" placeholder="0~1"></el-input></span>
</div>
</div>
<div class="set-card">
<div class="set-sum">补仓策略:{{ sumDca }}</div>
<div class="set-fields" v-if="pmap['PMS_DCA_MAX_RATIO']">
<span>是否补仓 <el-switch v-model="dcaOn"></el-switch></span>
<template v-if="dcaOn">
<span>上限(占底仓) <el-input v-model="pmap['PMS_DCA_MAX_RATIO'].value" size="small" style="width:88px" placeholder="0~1"></el-input></span>
<span>评估档(浮亏) <el-input v-model="pmap['PMS_DCA_TRIGGERS'].value" size="small" style="width:130px" placeholder="-0.08,-0.15"></el-input></span>
<span>深档确认 <el-input v-model="pmap['PMS_DCA_DEEP_CONFIRM'].value" size="small" style="width:88px"></el-input></span>
<span>回踩止损 <el-input v-model="pmap['PMS_FILL_MAX_LOSS'].value" size="small" style="width:88px"></el-input></span>
</template>
</div>
</div>
<div class="set-card">
<div class="set-sum">自主档位:{{ sumAutonomy }}</div>
<div class="set-fields" v-if="pmap['PMS_AUTONOMY']">
<el-select v-model="pmap['PMS_AUTONOMY'].value" size="small" style="width:200px">
<el-option label="全自动" value="full"></el-option>
<el-option label="只提议不下手" value="propose_only"></el-option>
<el-option label="暂停" value="off"></el-option>
</el-select>
</div>
</div>
<div class="row" style="margin-top:10px">
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">保存修改 ({{ dirtyCount }})</el-button>
<el-button size="small" @click="loadParams">放弃</el-button>
<span class="muted">关闭补仓=把「补仓上限」设为 0调低总仓上限可能触发降仓提议。</span>
</div>
</div>
<!-- 6) 系统在盯的候选 -->
<div class="panel tblk">
<h3>系统在盯的候选 <span class="muted" style="font-weight:400">· 今日选股计划 · 计划日 {{ planStatus.date || '—' }}</span></h3>
<div class="sub2">决策系统盯着、可能冒出建仓提议的票。<b style="color:var(--ink-2)">每只都标了今天为什么下单、或为什么没下单。</b></div>
<div class="pool-note" v-if="(openScan.notes||[]).length"><span v-for="(n,i) in openScan.notes" :key="i">{{ n }}<span v-if="i < openScan.notes.length-1"></span></span></div>
<div class="muted" v-if="planStatus.ok===false">上游计划暂不可用。</div>
<el-table v-else :data="planRows.slice(0,20)" size="small" max-height="360" empty-text="暂无">
<el-table-column label="#" width="52" align="right"><template #default="s"><span class="mono muted">{{ s.row.rank }}</span></template></el-table-column>
<el-table-column label="股票" min-width="150"><template #default="s"><b>{{ s.row.name || nm(s.row.ts_code) }}</b> <span class="muted mono">{{ s.row.ts_code }}</span></template></el-table-column>
<el-table-column label="主题" width="130"><template #default="s"><span class="tag" v-if="s.row.theme">{{ s.row.theme }}</span><span v-else class="muted"></span></template></el-table-column>
<el-table-column label="传导档" width="88"><template #default="s"><span class="muted">{{ s.row.tier || '—' }}</span></template></el-table-column>
<el-table-column label="处置 · 为什么" min-width="300"><template #default="s">
<span class="disp" :class="dispOf(s.row.ts_code).cls">● {{ dispOf(s.row.ts_code).label }}</span>
<div class="why" v-if="dispOf(s.row.ts_code).why">{{ dispOf(s.row.ts_code).why }}</div>
</template></el-table-column>
</el-table>
</div>
</div>
<div v-show="mode==='ops'">
<div class="metrics">
<div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
<div class="m"><div class="k">组合市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
<div class="m"><div class="k">总仓位</div>
<div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
<div class="m"><div class="k">可用额度</div><div class="v">{{ money(ov.cap_room) }}</div></div>
<div class="m"><div class="k">账户可用<span class="muted" v-if="ov.cash_source!=='ws'"> (估算)</span></div>
<div class="v" :class="ov.cash_source==='ws'?'':'muted'">{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}</div></div>
<div class="m"><div class="k">持仓数</div>
<div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
<div class="m"><div class="k">浮动盈亏</div>
<div class="v" :class="(ov.float_pnl||0)>=0?'up':'down'">{{ money(ov.float_pnl) }}</div></div>
<div class="m"><div class="k">厚垫 / 负垫</div>
<div class="v">{{ ov.solid_names }} / {{ ov.neg_names }}</div></div>
</div>
<el-tabs v-model="tab" type="border-card">
<!-- ==================== ① 参数设置 ==================== -->
<el-tab-pane label="参数设置" name="params">
<div class="row" style="margin-bottom:10px">
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
<div class="spacer"></div>
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">
保存修改 ({{ dirtyCount }})</el-button>
<el-button size="small" @click="loadParams">放弃修改</el-button>
</div>
<el-table :data="params" size="small" height="560" border>
<el-table-column prop="key" label="参数" width="240" class-name="mono"></el-table-column>
<el-table-column label="当前值" width="200">
<template #default="s">
<el-select v-if="s.row.key==='PMS_AUTONOMY'" v-model="s.row.value" size="small">
<el-option v-for="o in ['full','propose_only','off']" :key="o" :label="o" :value="o"></el-option>
</el-select>
<el-select v-else-if="s.row.key==='PMS_SECTOR_SOURCE'" v-model="s.row.value" size="small">
<el-option label="(空) 停用行业约束" value=""></el-option>
<el-option label="custom_table" value="custom_table"></el-option>
<el-option label="gp_stock_category" value="gp_stock_category"></el-option>
</el-select>
<el-switch v-else-if="s.row.type==='bool'" v-model="s.row.value"></el-switch>
<el-input v-else v-model="s.row.value" size="small"></el-input>
</template>
</el-table-column>
<el-table-column prop="file_default" label="文件初值" width="130"></el-table-column>
<el-table-column label="来源" width="80">
<template #default="s">
<el-tag size="small" :type="s.row.source==='table'?'success':'info'" effect="dark">
{{ s.row.source==='table' ? '页面' : '文件' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="desc" label="说明" min-width="260"></el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160" class-name="muted"></el-table-column>
</el-table>
</el-tab-pane>
<!-- ==================== ② 命令台 ==================== -->
<el-tab-pane label="命令台" name="cmd">
<div class="panel">
<h3>下达命令</h3>
<div class="row">
<el-select v-model="form.cmd_type" filterable placeholder="选择命令" style="width:300px"
@change="onCmdChange">
<el-option-group v-for="g in ['A','B','C']" :key="g" :label="groupLabel(g)">
<el-option v-for="c in catalog.filter(x=>x.group===g)" :key="c.cmd_type"
:label="c.label + ' · ' + c.cmd_type" :value="c.cmd_type"
:disabled="c.disabled">
<span>{{ c.label }}</span>
<span class="muted" style="float:right">{{ c.cls==='param'?'参数':'任务' }}</span>
</el-option>
</el-option-group>
</el-select>
<el-tag v-if="curSpec" size="small" effect="dark"
:type="curSpec.cls==='param'?'warning':'primary'">
{{ curSpec.cls==='param' ? '参数命令 (立即生效)' : '任务命令 (生成方案)' }}</el-tag>
<el-tag v-if="curSpec && curSpec.danger" size="small" type="danger" effect="dark">高危</el-tag>
</div>
<div class="muted" v-if="curSpec && curSpec.note" style="margin:8px 0">{{ curSpec.note }}</div>
<div class="row" style="margin-top:10px" v-if="curSpec">
<div v-for="(f,name) in curSpec.fields" :key="name" class="row">
<span class="muted">{{ fieldLabel(name) }}</span>
<el-select v-if="f.type==='enum'" v-model="form.params[name]" size="small" style="width:150px">
<el-option v-for="o in f.choices" :key="o" :label="o" :value="o"></el-option>
</el-select>
<el-input v-else v-model="form.params[name]" size="small" style="width:150px"
:placeholder="f.required===false ? '可留空(默认)' : ''">
<template v-if="f.type==='pct'" #append>%</template>
<template v-else-if="f.type==='money'" #append></template>
<template v-else-if="f.type==='price'" #append></template>
</el-input>
</div>
<el-input v-model="form.note" size="small" style="width:200px" placeholder="备注(可选)"></el-input>
<el-button type="primary" size="small" :loading="issuing" @click="issue(false)">下达</el-button>
</div>
<div class="muted" style="margin-top:8px" v-if="curSpec && curSpec.cls==='task'">
任务命令下达后立即生成方案并落 pms_plan; 指令下发由择时执行器负责 (下一批交付)。
</div>
</div>
<div class="panel">
<h3>在途命令与进度</h3>
<el-table :data="commands" size="small" border max-height="420"
:row-class-name="()=>''">
<el-table-column type="expand">
<template #default="s">
<div style="padding:8px 16px">
<div class="muted">参数: <span class="mono">{{ JSON.stringify(s.row.params) }}</span></div>
<div class="muted" v-if="s.row.progress && s.row.progress.notes">
说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div>
<el-button size="small" style="margin:8px 0" @click="loadPlans(s.row.command_id)">
查看方案明细</el-button>
<el-table v-if="plansOf===s.row.command_id" :data="plans" size="small" border>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="80"></el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column label="金额" width="110">
<template #default="p">{{ money(p.row.amount) }}</template></el-table-column>
<el-table-column prop="priority" label="优先级" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="90"></el-table-column>
<el-table-column prop="deadline" label="截止" width="110"></el-table-column>
<el-table-column prop="reason" label="理由" min-width="260"></el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="command_id" label="命令号" width="160" class-name="mono"></el-table-column>
<el-table-column prop="cmd_type" label="类型" width="160"></el-table-column>
<el-table-column prop="ts_code" label="标的" width="110" class-name="mono"></el-table-column>
<el-table-column label="状态" width="100">
<template #default="s">
<el-tag size="small" effect="dark" :type="stTag(s.row.status)">{{ s.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" min-width="220">
<template #default="s">
<div v-if="s.row.progress && s.row.progress.target_amount">
<el-progress :percentage="progPct(s.row.progress)" :stroke-width="10"
:text-inside="true" status="success"></el-progress>
<span class="muted">{{ money(s.row.progress.done_amount) }} /
{{ money(s.row.progress.target_amount) }}
<template v-if="s.row.progress.gap"> · 缺口 {{ money(s.row.progress.gap) }}</template>
</span>
</div>
<span v-else class="muted">{{ (s.row.progress||{}).plan_count || 0 }} 条方案</span>
</template>
</el-table-column>
<el-table-column prop="issued_at" label="下达时间" width="160" class-name="muted"></el-table-column>
<el-table-column label="操作" width="150">
<template #default="s">
<el-button size="small" :disabled="!canCancel(s.row)"
@click="cancelCmd(s.row.command_id)">撤销</el-button>
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
@click="replan(s.row.command_id)">重规划</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ③ 持仓与账本 ==================== -->
<el-tab-pane label="持仓与账本" name="pos">
<div class="panel">
<h3>持仓总览 ({{ positions.length }} 只)</h3>
<el-table :data="positions" size="small" border max-height="460">
<el-table-column type="expand">
<template #default="s">
<div style="padding:8px 16px">
<el-button size="small" @click="loadLots(s.row.ts_code)">查看批次</el-button>
<el-table v-if="lotsOf===s.row.ts_code" :data="lots" size="small" border
style="margin-top:8px">
<el-table-column prop="lot_type" label="批次" width="90"></el-table-column>
<el-table-column prop="qty" label="剩余" width="90"></el-table-column>
<el-table-column prop="closed_qty" label="已核销" width="90"></el-table-column>
<el-table-column prop="open_price" label="开仓价" width="100"></el-table-column>
<el-table-column prop="close_avg_price" label="核销均价" width="100"></el-table-column>
<el-table-column prop="realized_pnl" label="已实现" width="100"></el-table-column>
<el-table-column prop="open_date" label="开仓日" width="110"></el-table-column>
<el-table-column prop="instruction_id" label="来源指令" min-width="180"
class-name="mono"></el-table-column>
<el-table-column prop="note" label="备注" min-width="160"></el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="sector" label="行业" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="90"></el-table-column>
<el-table-column label="冻结" width="110">
<template #default="s">
<el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small"
type="danger" effect="dark">{{ s.row.frozen_reason }}</el-tag>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column prop="total_qty" label="持仓" width="90"></el-table-column>
<el-table-column prop="avail_qty" label="可卖" width="90"></el-table-column>
<el-table-column label="批次(底/补/加/补仓/T)" width="170">
<template #default="s"><span class="mono nowrap">{{ s.row.base_qty }}/{{ s.row.fill_qty }}/{{ s.row.add_qty }}/{{ s.row.dca_qty }}/{{ s.row.t0_qty }}</span></template>
</el-table-column>
<el-table-column prop="avg_cost" label="摊薄成本" width="100"></el-table-column>
<el-table-column prop="price" label="现价" width="90"></el-table-column>
<el-table-column label="安全垫" width="130">
<template #default="s">
<el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">
{{ pct(s.row.cushion_pct) }} {{ s.row.cushion_state }}</el-tag>
<span v-if="s.row.neg_cushion_days>0" class="muted"> 连负{{ s.row.neg_cushion_days }}日</span>
</template>
</el-table-column>
<el-table-column label="占规模" width="90">
<template #default="s">{{ pct(s.row.pct_of_scale) }}</template></el-table-column>
<el-table-column label="市值" width="110">
<template #default="s">{{ money(s.row.market_value) }}</template></el-table-column>
<el-table-column label="参考位(支/压/止损)" width="180">
<template #default="s"><span class="mono nowrap">{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}</span>
<span class="muted"> {{ s.row.ref_source||'' }}</span></template>
</el-table-column>
<el-table-column label="做T" width="80">
<template #default="s">
<el-tag v-if="s.row.t0_enabled" size="small" type="warning" effect="dark">
{{ pct(s.row.t0_ratio) }}</el-tag><span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel">
<h3>在途指令</h3>
<el-table :data="instructions" size="small" border max-height="320">
<el-table-column type="expand">
<template #default="s">
<div style="padding:6px 16px">
<div class="muted" v-if="(s.row.progress||{}).last_decision">
最近判定 [{{ s.row.progress.last_decision.at }}]
<b>{{ s.row.progress.last_decision.action }}</b>
{{ s.row.progress.last_decision.reason }}
</div>
<div class="muted" v-if="(s.row.progress||{}).deadline">
执行窗口截止: {{ s.row.progress.deadline }}
<template v-if="(s.row.progress||{}).window_verdict">
· {{ s.row.progress.window_verdict.note }}</template>
</div>
<el-table v-if="((s.row.progress||{}).children||[]).length"
:data="s.row.progress.children" size="small" border
style="margin-top:6px">
<el-table-column prop="ymd" label="日期" width="100"></el-table-column>
<el-table-column prop="at" label="时点" width="90"></el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column prop="limit" label="限价" width="90"></el-table-column>
<el-table-column prop="mode" label="通道" width="100"></el-table-column>
<el-table-column label="兜底" width="70">
<template #default="c">{{ c.row.forced ? '是' : '—' }}</template>
</el-table-column>
<el-table-column prop="reason" label="出手依据" min-width="260"></el-table-column>
</el-table>
<div v-else class="muted" style="margin-top:6px">尚无出手记录</div>
</div>
</template>
</el-table-column>
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="80"></el-table-column>
<el-table-column prop="side" label="方向" width="70"></el-table-column>
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
<el-table-column prop="exec_qty" label="已成交" width="80"></el-table-column>
<el-table-column label="进度" width="130">
<template #default="s">
<el-progress :percentage="insPct(s.row)" :stroke-width="10" :text-inside="true"></el-progress>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="115"></el-table-column>
<el-table-column prop="origin_type" label="来源" width="80"></el-table-column>
<el-table-column label="操作" width="90">
<template #default="s">
<el-button size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
</template>
</el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
{{ dm.hint }} 展开可看每日子单与择时依据; 成交量以下游回放为准, 不拿下发量当成交量。
</div>
</div>
<div class="panel">
<h3>评审账本 (规则闸 / 研判闸 / 人工裁决全量留痕)</h3>
<el-table :data="ledger" size="small" border max-height="260">
<el-table-column prop="decided_at" label="时间" width="160"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="90"></el-table-column>
<el-table-column prop="arbiter" label="评审方" width="90"></el-table-column>
<el-table-column label="结论" width="90">
<template #default="s">
<el-tag size="small" effect="dark" :type="s.row.verdict==='PASS'?'success':'danger'">
{{ s.row.verdict }}</el-tag></template>
</el-table-column>
<el-table-column prop="price_at" label="评审价" width="90"></el-table-column>
<el-table-column label="未通过项" min-width="240">
<template #default="s"><span class="muted">{{ (s.row.failed_checks||[]).join(' | ') }}</span></template>
</el-table-column>
<el-table-column prop="reason" label="理由" min-width="200"></el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ④ 提议确认 ==================== -->
<el-tab-pane name="prop">
<template #label>
提议确认
<el-badge v-if="proposals.length" :value="proposals.length" class="ml"></el-badge>
</template>
<div class="panel">
<h3>待确认队列 (自主档位 {{ ov.autonomy }})</h3>
<div class="muted" style="margin-bottom:8px">
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
</div>
<el-alert v-if="dm.judge && !dm.judge.available" type="warning" effect="dark" show-icon
:closable="false" style="margin-bottom:10px"
:title="'研判闸未接通 —— ' + dm.judge.reason + ' (补足/加仓/补仓一律降级为人工确认)'"></el-alert>
<el-table :data="proposals" size="small" border max-height="420">
<el-table-column prop="proposal_id" label="提议号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="90"></el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column label="硬数字" min-width="320">
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row.hard_numbers) }}</span></template>
</el-table-column>
<el-table-column prop="judge_verdict" label="研判" width="100"></el-table-column>
<el-table-column prop="expire_at" label="失效时间" width="160"></el-table-column>
<el-table-column label="裁决" width="170">
<template #default="s">
<el-button size="small" type="success" @click="decide(s.row,'ACCEPTED')">采纳</el-button>
<el-button size="small" type="danger" @click="decide(s.row,'DECLINED')">驳回</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ④ ws 通道 (只读监控) ====================
只读: 任何会产生新委托的动作都不放在页面上。联调发单仍走
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个
发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 -->
<el-tab-pane label="ws 通道" name="ws">
<div class="row" style="margin-bottom:10px">
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>
<el-tag :type="wsc.conn_state==='ONLINE' ? 'success':'warning'" effect="dark" size="small">
{{ wsc.conn_state || '-' }}</el-tag>
<span class="muted">心跳 {{ wsc.heartbeat_age_sec==null ? '从未' : wsc.heartbeat_age_sec+'s 前' }}</span>
<div class="spacer"></div>
<el-checkbox v-model="wsAuto" size="small">每 3 秒自动刷新</el-checkbox>
<el-button size="small" @click="loadWs">刷新</el-button>
</div>
<!-- 异常自检: 与 scripts/ws_smoke.py 用同一套判据, 页面和命令行的结论必须一致,
否则联调时两边打架比没有告警更糟 -->
<el-alert v-for="(w,i) in wsWarnings" :key="i" class="banner" type="warning" effect="dark"
show-icon :closable="false" :title="w"></el-alert>
<div class="metrics">
<div class="m"><div class="k">seq 已落库</div><div class="v">{{ wsc.last_seq }}</div></div>
<div class="m"><div class="k">seq 已确认</div><div class="v">{{ wsc.acked_seq }}</div></div>
<div class="m"><div class="k">待入账上行</div><div class="v">{{ wsc.inbox_pending }}</div></div>
<div class="m"><div class="k">收 / 发</div>
<div class="v">{{ wst.rx||0 }} / {{ wst.tx||0 }}</div></div>
<div class="m"><div class="k">成交 / 拒绝</div>
<div class="v">{{ wst.trades||0 }} / {{ wst.rejects||0 }}</div></div>
<div class="m"><div class="k">重连</div><div class="v">{{ wst.reconnects||0 }}</div></div>
<div class="m"><div class="k">对端时钟偏差</div>
<div class="v" :class="Math.abs(wst.peer_skew_ms||0)>20000?'up':''">
{{ wst.peer_skew_ms==null ? '—' : (wst.peer_skew_ms>0?'+':'') + wst.peer_skew_ms + 'ms' }}</div></div>
</div>
<div class="panel">
<h3>通道明细</h3>
<div class="row" style="gap:24px">
<span class="muted">下发模式 <span class="mono">{{ wsMode || '-' }}</span></span>
<span class="muted">上次连上 {{ wsc.connected_at || '—' }}</span>
<span class="muted">出口队列 <span class="mono">{{ JSON.stringify(wsc.queue||{}) }}</span></span>
</div>
<div v-if="wsc.last_error" class="muted" style="margin-top:8px;color:#e6a23c">
最后错误: {{ wsc.last_error }}</div>
<div v-if="wst.last_reject" class="muted" style="margin-top:6px">
最后一条拒绝: <span class="mono">{{ wst.last_reject }}</span></div>
<div v-if="wst.last_close" class="muted" style="margin-top:6px">
最后断开原因: <span class="mono">{{ wst.last_close }}</span></div>
</div>
<div class="panel">
<h3>出口委托 (pms_qmt_order)</h3>
<el-table :data="wsOrders" size="small" border max-height="300" empty-text="暂无委托">
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="side" label="方向" width="70"></el-table-column>
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
<el-table-column prop="limit_price" label="限价" width="90"></el-table-column>
<el-table-column prop="status" label="状态" width="120"></el-table-column>
<el-table-column prop="cum_qty" label="已成" width="80"></el-table-column>
<el-table-column prop="cancel_state" label="撤单" width="90"></el-table-column>
<el-table-column prop="reject_code" label="拒绝码" min-width="140"></el-table-column>
</el-table>
</div>
<div class="panel">
<h3>上行消息 (pms_qmt_inbox)
<span class="muted" style="font-weight:400">· 新→旧</span></h3>
<div class="row" style="margin-bottom:8px">
<el-checkbox v-model="wsHidePong" size="small">隐藏 pong</el-checkbox>
<span class="muted">pong 每 5 秒一条且按协议 §5 带 seq, 不隐藏会把成交冲没</span>
</div>
<el-table :data="wsInbox" size="small" border max-height="380" empty-text="还没收到上行消息">
<el-table-column prop="seq" label="seq" width="90" class-name="mono"></el-table-column>
<el-table-column prop="msg_type" label="类型" width="130"></el-table-column>
<el-table-column label="入账" width="80">
<template #default="s">
<span class="muted">{{ ({0:'待入账',1:'已入账',2:'已消化'})[s.row.processed] || '?' }}</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="380">
<template #default="s">
<span class="mono muted">{{ JSON.stringify(s.row.payload || {}) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 运维抽屉 -->
<el-drawer v-model="opsDrawer" title="运维操作 (与调度器同一份实现)" size="46%">
<div class="muted" style="margin-bottom:10px">
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
</div>
<div class="row">
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
<el-button @click="ops('materialize')" :loading="opsLoading">方案转指令</el-button>
<el-button type="primary" @click="ops('exec-tick?dry_run=true')" :loading="opsLoading">
出手试算 (不下发)</el-button>
<el-button @click="ops('exec-tick')" :loading="opsLoading">出手一跳</el-button>
<el-button @click="ops('sweep-windows')" :loading="opsLoading">窗口收口</el-button>
</div>
<div class="row" style="margin-top:8px">
<el-button type="primary" @click="ops('scan-proposals?dry_run=true')" :loading="opsLoading">
提议扫描试算 (不落表)</el-button>
<el-button @click="ops('scan-proposals')" :loading="opsLoading">提议扫描</el-button>
</div>
<div class="row" style="margin-top:8px">
<el-button @click="ops('replay')" :loading="opsLoading">成交回放</el-button>
<el-button @click="ops('reconcile')" :loading="opsLoading">账本对账</el-button>
<el-button @click="ops('premarket')" :loading="opsLoading">盘前准备</el-button>
<el-button @click="ops('daily-settle')" :loading="opsLoading">日终结算</el-button>
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
</div>
<div class="muted" style="margin-top:10px">
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
</div>
<pre class="json" v-if="opsResult">{{ opsResult }}</pre>
</el-drawer>
<!-- 上游选股计划抽屉 -->
<el-drawer v-model="planDrawer" title="上游选股计划 (候选池的事实源)" size="56%">
<div class="row" style="margin-bottom:8px">
<el-button type="primary" :loading="planLoading" @click="refreshPlan">
强刷 + 灌行业映射</el-button>
<el-button :loading="planLoading" @click="loadPlan">只重读</el-button>
<span class="muted" style="margin-left:8px">
计划不带价格与金额 —— 买多少/什么价一律 PMS 自己算; 行情取不到价的票会被剔除。
</span>
</div>
<el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false"
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
<div class="muted" v-else style="margin-bottom:8px">
计划日 <b>{{ planStatus.date }}</b> (日龄 {{ planStatus.age_tdays }} 交易日) ·
热度日 {{ planStatus.heat_date || '-' }} · 快照 {{ (planStatus.market_snapshot_days||[]).join(',') }} ·
上游主题上限 {{ planStatus.theme_cap }} · theme 灌映射 {{ JSON.stringify(planStatus.theme_sync||{}) }}
<br>请求 {{ JSON.stringify(planStatus.query||{}) }} · 漏斗: 打分池
主榜 {{ (planStatus.funnel||{}).scored_main }} / 观察 {{ (planStatus.funnel||{}).scored_observe }}
→(券商预期 + 目标价≥现价 + 主题限额 + top)→ 实收
主榜 {{ (planStatus.funnel||{}).returned_main }} / 观察 {{ (planStatus.funnel||{}).returned_observe }}
</div>
<el-alert v-if="planCand && planCand.tier_complete===false" type="warning" effect="dark"
show-icon :closable="false" style="margin-bottom:8px"
:title="'返回的全是白名单档位 (top=' + ((planStatus.requested||{}).top)
+ ' 吃满了) —— 白名单档位可能没取全, 调大 PMS_PLAN_TOP'">
</el-alert>
<div class="muted" v-if="planCand" style="margin-bottom:8px">
按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} →
取 {{ (planCand.items||[]).length }} 只 ·
白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} ·
丢弃 {{ JSON.stringify(planCand.dropped||{}) }}
<span v-if="(planCand.st_unknown||[]).length" style="color:#E6A23C">·
{{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行</span>
<br><span class="mono">{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}</span>
</div>
<!-- 榜单变化 (PMS 自算; 上游 changes 恒为 null) -->
<el-divider content-position="left">榜单变化</el-divider>
<div class="row" style="margin-bottom:6px">
<el-button size="small" :loading="chgLoading" @click="loadChanges">重算变化</el-button>
<span class="muted" style="margin-left:8px" v-if="chgSt.hint">{{ chgSt.hint }}</span>
</div>
<el-alert v-if="chgSt.in_sync === false" type="error" effect="dark" show-icon
:closable="false" style="margin-bottom:8px"
title="手上这份计划没落进快照 —— 下面比的是两个旧版本, 与当前候选池不是同一份">
</el-alert>
<el-alert v-else-if="chg && chg.kind === 'same_date_revision'" type="warning" effect="dark"
show-icon :closable="false" style="margin-bottom:8px"
:title="'同一计划日 ' + chg.curr_date + ' 的新版本 —— 上游重算过, '
+ '此前拿到的候选池与现在不是同一份 (上游 /plan 是实时算的, 没有版本戳)'">
</el-alert>
<el-alert v-if="chg && chg.prev_broken" type="error" effect="dark" show-icon
:closable="false" style="margin-bottom:8px"
title="上一版名册读不出来 (快照损坏) —— 这次比不了, 下面的空白不代表没有变化">
</el-alert>
<el-alert v-if="chgHeld.n_watch" type="error" effect="dark" show-icon :closable="false"
style="margin-bottom:8px"
:title="'持仓票 ' + chgHeld.n_watch + ' 只掉榜 / 降档 / 丢了券商覆盖 —— 见下表红字'">
</el-alert>
<el-alert v-if="chg && chg.date_regressed" type="warning" effect="dark" show-icon
:closable="false" style="margin-bottom:8px"
title="计划日往回走了 (拉过一份旧计划?) —— 新进/掉榜的方向是反的">
</el-alert>
<div class="muted" v-if="chg" style="margin-bottom:8px">
{{ chg.kind === 'first' ? '首次落快照' : (chg.prev_date + ' → ' + chg.curr_date) }} ·
名册 {{ chg.roster_size.prev }} → {{ chg.roster_size.curr }} ·
新进 {{ chg.counts.entered }} · 掉榜 {{ chg.counts.exited }} ·
升档 {{ chg.counts.tier_up }} · 降档 {{ chg.counts.tier_down }} ·
覆盖翻转 {{ chg.counts.bucket_moved }} · 名次跳变 {{ chg.counts.rank_jump }}
<span v-if="(chg.tail_churn.entered + chg.tail_churn.exited)">·
榜尾进出 {{ chg.tail_churn.entered + chg.tail_churn.exited }} 只未计入 (那一版吃满 top,
尾部是截断噪音不是观点变化)</span>
<span v-if="chg.tail_churn.held_exempt">· 其中
<b>{{ chg.tail_churn.held_exempt }} 只因是持仓票照报</b> (持仓票不受榜尾闸约束 ——
漏报一条就是一个仓位)</span>
<span v-if="chgRevised.length">· <b>同一计划日出现多版</b>:
{{ chgRevised.map(x => x[0] + '×' + x[1]).join(', ') }}</span>
</div>
<el-tabs v-model="chgTab" v-if="chg">
<el-tab-pane name="held" :label="'持仓票 (' + chgHeld.n_total + ')'">
<el-table :data="chgWatch.filter(x => x.held)" size="small" border max-height="220"
empty-text="持仓票没被摘也没降档">
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="why" label="发生了什么" min-width="200">
<template #default="s"><span style="color:#F56C6C">{{ s.row.why }}</span></template>
</el-table-column>
<el-table-column prop="rank" label="上版 rank" width="90"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
上游把一只<b>持仓票</b>摘出榜或降了档, 是"还该不该继续拿着"的直接信号 ——
单独拎出来免得被几十条新进榜淹掉。<b>它只是提示, 不自动产生任何指令</b>:
要减要清仍走命令台或提议确认。
</div>
</el-tab-pane>
<el-tab-pane name="watch" :label="'掉榜与降档 (' + chgWatch.length + ')'">
<el-table :data="chgWatch" size="small" border max-height="220" empty-text="无">
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="why" label="发生了什么" min-width="180"></el-table-column>
<el-table-column prop="rank" label="上版 rank" width="90"></el-table-column>
<el-table-column label="持仓" width="70">
<template #default="s">
<el-tag v-if="s.row.held" type="danger" size="small">持仓</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="in" :label="'新进榜 (' + chg.counts.entered + ')'">
<el-table :data="chg.entered" size="small" border max-height="220" empty-text="无">
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
<el-table-column prop="score" label="score" width="82"></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="up" :label="'升档 (' + chg.counts.tier_up + ')'">
<el-table :data="chg.tier_up" size="small" border max-height="220" empty-text="无">
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column label="档位" min-width="150">
<template #default="s">{{ s.row.tier_from }} → <b>{{ s.row.tier_to }}</b></template>
</el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="jump" :label="'名次跳变 (' + chg.counts.rank_jump + ')'">
<el-table :data="chg.rank_jump" size="small" border max-height="220" empty-text="无">
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column label="名次" min-width="150">
<template #default="s">
{{ s.row.rank_from }} → {{ s.row.rank_to }}
<span :style="{color: s.row.rank_delta > 0 ? '#F56C6C' : '#67C23A'}">
({{ s.row.rank_delta > 0 ? '+' : '' }}{{ s.row.rank_delta }})</span>
</template>
</el-table-column>
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="log" :label="'快照台账 (' + (chgLog.rows||[]).length + ')'">
<el-table :data="chgLog.rows" size="small" border max-height="220" empty-text="还没有快照">
<el-table-column prop="plan_date" label="计划日" width="104"></el-table-column>
<el-table-column prop="fetched_at" label="拉到的时刻" width="170"></el-table-column>
<el-table-column prop="n_main" label="主榜" width="70"></el-table-column>
<el-table-column prop="n_observe" label="观察" width="70"></el-table-column>
<el-table-column label="吃满 top" width="90">
<template #default="s">
<el-tag v-if="s.row.capped_main" type="warning" size="small">主榜</el-tag>
</template>
</el-table-column>
<el-table-column prop="digest" label="指纹" min-width="140"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
<b>同一「计划日」出现多行 = 上游那天重算过多版</b>(UPSTREAM_PLAN_API.md §7.3:
`/plan` 是实时算的, 应答里没有 generated_at, 下游本来没办法判断手上这份是哪一版)。
指纹不含 score —— 分数抖动但次序没变不算新版本, 否则表白涨而信息量为零。
</div>
</el-tab-pane>
</el-tabs>
<el-divider content-position="left">主榜明细</el-divider>
<el-table :data="planRows" size="small" border height="420">
<el-table-column prop="rank" label="rank" width="62"></el-table-column>
<el-table-column prop="ts_code" label="代码" width="104"></el-table-column>
<el-table-column prop="name" label="名称" width="92"></el-table-column>
<el-table-column prop="score" label="score" width="82"></el-table-column>
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
<el-table-column prop="heat" label="heat" width="78"></el-table-column>
<el-table-column label="预期空间" width="96">
<template #default="s">
<span class="muted">{{ s.row.upside==null ? '-' :
((s.row.upside>0?'+':'') + (s.row.upside*100).toFixed(0) + '%') }}</span>
</template>
</el-table-column>
<el-table-column prop="n_sources" label="源数" width="66"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大,
所以<b>永不参与排序</b> —— 排序始终按 score; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。
筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE /
PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_MIN_UPSIDE / PMS_PLAN_STALE_TDAYS。
<br>发给上游的三个: PMS_PLAN_TOP / PMS_PLAN_OBS_TOP / PMS_PLAN_THEME_CAP (0=不传, 用上游默认
20/10/5)。<b>PMS_PLAN_THEME_CAP_LOCAL</b> 是 PMS 侧的同主题限额, 在 TOP_N 截断之前生效 ——
让上游别裁 (THEME_CAP 设大) + PMS 自己裁, 前 N 名才不会被单一主题垄断。
<br><b>PMS_PLAN_EXCLUDE_ST</b> 默认开: 上游名单明确不剔除 ST/*ST, 这道闸在 PMS 侧。
「主题」是<b>传导主题</b>(事件驱动、天天变、覆盖约三成), <b>不是行业</b> —— 行业硬拦截
请把 PMS_SECTOR_SOURCE 设成 gp_stock_category, 别用 PMS_PLAN_THEME_SYNC。
</div>
<pre class="json" v-if="planResult">{{ planResult }}</pre>
</el-drawer>
<!-- 日报抽屉 -->
<el-drawer v-model="reportDrawer" :title="'运营日报 ' + (report.ymd||'')" size="52%">
<div v-if="(report.report||{}).attention">
<h3 style="color:#409EFF;font-size:14px">关注区</h3>
<el-table :data="report.report.attention" size="small" border>
<el-table-column prop="type" label="事项" width="130"></el-table-column>
<el-table-column label="内容">
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row) }}</span></template>
</el-table-column>
</el-table>
</div>
<pre class="json">{{ JSON.stringify(report.report||{}, null, 2) }}</pre>
</el-drawer>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
const api = axios.create({ timeout: 60000 });
createApp({
setup() {
const tab = ref('cmd'), loading = ref(false), err = ref('');
const mode = ref('trader');
const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({});
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
const positions = ref([]), lots = ref([]), lotsOf = ref('');
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]);
const showLedger = ref(false); // 「今天发生了什么」默认折叠
const openScan = ref({ by_code: {}, notes: [] });
const insTab = ref('live');
const stratDlg = reactive({ visible:false, busy:false, reasons:[], ts_code:'', price:0,
total_qty:0, avail_qty:0, cushion_pct:null, type:'T0', autonomy:'auto',
p:{ t_ratio:0.2, lower:0, upper:0, center:0, step_pct:0.02, per_lot:100, max_capital:0,
start_line:0.03, giveback:0.05, sell_ratio:1.0, hard_target:0 } });
const report = ref({}), reportDrawer = ref(false), dm = ref({});
const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false);
// 上游选股计划: 单独取数, 不塞进 /api/overview —— overview 是轮询的, 上游不通时
// 会把 10 秒超时拖进每一次页面刷新。
const planDrawer = ref(false), planLoading = ref(false), planRaw = ref({}),
planResult = ref('');
const planStatus = computed(() => planRaw.value.status || {});
const planRows = computed(() => planRaw.value.rows || []);
const planCand = computed(() => planRaw.value.candidates_raw || null);
// 榜单变化 (PMS 自算 —— 上游 changes 字段恒为 null)。另起一个接口, 不塞进 /api/upstream/plan:
// 它要读库, 而计划预览要读上游, 两者的故障域不同, 混在一起会互相拖垮。
const chgRaw = ref({}), chgLoading = ref(false), chgTab = ref('held');
const chgSt = computed(() => chgRaw.value.changes || {});
const chg = computed(() => chgSt.value.diff || null);
const chgHeld = computed(() => (chg.value || {}).held || {});
const chgLog = computed(() => chgRaw.value.log || {});
const chgRevised = computed(() => Object.entries(chgLog.value.revised || {}));
// 掉榜/降档合成一张表 —— 「上游把这只票摘了或降了档」是同一个意思的两种说法
const chgWatch = computed(() => {
const d = chg.value; if (!d) return [];
// 三类都算「上游把这只票看淡了」: 掉榜 / 降档 / 掉进观察档(=没券商覆盖了, 估值锚没了)。
// 与 plan_diff 的 held.n_watch 口径一致 —— 两边分家的话红条会亮而表里没有对应行。
return (d.exited || []).map(x => ({ ...x, why: '掉榜' }))
.concat((d.tier_down || []).map(x => ({ ...x, why: '降档 ' + x.tier_from + '→' + x.tier_to })))
.concat((d.bucket_moved || []).filter(x => x.moved_to === 'observe')
.map(x => ({ ...x, why: '券商覆盖没了 (主榜→观察档)' })));
});
const issuing = ref(false);
const form = reactive({ cmd_type: '', params: {}, note: '' });
// ---- ws 通道监控 (只读)
const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true);
let wsTimer = null;
// 总规模(用户命令参数) 与 账户实际总资产(ws 资金快照) 的偏差。三道仓位闸都按 scale 算,
// 资金校验按账户算 —— 差得远时方案会过闸但下不出去, 而页面上原来完全看不出来。
const scaleGap = computed(() => {
const ta = Number(ov.value.total_asset || 0), s = Number(ov.value.scale || 0);
if (!ta || !s) return 0;
const g = ta / s - 1;
return Math.abs(g) > 0.10 ? g : 0;
});
const wsc = computed(() => wsRaw.value.channel || {});
const wst = computed(() => wsc.value.stat || {});
const wsMode = computed(() => wsRaw.value.mode || '');
const wsOrders = computed(() => wsRaw.value.orders || []);
const wsInbox = computed(() => (wsRaw.value.inbox || [])
.filter(r => !(wsHidePong.value && r.msg_type === 'pong')));
// 判据与 scripts/ws_smoke.py 的自检逐条对应。改一边就要改另一边 ——
// 页面说"正常"而命令行说"异常"(或反过来), 比两边都没有告警更能误导人。
const wsWarnings = computed(() => {
const c = wsc.value, s = wst.value, out = [];
if (!c.process_alive)
out.push('pms-ws 进程不在线 (心跳陈旧) —— dispatcher 会一律拒发。'
+ '起进程: docker compose --profile ws up -d pms-ws');
const hsSrv = +(s.hs_server_seq || 0), hsLast = +(s.hs_last_seq || 0);
if (hsSrv && hsSrv < hsLast)
out.push(`序号倒挂: 握手时对端自报 ${hsSrv}, 本端水位已到 ${hsLast}。对端此后发的每一条`
+ `(含成交)都会因「不高于水位」被静默丢弃。不要自行下调水位 —— 会把 `
+ `${hsSrv}..${hsLast} 重复入账。须先与 QMT 侧对齐 seq 语义`);
if (+(s.dropped || 0))
out.push(`已丢弃 ${s.dropped} 条上行 (seq 不高于水位)。补发时命中是正常的, `
+ `持续增长说明序号语义对不上`);
if (s.ack_seq_degraded)
out.push('ack_seq 已降级停发 (对端不认这个类型, 协议 §4.5)。水位照常落库、重连补发靠 '
+ 'hello.last_seq, 不丢成交; 代价是 QMT 侧 Redis 清不掉');
if (+(s.proto_rejects || 0))
out.push(`协议级 reject ${s.proto_rejects} 条 (不带 instruction_id, 拒的不是委托而是`
+ `我们发的协议消息)。若收发计数同步增长, 基本是「ack→reject→再 ack」的自激循环`);
if (s.peer_skew_ms != null && Math.abs(+s.peer_skew_ms) > 20000)
out.push(`对端时钟偏差 ${s.peer_skew_ms} ms, 已逼近 QMT 侧 ±30 秒时间窗。再漂下去连 `
+ `place_order 都会被 TS_SKEW 拒, 现象是「下单没反应」。两台机器都对一下 NTP`);
if (c.resync_required)
out.push('resync_flag=1: 对端补发不全, 须走全量对账后经运维抽屉清除标记');
if (c.error) out.push(c.error);
return out;
});
const curSpec = computed(() => catalog.value.find(c => c.cmd_type === form.cmd_type));
const dirtyCount = computed(() =>
params.value.filter(p => String(p.value) !== String(paramsRaw.value[p.key])).length);
const money = v => (v === null || v === undefined || v === '') ? '—'
: Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
const pct = v => (v === null || v === undefined || v === '') ? '—'
: (Number(v) * 100).toFixed(2) + '%';
const groupLabel = g => ({ A: 'A 资金与总体参数', B: 'B 组合级动作', C: 'C 个股级动作' }[g]);
const fieldLabel = n => ({
pct: '比例', scale: '总规模', cap: '上限', target: '目标', n: '数量', mode: '档位',
ratio: '比例', window_tdays: '窗口(交易日)', ts_code: '股票代码', target_pct: '目标仓位',
sector: '行业', t_ratio: 'T仓比例', price: '价格', confirm: '二次确认',
target_command_id: '目标命令号'
}[n] || n);
const stTag = s => ({ EXECUTING: 'primary', DONE: 'success', PARTIAL: 'warning',
CANCELLED: 'info', EFFECTIVE: 'success', SUPERSEDED: 'info' }[s] || 'warning');
const cuTag = s => ({ SOLID: 'info', THIN: 'warning', NONE: 'warning' }[s] || 'info');
const canCancel = r => r.cmd_class === 'task' &&
['PENDING', 'PLANNING', 'EXECUTING', 'PARTIAL'].includes(r.status);
const progPct = p => {
const t = Number(p.target_amount || 0), d = Number(p.done_amount || 0);
return t > 0 ? Math.min(100, Math.round(d / t * 100)) : 0;
};
const insPct = r => {
const q = Number(r.qty || 0), e = Number(r.exec_qty || 0);
return q > 0 ? Math.min(100, Math.round(e / q * 100)) : 0;
};
// ===================== 交易员视图:翻译层 + 名称 + 动作 =====================
const T = {
action: { OPEN:'建新仓', FILL:'回踩补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保垫减仓',
EXIT:'清仓', T0:'做T', T0_ROUND:'做T', SWITCH:'换仓', HALT:'停手' },
side: { buy:'买入', sell:'卖出' },
autonomy: { full:'全自动', propose_only:'只提议不下手', off:'暂停' },
cushion: { SOLID:'厚垫', THIN:'薄垫', NONE:'浮亏无垫' },
lot: { BASE:'底仓', FILL:'回踩补足', ADD:'盈利加仓', DCA:'补仓', T0:'做T仓', RECON:'对账调整' },
frozen: { NONE:'', COMMAND_HALT:'命令冻结(禁增持)', BRAKE:'刹车冻结', MANUAL:'手动冻结' },
posStatus: { PLANNED:'计划建仓', OPENING:'建仓中', HOLDING:'持有中', EXITING:'清仓中', CLOSED:'已清仓' },
insStatus: { PROPOSED:'待校验', RULE_PASSED:'合规已过', JUDGE_PASSED:'复核已过', DISPATCHED:'已下发挂单',
CONFIRMED:'已成交', REJECTED:'被拒', EXPIRED:'到期作废', CANCELLED:'已撤销' },
arbiter: { rule:'合规检查', judge:'决策系统', user:'你' },
verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记录', UNAVAILABLE:'决策系统暂不可用' },
stype: { T0:'做T', GRID:'网格', TRAIL:'跟踪止盈' },
stratStatus: { ACTIVE:'运行中', PAUSED:'已暂停', CANCELLED:'已撤下', DONE:'已完成' },
};
const tx = (g, c) => {
const m = T[g];
if (m && Object.prototype.hasOwnProperty.call(m, c)) return m[c];
if (c == null || c === '') return '—';
return String(c); // 未登记的码:显示原样, 不臆造
};
const nameMap = ref({});
const nm = c => (nameMap.value[c] && nameMap.value[c].name) || c;
const fullName = c => (nameMap.value[c] && nameMap.value[c].full) || '';
async function loadNames() {
const set = new Set();
(positions.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
(proposals.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
(instructions.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
(ledger.value || []).forEach(p => p.ts_code && set.add(p.ts_code));
const codes = [...set].filter(Boolean);
if (!codes.length) return;
const d = await call('get', '/api/names?codes=' + encodeURIComponent(codes.join(',')));
const names = (d && (d.names || (d.data && d.data.names))) || null;
if (names) nameMap.value = { ...nameMap.value, ...names };
}
const heldPositions = computed(() => (positions.value || []).filter(p => (p.total_qty || 0) > 0));
// ---- 今日在办分档 + 候选处置 (2026-08-12 重构新增) ----
const todayYmd = computed(() => (String(health.value.now || '').slice(0, 10).replace(/-/g, '')) || '');
const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'];
const _sameDayIns = r => { const y = todayYmd.value;
return !y || String(r.instruction_id || '').indexOf('INS_' + y + '_') === 0; };
const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status)));
const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' && _sameDayIns(r)));
const insEnd = computed(() => (instructions.value || []).filter(r => ['EXPIRED', 'CANCELLED', 'REJECTED'].includes(r.status) && _sameDayIns(r)));
// 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。
function dispOf(code) {
const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN');
if (inst) return { cls: 'done', label: '已下单', why: '在『今日在办』(' + tx('insStatus', inst.status) + '' };
const prop = (proposals.value || []).find(p => p.ts_code === code && p.action === 'OPEN');
if (prop) return { cls: 'queue', label: '待你确认', why: '已生成提议,在『等我拍板』等你点头' };
const d = (openScan.value.by_code || {})[code];
if (d) {
if (d.disp === 'would') return { cls: 'would', label: '将建仓', why: d.why };
return { cls: 'deny', label: '被否决', why: d.why };
}
return { cls: 'wait', label: '在盯', why: '' };
}
const denyToday = computed(() => (planRows.value || []).map(r => {
const d = dispOf(r.ts_code);
return d.cls === 'deny' ? { ts_code: r.ts_code, name: (r.name || nm(r.ts_code)), why: d.why } : null;
}).filter(Boolean));
const settingKeys = ['PMS_TOTAL_SCALE', 'PMS_PORTFOLIO_CAP', 'PMS_STOCK_CAP',
'PMS_MAX_NAMES', 'PMS_CASH_RESERVE',
'PMS_DCA_MAX_RATIO', 'PMS_DCA_TRIGGERS', 'PMS_DCA_DEEP_CONFIRM',
'PMS_FILL_MAX_LOSS', 'PMS_AUTONOMY'];
const traderParams = computed(() => {
const ix = {}; settingKeys.forEach((k, i) => ix[k] = i);
return (params.value || []).filter(p => settingKeys.includes(p.key)).sort((a, b) => ix[a.key] - ix[b.key]);
});
const attention = computed(() => {
const o = ov.value || {}, out = [];
if (o.buy_halt) out.push('已暂停买入(卖出与止损不受影响)');
if (o.exec_halt) out.push('休假模式:所有自动动作暂停,仅保留对账与日报');
if (o.brake_until) out.push('组合刹车中(解除日 ' + o.brake_until + '');
if ((o.price_missing || []).length)
out.push(o.price_missing.length + ' 只取不到实时价(停牌等),其盈亏与安全垫暂不可用');
if (o.sector_ready === false) out.push('行业数据未就绪,行业类操作暂不可用');
const j = (dm.value || {}).judge;
if (j && j.available === false) out.push('决策系统暂不可用:补足/加仓/补仓一律降级为需你确认');
const c = wsc.value || {};
if (c.conn_state && c.conn_state !== 'ONLINE') out.push('行情/成交通道未连接(' + c.conn_state + '');
if (c.resync_required) out.push('通道待人工全量对账');
return out;
});
function scrollTo(id) { const e = document.getElementById(id); if (e) e.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
function propWhy(p) {
const h = p.hard_numbers || {}, bits = [];
if (h.rank != null) bits.push('候选榜第 ' + h.rank);
if (h.theme) bits.push('主题「' + h.theme + '」' + (h.tier ? (' · ' + h.tier) : ''));
if (h.upside != null) bits.push('预期空间 ' + (h.upside > 0 ? '+' : '') + (h.upside * 100).toFixed(0) + '%');
if (h.score != null) bits.push('评分 ' + (typeof h.score === 'number' ? h.score.toFixed(0) : h.score));
if (h.heat != null) bits.push('热度 ' + (typeof h.heat === 'number' ? h.heat.toFixed(2) : h.heat));
return bits.join(' · ') || '—';
}
function nextStep(r) {
if (r.frozen_reason && r.frozen_reason !== 'NONE') return '已冻结,暂不增持';
if (r.cushion_state === 'SOLID') return '安全垫较厚,达标可加仓';
if ((r.cushion_pct || 0) < 0) return '浮亏中,跌到评估档会考虑补仓(深档需你确认)';
return '持有观察';
}
async function promptNum(msg, title) {
try {
const { value } = await ElementPlus.ElMessageBox.prompt(msg, title, {});
const n = parseFloat(value);
if (isNaN(n)) { ElementPlus.ElMessage.warning('请输入数字'); return null; }
return n;
} catch (e) { return null; }
}
async function quickCmd(cmd_type, params, confirmMsg) {
if (confirmMsg) {
try { await ElementPlus.ElMessageBox.confirm(confirmMsg, '确认', { type: 'warning' }); }
catch (e) { return; }
}
let d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图' });
if (!d.ok && (d.conflicts || []).length) {
try {
await ElementPlus.ElMessageBox.confirm('与在途命令冲突,仍要下达?', '命令冲突', { type: 'warning' });
d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图', force: true });
} catch (e) { return; }
}
if (d.ok) { ElementPlus.ElMessage.success('已下达 ' + (d.command_id || '')); await loadAll(); }
else ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
}
// 个股
function actExitStock(row) { quickCmd('EXIT_STOCK', { ts_code: row.ts_code }, '清仓 ' + nm(row.ts_code) + '?系统会在窗口内择机把它卖光。'); }
async function actReduceStock(row) {
const curPct = row.pct_of_scale != null ? (row.pct_of_scale * 100).toFixed(1) + '%' : '—';
const v = await promptNum('当前 ' + row.total_qty + ' 股,市值 ' + money(row.market_value)
+ '(占规模 ' + curPct + ')。减到目标仓位(占总规模,%', '减仓 ' + nm(row.ts_code));
if (v != null) quickCmd('REDUCE_STOCK', { ts_code: row.ts_code, target_pct: v + '%' });
}
async function actStop(row) {
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ '。止损价(元,跌破即挂卖)', '设止损 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
}
async function actTargetPrice(row) {
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ '。目标价(元,到价生成止盈提议)', '设目标价 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });
}
function actFreeze(row) {
const on = row.frozen_reason && row.frozen_reason !== 'NONE';
quickCmd(on ? 'UNFREEZE_STOCK' : 'FREEZE_STOCK', { ts_code: row.ts_code });
}
function actBlacklist(row) { quickCmd('BLACKLIST_ADD', { ts_code: row.ts_code }, '把 ' + nm(row.ts_code) + ' 加入黑名单(永不买入)?'); }
function rowMore(c, row) {
if (c === 'stop') actStop(row);
else if (c === 'target') actTargetPrice(row);
else if (c === 'freeze') actFreeze(row);
else if (c === 'black') actBlacklist(row);
else if (c === 'strategy') openStrategy(row);
}
// 组合
function pHaltBuy() { quickCmd('HALT_BUY', {}, '暂停一切新增买入并撤销在途买入指令?卖出与止损不受影响。'); }
function pResumeBuy() { quickCmd('RESUME_BUY', {}); }
function pHaltAll() { quickCmd('HALT_ALL', {}, '进入休假模式:所有自动动作暂停,只保留对账与日报?'); }
function pResumeAll() { quickCmd('RESUME_ALL', {}); }
function pLiquidate() { quickCmd('LIQUIDATE_ALL', { confirm: 'YES' }, '【一键清仓】将把全部持仓按最快节奏卖出、不做择时优化。确认?'); }
async function pReduce() { const v = await promptNum('降低敞口(占总规模,%', '降仓'); if (v != null) quickCmd('REDUCE_EXPOSURE', { pct: v + '%' }); }
async function pIncrease() { const v = await promptNum('提高敞口(占总规模,%', '升仓'); if (v != null) quickCmd('INCREASE_EXPOSURE', { pct: v + '%' }); }
async function pSectorExit() {
let name; try { name = (await ElementPlus.ElMessageBox.prompt('要清仓的行业名称', '清仓某行业', {})).value; } catch (e) { return; }
if (name) quickCmd('SECTOR_EXIT', { sector: name }, '清仓行业「' + name + '」的全部持仓?');
}
// ---- 组合仓位调整: 显示当前 + 算出金额, 不盲填 ----
const pctOf = v => (v == null || v === '') ? '—' : (Number(v) * 100).toFixed(0) + '%';
const tgtPos = ref(null);
const posMoveValid = computed(() => {
const t = Number(tgtPos.value);
return tgtPos.value !== null && tgtPos.value !== '' && !isNaN(t) && t >= 0 && Number(ov.value.scale || 0) > 0;
});
const posMovePreview = computed(() => {
if (!posMoveValid.value) return '';
const cur = Number(ov.value.portfolio_pct || 0), scale = Number(ov.value.scale || 0);
const delta = Number(tgtPos.value) / 100 - cur, amt = Math.abs(delta) * scale;
if (Math.abs(delta) < 0.001) return '与当前仓位一致,无需调整。';
return delta < 0
? '→ 降仓:从 ' + pctOf(cur) + ' 降到 ' + tgtPos.value + '%,释放约 ' + money(amt) + ' 元。'
: '→ 升仓:从 ' + pctOf(cur) + ' 升到 ' + tgtPos.value + '%,动用约 ' + money(amt) + ' 元。';
});
function doPosMove() {
if (!posMoveValid.value) { ElementPlus.ElMessage.warning('请输入目标仓位'); return; }
const cur = Number(ov.value.portfolio_pct || 0), scale = Number(ov.value.scale || 0);
const delta = Number(tgtPos.value) / 100 - cur, amt = Math.abs(delta) * scale;
if (Math.abs(delta) < 0.001) { ElementPlus.ElMessage.info('与当前仓位一致'); return; }
if (delta < 0) quickCmd('REDUCE_EXPOSURE', { pct: (Math.abs(delta) * 100).toFixed(1) + '%' },
'降仓到 ' + tgtPos.value + '%,释放约 ' + money(amt) + ' 元?');
else quickCmd('INCREASE_EXPOSURE', { pct: (delta * 100).toFixed(1) + '%' },
'升仓到 ' + tgtPos.value + '%,动用约 ' + money(amt)
+ ' 元(可用资金 ' + money(ov.value.cash_source === 'ws' ? ov.value.cash_avail : ov.value.cash_est) + '');
}
// ---- 清仓行业: 下拉选持仓行业, 不手输 ----
const heldSectors = computed(() => {
const m = {};
(positions.value || []).filter(p => (p.total_qty || 0) > 0 && p.sector).forEach(p => {
if (!m[p.sector]) m[p.sector] = { sector: p.sector, count: 0, mv: 0 };
m[p.sector].count++; m[p.sector].mv += (p.market_value || 0);
});
return Object.values(m).sort((a, b) => b.mv - a.mv);
});
const secSel = ref('');
function doSectorExit() {
if (!secSel.value) { ElementPlus.ElMessage.warning('请选择行业'); return; }
const x = heldSectors.value.find(v => v.sector === secSel.value);
quickCmd('SECTOR_EXIT', { sector: secSel.value },
'清仓行业「' + secSel.value + '」的全部持仓(' + (x ? x.count : '?') + ' 只,约 '
+ (x ? money(x.mv) : '?') + ' 元)?');
}
// ---- 设置: 参数映射 + 组合摘要 (给用户看合成结果, 不是孤立参数) ----
const pmap = computed(() => { const m = {}; (params.value || []).forEach(p => m[p.key] = p); return m; });
const pval = k => { const p = pmap.value[k]; return p ? p.value : null; };
const dcaOn = computed({
get: () => Number(pval('PMS_DCA_MAX_RATIO') || 0) > 0,
set: v => { const p = pmap.value['PMS_DCA_MAX_RATIO']; if (!p) return; p.value = v ? (Number(p.value) > 0 ? p.value : 0.5) : 0; }
});
const sumPosition = computed(() =>
'规模 ' + money(pval('PMS_TOTAL_SCALE')) + ' 元;总仓 ≤ ' + pctOf(pval('PMS_PORTFOLIO_CAP'))
+ ',单股 ≤ ' + pctOf(pval('PMS_STOCK_CAP')) + ',最多 ' + (pval('PMS_MAX_NAMES') || '—')
+ ' 只,预留现金 ' + pctOf(pval('PMS_CASH_RESERVE')) + '。');
const sumDca = computed(() => {
if (!dcaOn.value) return '已关闭 —— 浮亏时不补仓。';
const trg = String(pval('PMS_DCA_TRIGGERS') || '').split(',').map(x => x.trim()).filter(Boolean)
.map(x => Math.abs(Number(x) * 100).toFixed(0) + '%').join('、');
const dc = Math.abs(Number(pval('PMS_DCA_DEEP_CONFIRM') || 0) * 100).toFixed(0) + '%';
const fl = Math.abs(Number(pval('PMS_FILL_MAX_LOSS') || 0) * 100).toFixed(0) + '%';
return '开启,上限为底仓的 ' + pctOf(pval('PMS_DCA_MAX_RATIO')) + '。浮亏 ' + (trg || '—')
+ ' 各评估一次补仓;浮亏 ' + dc + ' 及更深的补仓永远需你确认;浮亏超过 ' + fl
+ ' 就不再回踩补足、转入补仓评估。';
});
const sumAutonomy = computed(() => ({
full: '全自动 —— 系统自主提议并直接执行。',
propose_only: '只提议不下手 —— 系统提议,等你在「等我拍板」里确认。',
off: '暂停 —— 系统不自主提议。'
}[pval('PMS_AUTONOMY')] || (pval('PMS_AUTONOMY') || '—')));
// ===================== /交易员视图 =====================
async function call(method, url, body) {
try {
const r = await api[method](url, body);
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
return r.data;
} catch (e) { err.value = url + ' → ' + e.message; return { ok: false, error: e.message }; }
}
async function loadParams() {
const d = await call('get', '/api/params');
const list = ((d.data || d).params) || [];
params.value = list;
paramsRaw.value = Object.fromEntries(list.map(p => [p.key, p.value]));
}
async function saveParams() {
const items = params.value
.filter(p => String(p.value) !== String(paramsRaw.value[p.key]))
.map(p => ({ key: p.key, value: p.value }));
const d = await call('post', '/api/params', { items });
const bad = (d.results || []).filter(r => !r.ok);
if (bad.length) ElementPlus.ElMessage.error(bad.map(b => b.error).join('; '));
else ElementPlus.ElMessage.success('已保存 ' + items.length + ' 项');
await loadParams(); await loadOverview();
}
async function loadOverview() {
const d = await call('get', '/api/overview'); ov.value = d.data || d || {};
health.value = await call('get', '/health');
}
async function loadCatalog() {
const d = await call('get', '/api/commands/catalog');
catalog.value = ((d.data || d).commands) || [];
}
async function loadCommands() {
const d = await call('get', '/api/commands?limit=60'); commands.value = d.data || [];
}
async function loadPositions() {
const d = await call('get', '/api/positions'); positions.value = d.positions || [];
}
async function loadInstructions() {
const d = await call('get',
'/api/instructions?status=PROPOSED,RULE_PASSED,JUDGE_PASSED,DISPATCHED,CONFIRMED,EXPIRED,CANCELLED,REJECTED&limit=200');
instructions.value = d.data || [];
}
async function loadLedger() {
const d = await call('get', '/api/ledger?limit=60'); ledger.value = d.data || [];
}
async function loadProposals() {
const d = await call('get', '/api/proposals'); proposals.value = d.data || [];
}
async function loadOpenScan() {
const d = await call('get', '/api/open-scan');
openScan.value = (d && d.by_code) ? d : { by_code: {}, notes: [] };
}
async function loadPlans(cid) {
plansOf.value = cid;
const d = await call('get', '/api/plans?command_id=' + cid); plans.value = d.data || [];
}
async function loadLots(code) {
lotsOf.value = code;
const d = await call('get', '/api/positions/' + code + '/lots?status='); lots.value = d.data || [];
}
function onPosExpand(row, expanded) {
// ElementPlus 展开事件传 (row, 当前展开行数组); 兼容布尔。展开某行即拉它的批次。
const open = Array.isArray(expanded) ? expanded.some(r => r.ts_code === row.ts_code) : !!expanded;
if (open) loadLots(row.ts_code);
}
async function loadDispatchMode() {
const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {};
}
async function loadWs() {
const d = await call('get', '/api/ws-channel?limit=60');
wsRaw.value = d.data || d || {};
}
// 只在 ws 页签可见时轮询。3 秒一次对一个只读接口不算什么, 但让它在后台一直跑
// 会把日志和连接数刷得很难看, 排查问题时反而碍事。
function wsPollSync() {
const want = tab.value === 'ws' && wsAuto.value;
if (want && !wsTimer) { loadWs(); wsTimer = setInterval(loadWs, 3000); }
if (!want && wsTimer) { clearInterval(wsTimer); wsTimer = null; }
}
watch([tab, wsAuto], wsPollSync);
onUnmounted(() => { if (wsTimer) clearInterval(wsTimer); });
async function loadAll() {
loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan()]);
await loadNames();
loading.value = false;
wsPollSync();
}
async function cancelIns(iid) {
try {
await ElementPlus.ElMessageBox.confirm('撤销指令 ' + iid + '?', '确认',
{ type: 'warning' });
} catch (e) { return; }
const d = await call('post', '/api/instructions/' + iid + '/cancel', {});
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
await Promise.all([loadInstructions(), loadLedger()]);
}
function onCmdChange() {
form.params = {};
const s = curSpec.value; if (!s) return;
Object.entries(s.fields).forEach(([k, f]) => {
if (f.default !== undefined) form.params[k] = f.default;
if (f.type === 'enum' && f.choices && f.choices.length === 1) form.params[k] = f.choices[0];
});
}
function buildParams() {
const s = curSpec.value, out = {};
Object.entries(s.fields).forEach(([k, f]) => {
let v = form.params[k];
if (v === undefined || v === null || v === '') return;
out[k] = (f.type === 'pct') ? (String(v).trim() + '%') : v;
});
return out;
}
async function issue(force) {
const s = curSpec.value;
if (!s) { ElementPlus.ElMessage.warning('请先选择命令'); return; }
if (s.danger && !force) {
try {
await ElementPlus.ElMessageBox.confirm(
'【' + s.label + '】是高危命令, 将对全部持仓生效。确认执行?', '二次确认',
{ type: 'warning', confirmButtonText: '确认执行', cancelButtonText: '取消' });
} catch (e) { return; }
}
issuing.value = true;
const body = { cmd_type: s.cmd_type, params: buildParams(), note: form.note, force: !!force };
const d = await call('post', '/api/commands', body);
issuing.value = false;
if (d.ok) {
ElementPlus.ElMessage.success('命令 ' + d.command_id + ' 已下达 · ' + (d.status || ''));
const notes = ((d.plan || {}).notes || []); if (notes.length)
ElementPlus.ElMessage.info(notes.join(' | '));
err.value = '';
await Promise.all([loadCommands(), loadOverview(), loadPositions(), loadParams()]);
} else if ((d.conflicts || []).length) {
const txt = d.conflicts.map(c => c.with_cmd_type + '(' + c.with_command_id + '): ' + c.reason)
.join('\n');
try {
await ElementPlus.ElMessageBox.confirm(
'与在途命令冲突:\n' + txt + '\n\n命令冲突由用户裁决 —— 仍要并行下达?', '命令冲突',
{ type: 'warning', confirmButtonText: '仍然下达', cancelButtonText: '放弃' });
await issue(true);
} catch (e) { /* 用户放弃 */ }
} else {
ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
}
}
async function cancelCmd(cid) {
const d = await call('post', '/api/commands/' + cid + '/cancel');
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
await loadCommands();
}
async function replan(cid) {
const d = await call('post', '/api/commands/' + cid + '/replan');
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? '已重新生成方案' :
(d.errors || [d.error]).join('; '));
await loadCommands(); if (plansOf.value === cid) await loadPlans(cid);
}
async function decide(row, decision) {
const d = await call('post', '/api/proposals/' + row.proposal_id + '/decide', { decision });
if (d.ok) ElementPlus.ElMessage.success(decision === 'ACCEPTED'
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
else ElementPlus.ElMessage.error(d.error);
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
}
async function loadStrategies() {
const d = await call('get', '/api/strategies');
strategies.value = d.strategies || (d.data && d.data.strategies) || [];
stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data && d.data.enabled));
}
async function loadOpLog() {
const d = await call('get', '/api/op-log?limit=80');
opLog.value = d.rows || (d.data && d.data.rows) || [];
}
function openStrategy(row) {
const px = Number(row.price) || 0;
Object.assign(stratDlg, {
visible: true, busy: false, reasons: [], ts_code: row.ts_code,
price: px, total_qty: row.total_qty, avail_qty: row.avail_qty,
cushion_pct: row.cushion_pct, type: 'T0', autonomy: 'auto',
p: { t_ratio: 0.2, lower: +(px * 0.92).toFixed(2), upper: +(px * 1.08).toFixed(2),
center: px, step_pct: 0.02, per_lot: 100, max_capital: 0,
start_line: 0.03, giveback: 0.05, sell_ratio: 1.0, hard_target: 0 }
});
}
function stratPayload() {
const t = stratDlg.type, p = stratDlg.p;
const out = { ts_code: stratDlg.ts_code, type: t, autonomy: stratDlg.autonomy, params: {} };
if (t === 'T0') out.params = { t_ratio: +p.t_ratio };
else if (t === 'GRID') out.params = { lower:+p.lower, upper:+p.upper, center:+p.center,
step_pct:+p.step_pct, per_lot:+p.per_lot, max_capital:+p.max_capital };
else out.params = { start_line:+p.start_line, giveback:+p.giveback,
sell_ratio:+p.sell_ratio, hard_target:+p.hard_target };
return out;
}
async function validateStrategy() {
stratDlg.busy = true;
const d = await call('post', '/api/strategies/validate', stratPayload());
stratDlg.reasons = d.ok ? [] : (d.reasons || [d.error].filter(Boolean));
stratDlg.busy = false;
if (d.ok) ElementPlus.ElMessage.success('校验通过,可挂载');
}
async function attachStrategy() {
stratDlg.busy = true;
const d = await call('post', '/api/strategies', stratPayload());
stratDlg.busy = false;
if (d.ok) { ElementPlus.ElMessage.success('已挂载 ' + (d.strategy_id || ''));
stratDlg.visible = false; await Promise.all([loadStrategies(), loadOpLog()]); }
else { stratDlg.reasons = d.errors || d.reasons || [d.error].filter(Boolean);
ElementPlus.ElMessage.error('挂载被挡下,见下方原因'); }
}
async function setStrategyStatus(sid, status) {
const label = { PAUSED:'暂停', ACTIVE:'恢复', CANCELLED:'撤下' }[status] || status;
try { await ElementPlus.ElMessageBox.confirm(label + '该策略?', '确认', { type:'warning' }); }
catch (e) { return; }
const d = await call('post', '/api/strategies/' + sid + '/status', { status });
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? (label + '成功') : (d.error || '失败'));
await Promise.all([loadStrategies(), loadOpLog()]);
}
async function resumeBuy(row) {
try { await ElementPlus.ElMessageBox.confirm('恢复 ' + nm(row.ts_code) + ' 的策略买入?此前因决策系统风控预警自动暂停(卖出/平回一直正常)。', '确认', { type:'warning' }); }
catch (e) { return; }
const d = await call('post', '/api/strategies/' + row.strategy_id + '/resume-buy', {});
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? '已恢复买入' : (d.error || '失败'));
await Promise.all([loadStrategies(), loadOpLog()]);
}
const stratQty = computed(() => {
const p = stratDlg.p, total = stratDlg.total_qty || 0;
if (stratDlg.type === 'T0') return Math.floor(total * (+p.t_ratio || 0) / 100) * 100;
return 0;
});
function stratStateText(s) {
const st = s.state || {};
if (s.type === 'T0') return '今日 ' + (st.t_count_today || 0) + '/3 次'
+ (st.open_leg ? (' · 有未平回的做T' + (st.open_leg.dir === 'long' ? '正T已买待卖' : '反T已卖待买回') + '') : ' · 今日无未平回');
if (s.type === 'GRID') return '已买 ' + Object.keys(st.filled_levels || {}).length + ' 档 · 投入 '
+ money(st.invested || 0) + (st.below_floor ? ' · 跌破下界已停买' : '');
if (s.type === 'TRAIL') return '高点 ' + (st.high_water || '—') + ' · ' + (st.armed ? '已武装' : '未武装');
return '';
}
async function ops(name) {
opsLoading.value = true;
const d = await call('post', '/api/ops/' + name);
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
await loadAll();
}
async function loadSchema() {
opsLoading.value = true;
const d = await call('get', '/api/ops/downstream-schema');
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
}
async function loadPlan() {
planLoading.value = true;
const d = await call('get', '/api/upstream/plan?limit=50');
planRaw.value = (d && d.ok) ? d : { status: { ok: false, hint: (d||{}).error || '取数失败' } };
planLoading.value = false;
}
async function loadChanges() {
chgLoading.value = true;
const d = await call('get', '/api/upstream/plan-changes?limit=50');
chgRaw.value = (d && d.ok) ? d
: { changes: { ok: false, hint: (d || {}).error || '取数失败' }, log: {} };
chgLoading.value = false;
}
async function openPlan() { planDrawer.value = true; await Promise.all([loadPlan(), loadChanges()]); }
async function refreshPlan() {
planLoading.value = true;
const d = await call('post', '/api/ops/plan-refresh');
planResult.value = JSON.stringify(d, null, 2);
planLoading.value = false;
await Promise.all([loadPlan(), loadChanges(), loadOverview()]);
}
async function openReport() {
const d = await call('get', '/api/report'); report.value = d.data || d || {};
reportDrawer.value = true;
}
onMounted(() => { loadAll(); loadPlan(); }); // 计划状态并行拉, 不阻塞首屏
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
scaleGap,
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns,
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
loadPlan, openPlan, refreshPlan,
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,
loadChanges,
mode, tx, nm, fullName, nameMap, attention, heldPositions, traderParams,
propWhy, nextStep, scrollTo, actExitStock, actReduceStock, rowMore,
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,
pmap, pval, dcaOn, sumPosition, sumDca, sumAutonomy,
strategies, stratEnabled, opLog, stratDlg, stratQty, loadStrategies, loadOpLog,
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
showLedger, onPosExpand,
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday };
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>