Skip to content

Commit 98c8b06

Browse files
committed
Address starter model and replay issues
Closes #1.\nCloses #2.\nCloses #3.
1 parent 82952f3 commit 98c8b06

12 files changed

Lines changed: 283 additions & 32 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ by date and public-prep milestone.
1212

1313
- Code of Conduct for community participation expectations.
1414
- GitHub social-preview source asset for sharing the project.
15+
- Model assumptions documentation covering volume-as-open-interest proxy, sign
16+
convention, zero-gamma behavior, and known limitations.
1517
- Product vision notes for signature capabilities and contributor-facing ideas.
18+
- Synthetic ES 0DTE full-session replay dataset for no-credential testing.
19+
- Zero-gamma interpolation edge-case tests.
1620

1721
## 2026-06-12
1822

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ You can also run the terminal with normalized replay data:
5757

5858
```bash
5959
gex-terminal --replay sample_data/demo_replay.jsonl
60+
gex-terminal --replay sample_data/es_synthetic_full_session.jsonl
6061
```
6162

6263
Demo and replay modes are useful for UI and engine work that should not require

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,13 @@ Run with normalized replay data:
340340

341341
```bash
342342
gex-terminal --replay sample_data/demo_replay.jsonl
343+
gex-terminal --replay sample_data/es_synthetic_full_session.jsonl
343344
```
344345

346+
`demo_replay.jsonl` is the shortest local fixture. `es_synthetic_full_session.jsonl`
347+
is a synthetic ES 0DTE replay that walks through open, mid-session, and
348+
late-session activity without requiring live credentials.
349+
345350
Override `.env` settings from the command line:
346351

347352
```bash
@@ -413,6 +418,8 @@ pip install -e .
413418
- See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations.
414419
- See [CHANGELOG.md](CHANGELOG.md) for notable project changes.
415420
- See [docs/adapters.md](docs/adapters.md) for the provider adapter contract.
421+
- See [docs/model-assumptions.md](docs/model-assumptions.md) for GEX model
422+
assumptions and limitations.
416423
- See [docs/product-vision.md](docs/product-vision.md) for signature capability
417424
concepts and mockups.
418425
- See [ROADMAP.md](ROADMAP.md) for planned phases and future work.

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dashboard and more like a focused market-structure workstation. See
2626
risk-free rate, expiry target, provider, and update interval.
2727
- [x] Improve startup validation so missing credentials or unsupported provider
2828
settings fail with clear messages.
29-
- [ ] Document the current model assumptions, including the
29+
- [x] Document the current model assumptions, including the
3030
volume-as-open-interest proxy, call/put sign convention, and limitations versus
3131
proprietary dealer-positioning models.
3232

@@ -99,7 +99,7 @@ dashboard and more like a focused market-structure workstation. See
9999
- [x] Add provider registry scaffolds for Databento, IBKR, and yfinance.
100100
- [x] Add issue templates for bugs, feature requests, and provider adapters.
101101
- [x] Add a security policy for credential-handling issues.
102-
- [ ] Publish sample replay datasets so new users can evaluate the app without
102+
- [x] Publish sample replay datasets so new users can evaluate the app without
103103
paid data.
104104
- [ ] Add contribution notes for normalized provider payload fixtures.
105105
- [ ] Add a small set of labeled good-first issues after the first public push.
@@ -126,7 +126,7 @@ dashboard and more like a focused market-structure workstation. See
126126

127127
- Add tests for `IntradayGexEngine.calculate_gamma`.
128128
- Add tests for malformed JSON and missing fields in `StatefulGexConsumer`.
129-
- Add a small sample replay dataset.
129+
- Add additional replay datasets for different symbols, expirations, or regimes.
130130
- Improve terminal empty states before live data arrives.
131131
- Document a known Tradovate options-chain payload shape.
132132
- Add README screenshots once replay mode exists.

docs/adapters.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,13 @@ The replay adapter reads normalized JSONL records from disk:
6969

7070
```bash
7171
gex-terminal --replay sample_data/demo_replay.jsonl
72+
gex-terminal --replay sample_data/es_synthetic_full_session.jsonl
7273
```
7374

7475
Replay files are the preferred way to reproduce UI and engine behavior without
75-
live credentials.
76+
live credentials. `demo_replay.jsonl` is a compact screenshot/demo fixture, while
77+
`es_synthetic_full_session.jsonl` simulates open, mid-session, and late-session
78+
ES 0DTE activity for contributor testing.
7679

7780
## Tradovate Adapter
7881

docs/model-assumptions.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Model Assumptions
2+
3+
`gex-terminal` estimates intraday gamma exposure for research and engineering
4+
experimentation. It is not financial advice, an execution signal, or a
5+
replacement for licensed market-data products.
6+
7+
This document explains the current model assumptions so contributors can improve
8+
the calculation path without treating the output as a black box.
9+
10+
## Current Model
11+
12+
The engine calculates theoretical Black-Scholes gamma for each strike, then
13+
scales that gamma into dollar gamma exposure per 1% move in the underlying.
14+
15+
Current inputs:
16+
17+
- Current underlying spot or futures price.
18+
- Strike price.
19+
- Implied volatility.
20+
- Days to expiry.
21+
- Risk-free rate.
22+
- Contract multiplier.
23+
- Accumulated intraday call and put volume.
24+
25+
The terminal then aggregates strike-level exposure into:
26+
27+
- Total net GEX.
28+
- Gamma wall.
29+
- Call wall and put wall.
30+
- Zero-gamma node.
31+
- Concentration bands.
32+
- Call/put imbalance.
33+
34+
## Volume-As-Open-Interest Proxy
35+
36+
Official open interest is usually delayed and may not update throughout the
37+
session. For intraday research, `gex-terminal` currently uses cumulative session
38+
volume as a practical proxy for changing open interest.
39+
40+
This makes the terminal responsive to live activity, but it also introduces
41+
important limitations:
42+
43+
- Volume does not reveal whether trades opened or closed positions.
44+
- Volume can double-count churn at the same strike.
45+
- Volume does not distinguish customer flow from dealer flow.
46+
- Volume can overstate exposure during high-turnover sessions.
47+
- Low-volume strikes can appear less important than their true open interest
48+
would imply.
49+
50+
Future provider integrations should add official open interest when available
51+
and let users compare volume-weighted, open-interest-weighted, and hybrid views.
52+
53+
## Call And Put Sign Convention
54+
55+
The current model treats call exposure as positive and put exposure as negative:
56+
57+
- Call GEX = `call_volume * gamma_scaling_factor`
58+
- Put GEX = `put_volume * gamma_scaling_factor * -1`
59+
- Net GEX = `call_gex + put_gex`
60+
61+
This convention is simple and useful for building an explainable first model,
62+
but it is not a complete dealer-positioning model. In real markets, the sign of
63+
dealer hedging pressure depends on trade direction, customer/dealer positioning,
64+
inventory, and whether trades are opening or closing.
65+
66+
Future work should add dealer/customer direction inference when a provider
67+
exposes enough trade context to support it.
68+
69+
## Zero-Gamma Node
70+
71+
The zero-gamma node is estimated from the strike-level net GEX array:
72+
73+
- If a strike has exactly zero net GEX, that strike is returned.
74+
- If adjacent strikes change sign, the node is linearly interpolated between
75+
those strikes.
76+
- If no sign change exists, the model falls back to the strike with the smallest
77+
absolute net GEX.
78+
79+
This makes the output deterministic and stable for replay data, but it should be
80+
read as a structural estimate rather than an exact market boundary.
81+
82+
## Gamma Wall And Concentration
83+
84+
The gamma wall is the strike with the largest absolute net GEX concentration.
85+
The call wall and put wall are based on the largest call-side and put-side
86+
exposure respectively.
87+
88+
The concentration band reports the smallest strike range that contains a target
89+
share of absolute net GEX. This is intended to show whether exposure is tightly
90+
clustered or spread across the chain.
91+
92+
## Known Limitations
93+
94+
Current limitations versus more mature commercial dealer-positioning tools:
95+
96+
- No official open-interest ingestion yet.
97+
- No dealer/customer trade-direction model yet.
98+
- No opening/closing trade classification.
99+
- No vanna, charm, delta exposure, vega exposure, or theta exposure yet.
100+
- Live option-chain discovery still depends on provider-specific implementation
101+
work.
102+
- Market-maker inventory and proprietary positioning assumptions are not modeled.
103+
- The model does not account for all expiration-specific, settlement, or
104+
exchange microstructure details.
105+
106+
These limits are intentional to document. The project should prefer a transparent
107+
model that contributors can inspect and improve over opaque confidence.
108+
109+
## Contributor Guidance
110+
111+
When changing model behavior:
112+
113+
- Add deterministic tests for the exact assumption being changed.
114+
- Update this document when formulas, signs, inputs, or fallback behavior change.
115+
- Include replay or fixture data when the change depends on provider payloads.
116+
- Keep provider-specific parsing outside the engine.
117+
- Avoid presenting modeled levels as financial advice or guaranteed support and
118+
resistance.

gex_terminal/table_rows.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
def arrange_rows(rows, sort_mode, filter_mode, spot, max_volume):
2+
"""Filter then sort the matrix rows."""
3+
return sort_rows(filter_rows(rows, filter_mode, spot, max_volume), sort_mode)
4+
5+
6+
def filter_rows(rows, filter_mode, spot, max_volume):
7+
"""Filter rows by mode. Never returns empty if input was non-empty."""
8+
if filter_mode == "near" and spot:
9+
window = max(spot * 0.01, 1.0)
10+
subset = [row for row in rows if abs(row["strike"] - spot) <= window]
11+
return subset or list(rows)
12+
if filter_mode == "active" and max_volume:
13+
threshold = max_volume * 0.25
14+
subset = [row for row in rows if row["volume"] >= threshold]
15+
return subset or list(rows)
16+
return list(rows)
17+
18+
19+
def sort_rows(rows, sort_mode):
20+
if sort_mode == "net":
21+
return sorted(rows, key=lambda row: abs(row["net_gex"]), reverse=True)
22+
if sort_mode == "volume":
23+
return sorted(rows, key=lambda row: row["volume"], reverse=True)
24+
return sorted(rows, key=lambda row: row["strike"])

gex_terminal/tui.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from gex_terminal.consumer import StatefulGexConsumer
1515
from gex_terminal.engine import IntradayGexEngine
1616
from gex_terminal.snapshot import build_snapshot, write_snapshot
17+
from gex_terminal.table_rows import arrange_rows, filter_rows, sort_rows
1718

1819

1920
class GexTerminalApp(App):
@@ -616,31 +617,16 @@ def _hex_status(status: str) -> str:
616617
@staticmethod
617618
def _arrange_rows(rows, sort_mode, filter_mode, spot, max_volume):
618619
"""Filter then sort the matrix rows. Pure function for easy testing."""
619-
return GexTerminalApp._sort_rows(
620-
GexTerminalApp._filter_rows(rows, filter_mode, spot, max_volume),
621-
sort_mode,
622-
)
620+
return arrange_rows(rows, sort_mode, filter_mode, spot, max_volume)
623621

624622
@staticmethod
625623
def _filter_rows(rows, filter_mode, spot, max_volume):
626624
"""Filter rows by mode. Never returns empty if input was non-empty (usability safety)."""
627-
if filter_mode == "near" and spot:
628-
window = max(spot * 0.01, 1.0)
629-
subset = [row for row in rows if abs(row["strike"] - spot) <= window]
630-
return subset or list(rows)
631-
if filter_mode == "active" and max_volume:
632-
threshold = max_volume * 0.25
633-
subset = [row for row in rows if row["volume"] >= threshold]
634-
return subset or list(rows)
635-
return list(rows)
625+
return filter_rows(rows, filter_mode, spot, max_volume)
636626

637627
@staticmethod
638628
def _sort_rows(rows, sort_mode):
639-
if sort_mode == "net":
640-
return sorted(rows, key=lambda row: abs(row["net_gex"]), reverse=True)
641-
if sort_mode == "volume":
642-
return sorted(rows, key=lambda row: row["volume"], reverse=True)
643-
return sorted(rows, key=lambda row: row["strike"])
629+
return sort_rows(rows, sort_mode)
644630

645631

646632
async def run_mock_session():
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{"type":"underlying_tick","symbol":"ES","price":5928.25,"timestamp":"2026-06-12T13:30:00Z","session_phase":"open"}
2+
{"type":"options_volume_tick","strike":5875,"option_type":"C","volume":180,"iv":0.185,"expiry":"0DTE","timestamp":"2026-06-12T13:30:05Z","session_phase":"open"}
3+
{"type":"options_volume_tick","strike":5875,"option_type":"P","volume":820,"iv":0.185,"expiry":"0DTE","timestamp":"2026-06-12T13:30:06Z","session_phase":"open"}
4+
{"type":"options_volume_tick","strike":5900,"option_type":"C","volume":420,"iv":0.172,"expiry":"0DTE","timestamp":"2026-06-12T13:30:10Z","session_phase":"open"}
5+
{"type":"options_volume_tick","strike":5900,"option_type":"P","volume":1240,"iv":0.172,"expiry":"0DTE","timestamp":"2026-06-12T13:30:11Z","session_phase":"open"}
6+
{"type":"options_volume_tick","strike":5925,"option_type":"C","volume":960,"iv":0.158,"expiry":"0DTE","timestamp":"2026-06-12T13:30:15Z","session_phase":"open"}
7+
{"type":"options_volume_tick","strike":5925,"option_type":"P","volume":1380,"iv":0.158,"expiry":"0DTE","timestamp":"2026-06-12T13:30:16Z","session_phase":"open"}
8+
{"type":"options_volume_tick","strike":5950,"option_type":"C","volume":1160,"iv":0.149,"expiry":"0DTE","timestamp":"2026-06-12T13:30:20Z","session_phase":"open"}
9+
{"type":"options_volume_tick","strike":5950,"option_type":"P","volume":760,"iv":0.149,"expiry":"0DTE","timestamp":"2026-06-12T13:30:21Z","session_phase":"open"}
10+
{"type":"underlying_tick","symbol":"ES","price":5941.50,"timestamp":"2026-06-12T15:15:00Z","session_phase":"mid"}
11+
{"type":"options_volume_tick","strike":5900,"option_type":"C","volume":780,"iv":0.165,"expiry":"0DTE","timestamp":"2026-06-12T15:15:05Z","session_phase":"mid"}
12+
{"type":"options_volume_tick","strike":5900,"option_type":"P","volume":640,"iv":0.165,"expiry":"0DTE","timestamp":"2026-06-12T15:15:06Z","session_phase":"mid"}
13+
{"type":"options_volume_tick","strike":5925,"option_type":"C","volume":1420,"iv":0.151,"expiry":"0DTE","timestamp":"2026-06-12T15:15:10Z","session_phase":"mid"}
14+
{"type":"options_volume_tick","strike":5925,"option_type":"P","volume":920,"iv":0.151,"expiry":"0DTE","timestamp":"2026-06-12T15:15:11Z","session_phase":"mid"}
15+
{"type":"options_volume_tick","strike":5950,"option_type":"C","volume":2120,"iv":0.142,"expiry":"0DTE","timestamp":"2026-06-12T15:15:15Z","session_phase":"mid"}
16+
{"type":"options_volume_tick","strike":5950,"option_type":"P","volume":700,"iv":0.142,"expiry":"0DTE","timestamp":"2026-06-12T15:15:16Z","session_phase":"mid"}
17+
{"type":"options_volume_tick","strike":5975,"option_type":"C","volume":1780,"iv":0.146,"expiry":"0DTE","timestamp":"2026-06-12T15:15:20Z","session_phase":"mid"}
18+
{"type":"options_volume_tick","strike":5975,"option_type":"P","volume":520,"iv":0.146,"expiry":"0DTE","timestamp":"2026-06-12T15:15:21Z","session_phase":"mid"}
19+
{"type":"options_volume_tick","strike":6000,"option_type":"C","volume":920,"iv":0.153,"expiry":"0DTE","timestamp":"2026-06-12T15:15:25Z","session_phase":"mid"}
20+
{"type":"options_volume_tick","strike":6000,"option_type":"P","volume":310,"iv":0.153,"expiry":"0DTE","timestamp":"2026-06-12T15:15:26Z","session_phase":"mid"}
21+
{"type":"underlying_tick","symbol":"ES","price":5962.75,"timestamp":"2026-06-12T19:45:00Z","session_phase":"late"}
22+
{"type":"options_volume_tick","strike":5925,"option_type":"C","volume":620,"iv":0.145,"expiry":"0DTE","timestamp":"2026-06-12T19:45:05Z","session_phase":"late"}
23+
{"type":"options_volume_tick","strike":5925,"option_type":"P","volume":380,"iv":0.145,"expiry":"0DTE","timestamp":"2026-06-12T19:45:06Z","session_phase":"late"}
24+
{"type":"options_volume_tick","strike":5950,"option_type":"C","volume":1980,"iv":0.136,"expiry":"0DTE","timestamp":"2026-06-12T19:45:10Z","session_phase":"late"}
25+
{"type":"options_volume_tick","strike":5950,"option_type":"P","volume":420,"iv":0.136,"expiry":"0DTE","timestamp":"2026-06-12T19:45:11Z","session_phase":"late"}
26+
{"type":"options_volume_tick","strike":5975,"option_type":"C","volume":2420,"iv":0.139,"expiry":"0DTE","timestamp":"2026-06-12T19:45:15Z","session_phase":"late"}
27+
{"type":"options_volume_tick","strike":5975,"option_type":"P","volume":360,"iv":0.139,"expiry":"0DTE","timestamp":"2026-06-12T19:45:16Z","session_phase":"late"}
28+
{"type":"options_volume_tick","strike":6000,"option_type":"C","volume":2180,"iv":0.146,"expiry":"0DTE","timestamp":"2026-06-12T19:45:20Z","session_phase":"late"}
29+
{"type":"options_volume_tick","strike":6000,"option_type":"P","volume":280,"iv":0.146,"expiry":"0DTE","timestamp":"2026-06-12T19:45:21Z","session_phase":"late"}
30+
{"type":"options_volume_tick","strike":6025,"option_type":"C","volume":760,"iv":0.158,"expiry":"0DTE","timestamp":"2026-06-12T19:45:25Z","session_phase":"late"}
31+
{"type":"options_volume_tick","strike":6025,"option_type":"P","volume":180,"iv":0.158,"expiry":"0DTE","timestamp":"2026-06-12T19:45:26Z","session_phase":"late"}

tests/test_gex_engine.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ def test_interpolates_zero_gamma_between_sign_changes(self):
1414

1515
self.assertEqual(zero, 105.0)
1616

17+
def test_interpolates_zero_gamma_between_uneven_adjacent_strikes(self):
18+
strikes = np.array([100.0, 125.0])
19+
net_gex = np.array([-25.0, 75.0])
20+
21+
zero = IntradayGexEngine.interpolate_zero_gamma_strike(strikes, net_gex)
22+
23+
self.assertEqual(zero, 106.25)
24+
25+
def test_exact_zero_gamma_returns_that_strike(self):
26+
strikes = np.array([100.0, 110.0, 120.0])
27+
net_gex = np.array([-50.0, 0.0, 50.0])
28+
29+
zero = IntradayGexEngine.interpolate_zero_gamma_strike(strikes, net_gex)
30+
31+
self.assertEqual(zero, 110.0)
32+
1733
def test_zero_gamma_falls_back_to_nearest_absolute_exposure_without_sign_change(self):
1834
strikes = np.array([100.0, 110.0, 120.0])
1935
net_gex = np.array([80.0, 20.0, 40.0])
@@ -22,6 +38,30 @@ def test_zero_gamma_falls_back_to_nearest_absolute_exposure_without_sign_change(
2238

2339
self.assertEqual(zero, 110.0)
2440

41+
def test_zero_gamma_falls_back_for_all_negative_exposure(self):
42+
strikes = np.array([100.0, 110.0, 120.0])
43+
net_gex = np.array([-80.0, -20.0, -40.0])
44+
45+
zero = IntradayGexEngine.interpolate_zero_gamma_strike(strikes, net_gex)
46+
47+
self.assertEqual(zero, 110.0)
48+
49+
def test_multiple_sign_changes_choose_candidate_nearest_lowest_absolute_exposure(self):
50+
strikes = np.array([90.0, 100.0, 110.0, 120.0])
51+
net_gex = np.array([-10.0, 10.0, 1.0, -1.0])
52+
53+
zero = IntradayGexEngine.interpolate_zero_gamma_strike(strikes, net_gex)
54+
55+
self.assertEqual(zero, 115.0)
56+
57+
def test_empty_zero_gamma_input_returns_zero(self):
58+
zero = IntradayGexEngine.interpolate_zero_gamma_strike(
59+
np.array([]),
60+
np.array([]),
61+
)
62+
63+
self.assertEqual(zero, 0.0)
64+
2565
def test_compute_matrix_returns_gamma_and_nearest_zero_strike(self):
2666
engine = IntradayGexEngine(multiplier=50)
2767

0 commit comments

Comments
 (0)