-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathgui_app.py
More file actions
1726 lines (1570 loc) · 85.1 KB
/
Copy pathgui_app.py
File metadata and controls
1726 lines (1570 loc) · 85.1 KB
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
# _*_ coding: utf-8 _*_
"""
DeltaLab - 期权对冲回测 GUI 应用
基于 tkinter 构建,支持选择不同期权类型、回测方式(模拟/历史数据),
并以图表和表格形式展示回测结果。
"""
import sys
import os
# 仓库根加入 sys.path。pricing / deltalab_ui / history_* 都按顶层模块引用,
# 从别的工作目录启动、以及 PyInstaller 打包态都靠这一行兜底。它必须早于下面
# 任何一个项目内 import——原先压在 matplotlib 配置之后还能用,是因为那时第一
# 个项目内 import 出现得更晚;现在 deltalab_ui 也走这条路径,位置就不能再往
# 后放了。
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import threading
import datetime
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import tkinter.font as tkfont
import numpy as np
# theme 必须早于 matplotlib.pyplot 被 import:后端选择(含 headless 回退)在
# 它的模块顶部完成,晚于 pyplot 的首次 import 就不生效了。这里已不再直接用
# pyplot,但 theme 的 import 仍然要留着——本文件是 GUI 的入口,后端必须在
# 任何画图模块之前定下来。
from deltalab_ui.theme import (
FORM_ENTRY_CHARS, FORM_HINT_GAP, FORM_ROW_PADY, FORM_SECTION_GAP,
FORM_SECTION_PAD,
PALETTE,
_MONO_FONT_FAMILY, _SYSTEM, _UI_FONT_FAMILY,
_form_grid, _form_hint, _form_input, _form_label, _form_separator,
_resource_path,
)
# 部分是 re-export:测试与外部脚本一直按 ``gui_app.XXX`` 取值,拆分不该动它们
# 的取值路径。
#
# 但**执行链路用到的那几个名字不再从这里 re-export**(recommend_by_* /
# summarize_strategy_result / result_daily_frame):实现已随 worker 搬进
# deltalab_ui/runner.py 与 snapshot_store.py,它们从各自模块的全局取值。留在
# 这里只会变成陷阱——往 gui_app 上打补丁不报错、静默失效。删掉之后误打的地方
# 会直接 AttributeError。
#
# compare_strategies 也一并去掉了:多路径统计早已改由 ``bt.run_multi`` 产出,
# 它在界面层一个取用点都没有;此前三条挂在它上面的负向守卫("不得重新运行回
# 测" / "不应再运行当前期限路径对比")因此从来不会触发,同样已删除。
from pricing import HedgeBacktest, FixedTimeStrategy
from pricing.hedge_analysis import LOOKBACK_DAYS
import history_selection
from history_selection import (
# 前两个本文件不用,是 re-export:测试写的是
# ``from gui_app import DEFAULT_BAND_CANDIDATE_SIGMAS, MAX_BAND_CANDIDATES``。
DEFAULT_BAND_CANDIDATE_SIGMAS,
DEFAULT_FIXED_TIMES,
HISTORY_PERIOD_DEFS,
MAX_BAND_CANDIDATES,
MAX_HISTORY_CHART_CANDIDATES,
)
import deltalab_log
import history_store
import history_bar_cache
import backtest_pool_store
from deltalab_ui.constants import (
BASELINE_STRATEGY_STYLE_KEY,
HISTORY_CHART_METRIC_DISPLAY, HISTORY_CHART_METRIC_FROM_DISPLAY,
HISTORY_OBJECTIVE_DISPLAY, HISTORY_OBJECTIVE_FROM_DISPLAY,
MAX_COMPARISON_CHART_CURVES,
OPTION_CLASSES,
SIGMA_SOURCE_DISPLAY, SIGMA_SOURCE_FROM_DISPLAY,
SNAPSHOT_ORIGIN_DISPLAY, SNAPSHOT_ORIGIN_HISTORY_REPLAY,
SNAPSHOT_ORIGIN_MANUAL,
STRATEGY_CHART_COLORS, STRATEGY_CHART_DASHES, STRATEGY_CHART_MARKERS,
STRATEGY_CHART_SHADES,
STRATEGY_DISPLAY, STRATEGY_FROM_DISPLAY,
SUBTYPE_DISPLAY, SUBTYPE_FROM_DISPLAY,
WIND_AUTO_BAR_SIZE, WIND_BAR_SIZE_OPTIONS,
_OBJECTIVE_COLUMN_KEYS, _OBJECTIVE_RANKING_COLUMNS,
_WIND_BAND_BAR_LABEL, _WIND_BAR_MINUTES, _WIND_DATE_BUFFER_DAYS,
_WIND_FIXED_TIME_BAR_LABELS,
_build_snowball, _snowball_ko_observ,
)
from deltalab_ui.snapshot_store import SavedBacktestResult
from deltalab_ui.structure_docs import STRUCTURE_DOCS
from deltalab_ui import (
formatting,
history_setup,
panel_form,
runner,
widgets,
snapshot_detail,
snapshot_store,
view_compare,
view_history,
view_results,
wind_resolve,
)
# ============================================================
# 主窗口
# ============================================================
class BacktestApp(panel_form.FormPanelMixin,
history_setup.HistorySetupMixin,
runner.RunnerMixin,
view_history.HistoryViewMixin,
snapshot_store.SnapshotStoreMixin,
view_compare.ComparisonMixin,
view_results.ResultsMixin,
tk.Tk):
# `tkinter.Misc.__getattr__` 会把未定义属性转发给 self.tk,对尚未渲染
# 结果页的实例用 getattr(..., None) 兜底会递归。策略优选结果页按需创建
# 的状态统一在类级给出 None 默认值。
_history_pairs_cache = None
def __init__(self):
super().__init__()
self.title("DeltaLab - 期权对冲回测系统")
self.geometry("1600x1000")
# 左侧面板已启用垂直滚动, 这里可以给一个更宽容的最小尺寸,
# 即便在高 DPI / 小分辨率屏幕下也不会裁掉底部按钮.
self.minsize(1200, 720)
self.configure(bg=PALETTE["bg"])
self._active_job = None
self._saved_backtests = {}
self._saved_comparison_selection = set()
self._saved_backtest_sequence = 0
self._latest_backtest = None
self._latest_backtest_state = None
self._latest_retained_result_id = None
self._latest_history_state = None
self._latest_history_source_label = None
# 策略配色登记表:结果对比与策略优选共用,使同一策略跨页同色。
# 仍是会话级——跨会话的配色稳定性从没承诺过,结果池持久化后同一批
# 结果重开可能换色,这一点写在 GUI_USAGE 的 6.7 里。
self._strategy_style_registry = None
# 载入本机已保存的结果池。放在 _build_ui 之前:计数行与占位符在构建
# 时就要拿到真实条数,否则要么显示 0、要么得再刷一次。
self._saved_pool_load_error = ""
self._load_saved_pool()
self._apply_window_icon()
self._setup_styles()
self._build_ui()
self._param_entries = {}
self._on_option_class_change(None)
self._refresh_history_base_summary()
self._refresh_history_current_band_label()
# ---- 窗口图标 ----
def _apply_window_icon(self):
ico_path = _resource_path("assets", "deltalab.ico")
# 带透明边距的图标 (符合 macOS 网格), 直接运行脚本时 Dock 图标不会过大
padded_path = _resource_path("assets", "deltalab_padded.png")
png_path = padded_path if os.path.exists(padded_path) else _resource_path("assets", "deltalab.png")
# Windows: .ico 在任务栏/标题栏表现最佳
if _SYSTEM == "Windows" and os.path.exists(ico_path):
try:
self.iconbitmap(default=ico_path)
return
except tk.TclError:
pass
# 其它平台 (macOS / Linux) 或 Windows 回退: iconphoto + PNG
if os.path.exists(png_path):
try:
self._icon_photo = tk.PhotoImage(file=png_path)
self.iconphoto(True, self._icon_photo)
except tk.TclError:
pass
# ---- 样式 ----
def _setup_styles(self):
style = ttk.Style(self)
style.theme_use("clam")
base_font = (_UI_FONT_FAMILY, 10)
small_font = (_UI_FONT_FAMILY, 9)
brand_font = (_UI_FONT_FAMILY, 12, "bold")
header_font = (_UI_FONT_FAMILY, 10, "bold")
group_font = (_UI_FONT_FAMILY, 10, "bold")
tab_font = (_UI_FONT_FAMILY, 12, "bold")
btn_font = (_UI_FONT_FAMILY, 10)
run_font = (_UI_FONT_FAMILY, 11, "bold")
# 默认选项 (供 tk.* 原生控件继承)
self.option_add("*Font", base_font)
self.option_add("*TCombobox*Listbox*Font", base_font)
# ---- 通用 Frame / Label ----
style.configure("TFrame", background=PALETTE["bg"])
style.configure("Surface.TFrame", background=PALETTE["surface"])
style.configure("Card.TFrame",
background=PALETTE["surface"],
relief="flat", borderwidth=1)
style.configure("TLabel",
background=PALETTE["bg"],
foreground=PALETTE["text"],
font=base_font)
style.configure("Surface.TLabel",
background=PALETTE["surface"],
foreground=PALETTE["text"])
style.configure("Muted.TLabel",
background=PALETTE["bg"],
foreground=PALETTE["text_muted"],
font=small_font)
style.configure("SurfaceMuted.TLabel",
background=PALETTE["surface"],
foreground=PALETTE["text_muted"],
font=small_font)
# 左上角品牌标 (与右侧页签行同处一条水平带, 不再单独占一条横幅)
style.configure("Brand.TLabel",
background=PALETTE["bg"],
foreground=PALETTE["text"],
font=brand_font)
style.configure("Header.TLabel",
background=PALETTE["bg"],
foreground=PALETTE["text"],
font=header_font)
style.configure("Status.TLabel",
background=PALETTE["surface"],
foreground=PALETTE["text_muted"],
font=small_font,
padding=(8, 4))
# ---- LabelFrame (分组容器) ----
style.configure("TLabelframe",
background=PALETTE["surface"],
bordercolor=PALETTE["border"],
relief="solid", borderwidth=1)
style.configure("TLabelframe.Label",
background=PALETTE["surface"],
foreground=PALETTE["primary"],
font=group_font,
padding=(4, 0))
# ---- 输入控件 ----
# Entry 与 Combobox 的纵向内边距取同一个值, 两者才等高;
# 横向留 6 让文字不贴边 (Combobox 右侧还要放下箭头, 少 2)。
style.configure("TEntry",
fieldbackground=PALETTE["surface"],
foreground=PALETTE["text"],
bordercolor=PALETTE["border"],
lightcolor=PALETTE["border"],
darkcolor=PALETTE["border"],
padding=(6, 4))
style.map("TEntry",
bordercolor=[("focus", PALETTE["primary"])],
lightcolor=[("focus", PALETTE["primary"])])
style.configure("TCombobox",
fieldbackground=PALETTE["surface"],
background=PALETTE["surface"],
foreground=PALETTE["text"],
bordercolor=PALETTE["border"],
arrowcolor=PALETTE["text_muted"],
padding=(6, 4))
style.map("TCombobox",
fieldbackground=[("readonly", PALETTE["surface"])],
bordercolor=[("focus", PALETTE["primary"])],
arrowcolor=[("active", PALETTE["primary"])])
# ---- Radio/Check (背景分两套: Frame 背景 vs Surface 背景) ----
style.configure("TRadiobutton",
background=PALETTE["surface"],
foreground=PALETTE["text"],
font=base_font,
focuscolor=PALETTE["surface"])
style.map("TRadiobutton",
background=[("active", PALETTE["surface"]),
("disabled", PALETTE["surface"])],
foreground=[("disabled", PALETTE["text_light"]),
("active", PALETTE["primary"]),
("selected", PALETTE["text"])])
# 专门定制 clam 下的原生 Checkbutton 样式(采用无锯齿 Pixel Art 彻底消除 macOS Retina 拉伸模糊)
try:
bg_checked_crisp = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAZElEQVR4nGP8/PXnfwYKABMlmhkYGBhYYAyjvE8kaTw3iY86LqC/AbfmiJBvALpmrAZgU4QsrpbyhrAL0A3BpRmrATBFME34NON0AbohuDTjNABZEz7NeA0gRjNBA4gBjAOeGwEXWyXhhSE6UgAAAABJRU5ErkJggg=="
bg_unchecked_crisp = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAASElEQVR4nGP8/PXnfwYKABMlmhkYGBhYYIynL9+TpFFaXBDVAAYGBgZ1RXGiNN+8/xLOptgLowaMGsDAgJYSkVMYsYBxwHMjAKdYEIYsHHJgAAAAAElFTkSuQmCC"
self._cb_bg_checked = tk.PhotoImage(data=bg_checked_crisp)
self._cb_bg_unchecked = tk.PhotoImage(data=bg_unchecked_crisp)
style.element_create("Bg.Indicator", "image", self._cb_bg_unchecked,
("selected", self._cb_bg_checked), border=0, sticky="w")
sf_checked_crisp = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAY0lEQVR4nGP8////fwYKABMlmhkYGBhYYAy1lDckabw1R4Q6LqC/ATCnk2UAumasBmBThCyOHthYXYBuCC7NWA2AKYJpwqcZpwvQDcGXRnAGIkwToQSGNxaISZ0UJyTGAc+NAJGvJhltVByMAAAAAElFTkSuQmCC"
sf_unchecked_crisp = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAQklEQVR4nGP8////fwYKABMlmhkYGBhYYIyb91+SpFFdURzVAGRBQgDZMoq9MGrAqAEMDGgpkdTkzMDAwMA44LkRACG7EL4AADlaAAAAAElFTkSuQmCC"
self._cb_sf_checked = tk.PhotoImage(data=sf_checked_crisp)
self._cb_sf_unchecked = tk.PhotoImage(data=sf_unchecked_crisp)
style.element_create("Surface.Indicator", "image", self._cb_sf_unchecked,
("selected", self._cb_sf_checked), border=0, sticky="w")
style.layout("TCheckbutton", [
('Checkbutton.padding', {'sticky': 'nswe', 'children': [
('Bg.Indicator', {'side': 'left', 'sticky': ''}),
('Checkbutton.focus', {'side': 'left', 'sticky': '', 'children': [
('Checkbutton.label', {'sticky': 'nswe'})
]})
]})
])
style.layout("Surface.TCheckbutton", [
('Checkbutton.padding', {'sticky': 'nswe', 'children': [
('Surface.Indicator', {'side': 'left', 'sticky': ''}),
('Checkbutton.focus', {'side': 'left', 'sticky': '', 'children': [
('Checkbutton.label', {'sticky': 'nswe'})
]})
]})
])
except Exception:
pass
def _config_checkbutton(style_name, bg_color):
style.configure(style_name,
background=bg_color,
foreground=PALETTE["text"],
font=base_font,
focuscolor=bg_color)
style.map(style_name,
background=[("active", bg_color),
("disabled", bg_color)],
foreground=[("disabled", PALETTE["text_light"]),
("active", PALETTE["primary"]),
("selected", PALETTE["text"])])
_config_checkbutton("TCheckbutton", PALETTE["bg"])
_config_checkbutton("Surface.TCheckbutton", PALETTE["surface"])
# ---- 按钮 ----
style.configure("TButton",
font=btn_font,
background=PALETTE["surface"],
foreground=PALETTE["text"],
bordercolor=PALETTE["border"],
lightcolor=PALETTE["surface"],
darkcolor=PALETTE["border"],
focusthickness=0,
padding=(10, 5),
relief="flat")
style.map("TButton",
background=[("active", PALETTE["primary_light"]),
("pressed", PALETTE["selected"]),
("disabled", PALETTE["surface_alt"])],
foreground=[("active", PALETTE["primary"]),
("disabled", PALETTE["text_light"])],
bordercolor=[("active", PALETTE["primary"]),
("disabled", PALETTE["border_soft"])],
lightcolor=[("active", PALETTE["primary_light"]),
("disabled", PALETTE["border_soft"])],
darkcolor=[("active", PALETTE["primary"]),
("disabled", PALETTE["border_soft"])])
# 行内小按钮 (如 CSV 的「浏览…」): 纵向内边距与输入框对齐, 同高
style.configure("Field.TButton", padding=(10, 4))
# 主色按钮 (运行)
style.configure("Run.TButton",
font=run_font,
foreground="white",
background=PALETTE["primary"],
bordercolor=PALETTE["primary"],
padding=(12, 5),
relief="flat")
style.map("Run.TButton",
background=[("active", PALETTE["primary_hov"]),
("pressed", PALETTE["primary_act"]),
("disabled", "#9CA3AF")],
foreground=[("disabled", "#E5E7EB")])
# 次级按钮 (结构图)
style.configure("Accent.TButton",
font=btn_font,
foreground="white",
background=PALETTE["accent"],
bordercolor=PALETTE["accent"],
padding=(10, 5),
relief="flat")
style.map("Accent.TButton",
background=[("active", PALETTE["accent_hov"]),
("pressed", PALETTE["accent_hov"]),
("disabled", "#9CA3AF")],
foreground=[("disabled", "#E5E7EB")])
# 幽灵按钮:统一白底与浅描边,文字为次要色,悬停浮出主色底
style.configure("Ghost.TButton",
font=btn_font,
background=PALETTE["surface"],
foreground=PALETTE["text_muted"],
bordercolor=PALETTE["border"],
lightcolor=PALETTE["surface"],
darkcolor=PALETTE["border"],
focusthickness=0,
padding=(10, 5),
relief="flat")
style.map("Ghost.TButton",
background=[("active", PALETTE["primary_light"]),
("pressed", PALETTE["selected"]),
("disabled", PALETTE["surface_alt"])],
foreground=[("active", PALETTE["primary"]),
("disabled", PALETTE["text_light"])],
bordercolor=[("active", PALETTE["primary"]),
("disabled", PALETTE["border_soft"])],
lightcolor=[("active", PALETTE["primary_light"]),
("disabled", PALETTE["border_soft"])],
darkcolor=[("active", PALETTE["primary"]),
("disabled", PALETTE["border_soft"])])
# 危险按钮:删数据且不可撤销的那一类。做成红字描边而不是实心红——它
# 不是主行动,实心红摆在一排常规按钮里会比「运行回测」还抢眼;红字
# 加悬停浅红底,足够在点下去之前把性质说清楚。
style.configure("Danger.TButton",
font=btn_font,
background=PALETTE["surface"],
foreground=PALETTE["danger"],
bordercolor=PALETTE["border"],
lightcolor=PALETTE["surface"],
darkcolor=PALETTE["surface"],
focusthickness=0,
padding=(10, 5),
relief="flat")
style.map("Danger.TButton",
background=[("active", PALETTE["danger_light"]),
("pressed", PALETTE["danger_light"]),
("disabled", PALETTE["surface"])],
foreground=[("disabled", PALETTE["text_light"])],
bordercolor=[("active", PALETTE["danger"]),
("disabled", PALETTE["border_soft"])],
lightcolor=[("active", PALETTE["danger_light"]),
("pressed", PALETTE["danger_light"])],
darkcolor=[("active", PALETTE["danger_light"]),
("pressed", PALETTE["danger_light"])])
# ---- Notebook (现代卡片式 Tab) ----
style.configure("TNotebook",
background=PALETTE["bg"],
bordercolor=PALETTE["border_soft"],
tabmargins=(4, 8, 4, 0))
style.configure("TNotebook.Tab",
font=tab_font,
background=PALETTE["tab_inactive"],
foreground=PALETTE["text_muted"],
bordercolor=PALETTE["border_soft"],
padding=(20, 10, 20, 10),
focuscolor=PALETTE["bg"])
style.map("TNotebook.Tab",
background=[("selected", PALETTE["surface"]),
("active", PALETTE["border_soft"])],
foreground=[("selected", PALETTE["primary"]),
("active", PALETTE["text"])],
bordercolor=[("selected", PALETTE["primary"])],
expand=[("selected", (0, 2, 0, 0))])
# ---- Treeview (更宽松的行距, 更美观的表头) ----
style.configure("Treeview",
background=PALETTE["surface"],
fieldbackground=PALETTE["surface"],
foreground=PALETTE["text"],
bordercolor=PALETTE["border_soft"],
rowheight=28,
font=(_MONO_FONT_FAMILY, 9))
style.configure("Treeview.Heading",
background=PALETTE["primary_light"],
foreground=PALETTE["primary"],
font=header_font,
relief="flat",
padding=(8, 8))
style.map("Treeview.Heading",
background=[("active", PALETTE["selected"])])
style.map("Treeview",
background=[("selected", PALETTE["selected"])],
foreground=[("selected", PALETTE["primary"])])
# ---- Scrollbar ----
style.configure("Vertical.TScrollbar",
background=PALETTE["bg"],
troughcolor=PALETTE["bg"],
bordercolor=PALETTE["bg"],
arrowcolor=PALETTE["text_muted"],
gripcount=0)
style.map("Vertical.TScrollbar",
background=[("active", PALETTE["border"])])
style.configure("Horizontal.TScrollbar",
background=PALETTE["bg"],
troughcolor=PALETTE["bg"],
bordercolor=PALETTE["bg"],
arrowcolor=PALETTE["text_muted"],
gripcount=0)
style.map("Horizontal.TScrollbar",
background=[("active", PALETTE["border"])])
# ---- Progressbar ----
style.configure("TProgressbar",
background=PALETTE["primary"],
troughcolor=PALETTE["border_soft"],
bordercolor=PALETTE["border_soft"],
lightcolor=PALETTE["primary"],
darkcolor=PALETTE["primary"])
# ---- PanedWindow ----
style.configure("TPanedwindow", background=PALETTE["bg"])
style.configure("TPanedwindow.Sash",
background=PALETTE["border"],
sashthickness=6)
# ---- Separator ----
style.configure("TSeparator", background=PALETTE["border"])
# ---- 界面构建 ----
def _build_brand_row(self, parent):
"""左侧参数栏正上方的品牌标 (图标 + 名称 + 副标题).
高度按右侧页签行配平 (图标 32px + 上下各 8px 留白), 于是窗口顶部
这条水平带左右两侧都有内容, 宽屏下不会再空出一整条横幅.
"""
row = ttk.Frame(parent)
# padx=0: 图标左边缘与下方各 LabelFrame 的左边缘对齐 (Canvas 也在 x=0)
row.pack(side="top", fill="x", padx=0, pady=8)
# Tk 的 PhotoImage 只能按整数倍抽样缩小 (最近邻, 圆角会发毛), 所以
# 直接读按尺寸渲染好的 32px 图. 缺图/解码失败时只是没有图标, 不影响文字.
icon_path = _resource_path("assets", "deltalab_32.png")
self._brand_photo = None
if os.path.exists(icon_path):
try:
self._brand_photo = tk.PhotoImage(file=icon_path)
except tk.TclError:
pass
else:
ttk.Label(row, image=self._brand_photo,
style="Brand.TLabel").pack(side="left")
text_col = ttk.Frame(row)
text_col.pack(side="left", padx=(8, 0))
ttk.Label(text_col, text="DeltaLab", style="Brand.TLabel").pack(anchor="w")
ttk.Label(text_col, text="期权动态对冲回测系统",
style="Muted.TLabel").pack(anchor="w")
def _build_ui(self):
# 品牌标不再单独占一条通栏横幅:窗口标题栏已经写着同一句话,通栏
# 只是在宽屏上留出一条几乎全空的白带。改放到左侧参数栏正上方
# (见下面的 _build_brand_row),与右侧页签行同处一条水平带。
# 底部状态栏
# 必须排在 body 之前 pack:pack 按调用顺序分配空间,body 带 expand=True
# 会先吃满自己的请求高度;一旦右侧图表 + 表格的请求高度超过窗口可用高度
# (最大化到较矮的屏幕、或策略优选页有图有表时),后 pack 的状态栏就会被
# 挤出窗口底部只露半行。先占位则状态栏恒为 24px,压缩的是可伸缩的 body。
status_bar = ttk.Frame(self, style="Surface.TFrame")
status_bar.pack(fill="x", side="bottom")
ttk.Separator(status_bar, orient="horizontal").pack(fill="x")
self._status_var = tk.StringVar(
value="就绪 | 选择期权类型、设置参数后点击『运行回测』")
ttk.Label(status_bar, textvariable=self._status_var,
style="Status.TLabel", anchor="w").pack(fill="x", padx=10)
# 主体:左侧参数 + 右侧结果
body = ttk.PanedWindow(self, orient="horizontal")
body.pack(fill="both", expand=True, padx=12, pady=(8, 4))
# ─── 左侧面板 (整体包一层 Canvas + Scrollbar, 解决低分辨率/高 DPI 下底部按钮被裁) ───
left_outer = ttk.Frame(body)
body.add(left_outer, weight=1)
# 品牌标:必须先 pack, 才能拿到 left_outer 顶部这条; 之后的滚动条与
# Canvas 分掉剩下的空腔。它不进 Canvas —— 进去会跟着参数一起滚走。
self._build_brand_row(left_outer)
# 外层 Canvas 横向充满, Scrollbar 靠右
# width 仅作为 PanedWindow 初始 sash 位置的参考, 不阻止后续缩放;
# 取表单三列 (标签 + 输入 + 说明) 的自然宽度, 初次打开时右侧说明
# 文字不会被 sash 截断
self._left_canvas = tk.Canvas(
left_outer, highlightthickness=0, bd=0,
bg=PALETTE["surface"], width=544,
)
self._left_scrollbar = ttk.Scrollbar(
left_outer, orient="vertical", command=self._left_canvas.yview
)
self._left_canvas.configure(yscrollcommand=self._left_scrollbar.set)
self._left_scrollbar.pack(side="right", fill="y")
self._left_canvas.pack(side="left", fill="both", expand=True)
# inner frame: 真正承载左侧所有 LabelFrame/按钮; 父控件必须是 Canvas 本身.
self._left_inner = ttk.Frame(self._left_canvas, style="Surface.TFrame")
self._left_inner_id = self._left_canvas.create_window(
(0, 0), window=self._left_inner, anchor="nw"
)
# inner 尺寸变化 → 更新 scrollregion
def _on_inner_configure(event):
self._left_canvas.configure(scrollregion=self._left_canvas.bbox("all"))
self._left_inner.bind("<Configure>", _on_inner_configure)
# canvas 宽度变化 → inner 跟随横向铺满 (否则 inner 默认是内容宽度, 右侧会有空白)
def _on_canvas_configure(event):
self._left_canvas.itemconfigure(self._left_inner_id, width=event.width)
self._left_canvas.bind("<Configure>", _on_canvas_configure)
# ─── 鼠标滚轮 ───
# 旧实现是 <Enter> 时 bind_all、<Leave> 时 unbind_all. 问题在于指针只要
# 从 Canvas 挪到面板内任意子控件 (LabelFrame / Label / Entry / Combobox…),
# Canvas 就会收到 <Leave> 把滚轮解绑 —— 而参数区表面几乎全被子控件盖住,
# 于是表现为「鼠标停在参数上滚轮没反应, 只有压着右侧滚动条才滚得动」
# (滚动条能滚是 Tk 自带的 Scrollbar 类绑定, 与这里无关).
#
# 改成常驻一条全局绑定, 每次事件按**指针位置**判断是否落在左侧面板内:
# 落在外面就原样放行, 不会劫持右侧图表/表格的滚轮.
# 用指针位置而不是 event.widget, 是因为 Windows 上 Tk 把 <MouseWheel>
# 投递给**焦点控件**而非指针下的控件, 只看 event.widget 会判错.
_self_scrolling = {"Text", "Listbox", "Treeview"}
def _wheel_over_left_panel(event):
try:
widget = self.winfo_containing(event.x_root, event.y_root)
while widget is not None:
if widget is self._left_canvas:
return True
# 指针下方是自带滚动的控件, 让它自己处理, 不抢事件
if widget.winfo_class() in _self_scrolling:
return False
widget = getattr(widget, "master", None)
except (KeyError, tk.TclError):
# 指针不在本程序窗口内 / 控件刚被销毁
return False
return False
def _on_left_mousewheel(event):
if not _wheel_over_left_panel(event):
return None
# Windows: delta 为 ±120 的倍数; macOS: 每格 ±1 (触控板还更小).
step = -event.delta if _SYSTEM == "Darwin" else -event.delta / 120
units = int(step)
if units == 0 and step:
units = 1 if step > 0 else -1
self._left_canvas.yview_scroll(units, "units")
return "break"
def _on_left_mousewheel_linux(event):
if not _wheel_over_left_panel(event):
return None
self._left_canvas.yview_scroll(-3 if event.num == 4 else 3, "units")
return "break"
# 供测试/其它代码直接调用的入口 (指向本平台实际生效的那个处理器)
self._left_wheel_handler = (
_on_left_mousewheel_linux if _SYSTEM == "Linux" else _on_left_mousewheel)
if _SYSTEM == "Linux":
self.bind_all("<Button-4>", _on_left_mousewheel_linux, add="+")
self.bind_all("<Button-5>", _on_left_mousewheel_linux, add="+")
else:
self.bind_all("<MouseWheel>", _on_left_mousewheel, add="+")
# ttk 的 Combobox/Spinbox 自带滚轮类绑定, 悬停其上滚动会直接改选中值 ——
# 参数区里全是这种控件, 既会误改「大类」「子类型」等参数, 又会让人以为
# 面板滚不动. 覆盖成空处理: 不返回 "break", 事件继续走到上面的全局绑定,
# 于是滚轮在整个界面里统一是「滚面板」而不是「改值」.
for _wheel_cls in ("TCombobox", "TSpinbox"):
self.bind_class(_wheel_cls, "<MouseWheel>", lambda _event: None)
if _SYSTEM == "Linux":
self.bind_class(_wheel_cls, "<Button-4>", lambda _event: None)
self.bind_class(_wheel_cls, "<Button-5>", lambda _event: None)
# 之后所有原本放在 left 里的控件, 父容器改为 left_inner
left = self._left_inner
# 1) 期权大类
sec1 = ttk.LabelFrame(left, text=" 期权类型 ", padding=FORM_SECTION_PAD)
sec1.pack(fill="x", pady=(0, FORM_SECTION_GAP))
_form_grid(sec1)
_form_label(sec1, "大类:", 0)
self._class_var = tk.StringVar()
class_cb = ttk.Combobox(sec1, textvariable=self._class_var,
width=FORM_ENTRY_CHARS,
values=list(OPTION_CLASSES.keys()), state="readonly")
# 与下方各行同宽:输入列已加宽到 216px,装得下最长的结构名
# 「敲出计零·区间固赔·到期杠杆累计」(189px 文本 + 26px 箭头内边距)。
# 此前这两行单独跨两列去够那个宽度,结果同一张表里出现两种宽度。
_form_input(class_cb, 0)
class_cb.current(0)
class_cb.bind("<<ComboboxSelected>>", self._on_option_class_change)
_form_label(sec1, "子类型:", 1)
self._subtype_var = tk.StringVar()
self._subtype_cb = ttk.Combobox(sec1, textvariable=self._subtype_var,
width=FORM_ENTRY_CHARS, state="readonly")
_form_input(self._subtype_cb, 1)
self._subtype_cb.bind(
"<<ComboboxSelected>>", lambda _event: self._schedule_band_reference_sync())
# 2) 期权参数
# 说明: 外层左侧已经有整体 Canvas+Scrollbar, 这里不再嵌套独立滚动容器,
# 让参数区按内容自然撑开高度, 整体滚动由外层统一处理.
sec2 = ttk.LabelFrame(left, text=" 期权参数 ", padding=FORM_SECTION_PAD)
sec2.pack(fill="x", pady=(0, FORM_SECTION_GAP))
self._param_frame = ttk.Frame(sec2, style="Surface.TFrame")
self._param_frame.pack(fill="x", expand=True)
_form_grid(self._param_frame)
# 3) 回测设置
sec3 = ttk.LabelFrame(left, text=" 回测设置 ", padding=FORM_SECTION_PAD)
sec3.pack(fill="x", pady=(0, FORM_SECTION_GAP))
_form_grid(sec3)
_form_label(sec3, "数据来源:", 0)
self._source_var = tk.StringVar(value="simulate")
src_frame = ttk.Frame(sec3, style="Surface.TFrame")
# 单选组比输入列宽, 让它跨到说明列, 免得把输入列撑变形
_form_input(src_frame, 0, columnspan=2, sticky="w")
ttk.Radiobutton(src_frame, text="模拟", variable=self._source_var,
value="simulate", command=self._toggle_source).pack(side="left")
ttk.Radiobutton(src_frame, text="CSV", variable=self._source_var,
value="csv", command=self._toggle_source).pack(side="left", padx=(12, 0))
ttk.Radiobutton(src_frame, text="Wind", variable=self._source_var,
value="wind", command=self._toggle_source).pack(side="left", padx=(12, 0))
# 模拟 / CSV / Wind 三个子面板共用同一套列宽, 切换数据来源时
# 输入框不会左右跳动。
# 模拟参数
self._sim_frame = ttk.Frame(sec3, style="Surface.TFrame")
self._sim_frame.grid(row=1, column=0, columnspan=3, sticky="ew")
_form_grid(self._sim_frame)
_form_label(self._sim_frame, "种子:", 0)
self._seed_var = tk.StringVar(value="42")
_form_input(ttk.Entry(self._sim_frame, textvariable=self._seed_var,
width=FORM_ENTRY_CHARS), 0)
_form_label(self._sim_frame, "已实现波动率:", 1)
self._real_vol_var = tk.StringVar(value="")
_form_input(ttk.Entry(self._sim_frame, textvariable=self._real_vol_var,
width=FORM_ENTRY_CHARS), 1)
_form_hint(self._sim_frame, 1, "空=同隐含")
_form_label(self._sim_frame, "回测路径数 (MC):", 2)
self._npaths_var = tk.StringVar(value="10")
_form_input(ttk.Entry(self._sim_frame, textvariable=self._npaths_var,
width=FORM_ENTRY_CHARS), 2)
_form_label(self._sim_frame, "每日模拟采样点数:", 3)
self._spd_var = tk.StringVar(value="1")
self._spd_combo = ttk.Combobox(
self._sim_frame, textvariable=self._spd_var, width=FORM_ENTRY_CHARS,
values=("1", "4", "48", "240"), state="readonly")
_form_input(self._spd_combo, 3)
_form_hint(self._sim_frame, 3, "每个交易日等分的采样点数")
# CSV 参数
self._csv_frame = ttk.Frame(sec3, style="Surface.TFrame")
_form_grid(self._csv_frame)
_form_label(self._csv_frame, "文件:", 0)
self._csv_path_var = tk.StringVar()
_form_input(ttk.Entry(self._csv_frame, textvariable=self._csv_path_var,
width=FORM_ENTRY_CHARS), 0)
ttk.Button(self._csv_frame, text="浏览…", width=6,
style="Field.TButton", command=self._browse_csv).grid(
row=0, column=2, sticky="w",
padx=(FORM_HINT_GAP, 0), pady=FORM_ROW_PADY)
_form_label(self._csv_frame, "价格列:", 1)
self._csv_col_var = tk.StringVar(value="close")
_form_input(ttk.Entry(self._csv_frame, textvariable=self._csv_col_var,
width=FORM_ENTRY_CHARS), 1)
# Wind 参数
self._wind_frame = ttk.Frame(sec3, style="Surface.TFrame")
_form_grid(self._wind_frame)
_form_label(self._wind_frame, "代码:", 0)
self._wind_code_var = tk.StringVar(value="510050.SH")
_form_input(ttk.Entry(self._wind_frame, textvariable=self._wind_code_var,
width=FORM_ENTRY_CHARS), 0)
# 建仓日排在最上面:它才是读结果时关心的那一天。勾选倒推时这一格
# 由「数据截止日」按期权期限沿交易日历往前算出来并回填(置灰只读),
# 开关紧跟其后说明这格是谁算的;下一行才是作为主控的截止日。
_form_label(self._wind_frame, "建仓日:", 1)
_today = datetime.date.today()
# 截止日默认落在最近一个「已收盘」交易日:当天盘中拉行情,最后一个
# 交易日组只有半天 bar,分钟粒度的策略会被判成盘中残段而直接拒绝。
# 这也与历史择优默认截至日(不含当天)保持同一口径。
_wind_asof_default = BacktestApp._latest_trading_day(
_today - datetime.timedelta(days=1)).isoformat()
# 只是取消勾选前的占位:勾选倒推时首次刷新就会用倒推结果覆盖它。
_wind_start_default = (_today - datetime.timedelta(days=90)).strftime("%Y-%m-%d")
self._wind_start_var = tk.StringVar(value=_wind_start_default)
self._wind_start_entry = ttk.Entry(
self._wind_frame, textvariable=self._wind_start_var,
width=FORM_ENTRY_CHARS)
_form_input(self._wind_start_entry, 1)
self._wind_auto_start_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
self._wind_frame, text="按数据截止日倒推",
variable=self._wind_auto_start_var,
command=self._toggle_wind_auto_start,
style="Surface.TCheckbutton",
).grid(row=1, column=2, sticky="w",
padx=(FORM_HINT_GAP, 0), pady=FORM_ROW_PADY)
_form_label(self._wind_frame, "数据截止日:", 2)
self._wind_end_var = tk.StringVar(value=_wind_asof_default)
self._wind_end_entry = ttk.Entry(
self._wind_frame, textvariable=self._wind_end_var,
width=FORM_ENTRY_CHARS)
_form_input(self._wind_end_entry, 2)
# 界面上不再单列"实际区间",两个日期框就是本次真实取数区间,因此
# 截止日填成休市日时也要落到倒推真正用的那个锚点(不晚于它的最近
# 交易日)。逐次击键就落值会打断输入,只在提交时对齐。
self._wind_end_entry.bind(
"<FocusOut>", lambda _event: self._commit_wind_asof())
self._wind_end_entry.bind(
"<Return>", lambda _event: self._commit_wind_asof())
# 行情采样粒度不再由用户选择:每种策略的正确粒度是唯一确定的
# (收盘=日频、固定时刻=能覆盖目标时刻的最粗、价格波动=1 分钟),
# 手动调粗只会静默让结论偏乐观。这里只展示本次将要使用的粒度。
_form_label(self._wind_frame, "行情采样粒度:", 3)
self._wind_frequency_hint_var = tk.StringVar()
# 这一格是只读结论, 不是输入框, 因此贴着输入列起点直接铺开
ttk.Label(
self._wind_frame, textvariable=self._wind_frequency_hint_var,
style="SurfaceMuted.TLabel",
).grid(row=3, column=1, columnspan=2, sticky="w", pady=FORM_ROW_PADY)
self._toggle_wind_auto_start()
self._refresh_wind_frequency_hint()
self._wind_code_var.trace_add(
"write", lambda *_args: self._refresh_wind_frequency_hint())
self._wind_end_var.trace_add(
"write", lambda *_args: self._sync_wind_entry_date())
# 轻分割线
_form_separator(sec3, 2)
# 对冲参数
# 顺序:成本(成本率 + 滑点)→ 仓位(方向 / 数量 / 乘数)→ 策略。
# 滑点与成本率是同一类按成交量计的执行成本,排在一起;也因此不能留在
# 策略块之后——那里的专属参数随策略显隐,会带着滑点上下跳一百多像素。
row_h = 3
_form_label(sec3, "交易成本率(%):", row_h)
self._tc_var = tk.StringVar(value="0.01")
_form_input(ttk.Entry(sec3, textvariable=self._tc_var,
width=FORM_ENTRY_CHARS), row_h)
row_h += 1
_form_label(sec3, "滑点 (基点):", row_h)
self._slip_var = tk.StringVar(value="0")
_form_input(ttk.Entry(sec3, textvariable=self._slip_var,
width=FORM_ENTRY_CHARS), row_h)
row_h += 1
_form_label(sec3, "头寸方向:", row_h)
self._pos_var = tk.StringVar(value="1")
pos_frame = ttk.Frame(sec3, style="Surface.TFrame")
_form_input(pos_frame, row_h, columnspan=2, sticky="w")
ttk.Radiobutton(pos_frame, text="卖出 (Sell)", variable=self._pos_var,
value="1").pack(side="left")
ttk.Radiobutton(pos_frame, text="买入 (Buy)", variable=self._pos_var,
value="-1").pack(side="left", padx=(12, 0))
row_h += 1
_form_label(sec3, "交易数量:", row_h)
self._qty_var = tk.StringVar(value="100")
_form_input(ttk.Entry(sec3, textvariable=self._qty_var,
width=FORM_ENTRY_CHARS), row_h)
row_h += 1
_form_label(sec3, "合约乘数:", row_h)
self._mult_var = tk.StringVar(value="5")
_form_input(ttk.Entry(sec3, textvariable=self._mult_var,
width=FORM_ENTRY_CHARS), row_h)
_form_hint(sec3, row_h, "0=不取整")
# 轻分割线:高级对冲参数(策略 / intraday / 滑点)
row_h += 1
_form_separator(sec3, row_h)
row_h += 1
_form_label(sec3, "对冲策略:", row_h)
self._strategy_var = tk.StringVar(
value=STRATEGY_DISPLAY["close_to_close"])
self._strategy_combo = ttk.Combobox(
sec3, textvariable=self._strategy_var, width=FORM_ENTRY_CHARS,
values=tuple(STRATEGY_DISPLAY.values()), state="readonly",
)
_form_input(self._strategy_combo, row_h)
self._strategy_combo.bind("<<ComboboxSelected>>", lambda e: self._toggle_strategy())
# 紧跟策略下拉:它只修饰所选策略的触发时点,不是第四种策略。原先排在
# 固定时刻 / 带宽参数之后,会随策略切换在面板里上下跳。
# 默认开启:日内触发策略若不在收盘补一次,隔夜会裸露一整晚的
# Δ 敞口,而回测把这段风险记为零成本,结果偏乐观。
row_h += 1
self._force_day_close_hedge_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
sec3, text="每日收盘保底对冲(重复则跳过)",
variable=self._force_day_close_hedge_var,
command=self._refresh_history_base_summary,
style="Surface.TCheckbutton",
).grid(row=row_h, column=1, columnspan=2, sticky="w", pady=FORM_ROW_PADY)
# sigma 单位下的波动率来源参数;带宽数值统一使用下方输入框。
row_h += 1
self._sigma_band_frame = ttk.Frame(sec3, style="Surface.TFrame")
self._sigma_band_frame.grid(row=row_h, column=0, columnspan=3, sticky="ew")
_form_grid(self._sigma_band_frame)
self._k_var = tk.StringVar(value="1") # 旧状态字段兼容,不再单独展示
_form_label(self._sigma_band_frame, "波动率来源:", 0)
self._sigma_src_var = tk.StringVar(value=SIGMA_SOURCE_DISPLAY["implied"])
_form_input(ttk.Combobox(
self._sigma_band_frame, textvariable=self._sigma_src_var,
width=FORM_ENTRY_CHARS,
values=tuple(SIGMA_SOURCE_DISPLAY.values()), state="readonly"), 0)
_form_label(self._sigma_band_frame, "历史波动率回看天数:", 1)
self._sigma_win_var = tk.StringVar(value="20")
_form_input(ttk.Entry(self._sigma_band_frame,
textvariable=self._sigma_win_var,
width=FORM_ENTRY_CHARS), 1)
row_h += 1
self._fixed_time_frame = ttk.Frame(sec3, style="Surface.TFrame")
self._fixed_time_frame.grid(row=row_h, column=0, columnspan=3, sticky="ew")
_form_grid(self._fixed_time_frame)
_form_label(self._fixed_time_frame, "固定时刻:", 0)
self._fixed_times_var = tk.StringVar(value=DEFAULT_FIXED_TIMES)
self._fixed_times_entry = ttk.Entry(
self._fixed_time_frame, textvariable=self._fixed_times_var,
width=FORM_ENTRY_CHARS)
_form_input(self._fixed_times_entry, 0)
_form_hint(self._fixed_time_frame, 0, "HH:MM,逗号分隔")
# 三种带宽单位互为换算,逐行排布才能和上面的参数共用同一条对齐线。
self._band_frame = ttk.Frame(sec3, style="Surface.TFrame")
self._band_frame.grid(row=row_h, column=0, columnspan=3, sticky="ew")
_form_grid(self._band_frame)
_form_label(self._band_frame, "绝对间隔:", 0)
self._band_abs_var = tk.StringVar(value="1")
self._band_abs_entry = ttk.Entry(
self._band_frame, textvariable=self._band_abs_var,
width=FORM_ENTRY_CHARS)
_form_input(self._band_abs_entry, 0)
_form_label(self._band_frame, "相对间隔:", 1)
self._band_rel_var = tk.StringVar(value="0.01")
self._band_rel_entry = ttk.Entry(
self._band_frame, textvariable=self._band_rel_var,
width=FORM_ENTRY_CHARS)
_form_input(self._band_rel_entry, 1)
_form_hint(self._band_frame, 1, "0.01 = 1%")
_form_label(self._band_frame, "日波动率倍数:", 2)
self._band_sigma_var = tk.StringVar(value="0.779423")
self._band_sigma_entry = ttk.Entry(
self._band_frame, textvariable=self._band_sigma_var,
width=FORM_ENTRY_CHARS)
_form_input(self._band_sigma_entry, 2)
_form_hint(self._band_frame, 2, "编辑任一项后自动换算")
# 策略专属参数是本组最后一行:它随策略显隐,后面不能再排别的控件。
# 保留原状态字段供收集/兼容;其值由最后编辑的联动输入决定。
self._price_interval_var = tk.StringVar(value="1")
self._interval_type_var = tk.StringVar(value="absolute")
self._band_synced = False
self._band_syncing = False
self._band_last_edited = "absolute"
self._band_reference_after_id = None
for entry, var, kind in (
(self._band_abs_entry, self._band_abs_var, "absolute"),
(self._band_rel_entry, self._band_rel_var, "relative"),
(self._band_sigma_entry, self._band_sigma_var, "sigma")):
# StringVar trace 同时覆盖键入、粘贴与辅助输入法;
# 自动换算期间由 _band_syncing 阻断回入。
var.trace_add(
"write", lambda *_args, k=kind: self._mark_band_edited(k))
entry.bind("<FocusOut>", lambda e, k=kind: self._commit_band_input(k))
entry.bind("<Return>", lambda e, k=kind: self._commit_band_input(k, force=True))
# 依据默认策略(close_to_close)初始化专用参数可见性
self._toggle_strategy()
# 运行按钮
btn_frame = ttk.Frame(left)
btn_frame.pack(fill="x", pady=(2, 6))
self._run_btn = ttk.Button(btn_frame, text="▶ 运行回测", style="Run.TButton",
command=self._run_backtest)
self._run_btn.pack(fill="x", ipady=4)
self._retain_btn = ttk.Button(
btn_frame, text="+ 保留当前结果到对比", style="Accent.TButton",
command=self._retain_current_backtest, state="disabled",
)
self._retain_btn.pack(fill="x", ipady=2, pady=(FORM_SECTION_GAP - 2, 0))
# 左侧不再有「结果对比」按钮:同名标签页是等效入口,切过去时
# _on_notebook_tab_changed 会补建页面。已保留条数改挂到标签页标题上,
# 从任何页面都看得见。
self._struct_btn = ttk.Button(btn_frame, text="📊 绘制结构图",
style="Accent.TButton",
command=self._plot_structure)
self._struct_btn.pack(fill="x", ipady=2, pady=(FORM_SECTION_GAP - 2, 0))