chainview 0.1.2

Terminal UI for option chains, Greeks and volatility - real-time market data and backtest replay in your terminal.
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
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
//! The chain-matrix screen (strikes × call/put: bid/ask/mark/IV/Greeks)
//! (`docs/05-views-and-ux.md` §2.1, §6, §8; `docs/01-domain-model.md` §8).
//!
//! # States first, then the happy path
//!
//! [`draw`] renders the **loading**, **empty**, and provider-**error** states
//! before the populated matrix (the ChainView states-first agent-workflow rule,
//! `CLAUDE.md`), driven off [`LiveState::load`](crate::LiveState) and the store's
//! emptiness/health per the `docs/05` §2.1 prerequisite/recovery matrix:
//!
//! - [`ScreenLoad::Loading`] → a centered tick-driven spinner + "connecting to
//!   `<provider>`…".
//! - [`ScreenLoad::Ready`] with an empty chain → "no data for `<underlying>
//!   <expiry>`" + a hint.
//! - [`ScreenLoad::Error`] → the actionable message + the `r` reconnect
//!   affordance.
//!
//! A live feed that drops does **not** blank the screen: the last chain renders
//! **dimmed** with a `◐ stale` / `↻ reconnecting (n)` badge
//! ([`health_span`](crate::ui::theme::health_span)).
//!
//! # The draw path is pure
//!
//! [`draw`] takes `&LiveState` (never `&mut`) plus the `Copy` resolved [`Theme`]
//! and tick counter, and projects [`ChainRow`]/[`LegView`] view models **at draw
//! time**, borrowed from the store's [`OptionChain`] — no computation, no pricing,
//! no `GraphData`, no I/O, no state mutation (`docs/02-tui-architecture.md` §7).
//! View models are the only place display formatting happens; the domain stays
//! numeric (`docs/01-domain-model.md` §8).
//!
//! # Projection is honest: per-field precedence, `—` never a fabricated `0`
//!
//! Each Greek is resolved by the per-field precedence of `docs/01-domain-model.md`
//! §7 ([`resolve_leg`]): `delta` prefers the venue per-leg value and falls back to
//! the local sidecar; `gamma` comes from the style-keyed [`LegGreeks`]
//! (venue-or-local per its origin); `theta`/`vega` are always locally computed.
//! `iv` has a two-level precedence ([`resolve_iv`]): a per-style **venue** IV from
//! the sidecar, then a **locally computed** sidecar IV. Since #83 the Deribit adapter
//! seeds a per-style venue IV for BOTH legs at assembly, so the #25 interim's
//! call-only shared `OptionData::implied_volatility` fallback is **gone** — each leg
//! resolves its OWN IV from the style-keyed sidecar, so there is no call/put IV
//! collision and the put no longer renders `—` at seed. A field is `Some` only
//! when a real value resolved — projection never invents one, and a missing value
//! renders `—` (an em dash), never a fabricated `0` (`docs/01-domain-model.md` §5,
//! §7, §8).
//!
//! Two honesty guards on IV (both documented at [`project_iv`]/[`project_local_iv`]):
//! an IV of **exactly zero** is the venue's absent-IV sentinel (the upstream
//! `OptionChain::add_option` takes a **non-`Option`** IV, so an absent IV defaults
//! to `Positive::ZERO`), so a bare `0` IV projects to `None` and renders `—`; and a
//! **locally computed** IV below [`MIN_PLAUSIBLE_LOCAL_IV`] (0.5%) is economically
//! implausible for a live quote — the same reasoning as the exact-zero sentinel — so
//! it is cleared to `—` rather than painting a fabricated-looking near-zero
//! percentage. A **venue** (`Provider`) IV is trusted and never floored.
//!
//! The origin glyph (`~`) badges the **actual computed cell** — an `iv`/`gamma`/
//! `theta`/`vega`/`delta` value whose resolved origin is
//! [`GreeksOrigin::ComputedLocally`] — never the trustworthy venue field beside it,
//! so a mixed-origin row (venue delta + local theta) badges the local theta, not the
//! delta. The row-level [`greeks_origin`](LegView::greeks_origin) still rolls up to
//! [`GreeksOrigin::ComputedLocally`] whenever any present field is local.
//!
//! # Color is never the only signal
//!
//! The shared strike column shades by the `K/S` [`StrikeRelation`] bucket (not an
//! ITM/OTM label) and carries the `◀ATM` marker on the nearest listed strike; the
//! bid/ask cells carry a `▲`/`▼`/`·` tick-direction glyph; the stale badge pairs a
//! glyph with text — all legible under `NO_COLOR`
//! (`docs/05-views-and-ux.md` §7, `CLAUDE.md` accessibility policy).

use chrono::{DateTime, Datelike, Utc};
use crossterm::event::KeyEvent;
use optionstratlib::OptionStyle;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{Decimal, Positive};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Cell, Paragraph, Row, Table};

use crate::app::keymap::{ChainAction, KeyChord, resolve_chain};
use crate::app::{LegFocus, LiveState, ScreenLoad};
use crate::chain::{
    ChainStore, GreeksOrigin, InstrumentKey, LegGreeks, MIN_PLAUSIBLE_LOCAL_IV, StreamHealth,
    TickDir,
};
use crate::event::AppEvent;
use crate::ui::theme::{
    GreekColumn, GreekColumns, StrikeRelation, Theme, greek_columns_for_slots, health_span,
    sanitize, spinner_frame, strike_relation_marker_span, tick_dir_span,
};

// ===========================================================================
// View models — projected at draw time, borrowed from the store, never owned.
// ===========================================================================

/// One option leg (a call or a put) at one strike, projected from an
/// [`OptionData`] and the store's style-keyed analytics sidecar at draw time
/// (`docs/01-domain-model.md` §7, §8).
///
/// Each analytic field is resolved by the per-field precedence of §7 (`resolve_leg`):
/// `delta` prefers the venue value (`OptionData::delta_call` / `delta_put`) and
/// falls back to the local sidecar; `gamma` comes from the style-keyed [`LegGreeks`]
/// (venue-or-local per its origin); `iv` follows the two-level `resolve_iv`
/// precedence (per-style venue → locally computed); `theta`/`vega` are always locally
/// computed. A field is `Some`
/// only when a real value resolved — projection never invents one, and a `None` field
/// renders `—`, never a fabricated `0` (`project_iv`/`project_local_iv` also clear the
/// venue's absent-IV zero sentinel and a sub-plausibility local IV to `None`).
///
/// Each resolvable-from-venue-or-local field carries its resolved [`GreeksOrigin`]
/// so the origin glyph badges the **actual computed cell**, not a trustworthy venue
/// cell beside it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LegView {
    /// Best bid, or `None` when the feed omits it (renders `—`).
    pub bid: Option<Positive>,
    /// Best ask, or `None` when the feed omits it (renders `—`).
    pub ask: Option<Positive>,
    /// Mid/mark price, or `None` when a side is missing (renders `—`).
    pub mark: Option<Positive>,
    /// Implied volatility resolved by `resolve_iv`, or `None` when unavailable, the
    /// venue's absent-IV zero sentinel (`project_iv`), **or** a sub-plausibility
    /// locally-computed value (`project_local_iv`) — renders `—`, never `0.00%`.
    pub iv: Option<Positive>,
    /// Where the resolved `iv` came from: [`GreeksOrigin::Provider`] for a per-style
    /// venue IV, [`GreeksOrigin::ComputedLocally`] for a local inversion. Drives the
    /// origin glyph on the IV cell (only when `iv` is `Some` and local).
    pub iv_origin: GreeksOrigin,
    /// Delta — the venue per-leg value when present, else the local sidecar
    /// fallback; `None` when neither resolved (renders `—`).
    pub delta: Option<Decimal>,
    /// Where the resolved `delta` came from: [`GreeksOrigin::Provider`] for the venue
    /// per-leg value, [`GreeksOrigin::ComputedLocally`] for the local fallback. Drives
    /// the origin glyph on the delta cell (only when `delta` is `Some` and local).
    pub delta_origin: GreeksOrigin,
    /// Gamma from the style-keyed sidecar (venue-or-local), or `None` (renders `—`).
    pub gamma: Option<Decimal>,
    /// Where the resolved `gamma` came from (venue-or-local per its sidecar origin).
    /// Drives the origin glyph on the gamma cell (only when `gamma` is `Some` and
    /// local).
    pub gamma_origin: GreeksOrigin,
    /// Theta from the style-keyed sidecar — always locally computed; `None` until
    /// the local fill runs (renders `—`). Badged with the origin glyph whenever
    /// present.
    pub theta: Option<Decimal>,
    /// Vega from the style-keyed sidecar — always locally computed; `None` until
    /// the local fill runs (renders `—`). Badged with the origin glyph whenever
    /// present.
    pub vega: Option<Decimal>,
    /// Where this leg's rendered Greeks came from, rolled up across the resolved
    /// fields: [`GreeksOrigin::ComputedLocally`] when **any** present field is
    /// locally computed (so a mixed-origin row — venue delta + local vega — is
    /// honestly labelled), else [`GreeksOrigin::Provider`]. The per-cell origin
    /// glyph is driven by the per-field origins above, not by this rollup.
    pub greeks_origin: GreeksOrigin,
    /// The decayed last-tick direction of the bid, read from the store's retained
    /// baseline (`▲`/`▼`/`·`), cleared to `Flat` when the feed goes stale.
    pub bid_dir: TickDir,
    /// The decayed last-tick direction of the ask.
    pub ask_dir: TickDir,
}

impl LegView {
    /// Whether the given greek column's **present** resolved value is a
    /// locally-computed one — the per-cell origin-glyph predicate. `theta`/`vega`
    /// are always [`GreeksOrigin::ComputedLocally`], so they badge whenever present;
    /// `delta`/`gamma` badge only when their resolved origin is local. A `None` field
    /// is never local (an em dash is never badged).
    #[must_use]
    fn greek_is_local(&self, greek: GreekColumn) -> bool {
        match greek {
            GreekColumn::Delta => {
                self.delta.is_some() && matches!(self.delta_origin, GreeksOrigin::ComputedLocally)
            }
            GreekColumn::Gamma => {
                self.gamma.is_some() && matches!(self.gamma_origin, GreeksOrigin::ComputedLocally)
            }
            GreekColumn::Theta => self.theta.is_some(),
            GreekColumn::Vega => self.vega.is_some(),
        }
    }

    /// Whether the **present** resolved `iv` is locally computed — the IV cell's
    /// origin-glyph predicate. A venue (`Provider`) IV and a `None` IV are never
    /// badged.
    #[must_use]
    fn iv_is_local(&self) -> bool {
        self.iv.is_some() && matches!(self.iv_origin, GreeksOrigin::ComputedLocally)
    }
}

/// One strike row of the chain matrix — the call and put legs plus the shared,
/// option-style-**independent** `K/S` relation that shades the strike column
/// (`docs/01-domain-model.md` §8).
///
/// [`strike_relation`](ChainRow::strike_relation) is deliberately **not** an
/// ITM/OTM label: a call and a put at one strike have opposite ITM/OTM status, so
/// no single label on a shared strike row can be truthful.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChainRow {
    /// The strike price.
    pub strike: Positive,
    /// The call leg at this strike.
    pub call: LegView,
    /// The put leg at this strike.
    pub put: LegView,
    /// Where the strike sits relative to spot (`K/S`) — shades the strike column.
    pub strike_relation: StrikeRelation,
}

/// Project the **call** leg from an [`OptionData`] and its style-keyed
/// [`LegGreeks`], resolving each Greek by the §7 precedence ([`resolve_leg`]). The
/// venue delta is [`OptionData::delta_call`]; `iv`/`gamma`/`theta`/`vega` come from
/// the style-keyed sidecar. Since #83 the Deribit adapter seeds a per-style venue IV
/// for BOTH legs at assembly, so the call leg reads its own venue IV from the sidecar
/// — the lossy call-side `OptionData::implied_volatility` fallback is no longer
/// threaded in.
#[must_use]
fn project_call(
    od: &OptionData,
    leg: Option<&LegGreeks>,
    bid_dir: TickDir,
    ask_dir: TickDir,
) -> LegView {
    resolve_leg(
        od.call_bid,
        od.call_ask,
        od.call_middle,
        od.delta_call,
        leg,
        (bid_dir, ask_dir),
    )
}

/// Project the **put** leg from an [`OptionData`] and its style-keyed
/// [`LegGreeks`]. The venue delta is [`OptionData::delta_put`]; `iv`/`gamma` are the
/// **put** sidecar entry — split per style, so an unequal call/put iv/gamma both
/// survive (`docs/01-domain-model.md` §7), unlike the shared upstream fields. The put
/// now reads its own per-style venue IV from the sidecar (seeded at assembly, #83),
/// so — like the call — it resolves IV through the sidecar alone.
#[must_use]
fn project_put(
    od: &OptionData,
    leg: Option<&LegGreeks>,
    bid_dir: TickDir,
    ask_dir: TickDir,
) -> LegView {
    resolve_leg(
        od.put_bid,
        od.put_ask,
        od.put_middle,
        od.delta_put,
        leg,
        (bid_dir, ask_dir),
    )
}

/// Resolve one leg's [`LegView`] by the per-field §7 precedence — a **pure** read
/// over the venue row fields and the style-keyed sidecar [`LegGreeks`], inventing
/// nothing and pricing nothing:
///
/// - **delta**: the venue per-leg value (origin `Provider`) when present, else the
///   local sidecar `delta` (origin `ComputedLocally`).
/// - **iv**: the two-level [`resolve_iv`] precedence — a per-style venue sidecar IV,
///   then a floored local inversion.
/// - **gamma**: the sidecar `gamma` with its origin (venue-or-local per style).
/// - **theta/vega**: the sidecar values — always locally computed.
///
/// [`greeks_origin`](LegView::greeks_origin) rolls up to
/// [`GreeksOrigin::ComputedLocally`] when any resolved, present field is local. Each
/// field also carries its own resolved [`GreeksOrigin`], so the per-cell origin glyph
/// badges the actual computed cell. A field that resolves to `None` renders `—`.
#[must_use]
fn resolve_leg(
    bid: Option<Positive>,
    ask: Option<Positive>,
    mark: Option<Positive>,
    venue_delta: Option<Decimal>,
    leg: Option<&LegGreeks>,
    dirs: (TickDir, TickDir),
) -> LegView {
    let (bid_dir, ask_dir) = dirs;
    // delta: venue first, else the local sidecar fallback.
    let (delta, delta_origin) = match venue_delta {
        Some(value) => (Some(value), GreeksOrigin::Provider),
        None => match leg.and_then(|g| g.delta) {
            Some(value) => (Some(value), GreeksOrigin::ComputedLocally),
            None => (None, GreeksOrigin::Provider),
        },
    };
    // iv: the two-level precedence (per-style sidecar-venue → local floored).
    let (iv, iv_origin) = resolve_iv(leg);
    // gamma: the style-keyed sidecar, venue-or-local per its origin.
    let (gamma, gamma_origin) = match leg.and_then(|g| g.gamma.map(|value| (value, g.gamma_origin)))
    {
        Some((value, origin)) => (Some(value), origin),
        None => (None, GreeksOrigin::Provider),
    };
    // theta / vega: always locally computed when present.
    let theta = leg.and_then(|g| g.theta);
    let vega = leg.and_then(|g| g.vega);
    // Roll the origin up: any present, locally-computed field labels the row local.
    let delta_local = delta.is_some() && matches!(delta_origin, GreeksOrigin::ComputedLocally);
    let iv_local = iv.is_some() && matches!(iv_origin, GreeksOrigin::ComputedLocally);
    let gamma_local = gamma.is_some() && matches!(gamma_origin, GreeksOrigin::ComputedLocally);
    let any_local = delta_local || iv_local || gamma_local || theta.is_some() || vega.is_some();
    let greeks_origin = if any_local {
        GreeksOrigin::ComputedLocally
    } else {
        GreeksOrigin::Provider
    };
    LegView {
        bid,
        ask,
        mark,
        iv,
        iv_origin,
        delta,
        delta_origin,
        gamma,
        gamma_origin,
        theta,
        vega,
        greeks_origin,
        bid_dir,
        ask_dir,
    }
}

/// Resolve one leg's IV by the two-level §7 precedence, returning the value and its
/// resolved [`GreeksOrigin`] (a **pure** read — no pricing):
///
/// 1. **per-style venue IV** from the sidecar ([`LegGreeks::iv`] with origin
///    `Provider`), routed through [`project_iv`] (only the exact-zero absent sentinel
///    clears a venue IV — it is never floored);
/// 2. **locally computed** sidecar IV ([`LegGreeks::iv`] with origin
///    `ComputedLocally`), routed through [`project_local_iv`] so a sub-plausibility
///    near-zero degrades to `None`, origin `ComputedLocally`.
///
/// The sidecar IV carries exactly one origin, so the two levels are mutually
/// exclusive. Since #83 the Deribit adapter seeds a per-style venue IV for BOTH legs
/// at assembly, so the call-only shared `OptionData::implied_volatility` fallback the
/// #25 interim used is gone — each leg resolves its OWN IV from the sidecar (no more
/// call/put IV collision, and the put no longer renders `—` at seed).
#[must_use]
fn resolve_iv(leg: Option<&LegGreeks>) -> (Option<Positive>, GreeksOrigin) {
    if let Some((value, origin)) = leg.and_then(|g| g.iv.map(|v| (v, g.iv_origin))) {
        match origin {
            // A per-style VENUE IV wins outright (only the absent-zero sentinel clears).
            GreeksOrigin::Provider => {
                if let Some(iv) = project_iv(value) {
                    return (Some(iv), GreeksOrigin::Provider);
                }
                // exact-zero venue sentinel: no usable IV -> `—`.
            }
            // A LOCAL sidecar IV is subject to the plausibility floor.
            GreeksOrigin::ComputedLocally => {
                return (project_local_iv(value), GreeksOrigin::ComputedLocally);
            }
        }
    }
    (None, GreeksOrigin::Provider)
}

/// Project the non-`Option` [`OptionData::implied_volatility`] into a
/// `LegView.iv` **honestly** (the "Absent-IV vs 0% IV" decision from #15).
///
/// The upstream `OptionChain::add_option` takes a **non-`Option`** `Positive` IV,
/// so the Deribit adapter defaults an absent IV to `Positive::ZERO` — a row that
/// carries `0` cannot distinguish "venue sent no IV" from a genuine "IV = 0". A
/// listed option that is being quoted always has a strictly positive IV (a zero IV
/// prices the option at pure intrinsic, economically implausible for a live
/// quote), so a bare `0` is the absent-sentinel, **not** a real quote. This
/// projects it to `None` — the matrix renders `—`, exactly the honesty the Greeks
/// columns use — so a fabricated-looking `0.00%` never renders as a live IV. A
/// strictly positive IV projects to `Some(iv)`.
///
/// This guard clears the exact-zero **venue** sentinel; [`project_local_iv`] adds the
/// stronger sub-plausibility floor that applies to **locally computed** IVs only.
#[must_use]
fn project_iv(iv: Positive) -> Option<Positive> {
    if iv.is_zero() { None } else { Some(iv) }
}

/// Project a **locally computed** IV honestly: clear both the venue absent-zero
/// sentinel ([`project_iv`]) **and** any value below the plausibility floor
/// [`MIN_PLAUSIBLE_LOCAL_IV`] to `None`, so a mispriced near-zero local inversion
/// degrades to `—` instead of painting a fake percentage. A value at or above the
/// floor (e.g. a legitimate IG-equities local IV, always ≫ 0.5%) projects to
/// `Some(iv)`. This applies to `ComputedLocally`-origin IVs only — a venue IV is
/// trusted via [`project_iv`] and never floored.
#[must_use]
fn project_local_iv(iv: Positive) -> Option<Positive> {
    match project_iv(iv) {
        Some(value) if value.to_dec() >= MIN_PLAUSIBLE_LOCAL_IV => Some(value),
        _ => None,
    }
}

/// Project one strike row: both legs plus the shared `K/S` relation.
///
/// The direction indicators are read from the store's retained/decayed baseline as
/// of `as_of` — the tick-stamped wall clock threaded in from [`App::now`], so a
/// marker decays on wall-time while `draw` itself reads no wall clock; `None` (no
/// reference instant) yields `Flat`. The per-leg Greeks come from the store's
/// cached style-keyed sidecar ([`ChainStore::leg_greeks`]) — a read, never a
/// recompute. Building the per-leg [`InstrumentKey`] clones the (short)
/// underlying ticker, which is why projection runs for the **visible** rows only.
///
/// [`App::now`]: crate::app::App::now
#[must_use]
fn project_row(
    od: &OptionData,
    spot: Positive,
    store: &ChainStore,
    underlying: &str,
    expiration: DateTime<Utc>,
    as_of: Option<DateTime<Utc>>,
) -> ChainRow {
    let strike = od.strike_price;
    let call_key = leg_key(underlying, expiration, strike, OptionStyle::Call);
    let put_key = leg_key(underlying, expiration, strike, OptionStyle::Put);
    let (call_bid_dir, call_ask_dir) = leg_dirs(store, &call_key, as_of);
    let (put_bid_dir, put_ask_dir) = leg_dirs(store, &put_key, as_of);
    ChainRow {
        strike,
        call: project_call(od, store.leg_greeks(&call_key), call_bid_dir, call_ask_dir),
        put: project_put(od, store.leg_greeks(&put_key), put_bid_dir, put_ask_dir),
        strike_relation: StrikeRelation::classify(strike, spot),
    }
}

/// The store key for one `(underlying, expiry, strike, style)` leg — the read key
/// for both the direction baseline and the analytics sidecar.
#[must_use]
fn leg_key(
    underlying: &str,
    expiration: DateTime<Utc>,
    strike: Positive,
    style: OptionStyle,
) -> InstrumentKey {
    InstrumentKey {
        underlying: underlying.to_owned(),
        expiration_utc: expiration,
        strike,
        style,
    }
}

/// The `(bid_dir, ask_dir)` for one leg as of `as_of`, read from the store's
/// decayed baseline. Both are `Flat` when there is no reference instant.
#[must_use]
fn leg_dirs(
    store: &ChainStore,
    key: &InstrumentKey,
    as_of: Option<DateTime<Utc>>,
) -> (TickDir, TickDir) {
    let Some(now) = as_of else {
        return (TickDir::Flat, TickDir::Flat);
    };
    (store.bid_dir(key, now), store.ask_dir(key, now))
}

// ===========================================================================
// The draw entry point + the loading / empty / error states (states first).
// ===========================================================================

/// Draw the chain matrix for the live `state` into `area` — a pure render
/// (`docs/02-tui-architecture.md` §7). The empty/loading/error states render
/// before the populated matrix (the states-first rule); the store is borrowed,
/// never recomputed. `theme` (resolved, `NO_COLOR`-aware), `tick` (for the loading
/// spinner), and `now` (the tick-stamped wall clock the tick-direction markers
/// decay against) are all `Copy`, so purity holds — `draw` reads `now`, never a
/// wall clock.
pub fn draw(
    state: &LiveState,
    frame: &mut Frame,
    area: Rect,
    theme: Theme,
    tick: u64,
    now: DateTime<Utc>,
) {
    let chain = state.store.chain();
    match &state.load {
        ScreenLoad::Loading => {
            // A consistent two-line body (primary + secondary hint), matching the
            // empty/error states, vertically centered so it reads as a deliberate
            // state rather than content that failed to fill.
            draw_state_body(
                frame,
                area,
                theme,
                Text::from(vec![
                    Line::from(Span::styled(
                        format!(
                            "{} connecting to {}…",
                            spinner_frame(tick),
                            sanitize(state.source.provider.as_str())
                        ),
                        theme.accent(),
                    )),
                    Line::from(Span::styled("waiting for the first chain", theme.dim())),
                ]),
            );
        }
        ScreenLoad::Error { message } => {
            draw_state_body(
                frame,
                area,
                theme,
                Text::from(vec![
                    Line::from(Span::styled(
                        format!("! {}", sanitize(message)),
                        theme.warning(),
                    )),
                    Line::from(Span::styled("press r to reconnect", theme.dim())),
                ]),
            );
        }
        ScreenLoad::Ready => {
            if chain.options.is_empty() {
                draw_state_body(
                    frame,
                    area,
                    theme,
                    Text::from(vec![
                        Line::from(Span::styled(
                            format!(
                                "no data for {} {}",
                                sanitize(&chain.symbol),
                                sanitize(&chain.get_expiration_date())
                            ),
                            theme.dim(),
                        )),
                        Line::from(Span::styled(
                            "no strikes yet - press r to reconnect",
                            theme.dim(),
                        )),
                    ]),
                );
            } else {
                draw_matrix(state, frame, area, theme, now);
            }
        }
    }
}

/// Draw a state body (loading / empty / error) inside the framed "Chain" block,
/// **vertically centered** in the available height and horizontally centered — a
/// first-class, deliberate-looking state, never a blank void or a top-anchored
/// fragment. All three states share this two-line baseline.
fn draw_state_body(frame: &mut Frame, area: Rect, theme: Theme, text: Text<'static>) {
    let block = Block::bordered().title(Span::styled("Chain", theme.accent()));
    let inner = block.inner(area);
    frame.render_widget(block, area);
    // Reserve exactly the text height and center it in the body; `Flex::Center`
    // does the geometry, so there is no manual arithmetic (and no `saturating_*`).
    let height = u16::try_from(text.height())
        .unwrap_or(u16::MAX)
        .min(inner.height);
    let [centered] = Layout::vertical([Constraint::Length(height)])
        .flex(Flex::Center)
        .areas(inner);
    frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), centered);
}

// ===========================================================================
// The populated matrix.
// ===========================================================================

/// The width (columns) of a numeric cell.
const NUM_W: u16 = 8;
/// The width (columns) of the shared center strike cell (fits `◀ATM`).
const STRIKE_W: u16 = 10;
/// Approximate width consumed by the always-present columns (10 numeric cells +
/// the strike cell + inter-column spacing) — the base of the greek-slot budget.
const BASE_W: u16 = 100;
/// Approximate width one optional greek **slot** costs (one numeric cell per
/// side + spacing).
const SLOT_W: u16 = 18;

/// Draw the populated strike × call/put matrix with ATM anchoring, the shaded
/// strike column, responsive greek columns, and the stale/reconnecting badge.
///
/// `now` is the tick-stamped wall clock the tick-direction markers decay against
/// (`docs/01-domain-model.md` §6); it is read here, never a wall clock.
fn draw_matrix(state: &LiveState, frame: &mut Frame, area: Rect, theme: Theme, now: DateTime<Utc>) {
    let store = &state.store;
    let chain = store.chain();
    let spot = chain.underlying_price;
    let health = store.health();
    let stale = !matches!(health, StreamHealth::Live);
    // The tick-direction indicators decay against the tick-stamped wall clock, so a
    // bid-up/ask-down marker fades ~3 s after its last change on wall-time — NOT
    // pinned to `last_full_poll`, which would freeze the marker until the next poll.
    let as_of = Some(now);

    // The underlying/expiry for the per-leg InstrumentKey come from the store's
    // canonical chain key (absolute UTC), not the display strings.
    let key = store.chain_key();
    let underlying = key.1.clone();
    let expiration = key.2;

    let block = Block::bordered().title(matrix_title(chain, expiration, health, theme));
    let inner = block.inner(area);

    // The mandatory column set (strike + bid/ask/mark + IV + Δ) needs BASE_W inner
    // cols; below that the table would clip into a corrupt chain, so show an honest
    // "widen the terminal" state instead (`docs/05-views-and-ux.md` §8). Greek
    // columns still drop responsively ABOVE this floor via `greek_slots_for_width`,
    // and Δ stays present in every rendered chain (the theme-layer invariant).
    if inner.width < BASE_W {
        draw_state_body(
            frame,
            area,
            theme,
            Text::from(vec![
                Line::from(Span::styled("chain needs a wider terminal", theme.dim())),
                Line::from(""),
                Line::from(Span::styled(
                    format!("widen to at least {} cols", BASE_W + 2),
                    theme.dim(),
                )),
            ]),
        );
        return;
    }

    // v0.2 column set: Δ (always) plus the optional Γ/ν/Θ that fit at this width,
    // dropped in the `Γ → ν → Θ` order the #14 `greek_columns_for_slots` primitive
    // fixes (Θ retained first, Γ last) — now that the style-keyed analytics sidecar
    // populates all of them per leg.
    let greek_cols = greek_columns_for_slots(greek_slots_for_width(inner.width));
    let plan = columns(greek_cols);
    let widths: Vec<Constraint> = plan.iter().map(|col| col_width(*col)).collect();
    // The body height is the inner height minus the two-row header (the Calls/Puts
    // super-header line plus the per-column label line).
    let visible = floor_sub(usize::from(inner.height), 2);

    let strikes: Vec<&OptionData> = chain.options.iter().collect();
    let len = strikes.len();
    // The ATM index is cached off-draw on `LiveState` (recomputed only on a poll),
    // so the per-frame cost stays O(visible rows), not O(full ladder).
    let atm = state.atm_index();
    let anchor = clamp_anchor(state.selection.focused_row, atm, len);
    // The explicit user cursor (clamped to the current chain), distinct from the
    // ATM anchor used for scrolling before any row is focused.
    let selected = state
        .selection
        .focused_row
        .and_then(|row| (row < len).then_some(row));
    let start = window_start(anchor.unwrap_or(0), visible, len);

    let header = header_row(&plan, theme);
    let mut rows: Vec<Row> = Vec::with_capacity(visible.min(len));
    for (idx, od) in strikes.iter().enumerate().skip(start).take(visible) {
        let od: &OptionData = od;
        let row = project_row(od, spot, store, &underlying, expiration, as_of);
        let is_atm = atm == Some(idx);
        let is_selected = selected == Some(idx);
        let cells: Vec<Cell> = plan
            .iter()
            .map(|col| {
                col_cell(
                    *col,
                    &row,
                    theme,
                    is_selected,
                    state.selection.focused_leg,
                    is_atm,
                )
            })
            .collect();
        let mut table_row = Row::new(cells);
        if is_selected {
            table_row = table_row.style(Style::new().add_modifier(Modifier::BOLD));
        }
        rows.push(table_row);
    }

    let mut table = Table::new(rows, widths)
        .header(header)
        .block(block)
        .column_spacing(1);
    // Never blank on a dropped stream: the last chain renders dimmed, the badge in
    // the title carries the honest state.
    if stale {
        table = table.style(theme.dim());
    }
    frame.render_widget(table, area);
}

/// The block title `<symbol>  exp <date>  spot <S>`, with the stream-health badge
/// appended when the feed is not live (`◐ stale` / `↻ reconnecting (n)`).
///
/// The venue-controlled `symbol` is sanitized at this render edge; the expiry is
/// formatted from the canonical [`DateTime<Utc>`] as a bare date (every listed
/// contract settles at 08:00 UTC, so the time/offset are noise), so it carries no
/// venue bytes.
#[must_use]
fn matrix_title(
    chain: &OptionChain,
    expiration: DateTime<Utc>,
    health: &StreamHealth,
    theme: Theme,
) -> Line<'static> {
    let mut spans = vec![
        Span::styled(sanitize(&chain.symbol), theme.accent()),
        Span::raw(format!("  exp {}", fmt_expiry_date(expiration))),
        Span::raw(format!("  spot {}", fmt_strike(chain.underlying_price))),
    ];
    if !matches!(health, StreamHealth::Live) {
        spans.push(Span::raw("  "));
        spans.push(health_span(health, theme));
    }
    Line::from(spans)
}

/// The number of optional greek **slots** (0..=3) that fit at `width` — the budget
/// fed to the `Γ → ν → Θ` drop order (`greek_columns_for_slots`, `theme.rs`): `0`
/// keeps Δ only, `1` adds Θ, `2` adds ν, `3` adds Γ (Δ is always present). A rough,
/// truncation-safe estimate: below the budget the matrix keeps only Δ and the
/// price/IV columns and the [`Table`] clips gracefully.
#[must_use]
fn greek_slots_for_width(width: u16) -> usize {
    // `a - b` floored at zero; `max` guarantees the subtraction never underflows
    // (the ruleset bans `saturating_sub`, and `checked_sub(..).unwrap_or(0)` trips
    // clippy's manual-saturating lint).
    let over = width.max(BASE_W) - BASE_W;
    usize::from(over / SLOT_W).min(3)
}

/// `a - b` floored at zero, spelled so it can never underflow — the ruleset bans
/// `saturating_sub` and `checked_sub(..).unwrap_or(0)` trips clippy's
/// manual-saturating lint, so the floor is `a.max(b) - b`.
#[must_use]
fn floor_sub(a: usize, b: usize) -> usize {
    a.max(b) - b
}

/// The scroll anchor: the clamped user cursor if present, else the ATM index, else
/// row 0 — never an out-of-range index (`docs/02-tui-architecture.md`, `.get()` +
/// fallback discipline).
#[must_use]
fn clamp_anchor(focused: Option<usize>, atm: Option<usize>, len: usize) -> Option<usize> {
    if len == 0 {
        return None;
    }
    match focused {
        Some(row) if row < len => Some(row),
        // A poll shrank the chain under the cursor: fall back to the last row.
        Some(_) => Some(floor_sub(len, 1)),
        None => atm.or(Some(0)),
    }
}

/// The first visible row index so `anchor` stays on screen, clamped to `[0, len -
/// visible]`. Uses checked arithmetic only (no `saturating_*`, per the ruleset).
#[must_use]
fn window_start(anchor: usize, visible: usize, len: usize) -> usize {
    if visible == 0 || len <= visible {
        return 0;
    }
    let half = visible / 2;
    let ideal = floor_sub(anchor, half);
    let max_start = floor_sub(len, visible);
    ideal.min(max_start)
}

// ===========================================================================
// Column plan — one ordered list drives header, widths, and cells consistently.
// ===========================================================================

/// One column of the matrix. A single ordered [`columns`] list is derived from the
/// visible greek columns, and header labels, width constraints, and per-row cells
/// are all mapped from it, so they can never disagree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChainCol {
    /// A call-side greek column.
    CallGreek(GreekColumn),
    /// Call implied volatility.
    CallIv,
    /// Call bid (carries a tick-direction glyph).
    CallBid,
    /// Call ask (carries a tick-direction glyph).
    CallAsk,
    /// Call mark/mid.
    CallMark,
    /// The shared center strike column (shaded by relation, `◀ATM` marker).
    Strike,
    /// Put bid (carries a tick-direction glyph).
    PutBid,
    /// Put ask (carries a tick-direction glyph).
    PutAsk,
    /// Put mark/mid.
    PutMark,
    /// Put implied volatility.
    PutIv,
    /// A put-side greek column.
    PutGreek(GreekColumn),
}

/// Build the ordered v0.2 column plan: the call side mirrors the put side around
/// the center strike
/// (`Δ [Γ] [ν] [Θ] IV Bid Ask Mark | Strike | Bid Ask Mark IV [Θ] [ν] [Γ] Δ`). Δ is
/// always present; the optional Γ/ν/Θ are included per `greeks` — the responsive
/// set the `Γ → ν → Θ` drop order yields (Θ retained first, Γ last). The optional
/// greeks sit between Δ and IV on the call side and mirror on the put side, so the
/// plan stays a clean mirror at every width.
#[must_use]
fn columns(greeks: GreekColumns) -> Vec<ChainCol> {
    let mut plan = Vec::new();
    // Call side: Δ, then the optional greeks (outer→inner: Γ, ν, Θ), then IV/prices.
    plan.push(ChainCol::CallGreek(GreekColumn::Delta));
    if greeks.gamma {
        plan.push(ChainCol::CallGreek(GreekColumn::Gamma));
    }
    if greeks.vega {
        plan.push(ChainCol::CallGreek(GreekColumn::Vega));
    }
    if greeks.theta {
        plan.push(ChainCol::CallGreek(GreekColumn::Theta));
    }
    plan.push(ChainCol::CallIv);
    plan.push(ChainCol::CallBid);
    plan.push(ChainCol::CallAsk);
    plan.push(ChainCol::CallMark);
    plan.push(ChainCol::Strike);
    plan.push(ChainCol::PutBid);
    plan.push(ChainCol::PutAsk);
    plan.push(ChainCol::PutMark);
    plan.push(ChainCol::PutIv);
    // Put greeks mirror the call side (inner→outer: Θ, ν, Γ, then Δ outermost).
    if greeks.theta {
        plan.push(ChainCol::PutGreek(GreekColumn::Theta));
    }
    if greeks.vega {
        plan.push(ChainCol::PutGreek(GreekColumn::Vega));
    }
    if greeks.gamma {
        plan.push(ChainCol::PutGreek(GreekColumn::Gamma));
    }
    plan.push(ChainCol::PutGreek(GreekColumn::Delta));
    plan
}

/// The width constraint for a column (fixed, so the header and rows align).
#[must_use]
fn col_width(col: ChainCol) -> Constraint {
    match col {
        ChainCol::Strike => Constraint::Length(STRIKE_W),
        _ => Constraint::Length(NUM_W),
    }
}

/// The header label for a column.
#[must_use]
fn col_header(col: ChainCol) -> &'static str {
    match col {
        ChainCol::CallGreek(greek) | ChainCol::PutGreek(greek) => greek_label(greek),
        ChainCol::CallIv | ChainCol::PutIv => "IV",
        ChainCol::CallBid | ChainCol::PutBid => "Bid",
        ChainCol::CallAsk | ChainCol::PutAsk => "Ask",
        ChainCol::CallMark | ChainCol::PutMark => "Mark",
        ChainCol::Strike => "Strike",
    }
}

/// Which leg a column belongs to (`None` for the shared strike column) — drives
/// the leg-focus emphasis on the selected row.
#[must_use]
fn col_side(col: ChainCol) -> Option<LegFocus> {
    match col {
        ChainCol::CallGreek(_)
        | ChainCol::CallIv
        | ChainCol::CallBid
        | ChainCol::CallAsk
        | ChainCol::CallMark => Some(LegFocus::Call),
        ChainCol::PutGreek(_)
        | ChainCol::PutIv
        | ChainCol::PutBid
        | ChainCol::PutAsk
        | ChainCol::PutMark => Some(LegFocus::Put),
        ChainCol::Strike => None,
    }
}

/// The single-glyph label for a greek column.
#[must_use]
fn greek_label(greek: GreekColumn) -> &'static str {
    match greek {
        GreekColumn::Delta => "Δ",
        GreekColumn::Gamma => "Γ",
        GreekColumn::Vega => "ν",
        GreekColumn::Theta => "Θ",
    }
}

/// The `Calls` / `Puts` super-header marker for a column — placed on the two
/// `Mark` columns that flank the center `Strike`, so the mirror halves are labeled
/// unambiguously (`Calls  Strike  Puts`) without relying on color. Every other
/// column has no super-header text.
#[must_use]
fn group_label(col: ChainCol) -> &'static str {
    match col {
        ChainCol::CallMark => "Calls",
        ChainCol::PutMark => "Puts",
        _ => "",
    }
}

/// The two-line header row: a `Calls` / `Puts` super-header line above the
/// per-column labels, so which mirror half is calls vs puts is explicit (not a
/// guess from position). Numeric labels are right-aligned, `Strike` centered; the
/// super-header markers are centered over the `Mark` columns flanking the strike.
#[must_use]
fn header_row(plan: &[ChainCol], theme: Theme) -> Row<'static> {
    let cells: Vec<Cell> = plan
        .iter()
        .map(|col| {
            let align = if matches!(col, ChainCol::Strike) {
                Alignment::Center
            } else {
                Alignment::Right
            };
            let group = Line::from(group_label(*col)).alignment(Alignment::Center);
            let label = Line::from(col_header(*col)).alignment(align);
            Cell::from(Text::from(vec![group, label]))
        })
        .collect();
    Row::new(cells).height(2).style(theme.accent())
}

/// The cell for one column of one row, with leg-focus emphasis (an underline on
/// the focused leg's cells) applied on the selected row.
#[must_use]
fn col_cell(
    col: ChainCol,
    row: &ChainRow,
    theme: Theme,
    is_selected: bool,
    focused_leg: LegFocus,
    is_atm: bool,
) -> Cell<'static> {
    let cell = match col {
        ChainCol::CallGreek(greek) => greek_cell(&row.call, greek, theme),
        ChainCol::PutGreek(greek) => greek_cell(&row.put, greek, theme),
        ChainCol::CallIv => origin_num_cell(fmt_iv(row.call.iv), row.call.iv_is_local(), theme),
        ChainCol::PutIv => origin_num_cell(fmt_iv(row.put.iv), row.put.iv_is_local(), theme),
        ChainCol::CallBid => dir_cell(row.call.bid, row.call.bid_dir, theme),
        ChainCol::CallAsk => dir_cell(row.call.ask, row.call.ask_dir, theme),
        ChainCol::CallMark => num_cell(fmt_price(row.call.mark)),
        ChainCol::PutBid => dir_cell(row.put.bid, row.put.bid_dir, theme),
        ChainCol::PutAsk => dir_cell(row.put.ask, row.put.ask_dir, theme),
        ChainCol::PutMark => num_cell(fmt_price(row.put.mark)),
        ChainCol::Strike => strike_cell(row, theme, is_atm),
    };
    // The focused leg is underlined on the selected row — an intensity signal, so
    // it survives NO_COLOR and never fights the tick-direction color.
    match col_side(col) {
        Some(side) if is_selected && side == focused_leg => {
            cell.style(Style::new().add_modifier(Modifier::UNDERLINED))
        }
        _ => cell,
    }
}

/// A greek cell; it carries a subtle `~` origin glyph when **this specific field**
/// is ChainView's local computation ([`LegView::greek_is_local`]) — so on a
/// mixed-origin row the glyph badges the actual computed field (e.g. a local theta),
/// never the trustworthy venue field beside it (e.g. a venue delta), and a leg with
/// a local field but a `None` delta is still badged on that field. The glyph is an
/// intensity/text marker, so it survives `NO_COLOR` (color is never the only signal).
#[must_use]
fn greek_cell(leg: &LegView, greek: GreekColumn, theme: Theme) -> Cell<'static> {
    let value = match greek {
        GreekColumn::Delta => leg.delta,
        GreekColumn::Gamma => leg.gamma,
        GreekColumn::Vega => leg.vega,
        GreekColumn::Theta => leg.theta,
    };
    origin_num_cell(fmt_greek(value), leg.greek_is_local(greek), theme)
}

/// A right-aligned numeric cell that carries a trailing `~` origin glyph when
/// `local` — the single place the origin marker is painted, shared by the Greek
/// cells and the IV cell so they badge consistently. A non-local (venue/shared or
/// absent) value renders as a plain [`num_cell`]. The glyph is a text marker legible
/// under `NO_COLOR`.
#[must_use]
fn origin_num_cell(text: String, local: bool, theme: Theme) -> Cell<'static> {
    if local {
        let line = Line::from(vec![Span::raw(text), Span::styled("~", theme.warning())])
            .alignment(Alignment::Right);
        Cell::from(line)
    } else {
        num_cell(text)
    }
}

/// A right-aligned numeric cell.
#[must_use]
fn num_cell(text: String) -> Cell<'static> {
    Cell::from(Line::from(text).alignment(Alignment::Right))
}

/// A right-aligned price cell with a trailing tick-direction glyph (`▲`/`▼`/`·`) —
/// color-independent, so the direction reads under `NO_COLOR`.
///
/// A **missing** price carries no tick direction, so it renders just the em dash
/// (`—`) with no redundant trailing glyph.
#[must_use]
fn dir_cell(price: Option<Positive>, dir: TickDir, theme: Theme) -> Cell<'static> {
    if price.is_none() {
        return num_cell(fmt_price(price));
    }
    let line = Line::from(vec![
        Span::raw(fmt_price(price)),
        Span::raw(" "),
        tick_dir_span(dir, theme),
    ])
    .alignment(Alignment::Right);
    Cell::from(line)
}

/// The width (display columns) of the [`AT_SPOT_MARKER`](crate::ui::theme::AT_SPOT_MARKER)
/// `◀ATM`, reserved on every strike row so the ATM row does not left-shift its
/// digits out of the ladder.
const ATM_MARKER_W: usize = 4;

/// The shared center strike cell: the strike shaded by its `K/S` relation, with
/// the `◀ATM` marker on the nearest listed strike (both legible under `NO_COLOR`).
///
/// The marker's trailing width is reserved on **every** row (the marker, or an
/// equal-width blank), so the strike digits form a clean vertical ladder — the ATM
/// row no longer jogs the number left relative to the others.
#[must_use]
fn strike_cell(row: &ChainRow, theme: Theme, is_atm: bool) -> Cell<'static> {
    let mut spans = vec![
        Span::styled(
            fmt_strike(row.strike),
            theme.strike_relation_style(row.strike_relation),
        ),
        Span::raw(" "),
    ];
    if is_atm {
        spans.push(strike_relation_marker_span(StrikeRelation::AtSpot, theme));
    } else {
        spans.push(Span::raw(" ".repeat(ATM_MARKER_W)));
    }
    Cell::from(Line::from(spans).alignment(Alignment::Center))
}

// ===========================================================================
// Cell formatting — the ONE place display formatting happens; `—` never `0`.
// ===========================================================================

/// The em dash rendered for any value the provider did not supply — never a
/// fabricated `0` (`docs/01-domain-model.md` §5, §8).
const EM_DASH: &str = "—";

/// Format a price to two decimals, or `—` when absent. Guards the `Positive`
/// infinity sentinel so a non-finite value never paints (rule: guard `f64`
/// `NaN`/`Inf` before it reaches a widget).
#[must_use]
fn fmt_price(value: Option<Positive>) -> String {
    match value {
        Some(price) if price != Positive::MAX => format!("{price:.2}"),
        _ => EM_DASH.to_owned(),
    }
}

/// Format IV as a percentage to two decimals, or `—` when absent (including the
/// venue's absent-IV zero already resolved to `None` by [`project_iv`]).
///
/// The `× 100` uses **checked** multiplication: the adapter seam rejects
/// NaN/Inf/negative but **not** magnitude, so a finite-but-absurd IV (`> ~7.9e26`)
/// can survive a hostile/corrupt venue payload, and [`Decimal`]'s `Mul` **panics**
/// on overflow — a render-edge panic a pure draw must never risk (ADR-0007
/// untrusted-input hardening). On overflow it renders `—` rather than panicking.
#[must_use]
fn fmt_iv(value: Option<Positive>) -> String {
    match value {
        Some(iv) if iv != Positive::MAX => iv
            .to_dec()
            .checked_mul(Decimal::from(100))
            .map_or_else(|| EM_DASH.to_owned(), |pct| format!("{pct:.2}%")),
        _ => EM_DASH.to_owned(),
    }
}

/// The widest a formatted greek may be so it still fits the [`NUM_W`]-wide numeric
/// cell **with** the trailing `~` origin glyph a locally-computed greek carries
/// (`NUM_W - 1`). A signed multi-integer-digit greek at a fixed 4 dp (e.g. a
/// long-dated theta `-12.1322`, 8 chars) would otherwise overflow the cell and the
/// right-aligned line would clip its leading **sign**, rendering a negative theta as
/// a misleadingly positive number (surfaced by the #83 venue-IV-priced Greeks, which
/// are of realistic magnitude rather than the pre-#83 near-zero garbage that always
/// fit). `fmt_greek` scales precision down to fit, so the sign is never dropped.
const GREEK_MAX_CHARS: usize = (NUM_W as usize) - 1;

/// Format a greek to fit the numeric cell **without ever dropping its sign**, or `—`
/// when absent. A [`Decimal`] is fixed-point, so it carries no `NaN`/`Inf` to guard.
///
/// Small greeks (delta, gamma) keep the full four decimals; a larger-magnitude greek
/// (a long-dated theta/vega) trims fractional precision until the whole formatted
/// number fits [`GREEK_MAX_CHARS`], so the integer part and the leading `-` always
/// render — an honest value is never shown with the wrong sign at the render edge
/// (issue #83). This is a formatter concern the chain matrix owns; see `tui-expert`
/// for the column-width budget it complements.
#[must_use]
fn fmt_greek(value: Option<Decimal>) -> String {
    let Some(greek) = value else {
        return EM_DASH.to_owned();
    };
    // Prefer the most precise form that fits; fall back to the least precise (which,
    // for an implausibly huge greek, is still the best sign-preserving effort).
    for decimals in (0..=4usize).rev() {
        let formatted = format!("{greek:.decimals$}");
        if formatted.len() <= GREEK_MAX_CHARS {
            return formatted;
        }
    }
    format!("{greek:.0}")
}

/// Format an absolute-UTC expiry as a bare calendar date (`2025-06-27`) for the
/// matrix title — a display-edge formatter over the canonical [`DateTime<Utc>`]
/// (the domain stays a `DateTime`, not a display string). Built from the date
/// components, so it needs no `strftime`/locale.
#[must_use]
fn fmt_expiry_date(expiration: DateTime<Utc>) -> String {
    format!(
        "{:04}-{:02}-{:02}",
        expiration.year(),
        expiration.month(),
        expiration.day(),
    )
}

/// Format a strike, trailing zeros stripped (`Positive` `Display` normalizes), so
/// an integer strike reads `60000` and a fractional one keeps its places.
#[must_use]
fn fmt_strike(strike: Positive) -> String {
    if strike == Positive::MAX {
        return EM_DASH.to_owned();
    }
    format!("{}", strike.round_to(2))
}

// Every venue-controlled string that reaches this screen's render edge — the
// matrix title symbol/expiry, the empty-state underlying/expiry, the loading
// provider id, and the error message — is routed through the SINGLE shared
// [`sanitize`](crate::ui::theme::sanitize) (`src/ui/theme.rs`, hardened in #19),
// so the chain matrix and the status bar can never neutralize venue bytes
// differently (`docs/SECURITY.md` §6.4).

// ===========================================================================
// Key handling — resolved THROUGH the single keymap, no parallel table, no I/O.
// ===========================================================================

/// Handle a chain-local key, returning any follow-on [`AppEvent`] for the render
/// loop to fold (`docs/02-tui-architecture.md` §9). Pure — no I/O.
///
/// The chord resolves **through the single keybinding map**
/// ([`resolve_chain`](crate::resolve_chain), `src/app/keymap.rs`), so the chain
/// dispatch and the help overlay read one table and cannot drift — there is **no**
/// parallel key table here. Local navigation (strike cursor, leg focus) mutates
/// [`LiveState`] and returns `None` (the render loop detects the [`Selection`]
/// change and redraws, `docs/05-views-and-ux.md` §8). `a` appends the focused leg to
/// the payoff builder — the headline chain→`a`→builder gesture, sharing the Payoff
/// screen's [`append_focused_leg`](crate::ui::payoff) helper. Actions that need I/O
/// (multi-expiry subscribe, underlying switch, drill-in) are resolved but not yet
/// wired — never performed inline; they land with their data plumbing, exactly as the
/// replay screen defers its playback actions.
///
/// [`Selection`]: crate::Selection
#[must_use]
pub fn handle_key(state: &mut LiveState, key: KeyEvent) -> Option<AppEvent> {
    let chord = KeyChord::from_event(key)?;
    match resolve_chain(chord)? {
        ChainAction::MoveStrike => {
            move_strike(state, chord);
            None
        }
        ChainAction::FocusLeg => {
            focus_leg(state, chord);
            None
        }
        ChainAction::AddLeg => {
            // The headline gesture: focus a strike with `c`/`p`, press `a` to append
            // it to the payoff builder. Reuses the SAME append logic the Payoff screen
            // uses (ui→ui is allowed), which bumps the builder revision so the driver's
            // `live_view_sig` diff marks the frame dirty; an empty chain is a safe
            // no-op.
            crate::ui::payoff::append_focused_leg(state);
            None
        }
        // Resolved through the map, but their I/O plumbing is a later issue: a
        // multi-expiry subscribe path, an underlying list, and the drill-in view.
        // They never perform I/O inline here.
        ChainAction::SwitchExpiry | ChainAction::SwitchUnderlying | ChainAction::Drill => None,
    }
}

/// Move the strike cursor up/down within the chain bounds. The first move from an
/// unset cursor reveals it at the ATM anchor; later moves step by one, clamped —
/// never an out-of-range index.
fn move_strike(state: &mut LiveState, chord: KeyChord) {
    let len = state.store.chain().options.len();
    if len == 0 {
        return;
    }
    let down = matches!(chord, KeyChord::Down | KeyChord::Char('j'));
    let current = clamp_anchor(state.selection.focused_row, None, len);
    let next = match state.selection.focused_row {
        // First move: place the cursor at the cached ATM anchor rather than jumping.
        None => state.atm_index().unwrap_or(0),
        Some(_) => {
            let row = current.unwrap_or(0);
            if down {
                (row + 1).min(floor_sub(len, 1))
            } else {
                floor_sub(row, 1)
            }
        }
    };
    state.selection.focused_row = Some(next);
}

/// Focus the call or put leg (`c` / `p`), revealing the cursor at the ATM anchor if
/// no row is focused yet so the focus has a visible target.
fn focus_leg(state: &mut LiveState, chord: KeyChord) {
    state.selection.focused_leg = match chord {
        KeyChord::Char('p') => LegFocus::Put,
        // The FocusLeg action only binds `c`/`p`; `c` (and any defensive fallback)
        // focuses the call leg.
        _ => LegFocus::Call,
    };
    if state.selection.focused_row.is_none() && !state.store.chain().options.is_empty() {
        state.selection.focused_row = Some(state.atm_index().unwrap_or(0));
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use chrono::{DateTime, Utc};
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use optionstratlib::chains::OptionData;
    use optionstratlib::chains::chain::OptionChain;
    use optionstratlib::prelude::{Decimal, Positive};
    use optionstratlib::{ExpirationDate, OptionStyle};
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    use super::{
        ChainRow, LegView, clamp_anchor, draw, greek_slots_for_width, handle_key, project_call,
        project_iv, project_put, project_row, resolve_leg, window_start,
    };
    use crate::app::{LegFocus, LiveScreen, LiveState, Mode, ScreenLoad, Selection, SourceBinding};
    use crate::chain::{
        AliasCatalog, ChainFetch, ChainSource, ChainStore, ExpirySource, GreeksOrigin, GreeksRow,
        Instrument, InstrumentKey, LegGreeks, ProviderId, QuoteUpdate, StreamHealth, TickDir,
    };
    use crate::chain::{ContractSpecFingerprint, ExerciseStyle, SettlementStyle};
    use crate::config::ThemeChoice;
    use crate::providers::{ChainCapability, GreeksCapability, ProviderCapabilities};
    use crate::ui::theme::{GreekColumn, StrikeRelation, Theme};

    const EXP: i64 = 1_700_000_000;

    // --- Constructors (no unwrap/expect/indexing per the ruleset) ------------

    #[track_caller]
    fn pid(id: &str) -> ProviderId {
        match ProviderId::new(id) {
            Ok(p) => p,
            Err(e) => panic!("invalid provider id `{id}`: {e}"),
        }
    }

    #[track_caller]
    fn utc(secs: i64) -> DateTime<Utc> {
        match DateTime::<Utc>::from_timestamp(secs, 0) {
            Some(t) => t,
            None => panic!("invalid test timestamp: {secs}"),
        }
    }

    #[track_caller]
    fn pos(value: f64) -> Positive {
        match Positive::new(value) {
            Ok(p) => p,
            Err(e) => panic!("invalid test positive `{value}`: {e}"),
        }
    }

    #[track_caller]
    fn pos_dec(value: Decimal) -> Positive {
        match Positive::new_decimal(value) {
            Ok(p) => p,
            Err(e) => panic!("invalid test positive decimal: {e}"),
        }
    }

    fn dec(mantissa: i64, scale: u32) -> Decimal {
        Decimal::new(mantissa, scale)
    }

    /// A fully-populated call+put row at `strike` with an IV of 50%.
    fn full_row(strike: f64) -> OptionData {
        let mut od = OptionData {
            strike_price: pos(strike),
            call_bid: Some(pos(1.0)),
            call_ask: Some(pos(1.2)),
            put_bid: Some(pos(2.0)),
            put_ask: Some(pos(2.4)),
            implied_volatility: pos(0.5),
            delta_call: Some(dec(6, 1)),
            delta_put: Some(dec(-4, 1)),
            gamma: Some(dec(1, 2)),
            ..Default::default()
        };
        od.set_mid_prices();
        od
    }

    fn chain_with(strikes: &[f64]) -> OptionChain {
        let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        for strike in strikes {
            let _ = chain.options.insert(full_row(*strike));
        }
        chain
    }

    fn store_with(chain: OptionChain) -> ChainStore {
        ChainStore::seed(
            ChainFetch::new(
                chain,
                ExpirySource::new("BTC", utc(EXP), pid("deribit")),
                AliasCatalog::new(),
            ),
            ChainSource::Merged,
            Duration::from_secs(2),
            utc(EXP),
        )
    }

    fn caps() -> ProviderCapabilities {
        ProviderCapabilities::builder()
            .chain(ChainCapability::Assemble)
            .depth(true)
            .greeks(GreeksCapability::Provided)
            .build()
    }

    fn live_with(chain: OptionChain, load: ScreenLoad) -> LiveState {
        let mut live = LiveState::new(
            SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
            store_with(chain),
        );
        live.load = load;
        live
    }

    fn spec() -> ContractSpecFingerprint {
        ContractSpecFingerprint {
            contract_multiplier: 1,
            settlement: SettlementStyle::Cash,
            exercise: ExerciseStyle::European,
            quote_currency: "USD".to_owned(),
            venue_product_code: "BTC".to_owned(),
        }
    }

    fn instrument(strike: f64, style: OptionStyle) -> Instrument {
        Instrument {
            key: InstrumentKey {
                underlying: "BTC".to_owned(),
                expiration_utc: utc(EXP),
                strike: pos(strike),
                style,
            },
            provider: pid("deribit"),
            native_symbol: format!("BTC-{strike}-{}", style.as_str()),
            stream_symbol: None,
            spec: spec(),
        }
    }

    fn quote(strike: f64, style: OptionStyle, bid: f64, ask: f64, received: i64) -> QuoteUpdate {
        QuoteUpdate {
            instrument: instrument(strike, style),
            bid: Some(pos(bid)),
            ask: Some(pos(ask)),
            last: None,
            bid_size: None,
            ask_size: None,
            event_time: Some(utc(received)),
            received_time: utc(received),
        }
    }

    /// A `LegGreeks` overriding only the named analytic fields on the empty leg
    /// (every other field `None`, every origin defaulting to `ComputedLocally`).
    fn mk_leg(
        iv: Option<(Positive, GreeksOrigin)>,
        delta: Option<Decimal>,
        gamma: Option<(Decimal, GreeksOrigin)>,
        theta: Option<Decimal>,
        vega: Option<Decimal>,
    ) -> LegGreeks {
        let mut leg = LegGreeks::default();
        if let Some((value, origin)) = iv {
            leg.iv = Some(value);
            leg.iv_origin = origin;
        }
        leg.delta = delta; // the sidecar delta is always the local fallback
        if let Some((value, origin)) = gamma {
            leg.gamma = Some(value);
            leg.gamma_origin = origin;
        }
        leg.theta = theta;
        leg.vega = vega;
        leg
    }

    /// The absolute expiry a chain resolves to — the instant the analytics sidecar
    /// keys on, so a read key and the sidecar agree.
    #[track_caller]
    fn resolved_expiry(chain: &OptionChain) -> DateTime<Utc> {
        match chain.get_expiration() {
            Some(ExpirationDate::DateTime(dt)) => dt,
            other => panic!("expected an absolute-UTC chain expiry, got {other:?}"),
        }
    }

    /// A store whose `ExpirySource` expiry matches the chain's resolved expiry, so
    /// the sidecar's compute keys equal the UI read keys — the setup a
    /// populated-Greeks projection assertion needs.
    fn store_consistent(chain: OptionChain) -> ChainStore {
        let exp = resolved_expiry(&chain);
        ChainStore::seed(
            ChainFetch::new(
                chain,
                ExpirySource::new("BTC", exp, pid("deribit")),
                AliasCatalog::new(),
            ),
            ChainSource::Merged,
            Duration::from_secs(2),
            utc(EXP),
        )
    }

    /// A venue Greeks row at an explicit expiry, so its key matches the sidecar's
    /// compute key. Carries venue theta/vega/rho the sidecar deliberately discards.
    fn greeks_at(
        exp: DateTime<Utc>,
        strike: f64,
        style: OptionStyle,
        iv: f64,
        gamma: Decimal,
    ) -> GreeksRow {
        GreeksRow {
            instrument: Instrument {
                key: InstrumentKey {
                    underlying: "BTC".to_owned(),
                    expiration_utc: exp,
                    strike: pos(strike),
                    style,
                },
                provider: pid("deribit"),
                native_symbol: format!("BTC-{strike}-{}", style.as_str()),
                stream_symbol: None,
                spec: spec(),
            },
            iv: Some(pos(iv)),
            delta: None,
            gamma: Some(gamma),
            theta: Some(dec(-1, 1)),
            vega: Some(dec(2, 1)),
            rho: Some(dec(3, 1)),
            origin: GreeksOrigin::Provider,
            event_time: None,
            received_time: utc(EXP + 10),
        }
    }

    /// A strike row with realistic ATM premiums (so the local IV inversion converges
    /// robustly) plus venue delta/gamma — the fixture the populated-Greeks
    /// projection tests seed.
    fn priced_row(strike: f64) -> OptionData {
        let mut od = OptionData {
            strike_price: pos(strike),
            call_bid: Some(pos(3_000.0)),
            call_ask: Some(pos(3_100.0)),
            put_bid: Some(pos(2_000.0)),
            put_ask: Some(pos(2_100.0)),
            implied_volatility: pos(0.5),
            delta_call: Some(dec(6, 1)),
            delta_put: Some(dec(-4, 1)),
            gamma: Some(dec(1, 2)),
            ..Default::default()
        };
        od.set_mid_prices();
        od
    }

    fn priced_chain(strikes: &[f64]) -> OptionChain {
        let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        for strike in strikes {
            let _ = chain.options.insert(priced_row(*strike));
        }
        chain
    }

    /// A `Ready` live state around a prebuilt store — for render assertions over a
    /// store whose sidecar keys match its read keys.
    fn live_ready_from_store(store: ChainStore) -> LiveState {
        let mut live = LiveState::new(
            SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
            store,
        );
        live.load = ScreenLoad::Ready;
        live
    }

    #[track_caller]
    fn terminal(width: u16, height: u16) -> Terminal<TestBackend> {
        match Terminal::new(TestBackend::new(width, height)) {
            Ok(t) => t,
            Err(e) => panic!("TestBackend construction failed: {e}"),
        }
    }

    fn theme() -> Theme {
        Theme::resolve(ThemeChoice::Auto, false)
    }

    /// Draw the chain screen for `live` at `width`×`height` and return the frame
    /// text (row-major), for render assertions. Uses a fixed decay-reference `now`
    /// equal to the seed poll instant (markers are `Flat` without applied quotes);
    /// [`rendered_at`] drives a specific `now` for the tick-decay test.
    #[track_caller]
    fn rendered(live: &LiveState, width: u16, height: u16) -> String {
        rendered_at(live, width, height, utc(EXP))
    }

    /// Draw the chain screen at `width`×`height` with an explicit tick-stamped `now`
    /// (the wall-clock the tick-direction markers decay against) and return the
    /// frame text.
    #[track_caller]
    fn rendered_at(live: &LiveState, width: u16, height: u16, now: DateTime<Utc>) -> String {
        let mut term = terminal(width, height);
        match term.draw(|frame| draw(live, frame, frame.area(), theme(), 0, now)) {
            Ok(_) => {}
            Err(e) => panic!("draw failed: {e}"),
        }
        term.backend()
            .buffer()
            .content()
            .iter()
            .map(ratatui::buffer::Cell::symbol)
            .collect()
    }

    fn press(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    // --- Projection: None iff None -------------------------------------------

    #[test]
    fn test_project_call_leg_none_iff_none_field() {
        // A row with some sides absent: the LegView field is None exactly where the
        // OptionData field is None, never a fabricated value.
        let mut od = OptionData {
            strike_price: pos(60_000.0),
            call_bid: Some(pos(1.0)),
            call_ask: None,
            put_bid: Some(pos(2.0)),
            put_ask: Some(pos(2.4)),
            implied_volatility: pos(0.5),
            delta_call: Some(dec(5, 1)),
            gamma: None,
            ..Default::default()
        };
        od.set_mid_prices();
        // No sidecar entry: the price sides project verbatim, the venue delta wins,
        // and gamma/theta/vega/iv are absent (render `—`). Since #83 dropped the
        // call-side shared `od.implied_volatility` fallback, a call with NO sidecar IV
        // shows `—`, never the shared field — the per-style venue IV is seeded into the
        // sidecar at assembly instead of borrowed from the lossy shared slot.
        let call = project_call(&od, None, TickDir::Flat, TickDir::Flat);
        assert_eq!(call.bid, Some(pos(1.0)), "present bid projects Some");
        assert_eq!(call.ask, None, "absent ask projects None (renders em dash)");
        assert_eq!(call.mark, None, "no mid without both sides -> None");
        assert_eq!(call.delta, Some(dec(5, 1)), "venue delta_call wins");
        assert_eq!(call.gamma, None, "no sidecar entry -> gamma None");
        assert_eq!(
            call.iv, None,
            "no sidecar IV -> `—` (the #25 shared od.implied_volatility fallback is gone)"
        );
        assert_eq!(call.theta, None, "no sidecar entry -> theta None");
        assert_eq!(call.vega, None, "no sidecar entry -> vega None");
        // No local field resolved (venue delta only), so the row is Provider-origin.
        assert_eq!(call.greeks_origin, GreeksOrigin::Provider);
    }

    #[test]
    fn test_project_put_leg_reads_put_side_fields_and_put_sidecar() {
        let od = full_row(60_000.0);
        // The put sidecar entry supplies iv/gamma (per style, not the shared field).
        let put_leg = mk_leg(
            Some((pos(0.6), GreeksOrigin::Provider)),
            None,
            Some((dec(3, 2), GreeksOrigin::Provider)),
            None,
            None,
        );
        let put = project_put(&od, Some(&put_leg), TickDir::Up, TickDir::Down);
        assert_eq!(put.bid, od.put_bid);
        assert_eq!(put.ask, od.put_ask);
        assert_eq!(put.mark, od.put_middle);
        assert_eq!(
            put.delta, od.delta_put,
            "put reads the venue delta_put, not delta_call"
        );
        assert_eq!(
            put.gamma,
            Some(dec(3, 2)),
            "put gamma is the put sidecar entry, not the shared od.gamma"
        );
        assert_eq!(put.iv, Some(pos(0.6)), "put iv is the put sidecar entry");
        assert_eq!(put.bid_dir, TickDir::Up);
        assert_eq!(put.ask_dir, TickDir::Down);
    }

    // --- The absent-IV rule (#15): a bare 0 IV renders `—`, not `0.00%` ------

    #[test]
    fn test_project_iv_zero_projects_none_not_a_fabricated_quote() {
        // The venue's absent-IV sentinel is Positive::ZERO; it must project None so
        // the matrix renders `—`, never a fabricated-looking 0.00%.
        assert_eq!(project_iv(Positive::ZERO), None);
    }

    #[test]
    fn test_project_iv_positive_projects_some() {
        assert_eq!(project_iv(pos(0.5)), Some(pos(0.5)));
    }

    #[test]
    fn test_project_call_leg_absent_iv_zero_sidecar_is_none() {
        let od = OptionData {
            strike_price: pos(60_000.0),
            ..Default::default()
        };
        // The sidecar can carry the venue's absent-IV zero sentinel; it still
        // projects to None so the matrix renders `—`, never a fabricated 0.00%.
        let leg = mk_leg(
            Some((Positive::ZERO, GreeksOrigin::Provider)),
            None,
            None,
            None,
            None,
        );
        let call = project_call(&od, Some(&leg), TickDir::Flat, TickDir::Flat);
        assert_eq!(
            call.iv, None,
            "a zero-sentinel sidecar IV projects None, not Some(0)"
        );
        // The zero-IV field is not a present local field, so no origin glyph.
        assert_eq!(call.greeks_origin, GreeksOrigin::Provider);
    }

    // --- Per-field §7 precedence (resolve_leg) -------------------------------

    #[test]
    fn test_resolve_leg_delta_prefers_venue_over_local() {
        // A local sidecar delta is present, but a venue per-leg delta wins.
        let leg = mk_leg(None, Some(dec(-9, 1)), None, None, None);
        let v = resolve_leg(
            Some(pos(1.0)),
            Some(pos(1.2)),
            Some(pos(1.1)),
            Some(dec(6, 1)),
            Some(&leg),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(v.delta, Some(dec(6, 1)), "venue delta wins over local");
        // Only the venue delta resolved -> Provider (no glyph).
        assert_eq!(v.greeks_origin, GreeksOrigin::Provider);
    }

    #[test]
    fn test_resolve_leg_delta_falls_back_to_local() {
        // No venue delta -> the local sidecar delta is used, and it badges the row.
        let leg = mk_leg(None, Some(dec(-9, 1)), None, Some(dec(-5, 2)), None);
        let v = resolve_leg(
            None,
            None,
            None,
            None,
            Some(&leg),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(
            v.delta,
            Some(dec(-9, 1)),
            "no venue delta -> local fallback"
        );
        assert_eq!(v.greeks_origin, GreeksOrigin::ComputedLocally);
    }

    #[test]
    fn test_resolve_leg_iv_gamma_carry_sidecar_origin() {
        // Venue-origin iv/gamma (plus a venue delta) keep the row Provider.
        let venue = mk_leg(
            Some((pos(0.55), GreeksOrigin::Provider)),
            None,
            Some((dec(1, 4), GreeksOrigin::Provider)),
            None,
            None,
        );
        let v = resolve_leg(
            None,
            None,
            None,
            Some(dec(5, 1)),
            Some(&venue),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(v.iv, Some(pos(0.55)));
        assert_eq!(v.gamma, Some(dec(1, 4)));
        assert_eq!(
            v.greeks_origin,
            GreeksOrigin::Provider,
            "venue iv/gamma + venue delta -> Provider"
        );
        // A locally-computed iv or gamma badges the row local.
        let local = mk_leg(
            Some((pos(0.6), GreeksOrigin::ComputedLocally)),
            None,
            Some((dec(2, 4), GreeksOrigin::ComputedLocally)),
            None,
            None,
        );
        let w = resolve_leg(
            None,
            None,
            None,
            Some(dec(5, 1)),
            Some(&local),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(w.iv, Some(pos(0.6)));
        assert_eq!(w.gamma, Some(dec(2, 4)));
        assert_eq!(w.greeks_origin, GreeksOrigin::ComputedLocally);
    }

    #[test]
    fn test_resolve_leg_theta_vega_are_always_local() {
        // theta/vega only ever come from the local sidecar; present ones badge the
        // row local even alongside a venue delta (a mixed-origin row).
        let leg = mk_leg(None, None, None, Some(dec(-5, 2)), Some(dec(3, 2)));
        let v = resolve_leg(
            None,
            None,
            None,
            Some(dec(6, 1)),
            Some(&leg),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(v.theta, Some(dec(-5, 2)));
        assert_eq!(v.vega, Some(dec(3, 2)));
        assert_eq!(
            v.greeks_origin,
            GreeksOrigin::ComputedLocally,
            "venue delta + local vega is a mixed-origin, locally-badged row"
        );
    }

    #[test]
    fn test_resolve_leg_absent_fields_stay_none_render_em_dash() {
        // No venue delta and no sidecar entry: every analytic is None and renders
        // `—`, never a fabricated 0.
        let v = resolve_leg(None, None, None, None, None, (TickDir::Flat, TickDir::Flat));
        assert_eq!(v.delta, None);
        assert_eq!(v.iv, None);
        assert_eq!(v.gamma, None);
        assert_eq!(v.theta, None);
        assert_eq!(v.vega, None);
        assert_eq!(super::fmt_greek(v.theta), super::EM_DASH);
        assert_eq!(super::fmt_greek(v.delta), super::EM_DASH);
        assert_eq!(v.greeks_origin, GreeksOrigin::Provider);
    }

    // --- IV precedence: per-style venue sidecar IV + the local plausibility floor --

    #[test]
    fn test_resolve_iv_local_below_floor_clears_to_none() {
        // (a) A locally-computed IV below the plausibility floor (0.5%) is
        // economically implausible for a live quote -> cleared to None (renders `—`),
        // never a fabricated near-zero percentage.
        let leg = mk_leg(
            Some((pos(0.0003), GreeksOrigin::ComputedLocally)),
            None,
            None,
            None,
            None,
        );
        // The sub-floor local value is floored to `—` (there is no shared fallback).
        let v = resolve_leg(
            None,
            None,
            None,
            None,
            Some(&leg),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(v.iv, None, "a sub-0.5% local IV is floored to None");
        assert_eq!(super::fmt_iv(v.iv), super::EM_DASH);
    }

    #[test]
    fn test_resolve_iv_local_above_floor_still_shows() {
        // (b) A legitimate provider-computed IV (e.g. IG equities, always >> 0.5%)
        // clears the floor and renders as a percentage with the local-origin badge.
        let leg = mk_leg(
            Some((pos(0.25), GreeksOrigin::ComputedLocally)),
            None,
            None,
            None,
            None,
        );
        let v = resolve_leg(
            None,
            None,
            None,
            None,
            Some(&leg),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(v.iv, Some(pos(0.25)), "an at/above-floor local IV survives");
        assert_eq!(v.iv_origin, GreeksOrigin::ComputedLocally);
        assert!(v.iv_is_local(), "a present local IV badges the IV cell");
        assert_eq!(super::fmt_iv(v.iv), "25.00%");
    }

    #[test]
    fn test_resolve_iv_venue_below_floor_is_never_cleared() {
        // (c) A VENUE (Provider) IV is trusted even below the plausibility floor —
        // only the exact-zero absent sentinel clears a venue IV, never the floor.
        let leg = mk_leg(
            Some((pos(0.001), GreeksOrigin::Provider)),
            None,
            None,
            None,
            None,
        );
        let v = resolve_leg(
            None,
            None,
            None,
            None,
            Some(&leg),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(
            v.iv,
            Some(pos(0.001)),
            "a sub-floor venue IV is trusted, not floored"
        );
        assert_eq!(v.iv_origin, GreeksOrigin::Provider);
        assert!(!v.iv_is_local(), "a venue IV is never badged local");
    }

    #[test]
    fn test_resolve_iv_call_reads_per_style_venue_sidecar_not_shared_field() {
        // (d) #83: the call reads its OWN per-style venue IV from the sidecar (seeded
        // at assembly), NOT the shared `od.implied_volatility`. A per-style Provider IV
        // wins outright.
        let od = OptionData {
            strike_price: pos(60_000.0),
            implied_volatility: pos(0.4922),
            delta_call: Some(dec(6, 1)),
            ..Default::default()
        };
        let venue_leg = mk_leg(
            Some((pos(0.55), GreeksOrigin::Provider)),
            None,
            None,
            Some(dec(-1, 1)),
            None,
        );
        let call = project_call(&od, Some(&venue_leg), TickDir::Flat, TickDir::Flat);
        assert_eq!(
            call.iv,
            Some(pos(0.55)),
            "the per-style venue sidecar IV wins (not the shared od field's 0.4922)"
        );
        assert_eq!(call.iv_origin, GreeksOrigin::Provider);
        assert!(!call.iv_is_local(), "no local glyph on the venue IV");
    }

    #[test]
    fn test_resolve_iv_call_no_longer_uses_shared_field_fallback() {
        // (d.2) #83 DROPPED the #25 call-side shared-`od.implied_volatility` fallback.
        // A call whose sidecar holds only a near-zero LOCAL IV — with the shared field
        // SET — now shows `—` (the floored local), never the shared field's value.
        let od = OptionData {
            strike_price: pos(60_000.0),
            implied_volatility: pos(0.4922),
            delta_call: Some(dec(6, 1)),
            ..Default::default()
        };
        let leg = mk_leg(
            Some((pos(0.00003), GreeksOrigin::ComputedLocally)),
            None,
            None,
            Some(dec(-1, 1)),
            None,
        );
        let call = project_call(&od, Some(&leg), TickDir::Flat, TickDir::Flat);
        assert_eq!(
            call.iv, None,
            "the near-zero local is floored to `—` (the shared fallback is gone)"
        );
        assert_eq!(super::fmt_iv(call.iv), super::EM_DASH);
    }

    #[test]
    fn test_resolve_iv_put_ignores_shared_field_no_collision() {
        // (e) Both legs ignore the shared `od.implied_volatility` (#83): a PUT whose
        // sidecar has only a floored local IV shows `—`, never the call-side shared
        // field's value — the call/put IV collision the style-keyed sidecar prevents.
        let od = OptionData {
            strike_price: pos(60_000.0),
            implied_volatility: pos(0.4922),
            delta_put: Some(dec(-4, 1)),
            ..Default::default()
        };
        // Put sidecar: a near-zero LOCAL IV (floored) and no venue IV.
        let leg = mk_leg(
            Some((pos(0.002), GreeksOrigin::ComputedLocally)),
            None,
            None,
            Some(dec(-1, 1)),
            None,
        );
        let put = project_put(&od, Some(&leg), TickDir::Flat, TickDir::Flat);
        assert_eq!(put.iv, None, "the put never inherits the shared IV");
        assert_eq!(super::fmt_iv(put.iv), super::EM_DASH);
    }

    #[test]
    fn test_origin_glyph_badges_present_local_field_not_venue_delta() {
        // (f) The origin glyph appears iff a PRESENT resolved field is local, badging
        // the actual computed cell — not the trustworthy venue delta beside it.
        // Mixed-origin: venue delta (Provider) + local theta.
        let mixed = mk_leg(None, None, None, Some(dec(-5, 2)), None);
        let v = resolve_leg(
            None,
            None,
            None,
            Some(dec(6, 1)),
            Some(&mixed),
            (TickDir::Flat, TickDir::Flat),
        );
        assert!(
            !v.greek_is_local(GreekColumn::Delta),
            "the venue delta is not badged"
        );
        assert!(
            v.greek_is_local(GreekColumn::Theta),
            "the local theta is badged"
        );
        // A leg with a local theta but NO delta at all (None) still badges the theta,
        // where the old delta-gated glyph would have shown nothing.
        let no_delta = mk_leg(None, None, None, Some(dec(-5, 2)), None);
        let w = resolve_leg(
            None,
            None,
            None,
            None,
            Some(&no_delta),
            (TickDir::Flat, TickDir::Flat),
        );
        assert_eq!(w.delta, None, "no delta at all");
        assert!(
            !w.greek_is_local(GreekColumn::Delta),
            "an absent delta is never badged"
        );
        assert!(
            w.greek_is_local(GreekColumn::Theta),
            "the local theta still badges with a None delta"
        );
        assert_eq!(
            w.greeks_origin,
            GreeksOrigin::ComputedLocally,
            "rollup preserved: any present local field -> row local"
        );
        // A fully-venue leg (venue delta + venue iv/gamma) badges nothing.
        let venue = mk_leg(
            Some((pos(0.5), GreeksOrigin::Provider)),
            None,
            Some((dec(1, 4), GreeksOrigin::Provider)),
            None,
            None,
        );
        let f = resolve_leg(
            None,
            None,
            None,
            Some(dec(5, 1)),
            Some(&venue),
            (TickDir::Flat, TickDir::Flat),
        );
        assert!(!f.greek_is_local(GreekColumn::Delta));
        assert!(!f.greek_is_local(GreekColumn::Gamma));
        assert!(!f.iv_is_local());
        assert_eq!(f.greeks_origin, GreeksOrigin::Provider);
    }

    // --- Store-fed projection: sidecar populated, unequal legs survive -------

    #[test]
    fn test_project_row_populates_local_theta_vega_from_sidecar() {
        let chain = priced_chain(&[60_000.0]);
        let exp = resolved_expiry(&chain);
        let store = store_consistent(chain);
        let od = match store.chain().options.iter().next() {
            Some(od) => od.clone(),
            None => panic!("expected one row"),
        };
        let row = project_row(
            &od,
            pos(60_000.0),
            &store,
            "BTC",
            exp,
            store.last_full_poll(),
        );
        // The seed recompute filled the local analytics for the call leg.
        assert!(row.call.theta.is_some(), "local theta populated");
        assert!(row.call.vega.is_some(), "local vega populated");
        assert!(row.call.gamma.is_some(), "local gamma populated");
        // The call iv resolves to the LOCAL inversion at seed (a plausible ATM premium
        // clears the floor); #83 dropped the shared od.implied_volatility fallback, so
        // the sidecar's own local IV is what shows.
        assert!(row.call.iv.is_some(), "call iv resolves (local inversion)");
        // Venue delta is present, so delta resolves to it, but the local theta/vega
        // make the row ComputedLocally (a mixed-origin row).
        assert_eq!(row.call.delta, od.delta_call);
        assert_eq!(row.call.greeks_origin, GreeksOrigin::ComputedLocally);
    }

    #[test]
    fn test_unequal_call_put_iv_gamma_survive_projection_both_orders() {
        // The shared-field-loss fix: unequal call/put venue iv/gamma both survive,
        // independent of the arrival order (the style-keyed sidecar).
        let project_both = |call_first: bool| -> (LegView, LegView) {
            let chain = priced_chain(&[60_000.0]);
            let exp = resolved_expiry(&chain);
            let mut store = store_consistent(chain);
            let call = greeks_at(exp, 60_000.0, OptionStyle::Call, 0.40, dec(1, 2));
            let put = greeks_at(exp, 60_000.0, OptionStyle::Put, 0.60, dec(2, 2));
            if call_first {
                let _ = store.apply_greeks(&call);
                let _ = store.apply_greeks(&put);
            } else {
                let _ = store.apply_greeks(&put);
                let _ = store.apply_greeks(&call);
            }
            let od = match store.chain().options.iter().next() {
                Some(od) => od.clone(),
                None => panic!("expected one row"),
            };
            let row = project_row(
                &od,
                pos(60_000.0),
                &store,
                "BTC",
                exp,
                store.last_full_poll(),
            );
            (row.call, row.put)
        };
        for call_first in [true, false] {
            let (call, put) = project_both(call_first);
            assert_eq!(
                call.iv,
                Some(pos(0.40)),
                "call iv preserved (order={call_first})"
            );
            assert_eq!(
                put.iv,
                Some(pos(0.60)),
                "put iv preserved (order={call_first})"
            );
            assert_eq!(call.gamma, Some(dec(1, 2)), "call gamma preserved");
            assert_eq!(put.gamma, Some(dec(2, 2)), "put gamma preserved");
            assert_ne!(call.iv, put.iv, "unequal call/put iv both survive");
            assert_ne!(call.gamma, put.gamma, "unequal call/put gamma both survive");
        }
    }

    #[test]
    fn test_populated_matrix_shows_origin_glyph_for_local_greeks() {
        // A consistent-expiry store: the seed recompute fills local theta/vega, so
        // the rows are ComputedLocally and carry the `~` origin glyph.
        let live = live_ready_from_store(store_consistent(priced_chain(&[
            59_000.0, 60_000.0, 61_000.0,
        ])));
        let text = rendered(&live, 160, 20);
        assert!(
            text.contains('~'),
            "a locally-computed row shows the origin glyph"
        );
    }

    // --- StrikeRelation bucketing via the row projection ---------------------

    #[test]
    fn test_project_row_buckets_strike_relation_below_at_above() {
        let chain = chain_with(&[50_000.0, 60_000.0, 70_000.0]);
        let store = store_with(chain);
        let spot = pos(60_000.0);
        let expiration = utc(EXP);
        let below = match store
            .chain()
            .options
            .iter()
            .find(|o| o.strike_price == pos(50_000.0))
        {
            Some(od) => project_row(od, spot, &store, "BTC", expiration, store.last_full_poll()),
            None => panic!("expected a 50000 strike"),
        };
        assert_eq!(below.strike_relation, StrikeRelation::BelowSpot);
        let at = match store
            .chain()
            .options
            .iter()
            .find(|o| o.strike_price == pos(60_000.0))
        {
            Some(od) => project_row(od, spot, &store, "BTC", expiration, store.last_full_poll()),
            None => panic!("expected a 60000 strike"),
        };
        assert_eq!(at.strike_relation, StrikeRelation::AtSpot);
        let above = match store
            .chain()
            .options
            .iter()
            .find(|o| o.strike_price == pos(70_000.0))
        {
            Some(od) => project_row(od, spot, &store, "BTC", expiration, store.last_full_poll()),
            None => panic!("expected a 70000 strike"),
        };
        assert_eq!(above.strike_relation, StrikeRelation::AboveSpot);
    }

    // --- Direction projection reads the store's decayed baseline -------------

    #[test]
    fn test_project_row_projects_rising_bid_direction_up() {
        // Two rising quotes give the store an Up bid direction; the projection
        // reads it (as of the last-poll instant, which precedes the changes, so no
        // decay applies).
        let mut store = store_with(chain_with(&[60_000.0]));
        let _ = store.apply_quote(&quote(60_000.0, OptionStyle::Call, 1.0, 1.2, EXP + 100));
        let _ = store.apply_quote(&quote(60_000.0, OptionStyle::Call, 1.5, 1.7, EXP + 101));
        let od = match store.chain().options.iter().next() {
            Some(od) => od.clone(),
            None => panic!("expected one row"),
        };
        let row = project_row(
            &od,
            pos(60_000.0),
            &store,
            "BTC",
            utc(EXP),
            store.last_full_poll(),
        );
        assert_eq!(row.call.bid_dir, TickDir::Up, "a rising bid projects Up");
        assert_eq!(row.call.ask_dir, TickDir::Up, "a rising ask projects Up");
        // The put leg had no quotes -> Flat.
        assert_eq!(row.put.bid_dir, TickDir::Flat);
    }

    #[test]
    fn test_project_row_no_reference_instant_is_flat() {
        let store = store_with(chain_with(&[60_000.0]));
        let od = match store.chain().options.iter().next() {
            Some(od) => od.clone(),
            None => panic!("expected one row"),
        };
        // as_of = None -> directions default to Flat without touching the store.
        let row = project_row(&od, pos(60_000.0), &store, "BTC", utc(EXP), None);
        assert_eq!(row.call.bid_dir, TickDir::Flat);
        assert_eq!(row.put.ask_dir, TickDir::Flat);
    }

    // --- Windowing / anchoring helpers (no out-of-range index) ---------------

    #[test]
    fn test_clamp_anchor_falls_back_when_cursor_out_of_range() {
        assert_eq!(
            clamp_anchor(Some(2), Some(1), 5),
            Some(2),
            "in-range cursor kept"
        );
        assert_eq!(
            clamp_anchor(Some(9), Some(1), 5),
            Some(4),
            "over-range -> last row"
        );
        assert_eq!(clamp_anchor(None, Some(3), 5), Some(3), "unset -> ATM");
        assert_eq!(clamp_anchor(None, None, 0), None, "empty chain -> None");
    }

    #[test]
    fn test_window_start_keeps_anchor_visible() {
        assert_eq!(window_start(0, 5, 3), 0, "chain fits -> start 0");
        assert_eq!(window_start(10, 5, 20), 8, "centers the anchor");
        assert_eq!(window_start(19, 5, 20), 15, "clamps to the last window");
        assert_eq!(window_start(5, 0, 20), 0, "zero visible -> start 0");
    }

    #[test]
    fn test_greek_slots_for_width_is_bounded_and_grows() {
        assert_eq!(greek_slots_for_width(20), 0, "too narrow -> Delta only");
        assert!(greek_slots_for_width(200) <= 3, "at most three greek slots");
        assert!(
            greek_slots_for_width(200) >= greek_slots_for_width(120),
            "wider fits at least as many",
        );
    }

    // --- v0.2 column set: Δ always + responsive Γ→ν→Θ drop order -------------

    #[test]
    fn test_columns_full_greek_set_honors_drop_order() {
        use crate::ui::theme::greek_columns_for_slots;
        let call_greeks = |plan: &[super::ChainCol]| -> Vec<GreekColumn> {
            plan.iter()
                .filter_map(|col| match col {
                    super::ChainCol::CallGreek(greek) => Some(*greek),
                    _ => None,
                })
                .collect()
        };
        // Δ only at 0 slots; Θ retained first (1), then ν (2), then Γ (3).
        assert_eq!(
            call_greeks(&super::columns(greek_columns_for_slots(0))),
            vec![GreekColumn::Delta],
            "0 slots: Delta only",
        );
        assert_eq!(
            call_greeks(&super::columns(greek_columns_for_slots(1))),
            vec![GreekColumn::Delta, GreekColumn::Theta],
            "1 slot: Delta + Theta (Θ retained first)",
        );
        assert_eq!(
            call_greeks(&super::columns(greek_columns_for_slots(2))),
            vec![GreekColumn::Delta, GreekColumn::Vega, GreekColumn::Theta],
            "2 slots: adds Vega",
        );
        assert_eq!(
            call_greeks(&super::columns(greek_columns_for_slots(3))),
            vec![
                GreekColumn::Delta,
                GreekColumn::Gamma,
                GreekColumn::Vega,
                GreekColumn::Theta,
            ],
            "3 slots: adds Gamma (dropped first as width shrinks)",
        );
        // The put side mirrors the call side (Δ outermost on the far right).
        let put_greeks: Vec<GreekColumn> = super::columns(greek_columns_for_slots(3))
            .iter()
            .filter_map(|col| match col {
                super::ChainCol::PutGreek(greek) => Some(*greek),
                _ => None,
            })
            .collect();
        assert_eq!(
            put_greeks,
            vec![
                GreekColumn::Theta,
                GreekColumn::Vega,
                GreekColumn::Gamma,
                GreekColumn::Delta,
            ],
            "put side mirrors the call side",
        );
    }

    #[test]
    fn test_draw_matrix_greek_columns_are_responsive() {
        let live = live_ready_from_store(store_consistent(priced_chain(&[
            59_000.0, 60_000.0, 61_000.0,
        ])));
        // A common 120-col terminal fits one optional greek: Θ (retained first),
        // not Γ (which needs the widest layout).
        let common = rendered(&live, 120, 20);
        assert!(common.contains("Θ"), "theta column shows at 120 cols");
        assert!(!common.contains("Γ"), "gamma needs a wider terminal");
        // A wide terminal fits all three optional greeks.
        let wide = rendered(&live, 200, 20);
        assert!(wide.contains("Γ"), "gamma shows on a wide terminal");
        assert!(wide.contains("ν"), "vega shows on a wide terminal");
        assert!(wide.contains("Θ"), "theta shows on a wide terminal");
    }

    // --- fmt_iv never panics at the render edge on an absurd magnitude -------

    #[test]
    fn test_fmt_iv_overflowing_magnitude_renders_em_dash_not_panic() {
        // A finite-but-absurd IV can survive the adapter seam (magnitude is not
        // rejected). fmt_iv's checked ×100 renders `—`, never panics (ADR-0007).
        let huge = pos_dec(Decimal::MAX);
        assert_eq!(super::fmt_iv(Some(huge)), super::EM_DASH);
        // A normal IV still formats as a percentage.
        assert_eq!(super::fmt_iv(Some(pos(0.5))), "50.00%");
    }

    // --- States render before the happy path, deliberately, without panic ----

    #[test]
    fn test_draw_loading_state_shows_connecting_to_provider() {
        let live = live_with(chain_with(&[60_000.0]), ScreenLoad::Loading);
        let text = rendered(&live, 120, 20);
        assert!(text.contains("connecting to deribit"), "names the provider");
    }

    #[test]
    fn test_draw_empty_state_shows_no_data_hint() {
        let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let live = live_with(empty, ScreenLoad::Ready);
        let text = rendered(&live, 120, 20);
        assert!(text.contains("no data for BTC"), "names the underlying");
        assert!(text.contains("2025-06-27"), "names the expiry");
    }

    #[test]
    fn test_draw_error_state_shows_message_and_retry_key() {
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Loading);
        live.load = ScreenLoad::Error {
            message: "provider unreachable".to_owned(),
        };
        let text = rendered(&live, 120, 20);
        assert!(text.contains("provider unreachable"), "shows the message");
        assert!(text.contains("press r to reconnect"), "shows the retry key");
    }

    #[test]
    fn test_draw_populated_matrix_shows_strike_and_atm_marker() {
        let live = live_with(
            chain_with(&[59_000.0, 60_000.0, 61_000.0]),
            ScreenLoad::Ready,
        );
        let text = rendered(&live, 120, 20);
        assert!(text.contains("60000"), "renders a strike");
        assert!(text.contains("◀ATM"), "marks the ATM strike");
        assert!(text.contains("Strike"), "renders the header");
    }

    #[test]
    fn test_draw_stale_feed_shows_badge_not_blank() {
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
        live.source.health = StreamHealth::Reconnecting { attempt: 2 };
        live.store
            .apply_health(StreamHealth::Reconnecting { attempt: 2 });
        let text = rendered(&live, 120, 20);
        assert!(text.contains("60000"), "the last chain still renders");
        assert!(
            text.contains("reconnecting"),
            "shows the reconnecting badge"
        );
    }

    #[test]
    fn test_draw_absent_iv_row_renders_em_dash_not_zero_percent() {
        // A row whose IV is the absent-sentinel zero must render an em dash, never
        // a fabricated 0.00%.
        let mut od = OptionData {
            strike_price: pos(60_000.0),
            call_bid: Some(pos(1.0)),
            call_ask: Some(pos(1.2)),
            implied_volatility: Positive::ZERO,
            ..Default::default()
        };
        od.set_mid_prices();
        let mut chain = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let _ = chain.options.insert(od);
        let live = live_with(chain, ScreenLoad::Ready);
        let text = rendered(&live, 120, 20);
        assert!(
            text.contains(super::EM_DASH),
            "the absent IV renders an em dash"
        );
        assert!(!text.contains("0.00%"), "no fabricated 0.00% IV");
    }

    #[test]
    fn test_draw_reachable_states_render_across_sizes_without_panic() {
        // The chain screen's reachable states (populated / empty / loading / error /
        // stale) at several sizes never panic (extends render_never_panics to the
        // chain body directly).
        let populated = chain_with(&[58_000.0, 59_000.0, 60_000.0, 61_000.0, 62_000.0]);
        let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let mut stale = live_with(populated.clone(), ScreenLoad::Ready);
        stale
            .store
            .apply_health(StreamHealth::Stale { since: utc(EXP) });
        stale.source.health = StreamHealth::Stale { since: utc(EXP) };
        let states = [
            live_with(populated.clone(), ScreenLoad::Ready),
            live_with(empty, ScreenLoad::Ready),
            live_with(populated.clone(), ScreenLoad::Loading),
            {
                let mut e = live_with(populated, ScreenLoad::Ready);
                e.load = ScreenLoad::Error {
                    message: "boom".to_owned(),
                };
                e
            },
            stale,
        ];
        for live in &states {
            for (w, h) in [(40u16, 8u16), (80, 24), (120, 40), (160, 50)] {
                let _ = rendered(live, w, h);
            }
        }
    }

    // --- Fix: the tick-direction marker decays on the wall clock, not last poll --

    #[test]
    fn test_draw_direction_marker_decays_on_wall_clock_not_last_poll() {
        // Two rising call quotes give an Up bid/ask direction with `changed_at` at
        // EXP+101. Rendered with `now` at the change the ▲ marker shows; rendered
        // with `now` advanced past the ~3 s decay window it is gone — proving draw
        // decays against the tick-stamped `now` it is passed, NOT `last_full_poll`
        // (pinned at EXP, which would keep the marker until the next poll).
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
        let _ = live
            .store
            .apply_quote(&quote(60_000.0, OptionStyle::Call, 1.0, 1.2, EXP + 100));
        let _ = live
            .store
            .apply_quote(&quote(60_000.0, OptionStyle::Call, 1.5, 1.7, EXP + 101));
        let fresh = rendered_at(&live, 120, 12, utc(EXP + 101));
        assert!(
            fresh.contains('▲'),
            "a just-risen quote shows the up marker"
        );
        let decayed = rendered_at(&live, 120, 12, utc(EXP + 200));
        assert!(
            !decayed.contains('▲'),
            "past the decay window the marker decays on wall-time, not the last poll",
        );
    }

    // --- Fix: an 80-col terminal shows a widen hint, never a clipped chain -------

    #[test]
    fn test_draw_narrow_terminal_shows_widen_hint_not_clipped_chain() {
        // Below the mandatory-column width the chain would clip; at 40 and 80 cols
        // the screen shows an honest "widen" hint instead of a corrupt/clipped
        // matrix. NO_COLOR-safe (a dim text hint) and the greek drop order is
        // untouched.
        let live = live_with(
            chain_with(&[59_000.0, 60_000.0, 61_000.0]),
            ScreenLoad::Ready,
        );
        for w in [40u16, 80u16] {
            let text = rendered(&live, w, 12);
            assert!(
                text.contains("widen"),
                "at {w} cols the chain shows a widen hint, not a clipped table",
            );
            assert!(
                !text.contains("Strike"),
                "at {w} cols no clipped chain header leaks",
            );
        }
        // Above the mandatory-column width the real chain renders in full.
        let wide = rendered(&live, 120, 20);
        assert!(wide.contains("Strike"), "the chain renders at 120 cols");
        assert!(wide.contains("60000"), "a strike renders at 120 cols");
    }

    // --- Draw purity: draw takes &LiveState and mutates nothing --------------

    #[test]
    fn test_draw_is_pure_leaves_state_unchanged() {
        // `draw` takes `&LiveState`, so it cannot mutate the store, selection, or
        // load; assert the observable state is unchanged across a draw (no pricing
        // call, no mutation, no state flip).
        let live = live_with(
            chain_with(&[59_000.0, 60_000.0, 61_000.0]),
            ScreenLoad::Ready,
        );
        let before_len = live.store.chain().options.len();
        let before_poll = live.store.last_full_poll();
        let before_sel = live.selection;
        let before_health = matches!(live.store.health(), StreamHealth::Live);
        let _ = rendered(&live, 120, 40);
        assert_eq!(
            live.store.chain().options.len(),
            before_len,
            "no rows added/removed"
        );
        assert_eq!(live.store.last_full_poll(), before_poll, "no re-poll");
        assert_eq!(live.selection, before_sel, "selection unchanged");
        assert_eq!(
            matches!(live.store.health(), StreamHealth::Live),
            before_health,
            "health unchanged",
        );
    }

    #[test]
    fn test_draw_leaves_sidecar_unchanged_no_pricing_in_draw() {
        // A consistent-expiry store so the read key hits a real sidecar entry; the
        // projection reads the cached analytics and must invoke no pricing/recompute
        // in `draw` (the entry is byte-identical before and after a draw).
        let chain = priced_chain(&[60_000.0]);
        let exp = resolved_expiry(&chain);
        let live = live_ready_from_store(store_consistent(chain));
        let key = InstrumentKey {
            underlying: "BTC".to_owned(),
            expiration_utc: exp,
            strike: pos(60_000.0),
            style: OptionStyle::Call,
        };
        let before = live.store.leg_greeks(&key).copied();
        assert!(before.is_some(), "the seeded sidecar entry is present");
        let _ = rendered(&live, 160, 40);
        let after = live.store.leg_greeks(&key).copied();
        assert_eq!(
            before, after,
            "draw reads the cached sidecar and never recomputes or mutates it",
        );
    }

    // --- handle_key: nav resolves through the keymap, mutates local state ----

    #[test]
    fn test_handle_key_move_strike_down_reveals_cursor_at_atm_then_steps() {
        let mut live = live_with(
            chain_with(&[58_000.0, 60_000.0, 62_000.0]),
            ScreenLoad::Ready,
        );
        assert_eq!(live.selection.focused_row, None, "no cursor initially");
        // First `j` reveals the cursor at the ATM anchor (index 1 for spot 60000).
        assert!(handle_key(&mut live, press(KeyCode::Char('j'))).is_none());
        assert_eq!(live.selection.focused_row, Some(1), "cursor at ATM");
        // Next `j` steps down, clamped to the last row.
        let _ = handle_key(&mut live, press(KeyCode::Char('j')));
        assert_eq!(live.selection.focused_row, Some(2));
        let _ = handle_key(&mut live, press(KeyCode::Down));
        assert_eq!(
            live.selection.focused_row,
            Some(2),
            "clamped at the last row"
        );
        // `k` / Up step back up.
        let _ = handle_key(&mut live, press(KeyCode::Char('k')));
        assert_eq!(live.selection.focused_row, Some(1));
    }

    #[test]
    fn test_handle_key_focus_leg_sets_call_or_put() {
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
        let _ = handle_key(&mut live, press(KeyCode::Char('p')));
        assert_eq!(live.selection.focused_leg, LegFocus::Put);
        assert!(
            live.selection.focused_row.is_some(),
            "focus reveals the cursor"
        );
        let _ = handle_key(&mut live, press(KeyCode::Char('c')));
        assert_eq!(live.selection.focused_leg, LegFocus::Call);
    }

    #[test]
    fn test_handle_key_unbound_key_returns_none_and_changes_nothing() {
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
        let before = live.selection;
        assert!(handle_key(&mut live, press(KeyCode::Char('z'))).is_none());
        assert_eq!(live.selection, before, "an unbound key changes nothing");
    }

    #[test]
    fn test_handle_key_deferred_actions_are_noops_no_io() {
        // Expiry/underlying/drill resolve through the map but are not yet wired; they
        // return None and perform no I/O, changing no selection. (AddLeg `a` is wired
        // in #26 — covered separately — so it is not in this deferred set.)
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
        let before = live.selection;
        for code in [
            KeyCode::Char('l'), // SwitchExpiry
            KeyCode::Char(']'), // SwitchUnderlying
            KeyCode::Enter,     // Drill
        ] {
            assert!(handle_key(&mut live, press(code)).is_none());
        }
        assert_eq!(live.selection, before, "deferred actions change no state");
    }

    #[test]
    fn test_handle_key_add_leg_appends_focused_leg_to_builder_and_marks_dirty() {
        // The headline chain→`a`→builder gesture: focus a call with `c`, press `a`, and
        // the focused leg lands in the payoff builder; a second focus+`a` appends in
        // order. Each successful append bumps the builder revision (what the driver
        // diffs to mark the frame dirty).
        let mut live = live_with(
            chain_with(&[58_000.0, 60_000.0, 62_000.0]),
            ScreenLoad::Ready,
        );
        assert!(live.payoff_builder.is_empty(), "builder starts empty");
        let rev0 = live.payoff_builder.revision();

        // `c` focuses the call leg and reveals the cursor at the ATM anchor (index 1 =
        // 60000); `a` appends that focused call.
        let _ = handle_key(&mut live, press(KeyCode::Char('c')));
        assert_eq!(
            live.selection.focused_row,
            Some(1),
            "focus reveals the ATM cursor"
        );
        let _ = handle_key(&mut live, press(KeyCode::Char('a')));
        assert_eq!(live.payoff_builder.legs().len(), 1, "one leg appended");
        let leg0 = match live.payoff_builder.legs().first() {
            Some(leg) => *leg,
            None => panic!("expected a first leg"),
        };
        assert_eq!(leg0.strike, pos(60_000.0), "the focused strike is appended");
        assert_eq!(leg0.style, OptionStyle::Call, "the focused call leg");
        let rev1 = live.payoff_builder.revision();
        assert!(
            rev1 > rev0,
            "a successful append bumps the builder revision (marks the frame dirty)"
        );

        // Step down to 62000, focus the put leg, then `a` appends it AFTER the call.
        let _ = handle_key(&mut live, press(KeyCode::Char('j')));
        let _ = handle_key(&mut live, press(KeyCode::Char('p')));
        let _ = handle_key(&mut live, press(KeyCode::Char('a')));
        assert_eq!(
            live.payoff_builder.legs().len(),
            2,
            "second leg appended in order"
        );
        let leg1 = match live.payoff_builder.legs().get(1) {
            Some(leg) => *leg,
            None => panic!("expected a second leg"),
        };
        assert_eq!(
            leg1.strike,
            pos(62_000.0),
            "second leg is the newly focused strike"
        );
        assert_eq!(leg1.style, OptionStyle::Put, "second leg is a put");
        assert!(
            live.payoff_builder.revision() > rev1,
            "the second append bumps the revision again"
        );
    }

    #[test]
    fn test_handle_key_add_leg_on_empty_chain_is_safe_noop() {
        // An empty chain: `a` appends nothing and leaves the builder untouched (no
        // revision bump), the same bounds-safe no-op as before wiring.
        let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let mut live = live_with(empty, ScreenLoad::Ready);
        let rev0 = live.payoff_builder.revision();
        let _ = handle_key(&mut live, press(KeyCode::Char('a')));
        assert!(
            live.payoff_builder.is_empty(),
            "no leg appended on an empty chain"
        );
        assert_eq!(
            live.payoff_builder.revision(),
            rev0,
            "a no-op append does not bump the revision"
        );
    }

    #[test]
    fn test_handle_key_move_strike_on_empty_chain_is_noop() {
        let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let mut live = live_with(empty, ScreenLoad::Ready);
        let _ = handle_key(&mut live, press(KeyCode::Char('j')));
        assert_eq!(
            live.selection.focused_row, None,
            "no cursor on an empty chain"
        );
    }

    // --- Sanity: view models are Copy and round-trip through a live state -----

    #[test]
    fn test_chain_row_and_leg_view_are_constructible_copy_values() {
        let od = full_row(60_000.0);
        let row: ChainRow = ChainRow {
            strike: od.strike_price,
            call: project_call(&od, None, TickDir::Flat, TickDir::Flat),
            put: project_put(&od, None, TickDir::Flat, TickDir::Flat),
            strike_relation: StrikeRelation::AtSpot,
        };
        // Copy: using the value twice compiles without a move error.
        let copy: ChainRow = row;
        let _first: LegView = row.call;
        let _second: LegView = copy.call;
        assert_eq!(row.strike, copy.strike);
    }

    // Keep the imports for a Selection-shaped default used above meaningful.
    #[test]
    fn test_selection_default_focuses_call_leg() {
        let selection = Selection::default();
        assert_eq!(selection.focused_leg, LegFocus::Call);
        assert_eq!(selection.focused_row, None);
    }

    // Ensure the Mode/LiveScreen imports are exercised (a live state renders on the
    // Chain screen).
    #[test]
    fn test_live_state_defaults_to_chain_screen() {
        let live = live_with(chain_with(&[60_000.0]), ScreenLoad::Ready);
        let app_mode = Mode::Live(live);
        match app_mode {
            Mode::Live(state) => assert_eq!(state.screen, LiveScreen::Chain),
            Mode::Replay(_) => panic!("expected a live mode"),
        }
    }

    // =====================================================================
    // Render goldens (#19, docs/TESTING.md §4) + escape-sequence hygiene
    // (docs/SECURITY.md §6.4). Rendered into a TestBackend at a FIXED 120x40
    // and compared against a committed golden; deterministic (fixed as-of
    // instant / the fixture's own timestamps, no wall clock, no socket), so
    // the bytes are stable across machines.
    // =====================================================================

    /// A hostile venue-controlled underlying carrying an OSC clipboard-write
    /// (`ESC ] 52 … BEL`), a CSI clear-screen (`ESC [ 2J`), a raw newline/tab, and
    /// an 8-bit C1 `CSI` (`0x9B`) — the escape-hygiene probe. Written with `\u{..}`
    /// escapes, so the SOURCE file carries no raw control byte.
    const HOSTILE_SYMBOL: &str = "BTC\u{1b}]52;c;cHduZWQ=\u{7}\u{1b}[2J\nEVIL\t\u{9b}31m";

    /// Seed a [`LiveState`] on the Chain screen from an assembled [`ChainFetch`]
    /// (the adapter-seam output), with a Live source and a fixed as-of instant.
    fn live_from_fetch(fetch: ChainFetch, load: ScreenLoad) -> LiveState {
        let store = ChainStore::seed(fetch, ChainSource::Merged, Duration::from_secs(2), utc(EXP));
        let mut live = LiveState::new(
            SourceBinding::new(pid("deribit"), caps(), StreamHealth::Live),
            store,
        );
        live.load = load;
        live
    }

    /// Draw the chain body for `live` into a fixed 120x40 `TestBackend` (tick 0,
    /// so the loading spinner frame is fixed) and return the buffer as golden text.
    #[track_caller]
    fn render_chain_golden(live: &LiveState) -> String {
        use crate::ui::golden::{GOLDEN_HEIGHT, GOLDEN_WIDTH, buffer_to_text};
        let mut term = terminal(GOLDEN_WIDTH, GOLDEN_HEIGHT);
        match term.draw(|frame| draw(live, frame, frame.area(), theme(), 0, utc(EXP))) {
            Ok(_) => {}
            Err(e) => panic!("golden draw failed: {e}"),
        }
        buffer_to_text(term.backend().buffer())
    }

    #[test]
    fn test_chain_deribit_btc_atm_render_golden() {
        // The populated matrix, assembled from the recorded Deribit fixture through
        // the real adapter seam (fixture -> normalize -> assemble -> ChainStore ->
        // chain::draw).
        let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named("BTC");
        let live = live_from_fetch(fetch, ScreenLoad::Ready);
        let text = render_chain_golden(&live);
        crate::ui::golden::assert_golden("chain", "deribit_btc_atm.txt", &text);
    }

    #[test]
    fn test_populated_matrix_shows_greeks_row_and_computed_origin_glyph() {
        // Issue #28 (the v0.2 #25 acceptance): the populated matrix — assembled from
        // the recorded Deribit fixture through the real adapter seam — renders the
        // full responsive Greeks row with the `~` origin glyph on the locally-computed
        // fields. At the fixed 120x40 golden width the responsive set is Δ + Θ (the
        // `Γ → ν → Θ` drop order keeps Δ always and adds Θ first; Γ/ν need a wider
        // terminal than the golden's fixed 120), so this asserts the semantic content
        // the committed `deribit_btc_atm.txt` golden pins.
        let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named("BTC");
        let live = live_from_fetch(fetch, ScreenLoad::Ready);
        let text = render_chain_golden(&live);
        assert!(
            text.contains('Δ'),
            "the delta greek column header is present"
        );
        assert!(
            text.contains('Θ'),
            "the theta greek column header is present"
        );
        assert!(text.contains("IV"), "the IV column header is present");
        // The origin glyph badges the locally-computed Θ (venue theta is discarded, so
        // theta is always ComputedLocally) — proving the #25 origin badge renders.
        assert!(
            text.contains('~'),
            "the `~` origin glyph badges a ComputedLocally field (local theta)",
        );
        // The badged value renders as a real, SIGNED number with the glyph, never a
        // bare `~` and never a sign-clipped positive-looking value (issue #83): at the
        // golden's ~590-DTE as-of the venue-IV-priced theta is a genuine `-12.13`-ish
        // decay, width-fitted so the leading `-` survives.
        assert!(
            text.contains("-12.132~"),
            "a computed theta renders as a signed number followed by the origin glyph",
        );
    }

    #[test]
    fn test_chain_loading_render_golden() {
        // The pre-first-frame LOADING state: the vertically-centered spinner +
        // "connecting to deribit". tick 0 fixes the spinner frame, so it is stable.
        let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let live = live_with(empty, ScreenLoad::Loading);
        let text = render_chain_golden(&live);
        crate::ui::golden::assert_golden("chain", "loading.txt", &text);
    }

    #[test]
    fn test_chain_empty_render_golden() {
        // The EMPTY-Ready state (distinct from loading): "no data for BTC
        // 2025-06-27" + "no strikes yet - press r to reconnect".
        let empty = OptionChain::new("BTC", pos(60_000.0), "2025-06-27".to_owned(), None, None);
        let live = live_with(empty, ScreenLoad::Ready);
        let text = render_chain_golden(&live);
        crate::ui::golden::assert_golden("chain", "empty.txt", &text);
    }

    #[test]
    fn test_chain_provider_error_render_golden() {
        let mut live = live_with(chain_with(&[60_000.0]), ScreenLoad::Loading);
        live.load = ScreenLoad::Error {
            message: "provider unreachable".to_owned(),
        };
        let text = render_chain_golden(&live);
        crate::ui::golden::assert_golden("chain", "provider_error.txt", &text);
    }

    #[test]
    fn test_chain_stale_render_golden() {
        // The stale-feed state (a #18 acceptance criterion): the last chain still
        // renders (dimmed) with a `◐ stale` badge in the title — never blanked,
        // never shown as live. Deterministic (a fixed `since` instant).
        let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named("BTC");
        let mut live = live_from_fetch(fetch, ScreenLoad::Ready);
        live.source.health = StreamHealth::Stale { since: utc(EXP) };
        live.store
            .apply_health(StreamHealth::Stale { since: utc(EXP) });
        let text = render_chain_golden(&live);
        crate::ui::golden::assert_golden("chain", "stale.txt", &text);
    }

    #[test]
    fn test_chain_escape_hygiene_render_golden_renders_inert_text() {
        // A hostile venue-controlled symbol flows through the real adapter seam
        // (the domain keeps the bytes verbatim) into the rendered matrix title;
        // the render edge neutralizes it to inert visible text. The committed
        // golden proves it and carries NO raw escape byte.
        let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named(HOSTILE_SYMBOL);
        let live = live_from_fetch(fetch, ScreenLoad::Ready);
        let text = render_chain_golden(&live);
        assert!(
            !text.contains('\u{1b}'),
            "the rendered hostile symbol must carry no raw ESC byte",
        );
        assert!(
            !text.contains('\u{9b}'),
            "the rendered hostile symbol must carry no 8-bit CSI introducer",
        );
        assert!(
            !text.contains('\u{7}'),
            "the rendered hostile symbol must carry no BEL byte",
        );
        crate::ui::golden::assert_golden("chain", "escape_hygiene.txt", &text);
    }

    #[test]
    fn test_draw_hostile_symbol_renders_inert_across_sizes_without_panic() {
        // The hostile symbol renders as inert text at every size (including the
        // minimum body) — never a panic, never a residual escape/introducer byte.
        let fetch = crate::providers::deribit::fixture_btc_chain_fetch_named(HOSTILE_SYMBOL);
        let live = live_from_fetch(fetch, ScreenLoad::Ready);
        for (w, h) in [(40u16, 8u16), (80, 24), (120, 40), (200, 60)] {
            let text = rendered(&live, w, h);
            assert!(!text.contains('\u{1b}'), "no ESC byte at {w}x{h}");
            assert!(!text.contains('\u{9b}'), "no 8-bit CSI at {w}x{h}");
        }
    }
}