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