ek-ui 0.2.12

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
//! 共享观测状态:`UiPlugin`(总线面)写、`render_body`(窗口面)读。
//!
//! 线程形貌:总线侧在**插件 worker 线程**写;窗口侧在 **eframe 主线程**读;
//! 锁粒度 = 每次 `lock().clone()/snapshot()`,不跨帧持锁。

use std::collections::{BTreeMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use ek_data::bar::BarData;
use ek_dm::wire::BarsFrame;
use ek_station::wire::PlugRow;

/// 日志环形容量(S1b 门面;溢出不丢累计计数,见 [`LogRing::spilled`])。
pub const RING_CAP: usize = 512;

/// 环形日志:push 满则挤掉最旧一条,溢出计数单调(页面显示"已丢弃 N 条")。
#[derive(Debug)]
pub struct LogRing {
    lines: VecDeque<String>,
    cap: usize,
    spilled: u64,
}

impl Default for LogRing {
    fn default() -> Self {
        Self::new(RING_CAP)
    }
}

impl LogRing {
    /// 定容环形(测试可传小容量逼溢出)。
    pub fn new(cap: usize) -> Self {
        Self {
            lines: VecDeque::with_capacity(cap),
            cap,
            spilled: 0,
        }
    }

    /// 追加一行;满则挤旧 + spilled+1。
    pub fn push(&mut self, line: impl Into<String>) {
        if self.cap == 0 {
            self.spilled += 1;
            return;
        }
        if self.lines.len() == self.cap {
            self.lines.pop_front();
            self.spilled += 1;
        }
        self.lines.push_back(line.into());
    }

    /// 当前长度。
    pub fn len(&self) -> usize {
        self.lines.len()
    }

    /// 是否空行。
    pub fn is_empty(&self) -> bool {
        self.lines.is_empty()
    }

    /// 累计已丢弃条数(只有溢出才涨)。
    pub fn spilled(&self) -> u64 {
        self.spilled
    }

    /// 只读快照(渲染用;不消费)。
    pub fn snapshot(&self) -> Vec<String> {
        self.lines.iter().cloned().collect()
    }

    /// 全清;spilled 保留(历史溢出事实不抹)。
    pub fn clear(&mut self) {
        self.lines.clear();
    }
}

/// 一次 eTimer 拉取的服务/插件快照(渲染直接可用)。
#[derive(Debug, Clone, Default)]
pub struct BusSnap {
    /// svctl.list 全行(svctl 独裁班;状态原文)。
    pub rows: Vec<PlugRow>,
    /// 逐插件服务目录(id 清单;无服务为空)。
    pub services: Vec<(String, Vec<String>)>,
}

/// UI 操作指令(窗口面 → 总线面;eTimer 节拍被 ui 插件请辞)。
#[derive(Debug, Clone, PartialEq)]
pub enum UiCmd {
    /// svctl.start(工厂名;v1 与插件注册名同,BD-U5)。
    Start { factory: String },
    /// svctl.remove(插件名)。
    Remove { name: String },
    /// dm.reload 回灌(S4;push=true 逐条广播,行情面板实时见 Rows)。
    Reload {
        interval: i64,
        symbol: String,
        bars: Vec<BarData>,
    },
    /// 下单(S5;网关受理即 SUBMITTING 单上总线)。
    /// S6 起网关可选(`gateway.send_order`,空 = ctp)。
    /// 0.1.1(BD-UI-30):改走 `oms.submit_order` 五段管线(拆单/风控/逐腿
    /// 发送/记账,parent 聚合);`mode` = 拆单四态(勾选「自动开平」= Auto,
    /// 取消勾选 = None 原样旁路)。
    SendOrder {
        req: ek_data::order_request::OrderRequest,
        gateway: String,
        mode: ek_oms::LockMode,
    },
    /// 撤单(S5;携三段号所需的 orderid;S6 起网关同选)。
    /// 0.1.1(BD-UI-30):改走 `oms.cancel_order`(守卫 4000 不可绕过;
    /// symbol/exchange 由 oms 订单缓存解,不再随令携带)。
    CancelOrder { orderid: String, gateway: String },
    /// ctp.configure 网关配置(S6 测试面;界面手工输入表单直映,插件侧校验;
    /// 出队时 `to_configure_req` 抬成线上契约)。
    CtpConfigure(CtpForm),
    /// ctp.subscribe 订阅行情(S6 测试面;逗号分隔标的)。
    CtpSubscribe { symbols: Vec<String> },
}

/// 行情缓存单格容量(防涨;丢头保尾,bar 流单调)。
pub const KLINE_CELL_CAP: usize = 4096;

/// 二次确认步进:Armed = 首点待确认;Fired = 同名二点已发令;Switched = 目标迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmStep {
    Armed,
    Fired,
    Switched,
}

/// 隔离态判定(svctl.list 状态原文含 Quarantin → 红;ePlugQDown.<名> 是定向
/// 广播,未知名无法预订阅 —— 快照行扫描是唯一闭环面,BD-UI-17)。
#[must_use]
pub fn is_quarantined(state: &str) -> bool {
    state.contains("Quarantin")
}

// ================= S4:行情库(eBar./eBarUpdate. 上屏地面) =================

/// 单格 = (symbol, interval) → bar 升序序列(丢头保尾;datetime 单调)。
#[derive(Debug, Default)]
pub struct MarketStore {
    cells: Mutex<BTreeMap<(String, i64), Vec<BarData>>>,
    /// eBar. 帧接收计数(帧统计,刷新不丢帧观感面)。
    frames: AtomicU64,
    /// eBarUpdate. 接收计数。
    updates: AtomicU64,
    /// dm.reload 成功计数(回灌确认面)。
    reloads: AtomicU64,
}

/// 一格的可读视图(渲染/测试用)。
#[derive(Debug, Clone, PartialEq)]
pub struct CellView {
    pub symbol: String,
    pub interval: i64,
    pub bars: Vec<BarData>,
}

impl MarketStore {
    /// eBar. 收帧:逐 bar 并格(同 dt 替尾;更旧丢弃;更晚追加,溢出丢头)。
    pub fn on_frame(&self, frame: &BarsFrame) {
        let mut cells = self.cells.lock().unwrap();
        for b in &frame.bars {
            let key = (b.symbol.clone(), frame.interval);
            let cell = cells.entry(key).or_default();
            match cell.last() {
                Some(last) if b.datetime < last.datetime => continue,
                Some(last) if b.datetime == last.datetime => {
                    let slot = cell.last_mut().expect("len>1");
                    *slot = b.clone();
                }
                _ => {
                    cell.push(b.clone());
                    if cell.len() > KLINE_CELL_CAP {
                        cell.remove(0);
                    }
                }
            }
        }
        self.frames.fetch_add(1, Ordering::AcqRel);
    }

    /// eBarUpdate. 收单 bar 快照(实时门;同 dt 替尾,新 dt 追加 = in-flight bar)。
    pub fn on_update(&self, b: &BarData) {
        let mut cells = self.cells.lock().unwrap();
        let iv = b
            .interval
            .unwrap_or(ek_data::constant::Interval::MINUTE)
            .seconds();
        let key = (b.symbol.clone(), iv);
        let cell = cells.entry(key).or_default();
        match cell.last() {
            Some(last) if b.datetime == last.datetime => {
                let slot = cell.last_mut().expect("len>1");
                *slot = b.clone();
            }
            Some(last) if b.datetime < last.datetime => {}
            _ => {
                cell.push(b.clone());
                if cell.len() > KLINE_CELL_CAP {
                    cell.remove(0);
                }
            }
        }
        self.updates.fetch_add(1, Ordering::AcqRel);
    }

    /// dm.reload 回灌登记(RELOAD OK 计数;dm 侧行为独立)。
    pub fn note_reload(&self) {
        self.reloads.fetch_add(1, Ordering::AcqRel);
    }

    /// 全格只读快照(键序 = BTreeMap 序,symbol/interval 稳定)。
    pub fn cells(&self) -> Vec<CellView> {
        self.cells
            .lock()
            .unwrap()
            .iter()
            .map(|((sym, iv), bars)| CellView {
                symbol: sym.clone(),
                interval: *iv,
                bars: bars.clone(),
            })
            .collect()
    }

    /// 计算(Ai) 帧统计。
    pub fn counts(&self) -> (u64, u64, u64) {
        (
            self.frames.load(Ordering::Acquire),
            self.updates.load(Ordering::Acquire),
            self.reloads.load(Ordering::Acquire),
        )
    }
}

/// 回灌表单(v4:界面面已退役,总线口 `UiCmd::Reload` 保留;解析器留作
/// 测试与脚本面)。
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ReloadForm {
    pub interval: String,
    pub symbol: String,
    pub csv: String,
}

/// 票据网关选择子解析(空 → "ctp";含空白 = 错。0.2.10 起 sim 退场,
/// 兜底改真网关)。
pub fn parse_gateway(raw: &str) -> Result<String, String> {
    let g = raw.trim();
    if g.is_empty() {
        return Ok("ctp".to_string());
    }
    if g.chars().any(|c| c.is_whitespace()) {
        return Err(format!("网关解析: 含空白:{raw:?}"));
    }
    Ok(g.to_string())
}

/// CTP 网关配置表单(S6 测试面;界面手工输入源 → UiCmd::CtpConfigure →
/// ask `ctp.configure`。零配置口径:界面不读文件,值全部人敲;密码位掩码)。
#[derive(Debug, Clone, PartialEq)]
pub struct CtpForm {
    pub broker_id: String,
    pub td_addr: String,
    pub td_user: String,
    pub td_password: String,
    pub td_auth_code: String,
    pub td_app_id: String,
    pub md_addr: String,
    pub md_user: String,
    pub md_password: String,
    /// 订阅标的(逗号分隔;`订阅行情` 钮解析)。
    pub symbols: String,
    /// 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。
    /// 缺省只勾期货 —— 期权默认不勾,因为默认口径与改动前一致(只期货上抛)。
    pub accept_futures: bool,
    pub accept_options: bool,
}

/// 手写 `Default`(不能 derive:`accept_futures` 必须为 `true`,derive 会给 `false`,
/// 而两个都 false 在语义上是「什么都不收录」)。
impl Default for CtpForm {
    fn default() -> Self {
        Self {
            broker_id: String::new(),
            td_addr: String::new(),
            td_user: String::new(),
            td_password: String::new(),
            td_auth_code: String::new(),
            td_app_id: String::new(),
            md_addr: String::new(),
            md_user: String::new(),
            md_password: String::new(),
            symbols: String::new(),
            accept_futures: true,
            accept_options: false,
        }
    }
}

impl CtpForm {
    /// 勾选 → CTP 产品类码表(期货 `'1'`、期权 `'2'`)。
    /// 两个都不勾交空表给插件:插件侧归一化一律回落默认期货 —— **空不等于全丢**
    /// (共享合约表为空会让网关的持仓查询整条被跳过)。
    #[must_use]
    pub fn product_classes(&self) -> Vec<String> {
        let mut codes = Vec::new();
        if self.accept_futures {
            codes.push("1".to_string());
        }
        if self.accept_options {
            codes.push("2".to_string());
        }
        codes
    }

    /// 表单 → 线上契约(字段直映;md 回落由插件侧裁决,表单不预处理)。
    pub fn to_configure_req(&self) -> ek_ctp::plugin::ConfigureReq {
        ek_ctp::plugin::ConfigureReq {
            broker_id: self.broker_id.trim().to_string(),
            td_addr: self.td_addr.trim().to_string(),
            td_user: self.td_user.trim().to_string(),
            td_password: self.td_password.clone(),
            td_auth_code: self.td_auth_code.trim().to_string(),
            td_app_id: self.td_app_id.trim().to_string(),
            md_addr: self.md_addr.trim().to_string(),
            md_user: self.md_user.trim().to_string(),
            md_password: self.md_password.clone(),
            product_classes: self.product_classes(),
        }
    }

    /// 订阅位解析(逗号/空白分隔;空表 = Err)。
    pub fn parse_symbols(&self) -> Result<Vec<String>, String> {
        let list: Vec<String> = self
            .symbols
            .split([',', ',', ' '])
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        if list.is_empty() {
            return Err("订阅解析: 标的位空".to_string());
        }
        Ok(list)
    }
}

/// 交易监控快照(S5;ui 插件 eTimer 拍 ask oms 全量查询组帧)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TradeSnap {
    pub orders: Vec<ek_data::order::OrderData>,
    pub trades: Vec<ek_data::trade::TradeData>,
    pub positions: Vec<ek_data::position::PositionData>,
    /// 资金帧(oms.get_all_accounts;CTP configure 登录后有数)。
    pub accounts: Vec<ek_data::account::AccountData>,
}

/// CSV(每行 `dt,open,high,low,close,volume`;dt 为 epoch 秒)→ BarData 批。
/// 错行报行号;空 csv = Err;升序不强制(dm.reload 自会逐行入窗)。
pub fn parse_reload_csv(form: &ReloadForm) -> Result<(i64, String, Vec<BarData>), String> {
    let interval: i64 = form
        .interval
        .trim()
        .parse()
        .map_err(|_| format!("回灌 解析: interval 非整数:{:?}", form.interval))?;
    let symbol = form.symbol.trim().to_string();
    if symbol.is_empty() {
        return Err("回灌解析: symbol 空".to_string());
    }
    if form.csv.trim().is_empty() {
        return Err("回灌解析: csv 空".to_string());
    }
    let mut bars = Vec::new();
    for (i, raw) in form.csv.lines().enumerate() {
        let line = raw.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let parts: Vec<&str> = line.split(',').map(str::trim).collect();
        if parts.len() != 6 {
            return Err(format!("回灌解析: 第 {} 列数 ≠ 6", i + 1));
        }
        let num = |s: &str| -> Result<f64, String> {
            s.parse::<f64>()
                .map_err(|_| format!("回灌解析: 第 {} 列数值坏:{:?}", i + 1, s))
        };
        let dt_s: i64 = parts[0]
            .parse()
            .map_err(|_| format!("回灌解析: 第 {} 列 dt 坏:{:?}", i + 1, parts[0]))?;
        bars.push(BarData {
            symbol: symbol.clone(),
            datetime: dt_s * 1_000_000_000,
            interval: Some(
                ek_dm::wire::interval_of_seconds(interval)
                    .unwrap_or(ek_data::constant::Interval::MINUTE),
            ),
            open_price: num(parts[1])?,
            high_price: num(parts[2])?,
            low_price: num(parts[3])?,
            close_price: num(parts[4])?,
            volume: num(parts[5])?,
            ..BarData::default()
        });
    }
    if bars.is_empty() {
        return Err("回灌解析: 无有效行".to_string());
    }
    Ok((interval, symbol, bars))
}

// ================= v4 布局面(带/组 tab / tick 库 / 下单表单;docs/ek-ui-layout-v2-plan.md) =================

/// 左上组 tab(Python [委托|未成交];审计修正:行情是带不是 tab,K线/运维/CTP
/// 是弹窗不是 tab,故不设全局 10 值 UiTab——那是 v2 残影)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TopLeftTab {
    #[default]
    Orders,
    Active,
}

impl TopLeftTab {
    pub fn label(self) -> &'static str {
        match self {
            Self::Orders => "委托",
            Self::Active => "未成交",
        }
    }
}

/// 右下组 tab(Python [资金|日志])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BottomRightTab {
    #[default]
    Account,
    Logs,
}

impl BottomRightTab {
    pub fn label(self) -> &'static str {
        match self {
            Self::Account => "资金",
            Self::Logs => "日志",
        }
    }
}

/// 界面主题(BD-UI-34,用户问「有没有 PySide6 那种可换的金融向主题」):
/// 菜单钮一键循环切换(运行期态,无配置文件——ek-ui 零配置口径)。
/// 语义色(多红/空绿/隔离红/选中蓝)跨主题恒定——金融语义不随主题漂移。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Theme {
    /// 金融深色(默认):egui dark 骨架 + 深海军蓝面板/窗底(交易终端观感)。
    #[default]
    FinanceDark,
    /// egui 原生深色。
    EguiDark,
    /// egui 原生浅色。
    EguiLight,
    /// Qt Fusion 灰白近似(Python/PySide6 版观感迁移用)。
    QtClassic,
}

impl Theme {
    pub fn label(self) -> &'static str {
        match self {
            Self::FinanceDark => "金融深色",
            Self::EguiDark => "egui深色",
            Self::EguiLight => "egui浅色",
            Self::QtClassic => "Qt灰白",
        }
    }

    /// 循环下一主题(菜单钮一键循环口径,单层无下拉)。
    pub fn next(self) -> Self {
        match self {
            Self::FinanceDark => Self::EguiDark,
            Self::EguiDark => Self::EguiLight,
            Self::EguiLight => Self::QtClassic,
            Self::QtClassic => Self::FinanceDark,
        }
    }
}

/// tick 库(v2 行情 tab 唯一数据源):symbol → 最新 tick,逐 symbol 覆盖,
/// 有界 = 订阅面(ek-ctp 只发已订阅合约;无 bar 回落,用户裁定)。
#[derive(Debug, Default)]
pub struct TickStore {
    ticks: BTreeMap<String, ek_data::tick::TickData>,
}

impl TickStore {
    /// 逐合约覆盖(审计修正:键 = en_symbol「交易所.合约」,防跨所同码相撞;
    /// 交易所分组取 tick 自带 exchange)。
    pub fn put(&mut self, t: &ek_data::tick::TickData) {
        self.ticks
            .insert(format!("{}.{}", t.exchange.as_str(), t.symbol), t.clone());
    }

    /// 只读视图(交易所 → 合约排序,tab 分组顺序稳定)。
    pub fn rows(&self) -> Vec<ek_data::tick::TickData> {
        let mut v: Vec<ek_data::tick::TickData> = self.ticks.values().cloned().collect();
        v.sort_by(|a, b| {
            (a.exchange.as_str().to_string(), a.symbol.clone())
                .cmp(&(b.exchange.as_str().to_string(), b.symbol.clone()))
        });
        v
    }

    /// 按交易所分组(tab 键只列有 tick 的所,不预置全枚举)。
    pub fn groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
        let mut out: Vec<(String, Vec<ek_data::tick::TickData>)> = Vec::new();
        for t in self.rows() {
            let ex = t.exchange.as_str().to_string();
            match out.last_mut() {
                Some((e, v)) if *e == ex => v.push(t),
                _ => out.push((ex, vec![t])),
            }
        }
        out
    }

    pub fn len(&self) -> usize {
        self.ticks.len()
    }

    pub fn is_empty(&self) -> bool {
        self.ticks.is_empty()
    }
}

/// 下单面板表单(v2 常驻右侧;Python TradingWidget 字段面等价;操作面非配置面)。
#[derive(Debug, Clone, PartialEq)]
pub struct OrderForm {
    /// 交易所代码(下拉;from_str 抬成枚举)
    pub exchange: String,
    /// 合约代码(纯 symbol,不带前缀)
    pub symbol: String,
    pub direction: ek_data::constant::Direction,
    pub offset: ek_data::constant::Offset,
    pub order_type: ek_data::constant::OrderType,
    /// 输入面保持字符串(同 Python QLineEdit),委托时解析
    pub price: String,
    pub volume: String,
    /// 网关(下拉源 = trade_snap gateway 去重集 ∪ CTP 表单现值)
    pub gateway: String,
    /// 价格随行情勾选(勾选时价格框随选中合约最新 tick)
    pub follow_price: bool,
    /// 「自动开平」勾选(0.1.1 BD-UI-30;默认开 = LockMode::Auto 走拆单器,
    /// 关 = LockMode::None 原样旁路——守卫不可绕,拆单可逐请求旁路)
    pub auto_split: bool,
}

impl Default for OrderForm {
    fn default() -> Self {
        Self {
            exchange: "SHFE".to_string(),
            symbol: String::new(),
            direction: ek_data::constant::Direction::LONG,
            offset: ek_data::constant::Offset::OPEN,
            order_type: ek_data::constant::OrderType::LIMIT,
            price: String::new(),
            volume: "1".to_string(),
            gateway: String::new(),
            follow_price: true,
            auto_split: true,
        }
    }
}

impl OrderForm {
    /// 表单 → OrderRequest(与票据解析同一出口;错误红字就地,不进总线)。
    pub fn to_request(&self) -> Result<ek_data::order_request::OrderRequest, String> {
        use std::str::FromStr;
        let symbol = self.symbol.trim();
        if symbol.is_empty() {
            return Err("下单: 代码为空".to_string());
        }
        if symbol.contains('.') {
            return Err("下单: 代码不带交易所前缀(交易所走下拉)".to_string());
        }
        let exchange = ek_data::constant::Exchange::from_str(self.exchange.trim())
            .map_err(|e| format!("下单: {e}"))?;
        let price: f64 = self
            .price
            .trim()
            .parse()
            .map_err(|_| format!("下单: 价格无效 {:?}", self.price))?;
        let volume: f64 = self
            .volume
            .trim()
            .parse()
            .map_err(|_| format!("下单: 数量无效 {:?}", self.volume))?;
        if self.order_type != ek_data::constant::OrderType::MARKET && price <= 0.0 {
            return Err("下单: 限价单价格须为正".to_string());
        }
        if volume <= 0.0 {
            return Err("下单: 数量须为正".to_string());
        }
        Ok(ek_data::order_request::OrderRequest {
            symbol: symbol.to_string(),
            exchange,
            direction: self.direction,
            order_type: self.order_type,
            volume,
            price,
            offset: self.offset,
            ..ek_data::order_request::OrderRequest::default()
        })
    }
}

/// 合约库(v4 查询合约窗数据源):en_symbol → ContractData。
///
/// 数据面 = ek-ctp 登录后自动 reqQryInstrument 回流(`eContract.` 裸键逐条 +
/// `eContractFinish` 完成线,driver.rs:779)——**无 ask 口,UI 只收不催**
/// (用户裁定:订阅不在网关配置定死,查询窗勾选订阅 / 下单代码回车)。
#[derive(Debug, Default)]
pub struct ContractStore {
    contracts: BTreeMap<String, ek_data::contract::ContractData>,
}

impl ContractStore {
    pub fn put(&mut self, c: &ek_data::contract::ContractData) {
        self.contracts
            .insert(format!("{}.{}", c.exchange.as_str(), c.symbol), c.clone());
    }

    /// Python ContractManager 查询口径:代码/交易所/名称模糊,留空 = 全部。
    pub fn query(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
        let p = pattern.trim();
        self.contracts
            .values()
            .filter(|c| {
                p.is_empty()
                    || c.symbol.contains(p)
                    || c.exchange.as_str().eq_ignore_ascii_case(p)
                    || c.name.contains(p)
            })
            .cloned()
            .collect()
    }

    pub fn len(&self) -> usize {
        self.contracts.len()
    }

    pub fn is_empty(&self) -> bool {
        self.contracts.is_empty()
    }
}

/// 插件面板与宿主观察线程共用的观测状态。
#[derive(Debug, Default)]
pub struct UiShared {
    logs: Mutex<LogRing>,
    snap: Mutex<Arc<BusSnap>>,
    market: MarketStore,
    ctp_form: Mutex<CtpForm>,
    trade_snap: Mutex<Arc<TradeSnap>>,
    tick_store: Mutex<TickStore>,
    order_form: Mutex<OrderForm>,
    selected_exchange: Mutex<String>,
    topleft_tab: Mutex<TopLeftTab>,
    bottomright_tab: Mutex<BottomRightTab>,
    ops_window: AtomicBool,
    ctp_window: AtomicBool,
    contracts_window: AtomicBool,
    /// 界面主题(BD-UI-34;AtomicU8 = Theme 判别值,菜单钮循环)。
    theme: AtomicU8,
    contract_store: Mutex<ContractStore>,
    contract_query: Mutex<String>,
    contract_selected: Mutex<Vec<String>>,
    kline_windows: Mutex<Vec<String>>,
    rev: AtomicU64,
    /// 关窗/测试结束信号(窗口面置位,宿主观察线程消费)。
    stop: AtomicBool,
    /// svctl 操作指令队列(窗口面入,ui 插件 eTimer 出)。
    cmds: Mutex<VecDeque<UiCmd>>,
    /// 待二次确认的移除目标(单槽;再次同名点 = 发令)。
    pending_confirm: Mutex<Option<String>>,
    /// 表格行选中(BD-UI-31):表 id → 选中行键(每表一行;键由渲染侧构造,
    /// 纯界面态,不发指令;悬停跳色是渲染侧即时判断,不入状态)。
    row_sel: Mutex<BTreeMap<String, String>>,
}

impl UiShared {
    /// 空状态(快照空、日志空、rev 0)。
    pub fn new() -> Self {
        Self::default()
    }

    // ---- v2 布局面访问器(tick 库 / 表单 / 选中 / tab) ----

    /// 落一条 tick(eTick. 裸键上屏;逐 symbol 覆盖)。
    pub fn update_tick(&self, t: &ek_data::tick::TickData) {
        self.tick_store.lock().unwrap().put(t);
        self.bump();
    }

    /// tick 只读视图(排序后)。
    pub fn tick_rows(&self) -> Vec<ek_data::tick::TickData> {
        self.tick_store.lock().unwrap().rows()
    }

    /// tick 按交易所分组(行情 tab 二级 tab 键表)。
    pub fn tick_groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
        self.tick_store.lock().unwrap().groups()
    }

    /// tick 库格数。
    pub fn tick_count(&self) -> usize {
        self.tick_store.lock().unwrap().len()
    }

    /// 下单表单只读快照。
    pub fn order_form(&self) -> OrderForm {
        self.order_form.lock().unwrap().clone()
    }

    /// 下单表单写(渲染侧每控件改动即写)。
    pub fn set_order_form(&self, f: OrderForm) {
        *self.order_form.lock().unwrap() = f;
        self.bump();
    }

    /// 选中交易所(行情二级 tab 态;行情行单击 = 带入下单面板见 render 侧)。
    pub fn selected_exchange(&self) -> String {
        self.selected_exchange.lock().unwrap().clone()
    }

    /// 选中交易所写。
    pub fn set_selected_exchange(&self, s: impl Into<String>) {
        *self.selected_exchange.lock().unwrap() = s.into();
        self.bump();
    }

    /// 左上组当前 tab。
    pub fn topleft_tab(&self) -> TopLeftTab {
        *self.topleft_tab.lock().unwrap()
    }

    /// 左上组 tab 写。
    pub fn set_topleft_tab(&self, t: TopLeftTab) {
        *self.topleft_tab.lock().unwrap() = t;
        self.bump();
    }

    /// 右下组当前 tab。
    pub fn bottomright_tab(&self) -> BottomRightTab {
        *self.bottomright_tab.lock().unwrap()
    }

    /// 右下组 tab 写。
    pub fn set_bottomright_tab(&self, t: BottomRightTab) {
        *self.bottomright_tab.lock().unwrap() = t;
        self.bump();
    }

    // ---- v4 弹窗面(egui::Window 开关;kittest 可断言) ----

    /// 当前主题(BD-UI-34)。
    pub fn theme(&self) -> Theme {
        match self.theme.load(Ordering::Acquire) {
            1 => Theme::EguiDark,
            2 => Theme::EguiLight,
            3 => Theme::QtClassic,
            _ => Theme::FinanceDark,
        }
    }

    /// 主题写。
    pub fn set_theme(&self, t: Theme) {
        self.theme.store(t as u8, Ordering::Release);
        self.bump();
    }

    /// 菜单钮一键循环:切到下一主题并返回。
    pub fn cycle_theme(&self) -> Theme {
        let t = self.theme().next();
        self.set_theme(t);
        t
    }

    /// 运维窗开合。
    pub fn ops_window_open(&self) -> bool {
        self.ops_window.load(Ordering::Acquire)
    }

    /// 运维窗开合写。
    pub fn set_ops_window_open(&self, v: bool) {
        self.ops_window.store(v, Ordering::Release);
        self.bump();
    }

    /// CTP 配置窗开合。
    pub fn ctp_window_open(&self) -> bool {
        self.ctp_window.load(Ordering::Acquire)
    }

    /// CTP 配置窗开合写。
    pub fn set_ctp_window_open(&self, v: bool) {
        self.ctp_window.store(v, Ordering::Release);
        self.bump();
    }

    /// 已开 K线窗合约表(多开,去重)。
    pub fn kline_symbols(&self) -> Vec<String> {
        self.kline_windows.lock().unwrap().clone()
    }

    /// 开 K线窗(去重;日志留痕可断言)。
    pub fn open_kline(&self, symbol: impl Into<String>) {
        let s = symbol.into();
        let mut v = self.kline_windows.lock().unwrap();
        if !v.contains(&s) {
            v.push(s.clone());
        }
        drop(v);
        self.log_line(format!("[kline] 打开 K线窗 {s}"));
    }

    /// 关 K线窗(窗关闭钮 → 出表)。
    pub fn close_kline(&self, symbol: &str) {
        self.kline_windows.lock().unwrap().retain(|s| s != symbol);
        self.bump();
    }

    // ---- v4 查询合约窗面(合约库 / 查询串 / 勾选集) ----

    /// 查询合约窗开合。
    pub fn contracts_window_open(&self) -> bool {
        self.contracts_window.load(Ordering::Acquire)
    }

    /// 查询合约窗开合写。
    pub fn set_contracts_window_open(&self, v: bool) {
        self.contracts_window.store(v, Ordering::Release);
        self.bump();
    }

    /// 落一条合约(`eContract.` 裸键上屏)。
    pub fn update_contract(&self, c: &ek_data::contract::ContractData) {
        self.contract_store.lock().unwrap().put(c);
        self.bump();
    }

    /// 合约查询(Python ContractManager 口径:留空 = 全部)。
    pub fn contracts(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
        self.contract_store.lock().unwrap().query(pattern)
    }

    /// 合约库格数。
    pub fn contract_count(&self) -> usize {
        self.contract_store.lock().unwrap().len()
    }

    /// 查询串读写。
    pub fn contract_query(&self) -> String {
        self.contract_query.lock().unwrap().clone()
    }

    /// 查询串写。
    pub fn set_contract_query(&self, q: impl Into<String>) {
        *self.contract_query.lock().unwrap() = q.into();
        self.bump();
    }

    /// 勾选翻转(en_symbol)。
    pub fn toggle_contract_selection(&self, en_symbol: &str) {
        let mut v = self.contract_selected.lock().unwrap();
        match v.iter().position(|s| s == en_symbol) {
            Some(i) => {
                v.remove(i);
            }
            None => v.push(en_symbol.to_string()),
        }
        self.bump();
    }

    /// 当前勾选集。
    pub fn selected_contracts(&self) -> Vec<String> {
        self.contract_selected.lock().unwrap().clone()
    }

    /// 追加一行日志(总线面/宿主观察线程写)。
    pub fn log_line(&self, line: impl Into<String>) {
        self.logs.lock().unwrap().push(line);
        self.bump();
    }

    /// 清空日志(渲染"清空日志"按钮 / 测试)。
    pub fn clear_logs(&self) {
        self.logs.lock().unwrap().clear();
        self.bump();
    }

    /// 日志只读快照 + 溢出计数。
    pub fn logs_snapshot(&self) -> (Vec<String>, u64) {
        let r = self.logs.lock().unwrap();
        (r.snapshot(), r.spilled())
    }

    /// 日志当前长度(kittest 断言用)。
    pub fn logs_len(&self) -> usize {
        self.logs.lock().unwrap().len()
    }

    /// 落一次服务快照(eTimer 节流回调)。
    pub fn set_snapshot(&self, snap: BusSnap) {
        *self.snap.lock().unwrap() = Arc::new(snap);
        self.bump();
    }

    /// 当前快照(Arc 克隆,零拷贝走锁)。
    pub fn snapshot(&self) -> Arc<BusSnap> {
        Arc::clone(&self.snap.lock().unwrap())
    }

    /// revision:任何写入 +1(插件面 ePlug 事件也用;测试判定"有事件进来过")。
    pub fn rev(&self) -> u64 {
        self.rev.load(Ordering::Acquire)
    }

    /// 手动推一次 revision(总线面 ePlugUp/Down 记账用)。
    pub fn bump(&self) {
        self.rev.fetch_add(1, Ordering::AcqRel);
    }

    /// 置关窗信号(eframe App 关窗钩子调用)。
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Release);
    }

    /// 观察线程轮询用。
    pub fn is_stop(&self) -> bool {
        self.stop.load(Ordering::Acquire)
    }

    // ---- svctl 操作面(S2) ----

    /// 入一条操作指令(窗口面离场动作唯一通道;左位不直调)。
    pub fn push_cmd(&self, cmd: UiCmd) {
        self.cmds.lock().unwrap().push_back(cmd);
        self.bump();
    }

    /// 取走全部指令(eTimer 节拍辞行)。
    pub fn drain_cmds(&self) -> Vec<UiCmd> {
        self.cmds.lock().unwrap().drain(..).collect()
    }

    /// 当前待二次确认目标(Some = 二次确认中)。
    pub fn pending_confirm(&self) -> Option<String> {
        self.pending_confirm.lock().unwrap().clone()
    }

    /// 确认步进:同名二点 = 发令(Fired),异名 = 迁移(Switched),首点 = 进入待确认。
    pub fn confirm_step(&self, name: &str) -> ConfirmStep {
        let mut g = self.pending_confirm.lock().unwrap();
        match g.take() {
            Some(p) if p == name => ConfirmStep::Fired,
            Some(_) => {
                *g = Some(name.to_string());
                ConfirmStep::Switched
            }
            None => {
                *g = Some(name.to_string());
                ConfirmStep::Armed
            }
        }
    }

    /// 清除待确认(取消钮)。
    pub fn confirm_cancel(&self) {
        *self.pending_confirm.lock().unwrap() = None;
        self.bump();
    }

    // ---- S4 行情面 ----

    /// 行情库直读(读写两用:worker 写,渲染读)。
    pub fn market(&self) -> &MarketStore {
        &self.market
    }

    // ---- S5 交易面 ----

    /// 交易监控快照写(ui 插件 eTimer 拍)。
    pub fn set_trade_snap(&self, ts: TradeSnap) {
        *self.trade_snap.lock().unwrap() = Arc::new(ts);
        self.bump();
    }

    /// 交易监控快照读(渲染)。
    pub fn trade_snap(&self) -> Arc<TradeSnap> {
        self.trade_snap.lock().unwrap().clone()
    }

    // ---- S6 CTP 配置面 ----

    /// CTP 配置表单读(渲染克隆面)。
    pub fn ctp_form(&self) -> CtpForm {
        self.ctp_form.lock().unwrap().clone()
    }

    /// CTP 配置表单写。
    pub fn set_ctp_form(&self, f: CtpForm) {
        *self.ctp_form.lock().unwrap() = f;
        self.bump();
    }

    // ---- BD-UI-31 表格行选中面 ----

    /// 该行是否为所属表的选中行(渲染判断;无键 = 未选)。
    pub fn row_selected(&self, table: &str, key: &str) -> bool {
        self.row_sel
            .lock()
            .unwrap()
            .get(table)
            .is_some_and(|k| k == key)
    }

    /// 设所属表的选中行(每表一行;同键重复点 = 保持)。
    pub fn select_row(&self, table: &str, key: &str) {
        self.row_sel
            .lock()
            .unwrap()
            .insert(table.to_string(), key.to_string());
    }
}