-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathgui_app.py
More file actions
9690 lines (8974 loc) · 456 KB
/
Copy pathgui_app.py
File metadata and controls
9690 lines (8974 loc) · 456 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
import bisect
import copy
import hashlib
import platform
import threading
import datetime
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import SimpleNamespace
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import tkinter.font as tkfont
import numpy as np
import matplotlib
matplotlib.use("TkAgg")
import matplotlib.pyplot as plt
from matplotlib import font_manager
def _resource_path(*parts: str) -> str:
# PyInstaller 解压到 sys._MEIPASS; 开发态以源文件所在目录为根.
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, *parts)
# ---- 跨平台中文字体设置 ----
_SYSTEM = platform.system()
if _SYSTEM == "Darwin":
_CJK_CANDIDATES = ["PingFang SC", "Heiti SC", "STHeiti", "Arial Unicode MS",
"Hiragino Sans GB", "Songti SC"]
elif _SYSTEM == "Windows":
_CJK_CANDIDATES = ["Microsoft YaHei", "SimHei", "SimSun"]
else:
_CJK_CANDIDATES = ["Noto Sans CJK SC", "WenQuanYi Zen Hei",
"WenQuanYi Micro Hei", "Source Han Sans SC"]
_AVAILABLE_FONTS = {f.name for f in font_manager.fontManager.ttflist}
_CJK_FALLBACK = [f for f in _CJK_CANDIDATES if f in _AVAILABLE_FONTS] + ["DejaVu Sans"]
plt.rcParams['font.sans-serif'] = _CJK_FALLBACK
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['axes.unicode_minus'] = False
# 显式绑定一个 CJK 字体文件, 供 matplotlib text() 等接口通过 fontproperties 强制使用,
# 避免某些调用路径回退到不含 CJK 字形的默认字体导致乱码.
_MPL_CJK_FP = None
for _f in font_manager.fontManager.ttflist:
if _f.name in _CJK_CANDIDATES:
_MPL_CJK_FP = font_manager.FontProperties(fname=_f.fname)
break
# Tk/ttk 使用的中文 UI 字体族(取第一个可用的 CJK 字体)
_UI_FONT_FAMILY = _CJK_FALLBACK[0] if _CJK_FALLBACK[0] != "DejaVu Sans" else "TkDefaultFont"
# 等宽字体 (用于摘要/结构文本)
if _SYSTEM == "Windows":
_MONO_CANDIDATES = ["Cascadia Mono", "Consolas", "Courier New"]
elif _SYSTEM == "Darwin":
_MONO_CANDIDATES = ["Menlo", "Monaco", "Courier New"]
else:
_MONO_CANDIDATES = ["DejaVu Sans Mono", "Liberation Mono", "Courier New"]
_MONO_FONT_FAMILY = next((f for f in _MONO_CANDIDATES if f in _AVAILABLE_FONTS), "Courier")
# ---- 统一视觉调色板 (现代扁平风格, 蓝灰系) ----
PALETTE = {
"bg": "#F3F5F9", # 窗口底色
"surface": "#FFFFFF", # 卡片/面板
"surface_alt": "#F8FAFC", # 次级表面 (Text 背景, 斑马行)
"border": "#D8DEE8", # 边框
"border_soft": "#E5E9F0", # 轻分割线
"text": "#1F2937", # 主文字
"text_muted": "#6B7280", # 次要文字
"text_light": "#9CA3AF", # 更浅文字 (占位符)
"primary": "#2563EB", # 主色 (运行按钮)
"primary_hov": "#1D4ED8",
"primary_act": "#1E40AF",
"primary_light":"#EFF6FF", # 主色浅底
"accent": "#0EA5E9", # 次级按钮
"accent_hov": "#0284C7",
"success": "#16A34A",
"success_light":"#F0FDF4", # 成功浅底
"warning": "#D97706",
"warning_light":"#FFFBEB", # 警告浅底
"danger": "#DC2626",
"danger_light": "#FEF2F2", # 危险浅底
"selected": "#DBEAFE", # 选中高亮
"gold": "#B8860B", # 金色 (装饰线)
"tab_inactive": "#E2E8F0", # 未选中 tab 底色
}
# ---- 左侧参数面板的统一度量 (像素) ----
# 三个分组 (期权类型 / 期权参数 / 回测设置) 以及它们内部的子面板共用同一套
# 列宽, 输入框的左右边缘才能跨分组对齐; 列宽由 grid 的 minsize 强制, 各控件
# 自己的 width= 只是字符数下限, 因此统一改这里就能整体调节表单尺寸。
FORM_LABEL_W = 144 # 标签列宽 (含标签右侧留白; 容得下最长的雪球保证金标签)
FORM_INPUT_W = 168 # 输入列宽: 所有 Entry / Combobox 等宽
FORM_LABEL_GAP = 10 # 标签与输入框之间的留白
FORM_HINT_GAP = 8 # 输入框与右侧说明文字之间的留白
FORM_ROW_PADY = 4 # 每行上下留白 => 相邻控件间隔恒为 8
FORM_SECTION_PAD = 12 # 分组内边距
FORM_SECTION_GAP = 10 # 分组之间的间距
FORM_ENTRY_CHARS = 8 # 字符宽度只作下限, 实际宽度取 FORM_INPUT_W
def _form_grid(container):
"""把容器配成统一的三列表单: 标签 | 等宽输入 | 说明文字。
只有第三列可伸缩, 面板拉宽时输入框不会跟着变形, 跨分组始终等宽对齐。
"""
container.columnconfigure(0, minsize=FORM_LABEL_W, weight=0)
container.columnconfigure(1, minsize=FORM_INPUT_W, weight=0)
container.columnconfigure(2, weight=1)
def _form_label(parent, text, row, column=0, columnspan=1,
style="Surface.TLabel"):
"""表单标签: 统一右侧留白与行距。"""
widget = ttk.Label(parent, text=text, style=style)
widget.grid(row=row, column=column, columnspan=columnspan, sticky="w",
padx=(0, FORM_LABEL_GAP), pady=FORM_ROW_PADY)
return widget
def _form_input(widget, row, column=1, columnspan=1, sticky="ew"):
"""表单输入: 贴住输入列左边缘, 宽度由列宽统一。
多控件组合 (单选组、带勾选框的行) 传 columnspan=2 + sticky="w",
让它们向右溢出到说明列, 而不是把输入列撑宽。
"""
widget.grid(row=row, column=column, columnspan=columnspan,
sticky=sticky, pady=FORM_ROW_PADY)
return widget
def _form_hint(parent, row, text, column=2, columnspan=1):
"""输入框右侧的浅色说明文字。"""
widget = ttk.Label(parent, text=text, style="SurfaceMuted.TLabel")
widget.grid(row=row, column=column, columnspan=columnspan, sticky="w",
padx=(FORM_HINT_GAP, 0), pady=FORM_ROW_PADY)
return widget
def _form_separator(parent, row, columnspan=3):
"""分组内的轻分割线: 上下留白一致。"""
widget = ttk.Separator(parent, orient="horizontal")
widget.grid(row=row, column=0, columnspan=columnspan, sticky="ew",
pady=(FORM_ROW_PADY + 4, FORM_ROW_PADY + 2))
return widget
# matplotlib 整体风格配置 (与 Tk 主题协调)
plt.rcParams['axes.facecolor'] = PALETTE["surface"]
plt.rcParams['figure.facecolor'] = PALETTE["surface"]
plt.rcParams['axes.edgecolor'] = PALETTE["border"]
plt.rcParams['axes.labelcolor'] = PALETTE["text"]
plt.rcParams['xtick.color'] = PALETTE["text_muted"]
plt.rcParams['ytick.color'] = PALETTE["text_muted"]
plt.rcParams['axes.titlecolor'] = PALETTE["text"]
plt.rcParams['axes.titleweight'] = 'bold'
plt.rcParams['grid.color'] = PALETTE["border_soft"]
plt.rcParams['grid.linestyle'] = '--'
plt.rcParams['grid.linewidth'] = 0.6
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
# 确保 pricing 包可导入
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pricing import (
Option_AB, Option_AS, Option_DE, Option_SNB, Option_Vanilla, HedgeBacktest,
CloseToCloseStrategy, FixedTimeStrategy,
HedgeBandStrategy, StrategyCase, ContractHistoryPool,
compare_strategies, result_daily_frame,
summarize_strategy_result, history_window_summary,
history_replay_index,
)
from pricing.constants import ANNUAL_DAYS
from pricing.hedge_analysis import (
LOOKBACK_DAYS,
_aggregate_result_by_day,
DEFAULT_SELECTION_OBJECTIVE,
SELECTION_OBJECTIVES,
HistoryReplaySpec,
rerank_history,
recommend_by_contract_history_pool,
recommend_by_rolling_history,
)
# 载入结果包时按冻结参数重建每段的期权:原始运行也是按段初价重定基的,
# 用同一个函数才能保证重放与排名同源。
from pricing.hedge_backtest import _rescale_option_to_real_s0
from pricing.hedge_backtest import (
_infer_intraday_steps,
_rescale_strategy_to_real_s0,
_validate_fixed_time_data,
)
import history_selection
from history_selection import (
DEFAULT_BAND_CANDIDATE_SIGMAS,
DEFAULT_FIXED_TIMES,
HISTORY_PERIOD_DEFS,
MAX_BAND_CANDIDATES,
MAX_HISTORY_CHART_CANDIDATES,
)
import history_store
import history_bar_cache
# ============================================================
# 期权类型注册表
# ============================================================
def _snowball_ko_observ(T, first_obs, period):
"""按"锁定期 + 固定间隔 + 末次=到期"生成敲出观察交易日序号(1-based)。
全用交易日:首个观察在第 first_obs 日,其后每 period 个交易日一次,并
强制最后一次落在到期日 T(与到期不齐时末段为短桩)。返回升序去重列表。
"""
T = int(T)
first_obs = max(1, int(first_obs))
period = max(1, int(period))
days = list(range(first_obs, T + 1, period))
if not days or days[-1] != T:
days.append(T) # 末次观察 = 到期
return sorted({d for d in days if 1 <= d <= T})
def _build_snowball(st, p):
"""构造雪球(act=1 交易日计息)。观察日按锁定期+固定间隔生成(按完整交易日计算);
ko_step>0 时为降敲,KO 自期初值每观察日递减 ko_step 个点,得逐观察日 KO 向量。"""
T = int(p["T"])
ko_observ = _snowball_ko_observ(T, p["first_obs_d"], p["obs_period_d"])
step = float(p.get("ko_step", 0.0) or 0.0)
KO = [p["KO"] - step * i for i in range(len(ko_observ))] if step else p["KO"]
return Option_SNB(
st, p["s00"], p["s0"], p["K"], p["KI"], KO, T,
p["sigma"], p["coupon"], p["coupon_ko"], p["margin"], 1, p["cp"],
r=p["r"], q=p["q"], sr=[], ko_observ=ko_observ, nPath=p["nPath"],
margin_call=bool(p["margin_call"]),
)
OPTION_CLASSES = {
"香草期权 (Vanilla)": {
"class": Option_Vanilla,
"subtypes": ["Eu"],
"params": [
("s0", "初始价格 S0", float, 100.0),
("K", "行权价", float, 100.0),
("T_days", "期限(交易日)", int, 22),
("sigma", "波动率", float, 0.18),
("cp", "方向", int, 1, {"看涨 (Call)": 1, "看跌 (Put)": -1}),
("r", "无风险利率", float, 0.03),
("q", "分红率", float, 0.03),
],
"build": lambda st, p: Option_Vanilla(
st, p["s0"], [], p["K"], p["T_days"],
p["sigma"], p["cp"],
r=p["r"], q=p["q"], exe_mode=st,
),
},
"累计期权 (Decumulator)": {
"class": Option_DE,
"subtypes": [
"Opt_Decumulator", "Opt_Decumulator_Back",
"Opt_Decumulator_Fix", "Opt_Decumulator_Fix_E",
"Opt_EnDecumulator", "Opt_EnDecumulator_Fix",
"Opt_ASGQ_call_put", "Opt_ASGQ_EP", "Opt_ASGQ_EF", "Opt_ASGQ_EFF",
"Opt_ASGQ_DP", "Opt_ASGQ_DF", "Opt_ASGQ_DFF",
],
"params": [
("s0", "初始价格 S0", float, 100.0),
("K", "行权价", float, 90.0),
("T_days", "剩余期限(交易日)", int, 20),
("T_over", "已过天数", int, 0),
("sigma", "波动率", float, 0.18),
("H", "障碍价格", float, 110.0),
("N", "杠杆倍数", int, 2),
("cp", "方向", int, 1, {"看涨 (Call)": 1, "看跌 (Put)": -1}),
("fix", "固定赔付(可选)", float, 0.0),
("P", "保障价格(可选)", float, 0.0),
("amount", "固定金额(可选)", float, 0.0),
("r", "无风险利率", float, 0.03),
("q", "分红率", float, 0.03),
("nPath", "定价路径数 (MC)", int, 100000),
],
"build": lambda st, p: Option_DE(
st, p["s0"], [], p["K"], p["T_over"], p["T_days"],
list(range(1, p["T_days"] + p["T_over"] + 1)),
p["sigma"], p["H"], p["N"], p["cp"],
r=p["r"], q=p["q"], nPath=p["nPath"],
fix=p["fix"] if p["fix"] else None,
P=p["P"] if p["P"] else None,
amount=p["amount"] if p["amount"] else None,
),
},
"亚式期权 (Asian)": {
"class": Option_AS,
"subtypes": ["Asian", "EnhanceAsian"],
"params": [
("s0", "初始价格 S0", float, 100.0),
("K", "行权价", float, 100.0),
("E", "增强价(Enhanced)", float, 100.0),
("T", "期限(交易日)", int, 22),
("N", "观察日数", int, 22),
("sigma", "波动率", float, 0.15),
("cp", "方向", int, 1, {"看涨 (Call)": 1, "看跌 (Put)": -1}),
("minPay", "最低赔付", float, 0.0),
("maxPay", "最高赔付", float, 999999.0),
("r", "无风险利率", float, 0.03),
("q", "分红率", float, 0.03),
("nPath", "定价路径数 (MC)", int, 100000),
],
"build": lambda st, p: Option_AS(
st, p["s0"], [], p["K"], p["E"], p["T"], p["N"],
p["sigma"], p["cp"], p["minPay"], p["maxPay"],
r=p["r"], q=p["q"], nPath=p["nPath"]
),
},
"气囊期权 (Airbag)": {
"class": Option_AB,
"subtypes": ["Opt_Airbag"],
"params": [
("s0", "初始价格 S0", float, 100.0),
("K", "行权价", float, 100.0),
("KI", "敲入价", float, 90.0),
("T_days","期限(交易日)", int, 20),
("sigma", "波动率", float, 0.18),
("pr", "参与率", float, 0.8),
("pr_ki", "敲入参与率", float, 1.0),
("cp", "方向", int, 1, {"看涨 (Call)": 1, "看跌 (Put)": -1}),
("r", "无风险利率", float, 0.03),
("q", "分红率", float, 0.03),
("nPath", "定价路径数 (MC)", int, 100000),
],
"build": lambda st, p: Option_AB(
st, p["s0"], [], p["K"], p["KI"], p["T_days"],
list(range(1, p["T_days"] + 1)),
p["sigma"], p["pr"], p["pr_ki"], p["cp"],
r=p["r"], q=p["q"], nPath=p["nPath"]
),
},
"雪球期权 (Snowball)": {
"class": Option_SNB,
"subtypes": ["Opt_Snowball"],
"params": [
("s00", "入场价 S00", float, 100.0),
("s0", "最新价 S0", float, 100.0),
("K", "行权价", float, 100.0),
("KI", "敲入价", float, 80.0),
("KO", "期初敲出价", float, 103.0),
("T", "剩余期限(交易日)", int, 243),
# 锁定期/观察间隔:值用交易日(与引擎一致),下拉给月度预设辅助输入,
# 可编辑——既能选预设也能手填自定义交易日数(21 交易日 ≈ 1 个月)。
("first_obs_d","首次敲出观察", int, 63,
{"锁1月 (21)": 21, "锁2月 (42)": 42, "锁3月 (63)": 63, "锁6月 (126)": 126},
{"editable": True}),
("obs_period_d","观察间隔", int, 21,
{"月度 (21)": 21, "双月 (42)": 42, "季度 (63)": 63, "半年 (126)": 126},
{"editable": True}),
("ko_step", "每期降敲(点,0=平敲)", float, 0.0),
("sigma", "波动率", float, 0.15),
("coupon", "未敲出票息率(年化)", float, 0.15),
("coupon_ko", "敲出票息率(年化)", float, 0.15),
("margin_call", "保证金模式", int, 1, {"追保(亏损不封顶)": 1, "不追保(有限亏损)": 0}),
("margin", "保证金比例(不追保封顶)", float, 0.2),
("cp", "方向", int, -1, {"雪球 (卖看跌)": -1, "反雪球 (卖看涨)": 1}),
("r", "无风险利率", float, 0.03),
("q", "分红率", float, 0.03),
("nPath", "定价路径数 (MC)", int, 20000),
],
# act 固定为 1(交易日计息,无需交易日历);观察日=锁定期+固定间隔+末次到期,
# ko_step>0 时为降敲(逐观察日 KO 递减),见 _build_snowball。
"build": _build_snowball,
},
}
# ============================================================
# GUI 显示名 ↔ 后端内部键 映射
# 说明:后端 (hedge_backtest / Option_* 类) 使用英文/方法名做字符串匹配,
# 这里仅影响界面显示;读取 Combobox 值后需通过 *_FROM_DISPLAY 反向映射
# 还原为内部键再传给后端。
# ============================================================
SUBTYPE_DISPLAY = {
"Eu": "欧式",
"Opt_Decumulator": "普通累计",
"Opt_Decumulator_Back": "回归累计",
"Opt_Decumulator_Fix": "固定赔付回归累计",
"Opt_Decumulator_Fix_E": "固赔到期结算累计",
"Opt_EnDecumulator": "增强回归累计",
"Opt_EnDecumulator_Fix": "固定赔付增强累计",
"Opt_ASGQ_call_put": "到期熔断保障累计",
"Opt_ASGQ_EP": "熔断每日保障累计",
"Opt_ASGQ_EF": "熔断每日固赔累计",
"Opt_ASGQ_EFF": "熔断每日双固赔累计",
"Opt_ASGQ_DP": "每日熔断保障累计",
"Opt_ASGQ_DF": "每日熔断固赔累计",
"Opt_ASGQ_DFF": "每日熔断双固赔累计",
"Asian": "标准亚式",
"EnhanceAsian": "增强亚式",
"Opt_Airbag": "气囊",
"Opt_Snowball": "雪球",
}
SUBTYPE_FROM_DISPLAY = {v: k for k, v in SUBTYPE_DISPLAY.items()}
# 已保存结果列表要显示子类型的中文名。history_store 保持纯逻辑、不反
# 向依赖 GUI,所以由这里把映射注入进去。
history_store.SUBTYPE_DISPLAY = SUBTYPE_DISPLAY
STRATEGY_DISPLAY = {
"close_to_close": "每日收盘",
"fixed_times": "每日固定时刻",
"hedge_band": "价格波动触发调仓",
}
STRATEGY_FROM_DISPLAY = {v: k for k, v in STRATEGY_DISPLAY.items()}
# Wind 仍需要明确的起止边界和 BarSize;GUI 用“自动(推荐)”把这组底层
# 参数按单次回测 / 历史择优语义解析后,再传给既有后端 API。
WIND_AUTO_BAR_SIZE = "自动(推荐)"
WIND_BAR_SIZE_OPTIONS = (
WIND_AUTO_BAR_SIZE, "日频", "60分钟", "30分钟", "15分钟", "5分钟", "1分钟",
)
_WIND_BAR_MINUTES = {
"60分钟": 60, "30分钟": 30, "15分钟": 15, "5分钟": 5, "1分钟": 1,
}
# 采样粒度只有 WIND_BAR_SIZE_OPTIONS 一套标签;自动推荐与 Wind 请求参数
# 都从 _WIND_BAR_MINUTES 派生,避免下拉改名后留下对不上的字面量。
_WIND_FIXED_TIME_BAR_LABELS = ("15分钟", "5分钟", "1分钟")
# 价格波动触发只比较 bar 收盘价(HedgeBandStrategy.should_hedge),bar 内
# 穿带后回落的行情完全不可见,且漏掉的方向是单边的:少调仓 -> 低估交易
# 成本 -> 高估策略表现。因此自动粒度一律取最细的 1 分钟,不按带宽分档:
# 分档会让同一个候选的评分依赖“本批次里最窄的候选是谁”,而宽带下省下
# 的数据量也换不到可测量的精度。带宽与粒度的关系只用于量化手动选粗时
# 的代价,见 _WIND_BAND_MISS_RATES。
_WIND_BAND_BAR_LABEL = "1分钟"
_WIND_DATE_BUFFER_DAYS = 21
# 进程内交易日历缓存:("ok", [date...]) | ("error", 原因)。
_LOCAL_TRADING_CALENDAR = None
def _local_trading_calendar():
"""返回本地交易日历(升序 ``datetime.date`` 列表)与解析状态。
单次回测的建仓日必须落在真实交易日上,``_calendar_span_for_trading_days``
那套“自然日 + 节假日缓冲”只能用来估计取数区间,不能用来定位某一天。
解析结果(含失败原因)在进程内只算一次:日期提示挂在 StringVar trace 上,
每次击键都会调用,不能每次都去读文件,更不能反复触发联网刷新。
"""
global _LOCAL_TRADING_CALENDAR
if _LOCAL_TRADING_CALENDAR is None:
try:
from pricing.trade_calendar import load_calendar
days = load_calendar().astype("datetime64[D]").astype(object)
_LOCAL_TRADING_CALENDAR = ("ok", list(days))
except Exception as exc: # 缺日历文件且联网刷新也失败
_LOCAL_TRADING_CALENDAR = (
"error", str(exc) or exc.__class__.__name__)
return _LOCAL_TRADING_CALENDAR
# 排名依据的中英映射。两个口径都相对“日内不动”的基线取增量,区别只在于
# 看绝对多赚了多少,还是看这份增量的信噪比(每单位波动换来多少增量)。
HISTORY_OBJECTIVE_DISPLAY = {
"incremental_pnl": "增量收益(赚更多)",
"incremental_sharpe": "增量信噪比(更稳)",
}
HISTORY_OBJECTIVE_FROM_DISPLAY = {
value: key for key, value in HISTORY_OBJECTIVE_DISPLAY.items()
}
# 表格里对应两个排名口径的列,其表头可点击切换排名依据。
_OBJECTIVE_COLUMN_KEYS = {
"incremental_pnl": "incremental_pnl",
"incremental_sharpe": "incremental_sharpe",
}
# ranking 里承载这两个口径的列名。品种池模式不产出它们(跨合约不能直接
# 把金额 PnL 相加),展示层据此降级——见 _build_history_metric_tree。
_OBJECTIVE_RANKING_COLUMNS = (
"incremental_pnl_vs_c2c",
"incremental_sharpe_vs_c2c",
)
# 图表口径固定为整段接续(模式 "full"),不再有模式下拉,因此这里只留
# 指标的显示名。history_selection 的模型层仍实现着 single / typical,供
# 直接调用,但界面不再提供入口——它们与排名口径不一致。
HISTORY_CHART_METRIC_DISPLAY = {
"net": "净损益",
"gross": "成本前损益",
"tc": "交易成本",
}
HISTORY_CHART_METRIC_FROM_DISPLAY = {
value: key for key, value in HISTORY_CHART_METRIC_DISPLAY.items()
}
# 结果对比页与策略优选页此前各有一套曲线配色,同一个策略在两页会拿到不同
# 颜色,用户无法把两页的曲线对应起来。两页现在共用这一份色表和标记表,由
# BacktestApp._strategy_style 按会话内首次出现顺序登记。
STRATEGY_CHART_COLORS = (
"#2563EB", "#D97706", "#7C3AED", "#0F766E",
"#DB2777", "#DC2626", "#0891B2", "#65A30D",
"#9333EA", "#C2410C", "#4F46E5", "#047857",
)
STRATEGY_CHART_MARKERS = ("o", "s", "^", "D", "v", "P", "X", "<", ">", "h")
# 每日收盘在两页都是固定基准,必须始终占用同一个颜色位,不能因为登记顺序
# 不同而换色。
BASELINE_STRATEGY_STYLE_KEY = "close_to_close"
# 快照来源:只有 origin 是结构化字段,可供结果池分组与回跳使用;此前来源
# 只体现在用户可改的结果名前缀里,改名即丢失。
SNAPSHOT_ORIGIN_MANUAL = "manual"
SNAPSHOT_ORIGIN_HISTORY_VERIFY = "history_verify"
SNAPSHOT_ORIGIN_HISTORY_REPLAY = "history_replay"
SNAPSHOT_ORIGIN_DISPLAY = {
SNAPSHOT_ORIGIN_MANUAL: "手工回测",
SNAPSHOT_ORIGIN_HISTORY_VERIFY: "优选验证",
SNAPSHOT_ORIGIN_HISTORY_REPLAY: "分段重放",
}
@dataclass
class SavedBacktestResult:
"""会话内保留的轻量回测快照,只保存实时对比所需字段。"""
result_id: str
name: str
saved_at: datetime.datetime
summary_row: dict
daily_frame: object
strategy_label: str
parameter_summary: str
source_label: str
option_label: str
path_key: tuple
contract_key: tuple
economics_key: tuple
position: int
# 跨页配色键:曲线名是用户可改的结果名,不能用来对齐策略优选页的颜色。
style_key: str = BASELINE_STRATEGY_STYLE_KEY
# 结构化来源,不随重命名丢失;origin_meta 记录优选周期、批次与当时排名。
origin: str = SNAPSHOT_ORIGIN_MANUAL
origin_meta: dict = field(default_factory=dict)
SIGMA_SOURCE_DISPLAY = {
"implied": "隐含波动率",
"realized": "已实现波动率",
}
SIGMA_SOURCE_FROM_DISPLAY = {v: k for k, v in SIGMA_SOURCE_DISPLAY.items()}
# ============================================================
# 期权结构说明文档
# ============================================================
STRUCTURE_DOCS = {
("香草期权 (Vanilla)", "Eu"): (
"【欧式香草期权】\n"
"• Payoff: Call max(S_T−K,0) / Put max(K−S_T,0)\n"
"• 定价: Black-Scholes 封闭解\n\n"
"风险特征:\n"
" Delta 单调 0→1 (call) 或 −1→0 (put)\n"
" Gamma 集中于 ATM (S≈K), 随 T 缩小放大\n"
" Vega 对 ATM 最敏感, 随 √T 增长\n"
" Theta 为买方持续付出的时间价值"
),
("累计期权 (Decumulator)", "Opt_Decumulator"): (
"【普通累计 Opt_Decumulator】\n"
"每日观察 + 每日结算, 敲出即终止存续:\n"
" • 首次 S ≥ H (敲出): 当日及之后均停止累计\n"
" • K < S < H : 1 倍 (S − K) 结算\n"
" • S ≤ K : N 倍杠杆 (S − K) 结算\n\n"
"与 Back 的差异: Back 敲出仅当日计 0、后续仍继续观察;\n"
"本结构敲出即彻底了结, 路径依赖更强."
),
("累计期权 (Decumulator)", "Opt_Decumulator_Back"): (
"【回归累计 Opt_Decumulator_Back】\n"
"每日观察 + 每日结算, 三段式 cashflow:\n"
" • S ≥ H (敲出障碍): 当日 0 赔付\n"
" • K < S < H : 1 倍 (S − K) 结算\n"
" • S ≤ K : N 倍杠杆 (S − K) 结算\n\n"
"总损益 = 所有观察日折现加总.\n"
"卖方希望标的震荡于 (K, H) 区间, 触 K 承 N 倍下行."
),
("累计期权 (Decumulator)", "Opt_Decumulator_Fix"): (
"【固定赔付回归累计 Opt_Decumulator_Fix】\n"
"结构同 Back, 差异:\n"
" • K < S < H 区间按固定金额 `fix` 结算, 而非 (S−K)\n"
" • 敲出段/杠杆段逻辑不变\n\n"
"锁定中间段现金流, 便于账务管理."
),
("累计期权 (Decumulator)", "Opt_Decumulator_Fix_E"): (
"【固赔到期结算累计 Opt_Decumulator_Fix_E】\n"
"结构同 Fix, 差异在杠杆段结算方式:\n"
" • K ≤ S < H 区间: 每日固定金额 `fix`\n"
" • 杠杆段 (S ≤ K): 不每日结算, 仅按到期日收盘价\n"
" 一次性结算 (S_T − K) × 累计天数 × N\n\n"
"适合到期一次性交割杠杆腿的固赔回归累计."
),
("累计期权 (Decumulator)", "Opt_EnDecumulator"): (
"【增强回归累计 Opt_EnDecumulator】\n"
"三段式每日结算:\n"
" • S ≥ H : (S − H) 1 倍 (敲出后仍给买方正向收益)\n"
" • K < S < H: (S − K) 1 倍\n"
" • S ≤ K : (S − K) N 倍\n\n"
"相比 Back, 保留敲出后上行收益, 故称'增强'."
),
("累计期权 (Decumulator)", "Opt_EnDecumulator_Fix"): (
"【固定赔付增强累计 Opt_EnDecumulator_Fix】\n"
" • S ≥ H : (S − H) 1 倍\n"
" • K < S < H: 固定金额 `fix`\n"
" • S ≤ K : (S − K) N 倍"
),
("累计期权 (Decumulator)", "Opt_ASGQ_call_put"): (
"【到期观察熔断保障累计 ASGQ_call_put】\n"
"路径依赖 + 到期一次性结算:\n"
" • 若路径曾 S ≥ H (熔断): 熔断日后统一按 (S_T − P)\n"
" • 从未熔断: 按 (S_T − K), 若 S_T ≤ K 额外 N 倍\n\n"
"保障价 P 提供下行软保护, ASGQ = 熔断保障累计."
),
("累计期权 (Decumulator)", "Opt_ASGQ_EP"): (
"【熔断保障累计(每日结算) ASGQ_EP】\n"
" • 未熔断部分: 按日 (S − K) 累加\n"
" • 熔断日起 : 每日 (S − P) 结算"
),
("累计期权 (Decumulator)", "Opt_ASGQ_EF"): (
"【熔断固定赔付累计 ASGQ_EF】\n"
" • 未熔断部分: 按日 (S − K) 累加\n"
" • 熔断日起 : 每日固定金额 `amount`"
),
("累计期权 (Decumulator)", "Opt_ASGQ_EFF"): (
"【熔断每日双固赔累计 ASGQ_EFF】\n"
"到期观察 + 每日结算, 双固定赔付:\n"
" • K < S < H (区间): 每日固定金额 `fix`\n"
" • S ≤ K : 每日 (S − K) 1 倍\n"
" • 熔断日起 : 每日固定金额 `amount`\n"
" • 到期 S_T ≤ K 且未熔断: 额外结算\n"
" (S_T − K) × 累计天数 × (N − 1) 杠杆腿"
),
("累计期权 (Decumulator)", "Opt_ASGQ_DP"): (
"【每日观察熔断保障累计 ASGQ_DP】\n"
"每日观察 + 每日结算:\n"
" • 未熔断: (S − K), S ≤ K 时乘 N 倍\n"
" • 熔断日起: 每日 (S − P)\n\n"
"比到期版对路径更敏感, Delta/Gamma 跳跃更剧烈."
),
("累计期权 (Decumulator)", "Opt_ASGQ_DF"): (
"【每日观察熔断固定赔付累计 ASGQ_DF】\n"
" • 未熔断: (S − K), S ≤ K 时 N 倍\n"
" • 熔断日起: 每日固定金额 `amount`"
),
("累计期权 (Decumulator)", "Opt_ASGQ_DFF"): (
"【每日熔断双固赔累计 ASGQ_DFF】\n"
"每日观察 + 每日结算, 双固定赔付:\n"
" • K < S < H (区间): 每日固定金额 `fix`\n"
" • S ≤ K : (S − K) N 倍\n"
" • 熔断日起 : 每日固定金额 `amount`"
),
("亚式期权 (Asian)", "Asian"): (
"【亚式期权 Asian】\n"
"Payoff = clip( mean(S[-N:]) − K, minPay, maxPay ) × cp\n\n"
" • 取最后 N 个交易日均价与 K 的差额\n"
" • minPay / maxPay 限定赔付区间\n"
" • 平均化显著降低末日价格风险\n"
" • Gamma / Vega 远低于同期限 Vanilla"
),
("亚式期权 (Asian)", "EnhanceAsian"): (
"【增强亚式 EnhanceAsian】\n"
"每日先做价格增强:\n"
" • Call: 观察价 = max(S, E)\n"
" • Put : 观察价 = min(S, E)\n"
"再求均值与 K 比较, 并 clip 到 [minPay, maxPay].\n\n"
"E 提供'每日保底'效果, 提升买方期望."
),
("气囊期权 (Airbag)", "Opt_Airbag"): (
"【气囊期权 Opt_Airbag】\n"
"到期结算, 路径判断是否敲入 KI:\n"
" • 未敲入 (Call: min(S) > KI): pr × max(S_T − K, 0)\n"
" • 已敲入 : pr_ki × (S_T − K)\n\n"
"小幅下行时买方有软垫保护 (payoff=0 而非负);\n"
"一旦跌破 KI, 转为线性承担下行, 即'气囊爆掉'."
),
("雪球期权 (Snowball)", "Opt_Snowball"): (
"【雪球期权 Opt_Snowball】 (cp=-1 雪球 / cp=1 反雪球)\n"
"MC 定价, 路径依赖, 四种到期情形:\n"
" • 未敲入未敲出: 全期票息 s00 × coupon × 期限\n"
" • 敲出 (观察日触 KO): 敲出票息 × 持有期, 提前了结\n"
" • 敲入且敲出: 同敲出 (敲出优先)\n"
" • 敲入未敲出: 追保=承担完整亏损; 不追保=亏损按 margin × s00 封顶\n\n"
"敲入逐日监测; 敲出按固定间隔观察 (首次=锁定期后, 末次=到期日).\n"
"每期降敲(ko_step>0)时 KO 自期初值逐期递减, 越往后越易敲出.\n"
"卖方 (持有者) 短 vega/gamma、正 theta; 现价 ↗ 近 KO 时\n"
"Delta 与 Gamma 易出现剧烈跳变 (敲出悬崖)."
),
}
# ============================================================
# 主窗口
# ============================================================
class BacktestApp(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()
# 会话内第一个 close-to-close 快照固定为回测对比基准;只有删除它后
# 才会按保留顺序接替下一条 close-to-close,避免基准随勾选结果漂移。
self._saved_comparison_baseline_id = None
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
self._pending_history_retain_name = None
self._pending_history_retain_origin = None
# 策略配色登记表:结果对比与策略优选共用,使同一策略跨页同色。
self._strategy_style_registry = None
# 策略优选批量联动:把勾选候选逐条在当前单次回测路径上验证,
# 完成后送入结果池,使优选结论可直接在其它展示页面对照。
self._history_batch_queue = []
self._history_batch_total = 0
self._history_batch_done = 0
self._history_batch_failures = []
self._history_batch_position = None
self._history_batch_result_ids = []
self._history_batch_id = None
# 上一批验证的收尾结论,供优选页结果条按需展开与回跳。
self._history_batch_failure_details = []
self._history_batch_last_result_ids = []
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)
title_font = (_UI_FONT_FAMILY, 18, "bold")
subtitle_font = (_UI_FONT_FAMILY, 10)
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("Title.TLabel",
background=PALETTE["bg"],
foreground=PALETTE["text"],
font=title_font)
style.configure("Subtitle.TLabel",
background=PALETTE["bg"],
foreground=PALETTE["text_muted"],
font=subtitle_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"],
focusthickness=0,
padding=(10, 6),
relief="flat")
style.map("TButton",
background=[("active", PALETTE["border_soft"]),
("pressed", PALETTE["border"])],
bordercolor=[("active", PALETTE["primary"])])
# 行内小按钮 (如 CSV 的「浏览…」): 纵向内边距与输入框对齐, 同高
style.configure("Field.TButton", padding=(10, 4))
# 主色按钮 (运行)
style.configure("Run.TButton",
font=run_font,
foreground="white",
background=PALETTE["primary"],
bordercolor=PALETTE["primary"],
padding=(14, 8),
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, 6),
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["surface"],
lightcolor=PALETTE["surface"],
darkcolor=PALETTE["surface"],
focusthickness=0,
padding=(10, 6),
relief="flat")