Skip to content

Add Kalshi + Polymarket prediction market integration#68

Open
Paulo-BatistaFerraz wants to merge 33 commits into
visualHFT:masterfrom
Paulo-BatistaFerraz:master
Open

Add Kalshi + Polymarket prediction market integration#68
Paulo-BatistaFerraz wants to merge 33 commits into
visualHFT:masterfrom
Paulo-BatistaFerraz:master

Conversation

@Paulo-BatistaFerraz
Copy link
Copy Markdown

Summary

Adds first-class integration with two prediction-market venues (Kalshi + Polymarket) to VisualHFT, plus several supporting UI windows and SDK template improvements.

This is a large PR — happy to split into smaller, focused PRs if maintainers prefer. Opening this first as a single unit so the full scope is visible.

What's included

Kalshi integration

  • KalshiBrowserPoller, KalshiEventCatalog, KalshiCredentials, KalshiTradeHelper, KalshiViewRequest helpers
  • Live Events Browser with category grouping, search, OI/volume sorting, right-click context menu
  • Kalshi-style per-market ladder window with depth-bar column, category column, auto-scroll
  • Strike ladder window with monotonicity arbitrage scanner and cumulative depth chart
  • PMF (implied probability) window
  • Watch List window with search + context menu
  • Live trade tape for focused ticker
  • Demo-only order panel (gated, demo trading only)
  • Credentials are env-driven — no hardcoded keys or PEM paths

Polymarket integration

  • PolymarketBrowserPoller
  • Polymarket venue option in Events Browser

SDK template improvements

  • SDK-MarketConnectorTemplate: expanded JSON parser, exchange messages model, plugin settings improvements, template plugin scaffolding
  • SDK-StudyTemplate: README + viewmodel polish

Misc

  • Configurable demo PEM path
  • README screenshots (main window + events browser)
  • Fork README documenting Kalshi setup

Notes

  • 4 upstream commits behind — can rebase if requested
  • All net-new code lives under clearly-named files (Kalshi*, Polymarket*) so it's easy to isolate or remove
  • Demo trading is opt-in and isolated from production order flow

Test plan

  • Verify upstream builds still pass after merge
  • Smoke-test Kalshi Events Browser with valid credentials
  • Smoke-test Kalshi ladder windows on a live market
  • Smoke-test Polymarket poller
  • Confirm no regressions in existing connectors (Binance, Bitfinex, BitStamp, Gemini, Kraken, KuCoin)

- vmOrderBook.cs: lock cumulative-depth X axes to 0-100 cents with title
  'YES price (¢)' (binary-contract probability scale)
- ucDepth1.xaml: rename 'Bids' → 'YES Bid', 'Asks' → 'YES Ask' headers
  (asks come from reflected NO bids per the plugin's mapping rule)
- Disable VPIN, OTT_Ratio, MarketResilience studies — designed for
  continuous markets (informed-flow / toxic-trade detection); not
  meaningful on binary contracts. LOBImbalance kept (transfers cleanly).
- Removed disabled study DLLs from runtime bin

ScatterPoint patch from earlier session (oxyplot fork) carried forward.
New standalone window opened from a 'Kalshi Strikes' button on the
Dashboard. Subscribes to HelperOrderBook (every venue's order books),
filters to Kalshi tickers (prefix KX), parses 'EVENT-STRIKE' from the
ticker, and renders a live grid: Event | Strike | Ticker | YES Bid |
YES Ask | Spread | Mid Prob | Updated.

Files added:
- ViewModel/vmKalshiStrikeLadder.cs (KalshiStrikeRow + vmKalshiStrikeLadder)
- View/KalshiStrikeLadderWindow.xaml + .cs (dark theme, materialdesign)

Wired button + handler in Dashboard.xaml + Dashboard.xaml.cs.

Verified: plugin loads, polls KXHIGHTATL-26APR29-B82.5 successfully.
No errors in log. Strike ladder window opens cleanly.
- Revert hard-lock of cumulative-depth X axes to 0-100; auto-scale
  restored. Hard-lock broke non-Kalshi venues (KuCoin chart axes
  pinned to YES-price scale even when viewing BTC/USD etc).
  Conditional per-symbol locking deferred to Phase D.
- Re-add 'Kalshi Strikes' button to Dashboard.xaml (previous edit
  did not persist — empty git diff for this file at Phase B commit
  time; handler in Dashboard.xaml.cs was already there).

Companion change in visualhft-kalshi (separate repo):
  KalshiPlugin sets OrderBook.ProviderID=100, ProviderName='Kalshi'
  so the venue appears in VisualHFT's Providers' Status panel and
  is selectable as a venue throughout the app.
New View/KalshiLadderWindow + ViewModel/vmKalshiLadder show one market's
full depth styled like kalshi.com:
- Header card: Symbol, YES bid (green), NO bid (red, derived as 100 - YES ask),
  mid price, spread
- Asks DataGrid (top, red): Price ¢ | Contracts | cumulative Total $ (/)
- 'Mid' divider showing midprice
- Bids DataGrid (bottom, green): Price ¢ | Contracts | cumulative Total $

Wired from Strike Ladder: double-click any row → opens KalshiLadderWindow
for that ticker. Multiple ladders can be open simultaneously.

Mirrors the kalshi.com price-ladder UX (cents, cumulative dollars,
last-trade-style mid divider). Trade-button equivalents intentionally
omitted — this is a read-only data view by design.
KalshiStrikeRow.Category derived from ticker prefix:
KXHIGH/LOW/SNOW/RAIN → Weather; KXNBA/NFL/MLB → Sports;
KXBTC/ETH → Crypto; KXWTI/BRENT/NGAS → Commodities;
KXFED/RATE/CPI → Rates/Macro; KXSENATE/PRES → Politics;
KXSP500/NASDAQ → Equities.

New 'Cat' column at the leftmost position (sortable, color-coded).
Lets you slice the ladder by domain when watching multiple market types.
Each row in the per-market ladder now shows a horizontal bar
proportional to that level's contracts vs the largest visible level.
Asks bars are translucent red, bids bars translucent green. This
is the column-depth visual integrated with the price ladder —
matches what kalshi.com shows behind each price level.

KalshiLevelRow.BarWidth is recomputed on every poll based on the
max contracts across all current bid+ask levels.
For each event group (rows sharing EventTicker), sort strikes ascending
and check every adjacent pair K_low < K_high for two violations:

  1. Executable arb: yes_ask(K_low) < yes_bid(K_high). Free profit
     = bid(K_high) - ask(K_low) cents per pair, regardless of outcome.
  2. Theoretical violation: mid(K_low) < mid(K_high). Implied prob
     ordering broken at midpoint, sub-spread (not directly tradable).

Each row exposes ArbEdgeCents (max executable across pairs) and
TheoMonotonicityGapCents (max theoretical gap). New 'Arb' column in
the grid renders +X.X¢ in red if executable, ~X.X¢ in yellow if
theoretical-only.

Strike numeric is parsed from the suffix token (T103.99, B82.5, T63)
to enable correct ordering across heterogeneous strike formats.

Scanner runs in BeginInvoke after every order book update, so it's
always current relative to the visible state.

This is the foundation for the future trading algorithm: visualize
free-money signals; algorithm picks them up later.
vmKalshiPMF + KalshiPMFWindow:
  Subscribes to all Kalshi order books, filters by event-ticker prefix,
  derives the PMF from the strike-ladder mids:
    p_0 = 1 - P(>K_0)              left tail
    p_i = P(>K_{i-1}) - P(>K_i)    interior bins
    p_N = P(>K_N)                  right tail
  Computes median strike via cumulative interpolation. Renders as
  horizontal bars (peak = yellow, tails = gray, interior = blue).
  Live updates as the strikes tick.

Strike ladder context menu — right-click any row to:
  - Show implied PMF for this event
  - Open ladder for this market

Ladder window polish — auto-scroll on each book update so:
  - Best ask is always at the bottom (next to the mid divider)
  - Best bid is always at the top (next to the mid divider)
The 'action' stays in view as the book updates, regardless of depth.
VisualHFT/Helpers/KalshiTradeHelper.cs:
  Self-contained RSA-PSS signer + HTTP client wired exclusively to
  demo (demo-api.kalshi.co). Replicates plugin's signing logic to
  avoid VisualHFT.csproj depending on the plugin assembly. Exposes
  PlaceLimitAsync(side, action, price, count) and CancelAsync(id).
  Hard-coded safety: count ∈ [1, MAX_COUNT=5], price ∈ [1, 99]¢,
  ticker must start with KX.

KalshiLadderWindow:
  Adds a 'DEMO ORDER' panel below the bid grid. ComboBox for side
  (YES/NO) and action (Buy/Sell), TextBoxes for price and count,
  Submit + Cancel last buttons, status line.
  Tracks recently-placed order IDs so Cancel last works without
  the user needing to copy ids around.

Trading + viewing are intentionally on different envs:
  - Polling stays on prod (rich orderbooks, read-only key)
  - Orders go to demo ($1000 fake balance, full trade scope)
This avoids any path where a UI mistake hits prod with real money.

Order placement endpoint and cancel both verified live on demo
prior to wiring (POST /portfolio/orders → 201, DELETE → 200).
New button '📁 Events Browser' on Dashboard opens a tabbed window with
one tab per real Kalshi category (16 of them, e.g. Sports / Elections /
Crypto / Climate and Weather / Commodities). Each tab is a DataGrid:
Event Ticker | Series | Title | Sub-title.

Helpers/KalshiEventCatalog.cs:
  Self-contained signed REST client (mirrors KalshiTradeHelper pattern)
  that paginates GET /events?status=open&limit=200 through every cursor.
  Returns List<KalshiEventInfo>. Hits prod (richer universe).

ViewModel/vmKalshiEventBrowser.cs:
  Loads the catalog async, groups by API's category field, sorts by count
  desc. ObservableCollection<KalshiCategoryGroup> with header counts.

View/KalshiEventBrowserWindow.xaml + .cs:
  Dark-themed TabControl + DataGrid template per tab. Refresh button.
  Empty status text while loading; shows totals when done.

~6000 events across 16 categories on first load. Read-only browse — to
add an event to the polling list you still edit plugin Tickers. Dynamic
'Watch this event' subscription is a future enhancement.
Helpers/KalshiBrowserPoller.cs (new):
  Singleton poller in VisualHFT main project that watches a dynamic set
  of Kalshi tickers. On each book update, calls
  HelperOrderBook.Instance.UpdateData(book) — same bus the plugin uses.
  Tagged with the same Kalshi ProviderID/Name so symbols merge under the
  existing 'Kalshi' provider in the Provider/Symbol dropdown.
  Hits prod, 1Hz baseline, 50-deep books.

KalshiEventCatalog.GetEventMarketsAsync(evt) — new method that fetches
the strike list for one event via /events/{ticker}?with_nested_markets=true.

KalshiEventBrowserWindow:
  TabControl now wires MouseDoubleClick. On double-click of any event
  row, the handler walks up to the DataGridRow, gets the KalshiEventInfo,
  fetches its markets, and adds them to KalshiBrowserPoller.Instance.
  Window title updates to show how many tickers are now being watched.

Workflow: open browser → double-click any event in any category → that
event's strikes immediately start populating the strike ladder and
appear in the main view's Provider/Symbol dropdown.
Log analysis showed catalog was hitting Kalshi's basic-tier rate limit
on page 5 (429 too_many_requests), giving up with only 1000 events
instead of the actual ~6000.

KalshiEventCatalog.FetchAllOpenAsync now:
  - 200ms throttle between pages (= 5 req/s ceiling)
  - Per-page retry on 429 with exp backoff (1s/2s/4s/8s/16s, max 5)
    via FetchPageWithBackoffAsync
  - Process-wide cache (5-min TTL) so reopening the browser is instant
  - Refresh button calls InvalidateCache() to force a fresh fetch

First-load time goes from 'incomplete' to '~6s for full 6000 events'.
The plugin path auto-registers each new symbol with HelperSymbol via
BasePluginDataRetriever.RaiseOnDataReceived. The browser poller pushes
straight to HelperOrderBook, bypassing that — so symbols never appeared
in VisualHFT's Provider/Symbol dropdown after watching.

KalshiBrowserPoller.Watch now also calls HelperSymbol.Instance.UpdateData(t)
the moment a new ticker is added. Symbols appear immediately, even before
the first orderbook poll resolves.
Kalshi /events response has no liquidity info per event. Solution:
fetch all active /markets after events load, group by event_ticker,
sum open_interest_fp into a Dict<eventTicker, (oi, marketsCount)>.

KalshiEventCatalog.FetchEventLiquidityAsync paginates /markets with
the same throttle/backoff pattern as FetchAllOpenAsync. ~30 pages
typical, ~30-60s in background depending on active-market count.

KalshiEventInfo gains:
  OpenInterest (double, set after liquidity load) + OpenInterestText
  ('5.0K', '1.2M', etc), MarketCount (int).
  PropertyChanged notifications so the DataGrid updates live.

vmKalshiEventBrowser kicks off LoadLiquidityAsync on a background
task after events land. Once liquidity arrives, each category's
events get re-sorted by OI desc (ticker asc tiebreaker).

Window XAML: new 'OI' (yellow) + 'Mkts' columns at the leftmost
position, both right-aligned and sortable.
Two bugs surfaced by user screenshot showing only governance events
with OI=0:

1. KalshiEventCatalog.FetchEventLiquidityAsync used status=active in
   the /markets query — Kalshi rejected with 400 ('cold market not
   found' style error). Valid values are open/closed/settled. Switched
   to status=open. Liquidity now actually loads.

2. vmKalshiStrikeLadder + KalshiBrowserPoller.Watch filtered by KX
   prefix. Real Kalshi tickers include CONTROLH-2026, GOVPARTY*,
   EUEXIT, etc. that pre-date the KX naming scheme. Filters now use
   Kalshi ProviderID (100) for the ladder and length>=3 for the watch
   list, so all Kalshi events are tradeable through the same flow.
Volume in events browser:
  KalshiEventInfo.Volume + VolumeText (5.0K/1.2M format).
  KalshiEventCatalog.FetchEventLiquidityAsync returns
  Dict<eventTicker, (oi, vol, markets)>; aggregates volume_fp same
  way as open_interest_fp.
  Browser DataGrid: new 'Volume' column with SortMemberPath=Volume.

Watch List window (new):
  Dashboard '📋 Watch List' button opens KalshiWatchListWindow.
  vmKalshiWatchList subscribes to HelperOrderBook, filters by
  KalshiBrowserPoller.WatchedTickers + Kalshi providerId=100.
  Live grid: Ticker | Event | YES Bid | YES Ask | Mid | Spread |
  Updated | Remove. Manual 'Add ticker' input at the top calls
  KalshiBrowserPoller.Watch directly so additions flow to the same
  bus as double-click-from-browser.
  Remove button calls KalshiBrowserPoller.Unwatch and pulls the
  row from the table.

Anything on the watch list automatically also shows in the Strike
Ladder via shared HelperOrderBook subscription.
Helpers/KalshiViewRequest.cs (new): static event hub. Anything that
wants the main vmOrderBook to display a specific Kalshi ticker calls
KalshiViewRequest.Show(symbol, providerId).

vmOrderBook subscribes in its ctor and on each fire dispatches to the
UI thread, finds the matching Provider in _providers, sets
SelectedProvider + SelectedSymbol so the chart populates.

KalshiWatchListWindow: new MouseDoubleClick handler on the grid →
KalshiViewRequest.Show(row.Ticker, 100). Cursor=Hand + tooltip.

Strike Ladder kept on its existing double-click-opens-per-market-ladder
behavior; can wire a separate keybind later if desired.
After WatchEventAsync subscribes the event's markets, fire
KalshiViewRequest.Show(first_market, 100) so the user sees the chart
populate immediately. Same UX as Watch List double-click. The other
strikes for the event are still accessible via the Symbol dropdown.
WatchEventAsync now takes a loadChart bool. Plain double-click passes
true (current behavior). New right-click menu on event rows offers:
  - 📋 Add to Watch List (no chart)
  - 📊 Watch + Load Chart
  - 📁 Show all markets in Strike Ladder

Last option also opens the Strike Ladder window so the user can scan
all strikes of the event side-by-side. SelectedEvent() walks the
TabControl's selected content's visual tree to find the active grid
+ row across the dynamic per-category DataTemplate.
…ladder)

- TextBox 'Search' bound to vm.Search; FilteredRows is a CollectionView
  with a Filter that keeps rows whose Ticker or EventTicker contains
  the substring. Refresh on every keystroke.

- DataGrid right-click menu adds three actions:
    📊 Open kalshi.com-style ladder window — KalshiLadderWindow per ticker
    📈 Load in main chart — same as double-click
    ❌ Remove from Watch List

The kalshi-style ladder option fixes the user's complaint about KXBTCD
not displaying like kalshi.com — the standard ucDepth1 layout is
side-by-side bid/ask, while kalshi.com is asks-on-top with cumulative $.
KalshiLadderWindow already does the kalshi-style layout; this just
exposes it from the Watch List.
Header gains a 🔍 Search TextBox bound to vm.Search.

Each KalshiCategoryGroup now also exposes FilteredEvents (an
ICollectionView wrapping its Events). The view's filter checks
event_ticker / series_ticker / title / sub_title for the search
substring (case-insensitive).

Setting vm.Search refreshes every group's FilteredEvents view, so
typing in the box filters every category tab simultaneously without
having to switch tabs.

XAML: TabControl DataTemplate's DataGrid bound to FilteredEvents
instead of Events.
Issue: when only 3 ask levels exist, the asks DataGrid (in a Grid row
with Height='*') still claimed half the window and rendered the rows
at the TOP, leaving a huge gap above the Mid divider.

Fix: VerticalAlignment='Bottom' + MaxHeight=320 on AsksGrid so the
rendered rows sit just above the Mid divider when sparse, and the
spacer above absorbs the leftover height. Bids stay top-aligned.
Also added MinHeight=120 to both Grid rows so neither side collapses
when the other has tons of levels.

Mirrors kalshi.com: asks descend toward the mid line, bids ascend
away from it.
The previous layout used Grid rows with Height='*' for asks and bids,
which split the window evenly even when one side had only 1-2 levels.
Result: huge empty gaps in both halves for sparse books.

New layout: DockPanel with header pinned top, order panel pinned bottom,
and a single ScrollViewer in the middle containing
[Asks DataGrid] [Mid divider] [Bids DataGrid] in a StackPanel.

Both grids size to their content (ScrollViewer.CanContentScroll=False on
the inner grids; the outer ScrollViewer takes over). When the book is
sparse, the ladder is compact. When the book is deep, the outer
ScrollViewer scrolls the whole stack. Mirrors kalshi.com.
KalshiBrowserPoller now also polls /markets/trades for the ticker the
user has currently focused (set by KalshiViewRequest.Show whenever a
double-click happens in Watch List / Events Browser / Strike Ladder).
New trades are deduped by trade_id and pushed to
HelperTrade.Instance.UpdateData — same bus the existing trade-tape
panel binds to.

Mapping: Price=yes_price_dollars*100, Size=count_fp,
IsBuy=(taker_side=='yes'), Timestamp=created_time.

Single-ticker only (the focused one) to avoid blowing past Kalshi's
~10 req/s basic-tier limit when 100+ tickers are watched. Switching
focus seamlessly redirects the tape.

Per-ticker dedupe set capped at 400 ids, then reseeded from the latest
page so memory doesn't grow unbounded.
KalshiBrowserPoller.ComputeLoopDelayMs scales the loop sleep with
watched-ticker count so total req/s stays under TargetReqPerSec=8
(safe headroom under Kalshi's basic-tier ~10 nominal). Symptom this
fixes: with 100+ watched tickers we were issuing 100+ req/s and
silently 429-ing on most of them, leaving stale data.

Strike Ladder right-click menu now also offers:
  📉 Load this ticker into main chart
which fires KalshiViewRequest.Show — same hub used by Watch List
double-click and Events Browser double-click. Consistent navigation
across all three Kalshi-aware views.
The per-ticker ladder now has an opt-in cumulative depth chart above the
book. Off by default and persisted per-user via the existing
SettingsManager (new SettingKey.KALSHI_LADDER_DEPTH_CHART), so a fresh
window shows the ladder full-height with no reserved space for a chart
the user hasn't asked for.

Toggle lives in the market header next to mid/spread; Ctrl+D toggles it
when the window has focus, suppressed inside text inputs so order entry
typing is never hijacked. Three unit-mode chips (contracts, notional $,
% of total book) sit beside the toggle; selection is also persisted.

Chart is drawn with OxyPlot StairStepSeries so the curve is discrete by
construction (no smoothing). Y-axis is mirrored on both edges and clipped
to the 90th percentile of cumulative depth so a single whale level far
from the spread doesn't squash the near-spread shape flat — clipped
levels are flagged with an inline overflow indicator.
Header was showing only the raw ticker (e.g. KXNHLGAME-25APR30PHI-PHI),
which is unreadable at a glance and makes it easy to confuse two open
ladders for the same event. Ladder now hits /markets/{ticker} once on
open (process-wide cached, best-effort — empty on failure so the ticker
remains the fallback) and shows the most distinctive human-readable
field as the big header line: subtitle, then yes_sub_title, then title.
The ticker (and parent question, when subtitle is the headline) are
demoted to a smaller gray secondary line below.
KalshiTradeHelper.ForDemo() previously hardcoded the demo PEM at an absolute
path on the author's machine, which broke any clean clone. Now it resolves
the path in this order:

  1. KALSHI_DEMO_PEM environment variable
  2. %USERPROFILE%\.visualhft\kalshi-demo.pem
  3. The legacy hardcoded path (kept so the existing setup keeps working)

Error message names both supported options when none of the candidates
exists.

Also adds README.Kalshi.md documenting the two-repo layout (this fork +
the visualhft-kalshi plugin repo) and a short pointer to it from the top
of README.md so anyone landing on the repo sees it immediately.
The three Kalshi helpers (TradeHelper, BrowserPoller, EventCatalog)
previously baked in:

  - the author's prod and demo Kalshi access-key ids,
  - absolute paths to the author's local PEM files.

Both are gone. A new shared resolver Helpers/KalshiCredentials.cs reads:

  KALSHI_DEMO_KEY_ID  /  KALSHI_DEMO_PEM
  KALSHI_PROD_KEY_ID  /  KALSHI_PROD_PEM

PEM env vars also fall back to %USERPROFILE%\.visualhft\kalshi-{demo,prod}.pem
if unset. Key ids are required (no defaults). Failure modes:

  - Trade panel / Event catalog throw a clear error naming the missing
    variable.
  - Browser poller logs a warning and continues running with empty creds
    (so view-only UX without prod creds doesn't crash the app).

README.Kalshi.md updated with the four env vars and softer framing for
sharing the fork upstream.
The companion bundle repo (Paulo-BatistaFerraz/VisualHFT-Kalshi) ships
the same Kalshi-aware fork as a vendored copy with a slightly cleaner
KalshiCredentials helper:

  - Env vars use the *_PATH suffix:
      KALSHI_DEMO_PEM_PATH, KALSHI_PROD_PEM_PATH (prev: *_PEM)
  - Base URLs (DemoBase/ProdBase) live on KalshiCredentials, removing
    duplication across TradeHelper / EventCatalog / BrowserPoller.
  - Required-only API: Require() throws if missing, TryGetProd*() returns
    null. No %USERPROFILE% fallback.

This commit ports those choices over so the standalone fork and the
bundle repo's vendored copy are consistent — same env vars, same setup
docs, same code shape.

README.Kalshi.md updated with the new env var names and a pointer to
the bundle repo for one-clone setup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant