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