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