Skip to main content

ek_ui/
state.rs

1//! 共享观测状态:`UiPlugin`(总线面)写、`render_body`(窗口面)读。
2//!
3//! 线程形貌:总线侧在**插件 worker 线程**写;窗口侧在 **eframe 主线程**读;
4//! 锁粒度 = 每次 `lock().clone()/snapshot()`,不跨帧持锁。
5
6use std::collections::{BTreeMap, VecDeque};
7use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9
10use ek_data::bar::BarData;
11use ek_dm::wire::BarsFrame;
12use ek_station::wire::PlugRow;
13
14/// 日志环形容量(S1b 门面;溢出不丢累计计数,见 [`LogRing::spilled`])。
15pub const RING_CAP: usize = 512;
16
17/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
18/// 0.2.27 起从 render 侧提到 state(原 `render.rs` 那份随之删除):日志时间戳
19/// 在入环时就要打,而打戳与表格时间列(`fmt_dt`)必须是同一个口径,
20/// 不能一边 +08 一边本机时区。
21pub(crate) fn beijing_tz() -> chrono::FixedOffset {
22    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
23}
24
25/// 一行日志(BD-UI-41:Python 侧 LogMonitor 是带「时间/信息/接口」表头的表格
26/// (`widget.py:1167-1180`),我们原来只存正文串、渲染侧没有时间可排)。
27/// 时间戳在入环那一刻打,北京墙钟 `%H:%M:%S`(与 `fmt_dt` 同口径)。
28#[derive(Debug, Clone)]
29pub struct LogEntry {
30    /// 北京墙钟 `HH:MM:SS`。
31    pub at: String,
32    /// 正文。
33    pub text: String,
34}
35
36/// 环形日志:push 满则挤掉最旧一条,溢出计数单调(页面显示"已丢弃 N 条")。
37#[derive(Debug)]
38pub struct LogRing {
39    lines: VecDeque<LogEntry>,
40    cap: usize,
41    spilled: u64,
42}
43
44impl Default for LogRing {
45    fn default() -> Self {
46        Self::new(RING_CAP)
47    }
48}
49
50impl LogRing {
51    /// 定容环形(测试可传小容量逼溢出)。
52    pub fn new(cap: usize) -> Self {
53        Self {
54            lines: VecDeque::with_capacity(cap),
55            cap,
56            spilled: 0,
57        }
58    }
59
60    /// 追加一行;满则挤旧 + spilled+1。
61    pub fn push(&mut self, line: impl Into<String>) {
62        if self.cap == 0 {
63            self.spilled += 1;
64            return;
65        }
66        if self.lines.len() == self.cap {
67            self.lines.pop_front();
68            self.spilled += 1;
69        }
70        let at = chrono::Utc::now()
71            .with_timezone(&beijing_tz())
72            .format("%H:%M:%S")
73            .to_string();
74        self.lines.push_back(LogEntry {
75            at,
76            text: line.into(),
77        });
78    }
79
80    /// 当前长度。
81    pub fn len(&self) -> usize {
82        self.lines.len()
83    }
84
85    /// 是否空行。
86    pub fn is_empty(&self) -> bool {
87        self.lines.is_empty()
88    }
89
90    /// 累计已丢弃条数(只有溢出才涨)。
91    pub fn spilled(&self) -> u64 {
92        self.spilled
93    }
94
95    /// 只读快照(正文;测试与巡回取证用它,不关心时间)。
96    pub fn snapshot(&self) -> Vec<String> {
97        self.lines.iter().map(|e| e.text.clone()).collect()
98    }
99
100    /// 带时间戳的快照(渲染侧表格用:时间列 + 正文列)。
101    pub fn entries(&self) -> Vec<LogEntry> {
102        self.lines.iter().cloned().collect()
103    }
104
105    /// 全清;spilled 保留(历史溢出事实不抹)。
106    pub fn clear(&mut self) {
107        self.lines.clear();
108    }
109}
110
111/// 一次 eTimer 拉取的服务/插件快照(渲染直接可用)。
112#[derive(Debug, Clone, Default)]
113pub struct BusSnap {
114    /// svctl.list 全行(svctl 独裁班;状态原文)。
115    pub rows: Vec<PlugRow>,
116    /// 逐插件服务目录(id 清单;无服务为空)。
117    pub services: Vec<(String, Vec<String>)>,
118}
119
120/// UI 操作指令(窗口面 → 总线面;eTimer 节拍被 ui 插件请辞)。
121#[derive(Debug, Clone, PartialEq)]
122pub enum UiCmd {
123    /// svctl.start(工厂名 + 实例注册名;`name` 空 = 旧行为,工厂名即注册名)。
124    /// 多账号面:`factory="ctp"` + `name=ctp_<broker>_<账号>` 建第 N 个网关实例。
125    Start { factory: String, name: String },
126    /// svctl.remove(插件名)。
127    Remove { name: String },
128    /// dm.reload 回灌(S4;push=true 逐条广播,行情面板实时见 Rows)。
129    Reload {
130        interval: i64,
131        symbol: String,
132        bars: Vec<BarData>,
133    },
134    /// 下单(S5;网关受理即 SUBMITTING 单上总线)。
135    /// S6 起网关可选(`gateway.send_order`,空 = ctp)。
136    /// 0.1.1(BD-UI-30):改走 `oms.submit_order` 五段管线(拆单/风控/逐腿
137    /// 发送/记账,parent 聚合);`mode` = 拆单四态(勾选「自动开平」= Auto,
138    /// 取消勾选 = None 原样旁路)。
139    SendOrder {
140        req: ek_data::order_request::OrderRequest,
141        gateway: String,
142        mode: ek_oms::LockMode,
143    },
144    /// 撤单(S5;携三段号所需的 orderid;S6 起网关同选)。
145    /// 0.1.1(BD-UI-30):改走 `oms.cancel_order`(守卫 4000 不可绕过;
146    /// symbol/exchange 由 oms 订单缓存解,不再随令携带)。
147    CancelOrder { orderid: String, gateway: String },
148    /// `{gw}.configure` 账户配置(多账号面:网关名随令携带,不再硬编码 `ctp`;
149    /// 出队时 `to_configure_req` 抬成线上契约)。
150    /// 0.2.18 起界面侧不再用它装配账户(装配走 `Connect`);保留作低层口与测试面。
151    CtpConfigure { gateway: String, form: CtpForm },
152    /// **四态分离的「连接」(0.2.18,规划裁定:添加 ≠ 连接)**:
153    /// 一次做完 `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
154    /// (`Assembling → Assembled / AssembleFailed`)。界面『连接』『重试』钮走这一条,
155    /// 『添加账户』只写清册(不再一键装配 → 失败不会留下"注定被拒"的半死行)。
156    Connect { gateway: String, form: CtpForm },
157    /// `{gw}.subscribe` 订阅行情(多账号面:一律路由到**行情元账号**,
158    /// 从账号没有 MD 会话,发给它只会静默成功 —— 见规划 §5.C / §9.4-21)。
159    CtpSubscribe {
160        gateway: String,
161        symbols: Vec<String>,
162    },
163    /// **`{gw}.disconnect` 停会话(0.2.19,规划裁定 1:断开 ≠ 移除)**:
164    /// 网关实例、账号配置、订阅集、状态计数全保留,『连接/重试』可恢复(重登自动重订阅)。
165    /// 『移除』走的 `UiCmd::Remove`(svctl.remove)才是停实例 —— 两个动作不得混成一个。
166    Disconnect { gateway: String },
167}
168
169/// 行情缓存单格容量(防涨;丢头保尾,bar 流单调)。
170pub const KLINE_CELL_CAP: usize = 4096;
171
172/// 账户软上限(0.2.18 落地既有开放项:达上限『添加账户』入口禁用并提示,
173/// 界面侧不再无限长行)。
174pub const MAX_ACCOUNTS: usize = 8;
175
176/// 「查询断供」判据之一:资金/持仓查询**连续**被拒达此数(规划 Q10)。
177///
178/// 阈值住在界面,不住在网关:ek-ctp 只给 `td_query_reject_streak` 这个原料,
179/// 「连续几次算断供」是界面口径(它才知道自己多久刷一次)。
180pub const QUERY_REJECT_STREAK_LIMIT: u32 = 3;
181
182/// 「查询断供」判据之二:距最后一次**资金回报**超过这么多秒(规划 Q10)。
183///
184/// 轮转口径(ek-ctp 0.7.0)是资金/持仓交替,实测每类约 4 秒一拍;30 秒 = 约 7 拍
185/// 没见到资金,足够避开「刚登录还没轮到」的正常空窗,又不至于把 F2 那种断供拖很久才显形。
186pub const ACCOUNT_STALE_SECS: i64 = 30;
187
188/// `{gw}.status` 轮询节拍(eTimer 每 1 秒一拍;每 N 拍问一次)。
189///
190/// 2 拍 ≈ 2 秒:状态口在 ek-ctp 侧是只读口(不发查询、不写日志),但 8 个账户
191/// 每秒各一次 ask 仍是白给的总线流量;而掉线显形的时效性 2 秒足够。
192pub const STATUS_POLL_TICKS: u32 = 2;
193
194/// 二次确认步进:Armed = 首点待确认;Fired = 同名二点已发令;Switched = 目标迁移。
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum ConfirmStep {
197    Armed,
198    Fired,
199    Switched,
200}
201
202/// 隔离态判定(svctl.list 状态原文含 Quarantin → 红;ePlugQDown.<名> 是定向
203/// 广播,未知名无法预订阅 —— 快照行扫描是唯一闭环面,BD-UI-17)。
204#[must_use]
205pub fn is_quarantined(state: &str) -> bool {
206    state.contains("Quarantin")
207}
208
209// ================= S4:行情库(eBar./eBarUpdate. 上屏地面) =================
210
211/// 单格 = (symbol, interval) → bar 升序序列(丢头保尾;datetime 单调)。
212#[derive(Debug, Default)]
213pub struct MarketStore {
214    cells: Mutex<BTreeMap<(String, i64), Vec<BarData>>>,
215    /// eBar. 帧接收计数(帧统计,刷新不丢帧观感面)。
216    frames: AtomicU64,
217    /// eBarUpdate. 接收计数。
218    updates: AtomicU64,
219    /// dm.reload 成功计数(回灌确认面)。
220    reloads: AtomicU64,
221}
222
223/// 一格的可读视图(渲染/测试用)。
224#[derive(Debug, Clone, PartialEq)]
225pub struct CellView {
226    pub symbol: String,
227    pub interval: i64,
228    pub bars: Vec<BarData>,
229}
230
231impl MarketStore {
232    /// eBar. 收帧:逐 bar 并格(同 dt 替尾;更旧丢弃;更晚追加,溢出丢头)。
233    pub fn on_frame(&self, frame: &BarsFrame) {
234        let mut cells = self.cells.lock().unwrap();
235        for b in &frame.bars {
236            let key = (b.symbol.clone(), frame.interval);
237            let cell = cells.entry(key).or_default();
238            match cell.last() {
239                Some(last) if b.datetime < last.datetime => continue,
240                Some(last) if b.datetime == last.datetime => {
241                    let slot = cell.last_mut().expect("len>1");
242                    *slot = b.clone();
243                }
244                _ => {
245                    cell.push(b.clone());
246                    if cell.len() > KLINE_CELL_CAP {
247                        cell.remove(0);
248                    }
249                }
250            }
251        }
252        self.frames.fetch_add(1, Ordering::AcqRel);
253    }
254
255    /// eBarUpdate. 收单 bar 快照(实时门;同 dt 替尾,新 dt 追加 = in-flight bar)。
256    pub fn on_update(&self, b: &BarData) {
257        let mut cells = self.cells.lock().unwrap();
258        let iv = b
259            .interval
260            .unwrap_or(ek_data::constant::Interval::MINUTE)
261            .seconds();
262        let key = (b.symbol.clone(), iv);
263        let cell = cells.entry(key).or_default();
264        match cell.last() {
265            Some(last) if b.datetime == last.datetime => {
266                let slot = cell.last_mut().expect("len>1");
267                *slot = b.clone();
268            }
269            Some(last) if b.datetime < last.datetime => {}
270            _ => {
271                cell.push(b.clone());
272                if cell.len() > KLINE_CELL_CAP {
273                    cell.remove(0);
274                }
275            }
276        }
277        self.updates.fetch_add(1, Ordering::AcqRel);
278    }
279
280    /// dm.reload 回灌登记(RELOAD OK 计数;dm 侧行为独立)。
281    pub fn note_reload(&self) {
282        self.reloads.fetch_add(1, Ordering::AcqRel);
283    }
284
285    /// 全格只读快照(键序 = BTreeMap 序,symbol/interval 稳定)。
286    pub fn cells(&self) -> Vec<CellView> {
287        self.cells
288            .lock()
289            .unwrap()
290            .iter()
291            .map(|((sym, iv), bars)| CellView {
292                symbol: sym.clone(),
293                interval: *iv,
294                bars: bars.clone(),
295            })
296            .collect()
297    }
298
299    /// 计算(Ai) 帧统计。
300    pub fn counts(&self) -> (u64, u64, u64) {
301        (
302            self.frames.load(Ordering::Acquire),
303            self.updates.load(Ordering::Acquire),
304            self.reloads.load(Ordering::Acquire),
305        )
306    }
307}
308
309/// 回灌表单(v4:界面面已退役,总线口 `UiCmd::Reload` 保留;解析器留作
310/// 测试与脚本面)。
311#[derive(Debug, Default, Clone, PartialEq)]
312pub struct ReloadForm {
313    pub interval: String,
314    pub symbol: String,
315    pub csv: String,
316}
317
318/// 票据网关选择子解析(多账号面)。
319///
320/// 0.2.15 起**不再兜底成字面量 `"ctp"`**:多账号下"空网关"必须显式落到
321/// `fallback`(= [`UiShared::default_gateway`] 的阶梯:行级带入 → 上次选择 →
322/// 第一个已连接账户);一个账户都没有时回 `Err`,界面据此禁用下单钮。
323/// 单账号旧行为仍成立(fallback 就是那个账户的派生名)。
324pub fn parse_gateway(raw: &str, fallback: Option<&str>) -> Result<String, String> {
325    let g = raw.trim();
326    if !g.is_empty() {
327        if g.chars().any(|c| c.is_whitespace()) {
328            return Err(format!("网关解析: 含空白:{raw:?}"));
329        }
330        return Ok(g.to_string());
331    }
332    match fallback.map(|f| f.trim()).filter(|f| !f.is_empty()) {
333        Some(f) => Ok(f.to_string()),
334        None => Err("网关解析: 未选择账户(先在账户面板添加账户)".to_string()),
335    }
336}
337
338/// CTP 网关配置表单(S6 测试面;界面手工输入源 → UiCmd::CtpConfigure →
339/// ask `ctp.configure`。零配置口径:界面不读文件,值全部人敲;密码位掩码)。
340#[derive(Debug, Clone, PartialEq)]
341pub struct CtpForm {
342    pub broker_id: String,
343    pub td_addr: String,
344    pub td_user: String,
345    pub td_password: String,
346    pub td_auth_code: String,
347    pub td_app_id: String,
348    pub md_addr: String,
349    pub md_user: String,
350    pub md_password: String,
351    /// 订阅标的(逗号分隔;`订阅行情` 钮解析)。
352    pub symbols: String,
353    /// 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。
354    /// 缺省只勾期货 —— 期权默认不勾,因为默认口径与改动前一致(只期货上抛)。
355    pub accept_futures: bool,
356    pub accept_options: bool,
357}
358
359/// 手写 `Default`(不能 derive:`accept_futures` 必须为 `true`,derive 会给 `false`,
360/// 而两个都 false 在语义上是「什么都不收录」)。
361impl Default for CtpForm {
362    fn default() -> Self {
363        Self {
364            broker_id: String::new(),
365            td_addr: String::new(),
366            td_user: String::new(),
367            td_password: String::new(),
368            td_auth_code: String::new(),
369            td_app_id: String::new(),
370            md_addr: String::new(),
371            md_user: String::new(),
372            md_password: String::new(),
373            symbols: String::new(),
374            accept_futures: true,
375            accept_options: false,
376        }
377    }
378}
379
380impl CtpForm {
381    /// 勾选 → CTP 产品类码表(期货 `'1'`、期权 `'2'`)。
382    /// 两个都不勾交空表给插件:插件侧归一化一律回落默认期货 —— **空不等于全丢**
383    /// (共享合约表为空会让网关的持仓查询整条被跳过)。
384    #[must_use]
385    pub fn product_classes(&self) -> Vec<String> {
386        let mut codes = Vec::new();
387        if self.accept_futures {
388            codes.push("1".to_string());
389        }
390        if self.accept_options {
391            codes.push("2".to_string());
392        }
393        codes
394    }
395
396    /// 表单 → 线上契约(字段直映;md 回落由插件侧裁决,表单不预处理)。
397    pub fn to_configure_req(&self) -> ek_ctp::plugin::ConfigureReq {
398        ek_ctp::plugin::ConfigureReq {
399            broker_id: self.broker_id.trim().to_string(),
400            td_addr: self.td_addr.trim().to_string(),
401            td_user: self.td_user.trim().to_string(),
402            td_password: self.td_password.clone(),
403            td_auth_code: self.td_auth_code.trim().to_string(),
404            td_app_id: self.td_app_id.trim().to_string(),
405            md_addr: self.md_addr.trim().to_string(),
406            md_user: self.md_user.trim().to_string(),
407            md_password: self.md_password.clone(),
408            product_classes: self.product_classes(),
409        }
410    }
411
412    /// 订阅位解析(逗号/空白分隔;空表 = Err)。
413    pub fn parse_symbols(&self) -> Result<Vec<String>, String> {
414        let list: Vec<String> = self
415            .symbols
416            .split([',', ',', ' '])
417            .map(|s| s.trim().to_string())
418            .filter(|s| !s.is_empty())
419            .collect();
420        if list.is_empty() {
421            return Err("订阅解析: 标的位空".to_string());
422        }
423        Ok(list)
424    }
425
426    /// 添加/连接前的形制校验(规划 U7:旧口径是"前置为空照样建账户并下发两令")。
427    ///
428    /// 判据:`tcp://host:port` 形制(host 非空、端口为 1..=65535 的十进制)、
429    /// broker/账号非空、AppID/AuthCode 非空(CTP 登录必需,空值只会换来一次注定失败的登录)。
430    /// **md 前置允许为空** = 纯 TD 账户(单源纪律:非行情源不建 MD 会话)。
431    ///
432    /// 返回 `Err(第一条原因)` —— 界面红字呈现,不当『添加』按钮的硬拦截前置条件之外的东西
433    /// (校验只在『添加』『连接』两条入口上跑,低层口 `add_account` 不重复校验)。
434    pub fn validate(&self) -> Result<(), String> {
435        fn front(name: &str, v: &str) -> Result<(), String> {
436            let v = v.trim();
437            let Some(rest) = v.strip_prefix("tcp://") else {
438                return Err(format!("{name} 形制应为 tcp://host:port(当前 {:?})", v));
439            };
440            let Some((host, port)) = rest.rsplit_once(':') else {
441                return Err(format!("{name} 缺端口:{name} {:?}", v));
442            };
443            if host.trim().is_empty() {
444                return Err(format!("{name} 主机位空:{}", v));
445            }
446            let port: u32 = port
447                .parse()
448                .map_err(|_| format!("{name} 端口不是数字:{}", v))?;
449            if port == 0 || port > 65535 {
450                return Err(format!("{name} 端口越界(1..=65535):{}", v));
451            }
452            Ok(())
453        }
454        if self.broker_id.trim().is_empty() {
455            return Err("broker 位空".to_string());
456        }
457        if self.td_user.trim().is_empty() {
458            return Err("交易账号位空".to_string());
459        }
460        front("td 前置", &self.td_addr)?;
461        if !self.md_addr.trim().is_empty() {
462            front("md 前置", &self.md_addr)?;
463        }
464        if self.td_app_id.trim().is_empty() {
465            return Err("AppID 位空(CTP 登录必需)".to_string());
466        }
467        if self.td_auth_code.trim().is_empty() {
468            return Err("AuthCode 位空(CTP 登录必需)".to_string());
469        }
470        Ok(())
471    }
472}
473
474/// 账户装配四态(0.2.18,规划 §3.3「添加 ≠ 连接 ≠ 断开 ≠ 移除」)。
475///
476/// 口径边界(诚实):这一层只说**实例装配**,不说登录 —— 登录成败/掉线 ek-ctp 无状态口
477/// 可查(开放项 `ctp.status`,规划裁定 3:状态机等终态口,不做 eLog 自建过渡)。
478/// 因此 `Assembled` 的界面文案必须是「已装配·登录态未知」(短形:状态列宽有限,
479/// 长文案会被 Grid 挤成竖排 —— s39 快照实锤过),不得写成「已连接」。
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
481pub enum AccountStage {
482    /// 未装配:添加后未连接、实例已摘、或装配失败后未重试。
483    #[default]
484    NotAssembled,
485    /// 装配中:`UiCmd::Connect` 已入列,svctl 还没把这个注册名带回在场表。
486    Assembling,
487    /// 实例已装配(登录态未知)。
488    Assembled,
489    /// 装配失败:`svctl.start` 被拒/超时,或 `{gw}.configure` 被拒。可重试。
490    AssembleFailed,
491}
492
493impl AccountStage {
494    /// 界面文案(状态列 / 徽标)。
495    #[must_use]
496    pub fn label(self) -> &'static str {
497        match self {
498            AccountStage::NotAssembled => "未装配",
499            AccountStage::Assembling => "装配中…",
500            // 规划 §3.5 缓解:不让「已装配」被读成「在线」。
501            // (文案长度有实测约束:账户面板是 Grid,状态列文案过长会被挤成竖排换行。)
502            AccountStage::Assembled => "已装配·登录态未知",
503            AccountStage::AssembleFailed => "装配失败",
504        }
505    }
506}
507
508/// 登录态(0.2.19;事实源 = ek-ctp 0.9.0 `{gw}.status`,规划 P3 / 裁定 3)。
509///
510/// 与 [`AccountStage`] 是**两件事**:stage 说「实例在不在」,这一档说「会话活着没有」。
511/// 状态列把两者拼起来(`AccountRow::status_label`)。
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
513pub enum LoginState {
514    /// 还没读到读数,或读到了但没有任何登录证据。
515    ///
516    /// 网关的「句柄已建」(**`td_handle_up`**)**不等于在线** —— 掉线时它仍是真
517    /// (ek-ctp DESIGN D-STATUS ①),所以没有 `logged_in` 就只有这一档,不得显示「在线」。
518    #[default]
519    Unknown,
520    /// 网关回报 `td_logged_in`(纯行情账户看 `md_logged_in`)—— 在线的唯一硬证据。
521    Online,
522    /// 最近一次登录失败(网关在成功后会清零这一位,所以它显形 = 现在就是登不上)。
523    LoginFailed,
524    /// 柜台断过线且当前未登录(`td_disconnects > 0`;计数跨重连保留)。
525    FrontDown,
526    /// 登录着但查询连续被拒 / 资金回报超时(规划 Q10 两判据,阈值在界面)。
527    QueryStarved,
528    /// 用户主动『断开』(规划裁定 1):会话已停,实例与订阅还在,『连接』可恢复。
529    Stopped,
530}
531
532impl LoginState {
533    /// 状态列短文案(账户面板是 Grid,长文案会被挤成竖排 —— s39 快照实锤过)。
534    #[must_use]
535    pub fn label(self) -> &'static str {
536        match self {
537            LoginState::Unknown => "登录态未知",
538            LoginState::Online => "在线",
539            LoginState::LoginFailed => "登录失败",
540            LoginState::FrontDown => "掉线",
541            LoginState::QueryStarved => "查询断供",
542            LoginState::Stopped => "已断开",
543        }
544    }
545
546    /// 这一档要不要在状态列上着色(红/橙)。
547    #[must_use]
548    pub fn is_alarm(self) -> bool {
549        matches!(self, LoginState::LoginFailed | LoginState::FrontDown)
550    }
551}
552
553/// 「掉线 N 次」这类细节:N = 0 回 `None` —— 没有发生过就不该占状态列的位置。
554fn count_note(label: &str, n: u32) -> Option<String> {
555    (n > 0).then(|| format!("{label} {n} 次"))
556}
557
558/// 柜台错误原句 → **短原因**(账户行状态列用)。原句形状照 ek-ctp `map::error_text`
559/// (`map.rs:332-333`)= `{消息},代码:{id},信息:{正文}`。
560///
561/// 取「信息」的正文(去掉 CTP 侧的 `CTP:` 前缀)并把代码括在后面 = `不合法的登录(3)`。
562/// 形状对不上(网关没回带原因、或不是这条格式)就回 `None` —— 状态列宁可只写「登录失败」,
563/// 也不编一个原因出来。
564fn brief_counter_reason(full: &str) -> Option<String> {
565    let tail = full.rfind("信息:").map(|i| &full[i + "信息:".len()..])?;
566    let body = tail.trim().trim_start_matches("CTP:").trim();
567    if body.is_empty() {
568        return None;
569    }
570    let code = full
571        .rfind("代码:")
572        .map(|i| {
573            let rest = &full[i + "代码:".len()..];
574            let end = rest.find(',').unwrap_or(rest.len());
575            rest[..end].trim().to_string()
576        })
577        .filter(|c| !c.is_empty() && c.parse::<i64>().is_ok());
578    Some(match code {
579        Some(c) => format!("{body}({c})"),
580        None => body.to_string(),
581    })
582}
583
584/// 账户行(多账号面;规划 §5.B + 0.2.18 四态)。
585///
586/// 界面是账户清单的**唯一事实源**(不新增"账户注册表"插件):
587/// 网关实例的在场由 `svctl.list` 回填,身份与凭据留在这一行,**每账户一份 form**
588/// (旧「全界面共享一张表单」已退役 → 消 U4 凭据串味 / U6 取消编辑不清空)。
589#[derive(Debug, Clone, PartialEq, Default)]
590pub struct AccountRow {
591    /// 派生注册名(`ctp_<broker>_<账号>`;**寻址唯一用这个**,别名不参与)。
592    pub gateway: String,
593    /// 用户别名(显示用,可空 = 回退账号;唯一性由 `add_account` 校验,规划 Q3)。
594    pub alias: String,
595    pub broker_id: String,
596    pub account: String,
597    /// 是否行情元账号(**全局单源**——用户裁定"单数据源";已装配后锁定,规划 D3)。
598    pub md_source: bool,
599    /// 该账户自己的表单留档(编辑弹窗回填用;密码只存内存,裁定 2:不持久化)。
600    pub form: CtpForm,
601    /// 装配四态(规划 §3.3)。
602    pub stage: AccountStage,
603    /// 装配失败原因(`AssembleFailed` 时留显形;成功即清空)。
604    pub fail_note: Option<String>,
605    /// **网关实际收录回带值**(`ConfigureReply.product_classes`,规划 U9)。
606    ///
607    /// 与 `form.accept_*`(用户勾选)并排显示:勾选是意图,这一栏是事实。
608    /// 空 = 尚未装配过或旧版网关未回带。
609    pub effective_classes: Vec<String>,
610    /// 在场位(由 `svctl.list` 快照回填:该注册名在不在 = 实例已装配)。
611    /// 0.2.19 起登录成败另有事实源(`{gw}.status` → `login`),这一位只管实例。
612    pub connected: bool,
613    /// **登录态(0.2.19)**:`{gw}.status` 读数折出来的,与 `stage`/`connected` 正交。
614    ///
615    /// 默认 `Unknown`:装配成功本身不构成任何登录证据(ek-ctp D-STATUS ①)。
616    pub login: LoginState,
617    /// 状态细节(「掉线 2 次」「查询连续被拒 3 次」「资金回报已 45s 未更新」)。
618    /// `None` = 没有可说的细节(不是「一切正常」——一切正常由 `login` 说)。
619    pub status_note: Option<String>,
620    /// **柜台原因的短标签(0.4.1 十期 BD-UI-63)**:登录失败/掉线时从 `td_last_error` 里
621    /// 抠出「代码:N」与「信息:」的正文,给账户行的状态列用(红字后面直接跟着原因)。
622    ///
623    /// 为什么不直接把整句塞进状态列:柜台原句是「交易服务器登录失败,代码:3,信息:CTP:不
624    /// 合法的登录」这种长句,Grid 状态列被它挤宽就会把别的列顶窄(s39 那口径)。整句留在
625    /// `status_note` 里,窗下方「状态细节」那一行照旧给全。
626    pub login_brief: Option<String>,
627}
628
629impl AccountRow {
630    /// 由表单派生账户行(派生名唯一入口 = `ek_ctp::derived_gateway_name`)。
631    ///
632    /// 返回 `Err(原因)` = broker/账号非法或派生名超宿主 32 字符上限;
633    /// 界面须红字呈现,**不得静默替换**(规划 §3)。
634    pub fn derive(form: &CtpForm) -> Result<Self, String> {
635        let broker = form.broker_id.trim();
636        let account = form.td_user.trim();
637        let gateway = ek_ctp::derived_gateway_name(broker, account).ok_or_else(|| {
638            "账户: broker/账号 不能为空且只能含字母数字(且派生名 ≤32)".to_string()
639        })?;
640        Ok(Self {
641            gateway,
642            alias: String::new(),
643            broker_id: broker.to_string(),
644            account: account.to_string(),
645            md_source: false,
646            form: form.clone(),
647            stage: AccountStage::NotAssembled,
648            fail_note: None,
649            effective_classes: Vec::new(),
650            connected: false,
651            login: LoginState::Unknown,
652            status_note: None,
653            login_brief: None,
654        })
655    }
656
657    /// 状态列文案(装配态 + 登录态拼一起)。
658    ///
659    /// 「已装配」后面必须跟着登录态,不能光写「已装配」——0.2.18 那条「不得读成在线」
660    /// 的口径(规划 U5)现在有了真读数才真正立住:没有证据时写「已装配·登录态未知」。
661    /// 装配段一律走 `AccountStage::label()`,不在这里重抄一遍文案(两处不同式就是坑)。
662    #[must_use]
663    pub fn status_label(&self) -> String {
664        if self.stage == AccountStage::Assembled {
665            match self.login {
666                LoginState::Unknown => "已装配·登录态未知".to_string(),
667                other => format!("已装配·{}", other.label()),
668            }
669        } else {
670            self.stage.label().to_string()
671        }
672    }
673
674    /// 把 `{gw}.status` 读数折成界面登录态 + 细节(0.2.19;规划 P3 / Q10)。
675    ///
676    /// 判据顺序写明(每条都有理由):
677    /// 1. **登录失败先判** —— 网关在登录成功后会清零 `td_login_failed`,所以它显形就是现在登不上;
678    /// 2. **登录着** → 才谈「查询断供」,三条判据:连续被拒达阈 / 距最后一次资金回报超时 /
679    ///    **登录后资金回报从未到手且已超过同一个 stale 阈值**(0.2.21,实连 S15f 逼出来的第三条);
680    ///    都没中 = 在线;
681    /// 3. **没登录** → 用户主动『断开』优先(那是自己按的钮,比柜台掉线计数更贴近用户意图),
682    ///    其次才是柜台掉线(`disconnects > 0`),最后才是「未知」;
683    /// 4. **纯行情账户**(td 前置留档为空)看 MD 半边 —— 看 TD 半边永远是「未知」,那是假信息。
684    ///
685    /// 「从未发生」= `None` 不当 0 用:网关没回报过资金时刻 ≠ 「0 秒前刚回报过」。
686    pub fn apply_status(
687        &mut self,
688        st: &ek_ctp::plugin::GatewayStatus,
689        now_ms: i64,
690        reject_limit: u32,
691        stale_secs: i64,
692    ) {
693        let td_side = !self.form.td_addr.trim().is_empty();
694        let (login, note) = if td_side {
695            if st.td_login_failed && !st.td_logged_in {
696                (
697                    LoginState::LoginFailed,
698                    st.td_last_error
699                        .clone()
700                        .or_else(|| Some("登录失败(网关未回带原因)".to_string())),
701                )
702            } else if st.td_logged_in {
703                if st.td_query_reject_streak >= reject_limit {
704                    (
705                        LoginState::QueryStarved,
706                        Some(format!("查询连续被拒 {} 次", st.td_query_reject_streak)),
707                    )
708                } else if let Some(last) = st.td_last_account_ms {
709                    let idle_ms = now_ms.saturating_sub(last);
710                    if idle_ms > stale_secs.saturating_mul(1_000) {
711                        (
712                            LoginState::QueryStarved,
713                            Some(format!("资金回报已 {}s 未更新", idle_ms / 1_000)),
714                        )
715                    } else {
716                        (LoginState::Online, count_note("掉线", st.td_disconnects))
717                    }
718                } else {
719                    // 登录成功但资金一次都没到手。轮转第一拍本就要 2~6 秒(ek-ctp 0.7.0 口径),
720                    // 所以刚登录那几秒只算「尚未到手」不算断供;但**"从未到手"不能永远算在线** ——
721                    // 2026-10-22 实连 S15f 实测:账户 B `logged_in=true`、查询被拒 0 次、
722                    // 资金时刻 `None` 持续 390 秒以上,界面上却一直显示「在线 + 资金回报尚未到手」。
723                    // 超过同一个 stale 阈值就翻「查询断供」:判据原料齐了(登录时刻 + 读数时刻),
724                    // 不需要网关多给任何东西。
725                    let since_login_ms = st.td_last_login_ok_ms.map(|ok| now_ms.saturating_sub(ok));
726                    match since_login_ms {
727                        Some(ms) if ms > stale_secs.saturating_mul(1_000) => (
728                            LoginState::QueryStarved,
729                            Some(format!("资金回报从未到手(登录已 {}s)", ms / 1_000)),
730                        ),
731                        _ => (LoginState::Online, Some("资金回报尚未到手".to_string())),
732                    }
733                }
734            } else if self.login == LoginState::Stopped {
735                let extra = if st.td_disconnects > 0 {
736                    format!("(此前掉线 {} 次)", st.td_disconnects)
737                } else {
738                    String::new()
739                };
740                (
741                    LoginState::Stopped,
742                    Some(format!("会话已停{extra};『连接』可恢复")),
743                )
744            } else if st.td_disconnects > 0 {
745                (
746                    LoginState::FrontDown,
747                    Some(format!("掉线 {} 次", st.td_disconnects)),
748                )
749            } else {
750                (LoginState::Unknown, st.td_last_error.clone())
751            }
752        } else if st.md_logged_in {
753            (
754                LoginState::Online,
755                count_note("掉线", st.md_disconnects)
756                    .or_else(|| Some("纯行情账户(无 TD 会话)".to_string())),
757            )
758        } else if self.login == LoginState::Stopped {
759            (
760                LoginState::Stopped,
761                Some("会话已停;『连接』可恢复".to_string()),
762            )
763        } else if st.md_disconnects > 0 {
764            (
765                LoginState::FrontDown,
766                Some(format!("掉线 {} 次", st.md_disconnects)),
767            )
768        } else {
769            (LoginState::Unknown, st.md_last_error.clone())
770        };
771        // 0.9.1 分项接上(随 ek-ctp 0.9.1):总数与连续串**不分路**(消费侧要的是
772        // 「查询这条路连续被拒几次」),但用户要知道被拒的是哪一路 —— 合约一条没到手时,
773        // 下一笔单会被 ContractGate 以 `contract:unknown` 拒掉,而状态列只写「在线」
774        // 就是假信息。加在句尾而不是另开一行:Grid 列宽会被长文案挤成竖排(s39 实锤)。
775        let note = if td_side && st.td_instrument_rejects > 0 {
776            let add = format!("合约查询被拒 {} 次", st.td_instrument_rejects);
777            match note {
778                // 已经点名过合约查询的句子不重复加。
779                Some(base) if base.contains("合约查询被拒") => Some(base),
780                Some(base) => Some(format!("{base};{add}")),
781                None => Some(add),
782            }
783        } else {
784            note
785        };
786        self.login = login;
787        self.status_note = note;
788        // 告警态(登录失败 / 掉线)才配「柜台原因」短标签:在线、未知、已断开都不占状态列。
789        // 这一位是账户行红字后面那段原因的**唯一来源**(BD-UI-63)。
790        self.login_brief = if login.is_alarm() {
791            st.td_last_error
792                .as_deref()
793                .or(st.md_last_error.as_deref())
794                .and_then(brief_counter_reason)
795        } else {
796            None
797        };
798    }
799
800    /// 显示名:别名优先,空则回退账号(寻址仍只用 `gateway`,规划 Q3)。
801    #[must_use]
802    pub fn display_name(&self) -> String {
803        let a = self.alias.trim();
804        if a.is_empty() {
805            self.account.clone()
806        } else {
807            a.to_string()
808        }
809    }
810}
811
812/// 交易监控快照(S5;ui 插件 eTimer 拍 ask oms 全量查询组帧)。
813#[derive(Debug, Clone, PartialEq, Default)]
814pub struct TradeSnap {
815    pub orders: Vec<ek_data::order::OrderData>,
816    pub trades: Vec<ek_data::trade::TradeData>,
817    pub positions: Vec<ek_data::position::PositionData>,
818    /// 资金帧(oms.get_all_accounts;CTP configure 登录后有数)。
819    pub accounts: Vec<ek_data::account::AccountData>,
820}
821
822/// CSV(每行 `dt,open,high,low,close,volume`;dt 为 epoch 秒)→ BarData 批。
823/// 错行报行号;空 csv = Err;升序不强制(dm.reload 自会逐行入窗)。
824pub fn parse_reload_csv(form: &ReloadForm) -> Result<(i64, String, Vec<BarData>), String> {
825    let interval: i64 = form
826        .interval
827        .trim()
828        .parse()
829        .map_err(|_| format!("回灌 解析: interval 非整数:{:?}", form.interval))?;
830    let symbol = form.symbol.trim().to_string();
831    if symbol.is_empty() {
832        return Err("回灌解析: symbol 空".to_string());
833    }
834    if form.csv.trim().is_empty() {
835        return Err("回灌解析: csv 空".to_string());
836    }
837    let mut bars = Vec::new();
838    for (i, raw) in form.csv.lines().enumerate() {
839        let line = raw.trim();
840        if line.is_empty() || line.starts_with('#') {
841            continue;
842        }
843        let parts: Vec<&str> = line.split(',').map(str::trim).collect();
844        if parts.len() != 6 {
845            return Err(format!("回灌解析: 第 {} 列数 ≠ 6", i + 1));
846        }
847        let num = |s: &str| -> Result<f64, String> {
848            s.parse::<f64>()
849                .map_err(|_| format!("回灌解析: 第 {} 列数值坏:{:?}", i + 1, s))
850        };
851        let dt_s: i64 = parts[0]
852            .parse()
853            .map_err(|_| format!("回灌解析: 第 {} 列 dt 坏:{:?}", i + 1, parts[0]))?;
854        bars.push(BarData {
855            symbol: symbol.clone(),
856            datetime: dt_s * 1_000_000_000,
857            interval: Some(
858                ek_dm::wire::interval_of_seconds(interval)
859                    .unwrap_or(ek_data::constant::Interval::MINUTE),
860            ),
861            open_price: num(parts[1])?,
862            high_price: num(parts[2])?,
863            low_price: num(parts[3])?,
864            close_price: num(parts[4])?,
865            volume: num(parts[5])?,
866            ..BarData::default()
867        });
868    }
869    if bars.is_empty() {
870        return Err("回灌解析: 无有效行".to_string());
871    }
872    Ok((interval, symbol, bars))
873}
874
875// ================= v4 布局面(带/组 tab / tick 库 / 下单表单;docs/ek-ui-layout-v2-plan.md) =================
876
877/// 左上组 tab(Python [委托|未成交];审计修正:行情是带不是 tab,K线/运维/CTP
878/// 是弹窗不是 tab,故不设全局 10 值 UiTab——那是 v2 残影)。
879#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
880pub enum TopLeftTab {
881    #[default]
882    Orders,
883    Active,
884}
885
886impl TopLeftTab {
887    pub fn label(self) -> &'static str {
888        match self {
889            Self::Orders => "委托",
890            Self::Active => "未成交",
891        }
892    }
893}
894
895/// 右下组 tab(Python [资金|日志])。
896#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
897pub enum BottomRightTab {
898    #[default]
899    Account,
900    Logs,
901}
902
903impl BottomRightTab {
904    pub fn label(self) -> &'static str {
905        match self {
906            Self::Account => "资金",
907            Self::Logs => "日志",
908        }
909    }
910}
911
912/// 数据区两刀的份额(0.4.1 BD-UI-54)。Python `right_splitter` = vertical 50:50,
913/// 我们的左右刀与之正交;两值都夹在 `crate::splitters::clamp_frac` 界内。
914#[derive(Debug, Clone, Copy, PartialEq)]
915pub struct DataSplit {
916    /// 左右刀:左列占比(委托|未成交 在左)。
917    pub col: f32,
918    /// 上下刀:上行占比(委托/成交 在上)。
919    pub row: f32,
920}
921
922impl Default for DataSplit {
923    fn default() -> Self {
924        Self {
925            col: crate::splitters::DATA_SPLIT_DEFAULT,
926            row: crate::splitters::DATA_SPLIT_DEFAULT,
927        }
928    }
929}
930
931/// 行情带六所固定 tab 序(0.4.1 BD-UI-54:六所各一格 = 用户裁定「每个交易所
932/// 都要一个」;原 `crate::dock::MARKET_ORDER` 随 dock 拆除,常量原样搬到这里)。
933pub const MARKET_ORDER: [ek_data::constant::Exchange; 6] = [
934    ek_data::constant::Exchange::SHFE,
935    ek_data::constant::Exchange::DCE,
936    ek_data::constant::Exchange::CZCE,
937    ek_data::constant::Exchange::CFFEX,
938    ek_data::constant::Exchange::INE,
939    ek_data::constant::Exchange::GFEX,
940];
941
942// 0.2.30(BD-UI-46,裁定 8):主题面整块删除 —— 只保留一套主题(当时 = Qt深色
943// qdarkstyle 3.2.3 复刻;0.4.1 四期 BD-UI-57 换成 shadcn Nova 深色,「只有一套」这条
944// 裁定不变,换的是值),视觉规格搬进 `crate::tokens`。原来的 `Theme` 五变体枚举、
945// `label()`、`next()` 循环、`UiShared::theme()/set_theme()/cycle_theme()`、菜单循环钮
946// 全部退役。这是一次**有意的公共面收缩**:`ek_ui::state::Theme` 不再存在。外部没有任何
947// crate 依赖 ek-ui,所以收缩不触发联动;但它不可逆,记在 CHANGELOG 0.2.30。
948
949/// tick 库(v2 行情 tab 唯一数据源):symbol → 最新 tick,逐 symbol 覆盖,
950/// 有界 = 订阅面(ek-ctp 只发已订阅合约;无 bar 回落,用户裁定)。
951#[derive(Debug, Default)]
952pub struct TickStore {
953    ticks: BTreeMap<String, ek_data::tick::TickData>,
954}
955
956impl TickStore {
957    /// 逐合约覆盖(审计修正:键 = en_symbol「交易所.合约」,防跨所同码相撞;
958    /// 交易所分组取 tick 自带 exchange)。
959    pub fn put(&mut self, t: &ek_data::tick::TickData) {
960        self.ticks
961            .insert(format!("{}.{}", t.exchange.as_str(), t.symbol), t.clone());
962    }
963
964    /// 只读视图(交易所 → 合约排序,tab 分组顺序稳定)。
965    pub fn rows(&self) -> Vec<ek_data::tick::TickData> {
966        let mut v: Vec<ek_data::tick::TickData> = self.ticks.values().cloned().collect();
967        v.sort_by(|a, b| {
968            (a.exchange.as_str().to_string(), a.symbol.clone())
969                .cmp(&(b.exchange.as_str().to_string(), b.symbol.clone()))
970        });
971        v
972    }
973
974    /// 按交易所分组(tab 键只列有 tick 的所,不预置全枚举)。
975    pub fn groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
976        let mut out: Vec<(String, Vec<ek_data::tick::TickData>)> = Vec::new();
977        for t in self.rows() {
978            let ex = t.exchange.as_str().to_string();
979            match out.last_mut() {
980                Some((e, v)) if *e == ex => v.push(t),
981                _ => out.push((ex, vec![t])),
982            }
983        }
984        out
985    }
986
987    pub fn len(&self) -> usize {
988        self.ticks.len()
989    }
990
991    pub fn is_empty(&self) -> bool {
992        self.ticks.is_empty()
993    }
994}
995
996/// 下单面板表单(v2 常驻右侧;Python TradingWidget 字段面等价;操作面非配置面)。
997#[derive(Debug, Clone, PartialEq)]
998pub struct OrderForm {
999    /// 交易所代码(下拉;from_str 抬成枚举)
1000    pub exchange: String,
1001    /// 合约代码(纯 symbol,不带前缀)
1002    pub symbol: String,
1003    pub direction: ek_data::constant::Direction,
1004    pub offset: ek_data::constant::Offset,
1005    pub order_type: ek_data::constant::OrderType,
1006    /// 输入面保持字符串(同 Python QLineEdit),委托时解析
1007    pub price: String,
1008    pub volume: String,
1009    /// 网关(下拉源 = trade_snap gateway 去重集 ∪ CTP 表单现值)
1010    pub gateway: String,
1011    /// 价格随行情勾选(勾选时价格框随选中合约最新 tick)
1012    pub follow_price: bool,
1013    /// 「自动开平」勾选(0.1.1 BD-UI-30;默认开 = LockMode::Auto 走拆单器,
1014    /// 关 = LockMode::None 原样旁路——守卫不可绕,拆单可逐请求旁路)
1015    pub auto_split: bool,
1016}
1017
1018impl Default for OrderForm {
1019    fn default() -> Self {
1020        Self {
1021            exchange: "SHFE".to_string(),
1022            symbol: String::new(),
1023            direction: ek_data::constant::Direction::LONG,
1024            offset: ek_data::constant::Offset::OPEN,
1025            order_type: ek_data::constant::OrderType::LIMIT,
1026            price: String::new(),
1027            volume: "1".to_string(),
1028            gateway: String::new(),
1029            follow_price: true,
1030            auto_split: true,
1031        }
1032    }
1033}
1034
1035impl OrderForm {
1036    /// 表单 → OrderRequest(与票据解析同一出口;错误红字就地,不进总线)。
1037    pub fn to_request(&self) -> Result<ek_data::order_request::OrderRequest, String> {
1038        use std::str::FromStr;
1039        let symbol = self.symbol.trim();
1040        if symbol.is_empty() {
1041            return Err("下单: 代码为空".to_string());
1042        }
1043        if symbol.contains('.') {
1044            return Err("下单: 代码不带交易所前缀(交易所走下拉)".to_string());
1045        }
1046        let exchange = ek_data::constant::Exchange::from_str(self.exchange.trim())
1047            .map_err(|e| format!("下单: {e}"))?;
1048        let price: f64 = self
1049            .price
1050            .trim()
1051            .parse()
1052            .map_err(|_| format!("下单: 价格无效 {:?}", self.price))?;
1053        let volume: f64 = self
1054            .volume
1055            .trim()
1056            .parse()
1057            .map_err(|_| format!("下单: 数量无效 {:?}", self.volume))?;
1058        if self.order_type != ek_data::constant::OrderType::MARKET && price <= 0.0 {
1059            return Err("下单: 限价单价格须为正".to_string());
1060        }
1061        if volume <= 0.0 {
1062            return Err("下单: 数量须为正".to_string());
1063        }
1064        Ok(ek_data::order_request::OrderRequest {
1065            symbol: symbol.to_string(),
1066            exchange,
1067            direction: self.direction,
1068            order_type: self.order_type,
1069            volume,
1070            price,
1071            offset: self.offset,
1072            ..ek_data::order_request::OrderRequest::default()
1073        })
1074    }
1075}
1076
1077/// 合约库(v4 查询合约窗数据源):en_symbol → ContractData。
1078///
1079/// 数据面 = ek-ctp 登录后自动 reqQryInstrument 回流(`eContract.` 裸键逐条 +
1080/// `eContractFinish` 完成线,driver.rs:779)——**无 ask 口,UI 只收不催**
1081/// (用户裁定:订阅不在网关配置定死,查询窗勾选订阅 / 下单代码回车)。
1082#[derive(Debug, Default)]
1083pub struct ContractStore {
1084    contracts: BTreeMap<String, ek_data::contract::ContractData>,
1085}
1086
1087impl ContractStore {
1088    pub fn put(&mut self, c: &ek_data::contract::ContractData) {
1089        self.contracts
1090            .insert(format!("{}.{}", c.exchange.as_str(), c.symbol), c.clone());
1091    }
1092
1093    /// Python ContractManager 查询口径:代码/交易所/名称模糊,留空 = 全部。
1094    pub fn query(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
1095        let p = pattern.trim();
1096        self.contracts
1097            .values()
1098            .filter(|c| {
1099                p.is_empty()
1100                    || c.symbol.contains(p)
1101                    || c.exchange.as_str().eq_ignore_ascii_case(p)
1102                    || c.name.contains(p)
1103            })
1104            .cloned()
1105            .collect()
1106    }
1107
1108    pub fn len(&self) -> usize {
1109        self.contracts.len()
1110    }
1111
1112    pub fn is_empty(&self) -> bool {
1113        self.contracts.is_empty()
1114    }
1115}
1116
1117/// 插件面板与宿主观察线程共用的观测状态。
1118#[derive(Debug, Default)]
1119pub struct UiShared {
1120    logs: Mutex<LogRing>,
1121    snap: Mutex<Arc<BusSnap>>,
1122    market: MarketStore,
1123    ctp_form: Mutex<CtpForm>,
1124    trade_snap: Mutex<Arc<TradeSnap>>,
1125    tick_store: Mutex<TickStore>,
1126    order_form: Mutex<OrderForm>,
1127    /// 数据区 2×2 两刀的份额(0.4.1 BD-UI-54:拆 dock 后归薄 QSplitter;
1128    /// col = 左右刀的左占比,row = 上下刀的上占比,缺省 = Python right_splitter 50:50)。
1129    data_split: Mutex<DataSplit>,
1130    /// 行情带当前 tab(按所;六所固定 tab 条,谁在前台就是谁)。
1131    selected_exchange: Mutex<String>,
1132    /// 委托|未成交 组的当前 tab。
1133    topleft_tab: Mutex<TopLeftTab>,
1134    /// 资金|日志 组的当前 tab。
1135    bottomright_tab: Mutex<BottomRightTab>,
1136    ops_window: AtomicBool,
1137    /// 账户面板(多账号面;取代旧单账号 CTP 配置窗的入口 —— 旧窗已退役,
1138    /// 旧 `ctp_window` 位随之删除,不留死状态)。
1139    accounts_window: AtomicBool,
1140    contracts_window: AtomicBool,
1141    contract_store: Mutex<ContractStore>,
1142    contract_query: Mutex<String>,
1143    contract_selected: Mutex<Vec<String>>,
1144    kline_windows: Mutex<Vec<String>>,
1145    rev: AtomicU64,
1146    /// 关窗/测试结束信号(窗口面置位,宿主观察线程消费)。
1147    stop: AtomicBool,
1148    /// svctl 操作指令队列(窗口面入,ui 插件 eTimer 出)。
1149    cmds: Mutex<VecDeque<UiCmd>>,
1150    /// 待二次确认的移除目标(单槽;再次同名点 = 发令)。
1151    pending_confirm: Mutex<Option<String>>,
1152    /// 账户清单(多账号面;界面唯一事实源)。
1153    accounts: Mutex<Vec<AccountRow>>,
1154    /// 账户配置弹窗开合(0.2.18 Commit B:『添加』与『编辑』同一弹窗两种模式)。
1155    account_edit_window: AtomicBool,
1156    /// 弹窗模式:`Some(派生名)` = 编辑该账户;**`None` = 添加新账户**(草稿位仍是 `ctp_form`)。
1157    editing_account: Mutex<Option<String>>,
1158    /// 弹窗里的别名草稿(『保存』时才写回账户行)。
1159    edit_alias: Mutex<String>,
1160    /// 弹窗内的拦截原因(校验不过 / 装配被拒):弹窗**不关**,原因上屏(规划 U3 / Q26)。
1161    edit_error: Mutex<Option<String>>,
1162    /// 行情元账号的注册名(空 = 未选;连接后锁定,规划 D3)。
1163    md_source: Mutex<String>,
1164    /// 订阅集(裸代码;断开重连后按它重放订阅,规划 §5.C)。
1165    subscriptions: Mutex<Vec<String>>,
1166    /// 表格行选中(BD-UI-31):表 id → 选中行键(每表一行;键由渲染侧构造,
1167    /// 纯界面态,不发指令;悬停跳色是渲染侧即时判断,不入状态)。
1168    row_sel: Mutex<BTreeMap<String, String>>,
1169    /// 0.3.1(Wave D,BD-UI-49)表格排序键:`表 id -> Some((列序, 升序))`,`None` = 不排序。
1170    /// 排序键住在界面状态,不进数据模型(W2 的判据就是这条 -- Python `widget.py:396-419`
1171    /// 那个「更新途中重排」的病,根因是排序作用在正在被喂的模型上)。
1172    table_sorts: Mutex<BTreeMap<String, Option<(usize, bool)>>>,
1173    /// 「列边界可拖」的开关(0.3.1 右键菜单「调整列宽」)已在 0.4.1 五期(BD-UI-58)退役:
1174    /// 可拖 + 列线是全部表的固定形制,不再是可开关的东西 → 字段与访问器一并删。
1175    /// `{gw}.status` 轮询节拍(eTimer 每 1 秒一拍,每 `STATUS_POLL_TICKS` 拍问一次)。
1176    status_tick: AtomicU32,
1177    /// 「查询断供」阈值覆盖 `Some((连续被拒次数, 资金超时秒))`;`None` = 用缺省常量。
1178    /// 阈值住在界面不在网关(ek-ctp D-STATUS),覆盖位是给测试与后续配置面留的口子。
1179    status_thresholds: Mutex<Option<(u32, i64)>>,
1180    /// 0.3.2(Wave E,BD-UI-50)攒着待灌进 toast 队列的告警(界面层产生,渲染帧收口)。
1181    pending_alerts: Mutex<Vec<(crate::toast::AlertLevel, String)>>,
1182    /// 常驻 toast 队列(住在界面状态,不是 render 的局部变量 —— 否则 8 秒自动关与
1183    /// 同屏上限都没有意义)。`None` = 还没按我们的口径建过。
1184    toasts: crate::toast::ToastsCell,
1185    /// 已经弹过 toast 的告警键(去重:同一账户同一状态只弹一次,照 Python AlertEngine 的去重)。
1186    alerted: Mutex<std::collections::BTreeSet<String>>,
1187    /// 0.3.1(Wave D,BD-UI-49)退出确认窗是否开着(照 Python `closeEvent`:关窗先问一句)。
1188    exit_confirm_open: AtomicBool,
1189    /// 用户已在确认窗里点过「确认退出」—— 之后不再拦关闭(否则确认完也关不掉)。
1190    exit_confirmed: AtomicBool,
1191}
1192
1193impl UiShared {
1194    /// 空状态(快照空、日志空、rev 0)。
1195    pub fn new() -> Self {
1196        Self::default()
1197    }
1198
1199    // ---- v2 布局面访问器(tick 库 / 表单 / 选中 / tab) ----
1200
1201    /// 落一条 tick(eTick. 裸键上屏;逐 symbol 覆盖)。
1202    pub fn update_tick(&self, t: &ek_data::tick::TickData) {
1203        self.tick_store.lock().unwrap().put(t);
1204        self.bump();
1205    }
1206
1207    /// tick 只读视图(排序后)。
1208    pub fn tick_rows(&self) -> Vec<ek_data::tick::TickData> {
1209        self.tick_store.lock().unwrap().rows()
1210    }
1211
1212    /// tick 按交易所分组(行情 tab 二级 tab 键表)。
1213    pub fn tick_groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
1214        self.tick_store.lock().unwrap().groups()
1215    }
1216
1217    /// tick 库格数。
1218    pub fn tick_count(&self) -> usize {
1219        self.tick_store.lock().unwrap().len()
1220    }
1221
1222    /// 下单表单只读快照。
1223    pub fn order_form(&self) -> OrderForm {
1224        self.order_form.lock().unwrap().clone()
1225    }
1226
1227    /// 下单表单写(渲染侧每控件改动即写)。
1228    pub fn set_order_form(&self, f: OrderForm) {
1229        *self.order_form.lock().unwrap() = f;
1230        self.bump();
1231    }
1232
1233    /// 选中交易所(行情带当前 tab;不在六所内的取值由本口夹回首所 —— 空/脏值
1234    /// 都落到 SHFE,调用方永远拿到一个可用的 tab 名;行情行单击只带入下单面板,
1235    /// tab 归 tab 条管)。
1236    pub fn selected_exchange(&self) -> String {
1237        let s = self.selected_exchange.lock().unwrap().clone();
1238        if MARKET_ORDER.iter().any(|e| e.as_str() == s) {
1239            s
1240        } else {
1241            MARKET_ORDER[0].as_str().to_string()
1242        }
1243    }
1244
1245    /// 选中交易所写。
1246    pub fn set_selected_exchange(&self, s: impl Into<String>) {
1247        *self.selected_exchange.lock().unwrap() = s.into();
1248        self.bump();
1249    }
1250
1251    /// 左上组当前 tab。
1252    pub fn topleft_tab(&self) -> TopLeftTab {
1253        *self.topleft_tab.lock().unwrap()
1254    }
1255
1256    /// 左上组 tab 写。
1257    pub fn set_topleft_tab(&self, t: TopLeftTab) {
1258        *self.topleft_tab.lock().unwrap() = t;
1259        self.bump();
1260    }
1261
1262    /// 右下组当前 tab。
1263    pub fn bottomright_tab(&self) -> BottomRightTab {
1264        *self.bottomright_tab.lock().unwrap()
1265    }
1266
1267    /// 右下组 tab 写。
1268    pub fn set_bottomright_tab(&self, t: BottomRightTab) {
1269        *self.bottomright_tab.lock().unwrap() = t;
1270        self.bump();
1271    }
1272
1273    /// 数据区分刀份额读(渲染期)。
1274    pub fn data_split(&self) -> DataSplit {
1275        *self.data_split.lock().unwrap()
1276    }
1277
1278    /// 数据区分刀份额写(分隔条拖拽回调;写入前就地夹取到可用区,QSplitter
1279    /// minimumSize 语义 —— 调用方传多少都不出界)。
1280    pub fn set_data_split(&self, s: DataSplit) {
1281        let s = DataSplit {
1282            col: crate::splitters::clamp_frac(s.col),
1283            row: crate::splitters::clamp_frac(s.row),
1284        };
1285        let mut g = self.data_split.lock().unwrap();
1286        if g.col != s.col || g.row != s.row {
1287            *g = s;
1288            self.bump();
1289        }
1290    }
1291
1292    /// 「恢复布局」:数据区分刀回 Python right_splitter 口径(两刀各半),
1293    /// 三个 tab 态回缺省。dock 时代的「整树替换」不再存在 —— 布局骨架本来就是
1294    /// Python 等价的原生面板,没有树可换。
1295    pub fn reset_layout(&self) {
1296        *self.data_split.lock().unwrap() = DataSplit::default();
1297        *self.selected_exchange.lock().unwrap() = String::new();
1298        *self.topleft_tab.lock().unwrap() = TopLeftTab::default();
1299        *self.bottomright_tab.lock().unwrap() = BottomRightTab::default();
1300        self.bump();
1301    }
1302
1303    // ---- v4 弹窗面(egui::Window 开关;kittest 可断言) ----
1304
1305    /// 运维窗开合。
1306    pub fn ops_window_open(&self) -> bool {
1307        self.ops_window.load(Ordering::Acquire)
1308    }
1309
1310    /// 运维窗开合写。
1311    /// 某张表当前的排序键(`None` = 按数据源原序)。
1312    pub fn table_sort(&self, table: &str) -> Option<(usize, bool)> {
1313        self.table_sorts
1314            .lock()
1315            .unwrap()
1316            .get(table)
1317            .copied()
1318            .flatten()
1319    }
1320
1321    /// 表头点击:同列再点 = 翻向;异列 = 新列升序(与 Python QTableView 单击表头同口径)。
1322    pub fn toggle_table_sort(&self, table: &str, col: usize) {
1323        let mut m = self.table_sorts.lock().unwrap();
1324        let asc = match m.get(table).copied().flatten() {
1325            Some((c, a)) if c == col => !a,
1326            _ => true,
1327        };
1328        m.insert(table.to_string(), Some((col, asc)));
1329    }
1330
1331    pub fn set_ops_window_open(&self, v: bool) {
1332        self.ops_window.store(v, Ordering::Release);
1333        self.bump();
1334    }
1335
1336    // ---- 多账号面(规划 §5.B / §5.C) ----
1337
1338    /// 账户面板开合。
1339    pub fn accounts_window_open(&self) -> bool {
1340        self.accounts_window.load(Ordering::Acquire)
1341    }
1342
1343    /// 账户面板开合写。
1344    pub fn set_accounts_window_open(&self, v: bool) {
1345        self.accounts_window.store(v, Ordering::Release);
1346        self.bump();
1347    }
1348
1349    /// 账户配置弹窗开合(0.2.18 Commit B)。
1350    pub fn account_edit_open(&self) -> bool {
1351        self.account_edit_window.load(Ordering::Acquire)
1352    }
1353
1354    /// 当前弹窗模式:`Some(派生名)` = 编辑;`None` = 添加。
1355    pub fn editing_account(&self) -> Option<String> {
1356        self.editing_account.lock().unwrap().clone()
1357    }
1358
1359    /// 打开配置弹窗并载入草稿:`Some(派生名)` = 编辑模式(草稿 = 该账户留档);
1360    /// `None` = 添加模式(草稿 = `draft_from_existing()`,凭据位空)。
1361    /// 账户不在清册 = `false`(不开一个编辑不存在账户的窗)。
1362    pub fn begin_account_edit(&self, gateway: Option<&str>) -> bool {
1363        match gateway {
1364            Some(gw) => {
1365                let Some(row) = self.account(gw) else {
1366                    return false;
1367                };
1368                *self.editing_account.lock().unwrap() = Some(gw.to_string());
1369                *self.edit_alias.lock().unwrap() = row.alias.clone();
1370                self.set_ctp_form(row.form.clone());
1371            }
1372            None => {
1373                *self.editing_account.lock().unwrap() = None;
1374                *self.edit_alias.lock().unwrap() = String::new();
1375                let draft = self.draft_from_existing();
1376                self.set_ctp_form(draft);
1377            }
1378        }
1379        *self.edit_error.lock().unwrap() = None;
1380        self.account_edit_window.store(true, Ordering::Release);
1381        self.bump();
1382        true
1383    }
1384
1385    /// 关闭配置弹窗 = **放弃未保存**(规划 Q24:绝不静默写入清册留档)。
1386    pub fn end_account_edit(&self) {
1387        *self.editing_account.lock().unwrap() = None;
1388        *self.edit_alias.lock().unwrap() = String::new();
1389        *self.edit_error.lock().unwrap() = None;
1390        self.account_edit_window.store(false, Ordering::Release);
1391        let draft = self.draft_from_existing();
1392        self.set_ctp_form(draft);
1393        self.bump();
1394    }
1395
1396    /// 摘除一行账户时的连带口径:若这行正被配置弹窗**编辑**,立即关弹窗放弃。
1397    ///
1398    /// 不这么做的话:弹窗是非 modal 的,用户可以在弹窗开着时点同一行的『移除』——
1399    /// 编辑目标消失后弹窗会静默退化成「添加模式」(只读句消失、字段全部可编辑),
1400    /// 此时点『保存』会拿旧凭据建出一个**新账户**。这是执行核查抓到的坑。
1401    pub fn end_account_edit_if_editing(&self, gateway: &str) {
1402        if self.editing_account().as_deref() == Some(gateway) {
1403            self.end_account_edit();
1404        }
1405    }
1406
1407    /// 弹窗别名草稿读。
1408    pub fn edit_alias(&self) -> String {
1409        self.edit_alias.lock().unwrap().clone()
1410    }
1411
1412    /// 弹窗别名草稿写。
1413    pub fn set_edit_alias(&self, v: impl Into<String>) {
1414        *self.edit_alias.lock().unwrap() = v.into();
1415        self.bump();
1416    }
1417
1418    /// 弹窗拦截原因读(`None` = 无)。
1419    pub fn edit_error(&self) -> Option<String> {
1420        self.edit_error.lock().unwrap().clone()
1421    }
1422
1423    /// 弹窗拦截原因写(`None` = 清)。
1424    pub fn set_edit_error(&self, e: Option<String>) {
1425        *self.edit_error.lock().unwrap() = e;
1426        self.bump();
1427    }
1428
1429    /// 弹窗草稿与该账户留档是否不一致(dirty;添加模式恒为"有内容待保存")。
1430    pub fn account_edit_dirty(&self) -> bool {
1431        let editing = self.editing_account();
1432        let draft = self.ctp_form();
1433        match editing {
1434            Some(gw) => self.account(&gw).map(|a| a.form != draft).unwrap_or(false),
1435            None => draft != CtpForm::default() && !draft.td_user.trim().is_empty(),
1436        }
1437    }
1438
1439    /// 账户清单只读视图(注册序)。
1440    pub fn accounts(&self) -> Vec<AccountRow> {
1441        self.accounts.lock().unwrap().clone()
1442    }
1443
1444    /// 单行账户查询。
1445    pub fn account(&self, gateway: &str) -> Option<AccountRow> {
1446        self.accounts
1447            .lock()
1448            .unwrap()
1449            .iter()
1450            .find(|a| a.gateway == gateway)
1451            .cloned()
1452    }
1453
1454    /// 新增一行账户(**只写清册,不装配** —— 规划裁定「添加 ≠ 连接」)。
1455    ///
1456    /// `Err(原因)` 三种:派生名已存在(同 broker+账号重复添加)、别名重名(规划 Q3)、
1457    /// 达账户软上限 `MAX_ACCOUNTS`。宿主侧还有第二道 `Duplicate` 守卫;界面侧先拦是为了
1458    /// 不建出一个"注定被拒"的请求。
1459    pub fn add_account(&self, row: AccountRow) -> Result<(), String> {
1460        let mut acc = self.accounts.lock().unwrap();
1461        if acc.iter().any(|a| a.gateway == row.gateway) {
1462            return Err(format!("账户 {} 已存在(同 broker + 账号)", row.gateway));
1463        }
1464        let alias = row.alias.trim();
1465        if !alias.is_empty()
1466            && acc
1467                .iter()
1468                .any(|a| a.alias.trim().eq_ignore_ascii_case(alias))
1469        {
1470            return Err(format!("别名 {alias:?} 已被其他账户占用(别名须唯一)"));
1471        }
1472        if acc.len() >= MAX_ACCOUNTS {
1473            return Err(format!(
1474                "账户已达软上限 {MAX_ACCOUNTS} 个(先移除一个再添加)"
1475            ));
1476        }
1477        acc.push(row);
1478        drop(acc);
1479        self.bump();
1480        Ok(())
1481    }
1482
1483    /// 是否还能添加账户。
1484    #[must_use]
1485    pub fn can_add_account(&self) -> bool {
1486        self.accounts.lock().unwrap().len() < MAX_ACCOUNTS
1487    }
1488
1489    /// 改一行账户的别名(按 gateway 主键;不存在 = false)。
1490    ///
1491    /// 别名只用于显示与选择,**不参与寻址**(寻址恒用派生注册名,规划 Q3)。
1492    /// 别名须**唯一**(与 `add_account` 同一守卫):空别名 = 不设别名(合法);
1493    /// 与他人重名 = false(调用方须显形,不得静默改成重名别名)。
1494    pub fn set_account_alias(&self, gateway: &str, alias: &str) -> bool {
1495        let alias = alias.trim().to_string();
1496        let mut acc = self.accounts.lock().unwrap();
1497        if !alias.is_empty()
1498            && acc
1499                .iter()
1500                .any(|a| a.gateway != gateway && a.alias.eq_ignore_ascii_case(&alias))
1501        {
1502            return false;
1503        }
1504        match acc.iter_mut().find(|a| a.gateway == gateway) {
1505            Some(a) => {
1506                a.alias = alias;
1507                drop(acc);
1508                self.bump();
1509                true
1510            }
1511            None => false,
1512        }
1513    }
1514
1515    /// `UiCmd::Connect` 已入列 → 四态置「装配中」并清掉上一次的失败原因。
1516    ///
1517    /// 登录态一并退回 `Unknown`(0.2.19):新一轮装配开始时,上一轮的「在线/掉线」
1518    /// 不再是这一轮的事实 —— 留着会让用户在重连过程中看到「在线」。
1519    pub fn note_connect_sent(&self, gateway: &str) {
1520        let mut acc = self.accounts.lock().unwrap();
1521        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1522            a.stage = AccountStage::Assembling;
1523            a.fail_note = None;
1524            a.login = LoginState::Unknown;
1525            a.status_note = None;
1526            a.login_brief = None;
1527            drop(acc);
1528            self.bump();
1529        }
1530    }
1531
1532    /// 装配结果回写:`Ok(())` → 「实例已装配」;`Err(原因)` → 「装配失败」+ 原因留显形。
1533    ///
1534    /// 失败**不回滚清册行**(规划 U3 定盘:行留着,给『重试』;旧口径"必须先移除才能重试"作废)。
1535    pub fn note_connect_result(&self, gateway: &str, result: Result<(), String>) {
1536        let mut acc = self.accounts.lock().unwrap();
1537        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1538            match result {
1539                Ok(()) => {
1540                    a.stage = AccountStage::Assembled;
1541                    a.fail_note = None;
1542                }
1543                Err(e) => {
1544                    a.stage = AccountStage::AssembleFailed;
1545                    a.fail_note = Some(e);
1546                }
1547            }
1548            drop(acc);
1549            self.bump();
1550        }
1551    }
1552
1553    /// 记录网关实际收录的产品类(`ConfigureReply.product_classes` 回带,规划 U9)。
1554    ///
1555    /// 只存不改用户勾选:勾选是意图,这一栏是事实,两者并排显示。
1556    pub fn note_effective_product_classes(&self, gateway: &str, classes: &[String]) {
1557        let mut acc = self.accounts.lock().unwrap();
1558        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1559            a.effective_classes = classes.to_vec();
1560            drop(acc);
1561            self.bump();
1562        }
1563    }
1564
1565    // ---- 0.2.19 状态面(`{gw}.status` 轮询结果落账;规划 P3 / 裁定 3) ----
1566
1567    /// 当前生效的「查询断供」阈值:`(连续被拒次数, 资金回报超时秒)`。
1568    #[must_use]
1569    pub fn status_thresholds(&self) -> (u32, i64) {
1570        self.status_thresholds
1571            .lock()
1572            .unwrap()
1573            .unwrap_or((QUERY_REJECT_STREAK_LIMIT, ACCOUNT_STALE_SECS))
1574    }
1575
1576    /// 覆盖阈值(测试夹具把两判据收紧到「1 次 / 0 秒」,才好在没有真柜台的用例里显形)。
1577    pub fn set_status_thresholds(&self, reject_limit: u32, stale_secs: i64) {
1578        *self.status_thresholds.lock().unwrap() = Some((reject_limit, stale_secs));
1579        self.bump();
1580    }
1581
1582    /// 轮询节拍:eTimer 每拍调一次,每 `STATUS_POLL_TICKS` 拍回一次 `true`。
1583    pub fn status_poll_due(&self) -> bool {
1584        let n = self.status_tick.fetch_add(1, Ordering::Relaxed) + 1;
1585        n.is_multiple_of(STATUS_POLL_TICKS)
1586    }
1587
1588    /// `{gw}.status` 读数回写 → 该行登录态与细节重算。
1589    ///
1590    /// 0.3.2(Wave E,BD-UI-50)这里同时是**告警的唯一产生口**:轮询一拍问出告警态就灌 toast。
1591    /// 为什么住这儿而不是账户面板的渲染里(Python `AlertEngine` 的对照口径):
1592    /// 告警是「状态口问出来的事实」,不是「某张窗画出来的东西」——账户窗没开也必须知道掉线了。
1593    /// 去重键 = 网关 + 登录态:同一状态只弹一次,状态变了才算新告警(否则每轮轮询一条,
1594    /// 8 秒自动关与同屏上限 3 都没有意义)。
1595    pub fn note_gateway_status(&self, gateway: &str, st: &ek_ctp::plugin::GatewayStatus) {
1596        let (limit, stale) = self.status_thresholds();
1597        let now = ek_ctp::map::now_epoch_ms();
1598        // 先在账户锁里把「这一拍是不是告警态」读出来,出锁再发 toast
1599        // (accounts / alerted / pending_alerts 是三颗独立的锁,不嵌套持有)。
1600        let mut alarm: Option<(LoginState, String)> = None;
1601        let mut found = false;
1602        {
1603            let mut acc = self.accounts.lock().unwrap();
1604            if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1605                found = true;
1606                a.apply_status(st, now, limit, stale);
1607                if a.login.is_alarm() || a.login == LoginState::QueryStarved {
1608                    alarm = Some((
1609                        a.login,
1610                        a.status_note
1611                            .clone()
1612                            .unwrap_or_else(|| a.login.label().to_string()),
1613                    ));
1614                }
1615            }
1616        }
1617        if found {
1618            self.bump();
1619        }
1620        if let Some((login, note)) = alarm
1621            && self.alert_once(format!("{gateway}|{}", login.label()))
1622        {
1623            self.push_alert(
1624                crate::toast::AlertLevel::Error,
1625                format!("账户 {gateway}:{note}"),
1626            );
1627        }
1628    }
1629
1630    /// 状态口问不到(网关不在场 / ask 超时):登录态退回 `Unknown` 并写明原因。
1631    ///
1632    /// **不得保留上一轮的「在线」**:问不到就是没有证据,留着旧读数等于替网关谎称在线。
1633    pub fn note_status_unreachable(&self, gateway: &str, why: &str) {
1634        let mut acc = self.accounts.lock().unwrap();
1635        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1636            a.login = LoginState::Unknown;
1637            a.status_note = Some(format!("状态口问不到:{why}"));
1638            a.login_brief = None;
1639            drop(acc);
1640            self.bump();
1641        }
1642    }
1643
1644    /// 『断开』已发令 → 该行立刻显形「已断开」(不等下一轮轮询;用户按了就该马上看到)。
1645    pub fn mark_account_stopped(&self, gateway: &str) {
1646        let mut acc = self.accounts.lock().unwrap();
1647        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1648            a.login = LoginState::Stopped;
1649            a.status_note = Some("会话已停;『连接』可恢复".to_string());
1650            a.login_brief = None;
1651            drop(acc);
1652            self.bump();
1653        }
1654    }
1655
1656    /// 装配前把表单落到该账户留档,并按**全局单源纪律**裁决 md 位:
1657    /// 非行情源账户 → `md_addr` 就地清空(**留档与下发同一份**,消 U4 的"只清下发不清留档")。
1658    pub fn prepare_connect_form(&self, gateway: &str, form: CtpForm) -> CtpForm {
1659        let source = self.md_source();
1660        let mut f = form;
1661        if source.as_str() != gateway {
1662            f.md_addr.clear();
1663            f.md_user.clear();
1664            f.md_password.clear();
1665        }
1666        self.update_account_form(gateway, f.clone());
1667        f
1668    }
1669
1670    /// 『添加账户』弹窗的草稿位:沿用当前在场账户的**非凭据字段**
1671    /// (broker / AppID / AuthCode / 收录勾选),凭据位(账号、密码、前置)一律清空
1672    /// (规划 Q20 的零风险缓解:少敲几遍,但绝不把上一家的密码带进下一家)。
1673    #[must_use]
1674    pub fn draft_from_existing(&self) -> CtpForm {
1675        let acc = self.accounts.lock().unwrap();
1676        let mut draft = CtpForm::default();
1677        if let Some(a) = acc.last() {
1678            draft.broker_id = a.form.broker_id.clone();
1679            draft.td_auth_code = a.form.td_auth_code.clone();
1680            draft.td_app_id = a.form.td_app_id.clone();
1681            draft.accept_futures = a.form.accept_futures;
1682            draft.accept_options = a.form.accept_options;
1683        }
1684        draft
1685    }
1686
1687    /// 更新一行账户的表单留档(按 gateway 主键;不存在 = false)。
1688    pub fn update_account_form(&self, gateway: &str, form: CtpForm) -> bool {
1689        let mut acc = self.accounts.lock().unwrap();
1690        match acc.iter_mut().find(|a| a.gateway == gateway) {
1691            Some(a) => {
1692                a.form = form;
1693                drop(acc);
1694                self.bump();
1695                true
1696            }
1697            None => false,
1698        }
1699    }
1700
1701    /// 摘除一行账户(界面侧清册;网关实例由 `UiCmd::Remove` 走 svctl 摘)。
1702    pub fn remove_account(&self, gateway: &str) -> bool {
1703        let mut acc = self.accounts.lock().unwrap();
1704        let before = acc.len();
1705        acc.retain(|a| a.gateway != gateway);
1706        let removed = acc.len() != before;
1707        if removed {
1708            // 元账号被摘 → 源位清空(等用户重新指定);订阅集保留(规划 §5.C)。
1709            let mut md = self.md_source.lock().unwrap();
1710            if md.as_str() == gateway {
1711                md.clear();
1712            }
1713            drop(acc);
1714            self.bump();
1715        }
1716        removed
1717    }
1718
1719    /// 用 svctl 快照回填账户在场位与四态;回带**本轮新装配**的网关名(订阅重放用)。
1720    ///
1721    /// 四态口径:svctl 带回这个名字 = 实例已装配(清掉失败原因);
1722    /// 名字不在场且原先记着「已装配」= 回到「未装配」(被摘/宿主崩了);
1723    /// 「装配中」与「装配失败」是界面侧记的,**不被在场表覆盖**。
1724    ///
1725    /// 0.2.19 附带一条:实例不在场时**登录态读数一并作废** —— 会话读数属于那个实例,
1726    /// 实例没了还留着「在线/掉线 2 次」,就是在替一个不存在的会话说话。
1727    pub fn sync_accounts_connected(&self, rows: &[PlugRow]) -> Vec<String> {
1728        let mut newly = Vec::new();
1729        {
1730            let mut acc = self.accounts.lock().unwrap();
1731            for a in acc.iter_mut() {
1732                let up = rows.iter().any(|r| r.name == a.gateway);
1733                if up && !a.connected {
1734                    newly.push(a.gateway.clone());
1735                }
1736                a.connected = up;
1737                if up {
1738                    a.stage = AccountStage::Assembled;
1739                    a.fail_note = None;
1740                } else if a.stage == AccountStage::Assembled {
1741                    a.stage = AccountStage::NotAssembled;
1742                    a.login = LoginState::Unknown;
1743                    a.status_note = None;
1744                    a.login_brief = None;
1745                }
1746            }
1747        }
1748        if !newly.is_empty() {
1749            self.bump();
1750        }
1751        newly
1752    }
1753
1754    /// 行情元账号注册名(空 = 未选)。
1755    pub fn md_source(&self) -> String {
1756        self.md_source.lock().unwrap().clone()
1757    }
1758
1759    /// 是否已有账户装配 —— 元账号锁定判定(规划 D3:连接后不允许切换)。
1760    pub fn accounts_locked(&self) -> bool {
1761        self.accounts.lock().unwrap().iter().any(|a| a.connected)
1762    }
1763
1764    /// 指定行情元账号(仅配置阶段;任何账户已连接 = 拒,界面须提示"先断开")。
1765    pub fn set_md_source(&self, gateway: &str) -> Result<(), String> {
1766        {
1767            let acc = self.accounts.lock().unwrap();
1768            if acc.iter().any(|a| a.connected) {
1769                return Err("元账号已锁定:有账户在线,切换行情源需先断开该账户".to_string());
1770            }
1771            if !acc.iter().any(|a| a.gateway == gateway) {
1772                return Err(format!("账户 {gateway} 不在清册"));
1773            }
1774        }
1775        let mut acc = self.accounts.lock().unwrap();
1776        for a in acc.iter_mut() {
1777            a.md_source = a.gateway == gateway;
1778        }
1779        drop(acc);
1780        let mut md = self.md_source.lock().unwrap();
1781        *md = gateway.to_string();
1782        self.bump();
1783        Ok(())
1784    }
1785
1786    /// 订阅集只读视图。
1787    pub fn subscriptions(&self) -> Vec<String> {
1788        self.subscriptions.lock().unwrap().clone()
1789    }
1790
1791    /// 登记订阅(去重保序;断开重连后按它重放)。
1792    pub fn note_subscriptions(&self, symbols: &[String]) {
1793        {
1794            let mut subs = self.subscriptions.lock().unwrap();
1795            for s in symbols {
1796                let s = s.trim();
1797                if !s.is_empty() && !subs.iter().any(|x| x == s) {
1798                    subs.push(s.to_string());
1799                }
1800            }
1801        }
1802        self.bump();
1803    }
1804
1805    /// 下单默认账户阶梯(替代旧"空 → 字面量 ctp"兜底,规划 §5.B;
1806    /// **0.2.18 四态后修正,规划 Q4**):
1807    /// 上次手动选择(须仍在清册且已装配)→ 第一个已装配账户;
1808    /// **未装配账户不得进默认** —— 旧阶梯的"退到第一个账户"会把只添加了还没连接的
1809    /// 账户选成下单归属,发出去只会撞 `unknown:gateway`。
1810    /// 一个都没有 = `None`(界面据此禁用下单钮)。
1811    pub fn default_gateway(&self) -> Option<String> {
1812        let chosen = {
1813            let f = self.order_form.lock().unwrap();
1814            f.gateway.trim().to_string()
1815        };
1816        if !chosen.is_empty() && self.account(&chosen).is_some_and(|a| a.connected) {
1817            return Some(chosen);
1818        }
1819        let acc = self.accounts.lock().unwrap();
1820        acc.iter().find(|a| a.connected).map(|a| a.gateway.clone())
1821    }
1822
1823    /// 已开 K线窗合约表(多开,去重)。
1824    pub fn kline_symbols(&self) -> Vec<String> {
1825        self.kline_windows.lock().unwrap().clone()
1826    }
1827
1828    /// 开 K线窗(去重;日志留痕可断言)。
1829    pub fn open_kline(&self, symbol: impl Into<String>) {
1830        let s = symbol.into();
1831        let mut v = self.kline_windows.lock().unwrap();
1832        if !v.contains(&s) {
1833            v.push(s.clone());
1834        }
1835        drop(v);
1836        self.log_line(format!("[kline] 打开 K线窗 {s}"));
1837    }
1838
1839    /// 关 K线窗(窗关闭钮 → 出表)。
1840    pub fn close_kline(&self, symbol: &str) {
1841        self.kline_windows.lock().unwrap().retain(|s| s != symbol);
1842        self.bump();
1843    }
1844
1845    // ---- v4 查询合约窗面(合约库 / 查询串 / 勾选集) ----
1846
1847    /// 查询合约窗开合。
1848    pub fn contracts_window_open(&self) -> bool {
1849        self.contracts_window.load(Ordering::Acquire)
1850    }
1851
1852    /// 查询合约窗开合写。
1853    pub fn set_contracts_window_open(&self, v: bool) {
1854        self.contracts_window.store(v, Ordering::Release);
1855        self.bump();
1856    }
1857
1858    /// 落一条合约(`eContract.` 裸键上屏)。
1859    pub fn update_contract(&self, c: &ek_data::contract::ContractData) {
1860        self.contract_store.lock().unwrap().put(c);
1861        self.bump();
1862    }
1863
1864    /// 合约查询(Python ContractManager 口径:留空 = 全部)。
1865    pub fn contracts(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
1866        self.contract_store.lock().unwrap().query(pattern)
1867    }
1868
1869    /// 合约库格数。
1870    pub fn contract_count(&self) -> usize {
1871        self.contract_store.lock().unwrap().len()
1872    }
1873
1874    /// 查询串读写。
1875    pub fn contract_query(&self) -> String {
1876        self.contract_query.lock().unwrap().clone()
1877    }
1878
1879    /// 查询串写。
1880    pub fn set_contract_query(&self, q: impl Into<String>) {
1881        *self.contract_query.lock().unwrap() = q.into();
1882        self.bump();
1883    }
1884
1885    /// 勾选翻转(en_symbol)。
1886    pub fn toggle_contract_selection(&self, en_symbol: &str) {
1887        let mut v = self.contract_selected.lock().unwrap();
1888        match v.iter().position(|s| s == en_symbol) {
1889            Some(i) => {
1890                v.remove(i);
1891            }
1892            None => v.push(en_symbol.to_string()),
1893        }
1894        self.bump();
1895    }
1896
1897    /// 当前勾选集。
1898    pub fn selected_contracts(&self) -> Vec<String> {
1899        self.contract_selected.lock().unwrap().clone()
1900    }
1901
1902    /// 追加一行日志(总线面/宿主观察线程写)。
1903    pub fn log_line(&self, line: impl Into<String>) {
1904        self.logs.lock().unwrap().push(line);
1905        self.bump();
1906    }
1907
1908    /// 清空日志(渲染"清空日志"按钮 / 测试)。
1909    pub fn clear_logs(&self) {
1910        self.logs.lock().unwrap().clear();
1911        self.bump();
1912    }
1913
1914    /// 日志只读快照 + 溢出计数。
1915    pub fn logs_snapshot(&self) -> (Vec<String>, u64) {
1916        let r = self.logs.lock().unwrap();
1917        (r.snapshot(), r.spilled())
1918    }
1919
1920    /// 带时间戳的日志快照(BD-UI-41 渲染侧:日志表格的「时间」列)。
1921    pub fn logs_entries(&self) -> (Vec<LogEntry>, u64) {
1922        let r = self.logs.lock().unwrap();
1923        (r.entries(), r.spilled())
1924    }
1925
1926    /// 日志当前长度(kittest 断言用)。
1927    pub fn logs_len(&self) -> usize {
1928        self.logs.lock().unwrap().len()
1929    }
1930
1931    /// 落一次服务快照(eTimer 节流回调)。
1932    pub fn set_snapshot(&self, snap: BusSnap) {
1933        *self.snap.lock().unwrap() = Arc::new(snap);
1934        self.bump();
1935    }
1936
1937    /// 当前快照(Arc 克隆,零拷贝走锁)。
1938    pub fn snapshot(&self) -> Arc<BusSnap> {
1939        Arc::clone(&self.snap.lock().unwrap())
1940    }
1941
1942    /// revision:任何写入 +1(插件面 ePlug 事件也用;测试判定"有事件进来过")。
1943    pub fn rev(&self) -> u64 {
1944        self.rev.load(Ordering::Acquire)
1945    }
1946
1947    /// 手动推一次 revision(总线面 ePlugUp/Down 记账用)。
1948    pub fn bump(&self) {
1949        self.rev.fetch_add(1, Ordering::AcqRel);
1950    }
1951
1952    /// 置关窗信号(eframe App 关窗钩子调用)。
1953    pub fn request_stop(&self) {
1954        self.stop.store(true, Ordering::Release);
1955    }
1956
1957    /// 观察线程轮询用。
1958    /// 0.3.2(Wave E,BD-UI-50)告警入口:界面层调它,渲染帧灌进 toast 队列。
1959    pub fn push_alert(&self, level: crate::toast::AlertLevel, text: impl Into<String>) {
1960        self.pending_alerts
1961            .lock()
1962            .unwrap_or_else(std::sync::PoisonError::into_inner)
1963            .push((level, text.into()));
1964    }
1965
1966    /// 取走待灌的告警(render 每帧一次)。
1967    pub(crate) fn take_alerts(&self) -> Vec<(crate::toast::AlertLevel, String)> {
1968        let mut q = self
1969            .pending_alerts
1970            .lock()
1971            .unwrap_or_else(std::sync::PoisonError::into_inner);
1972        std::mem::take(&mut *q)
1973    }
1974
1975    /// 常驻 toast 队列。
1976    pub(crate) fn toasts(&self) -> std::sync::MutexGuard<'_, Option<egui_notify::Toasts>> {
1977        self.toasts.lock()
1978    }
1979
1980    /// 去重:这条告警第一次出现返回 true(同一键不再重复弹)。
1981    pub fn alert_once(&self, key: impl Into<String>) -> bool {
1982        let key = key.into();
1983        self.alerted
1984            .lock()
1985            .unwrap_or_else(std::sync::PoisonError::into_inner)
1986            .insert(key)
1987    }
1988
1989    /// 待灌进 toast 的告警条数(状态层判据用;灌进队列由渲染帧收口)。
1990    #[must_use]
1991    pub fn pending_alert_count(&self) -> usize {
1992        self.pending_alerts
1993            .lock()
1994            .unwrap_or_else(std::sync::PoisonError::into_inner)
1995            .len()
1996    }
1997
1998    /// 已弹过的告警键数(去重账本;判据用它核「同一状态不重复弹」)。
1999    #[must_use]
2000    pub fn alerted_count(&self) -> usize {
2001        self.alerted
2002            .lock()
2003            .unwrap_or_else(std::sync::PoisonError::into_inner)
2004            .len()
2005    }
2006
2007    /// 退出确认窗:开 / 关 / 是否已确认放行(0.3.1 BD-UI-49)。
2008    pub fn exit_confirm_open(&self) -> bool {
2009        self.exit_confirm_open
2010            .load(std::sync::atomic::Ordering::Relaxed)
2011    }
2012
2013    pub fn open_exit_confirm(&self) {
2014        self.exit_confirm_open
2015            .store(true, std::sync::atomic::Ordering::Relaxed);
2016    }
2017
2018    pub fn close_exit_confirm(&self) {
2019        self.exit_confirm_open
2020            .store(false, std::sync::atomic::Ordering::Relaxed);
2021    }
2022
2023    pub fn exit_confirmed(&self) -> bool {
2024        self.exit_confirmed
2025            .load(std::sync::atomic::Ordering::Relaxed)
2026    }
2027
2028    pub fn confirm_exit(&self) {
2029        self.exit_confirmed
2030            .store(true, std::sync::atomic::Ordering::Relaxed);
2031    }
2032
2033    pub fn is_stop(&self) -> bool {
2034        self.stop.load(Ordering::Acquire)
2035    }
2036
2037    // ---- svctl 操作面(S2) ----
2038
2039    /// 入一条操作指令(窗口面离场动作唯一通道;左位不直调)。
2040    pub fn push_cmd(&self, cmd: UiCmd) {
2041        self.cmds.lock().unwrap().push_back(cmd);
2042        self.bump();
2043    }
2044
2045    /// 取走全部指令(eTimer 节拍辞行)。
2046    pub fn drain_cmds(&self) -> Vec<UiCmd> {
2047        self.cmds.lock().unwrap().drain(..).collect()
2048    }
2049
2050    /// 当前待二次确认目标(Some = 二次确认中)。
2051    pub fn pending_confirm(&self) -> Option<String> {
2052        self.pending_confirm.lock().unwrap().clone()
2053    }
2054
2055    /// 确认步进:同名二点 = 发令(Fired),异名 = 迁移(Switched),首点 = 进入待确认。
2056    pub fn confirm_step(&self, name: &str) -> ConfirmStep {
2057        let mut g = self.pending_confirm.lock().unwrap();
2058        match g.take() {
2059            Some(p) if p == name => ConfirmStep::Fired,
2060            Some(_) => {
2061                *g = Some(name.to_string());
2062                ConfirmStep::Switched
2063            }
2064            None => {
2065                *g = Some(name.to_string());
2066                ConfirmStep::Armed
2067            }
2068        }
2069    }
2070
2071    /// 清除待确认(取消钮)。
2072    pub fn confirm_cancel(&self) {
2073        *self.pending_confirm.lock().unwrap() = None;
2074        self.bump();
2075    }
2076
2077    // ---- S4 行情面 ----
2078
2079    /// 行情库直读(读写两用:worker 写,渲染读)。
2080    pub fn market(&self) -> &MarketStore {
2081        &self.market
2082    }
2083
2084    // ---- S5 交易面 ----
2085
2086    /// 交易监控快照写(ui 插件 eTimer 拍)。
2087    pub fn set_trade_snap(&self, ts: TradeSnap) {
2088        *self.trade_snap.lock().unwrap() = Arc::new(ts);
2089        self.bump();
2090    }
2091
2092    /// 交易监控快照读(渲染)。
2093    pub fn trade_snap(&self) -> Arc<TradeSnap> {
2094        self.trade_snap.lock().unwrap().clone()
2095    }
2096
2097    // ---- S6 CTP 配置面 ----
2098
2099    /// CTP 配置表单读(渲染克隆面)。
2100    pub fn ctp_form(&self) -> CtpForm {
2101        self.ctp_form.lock().unwrap().clone()
2102    }
2103
2104    /// CTP 配置表单写。
2105    pub fn set_ctp_form(&self, f: CtpForm) {
2106        *self.ctp_form.lock().unwrap() = f;
2107        self.bump();
2108    }
2109
2110    // ---- BD-UI-31 表格行选中面 ----
2111
2112    /// 该行是否为所属表的选中行(渲染判断;无键 = 未选)。
2113    pub fn row_selected(&self, table: &str, key: &str) -> bool {
2114        self.row_sel
2115            .lock()
2116            .unwrap()
2117            .get(table)
2118            .is_some_and(|k| k == key)
2119    }
2120
2121    /// 设所属表的选中行(每表一行;同键重复点 = 保持)。
2122    pub fn select_row(&self, table: &str, key: &str) {
2123        self.row_sel
2124            .lock()
2125            .unwrap()
2126            .insert(table.to_string(), key.to_string());
2127    }
2128}