diff --git a/Helpers/KalshiBrowserPoller.cs b/Helpers/KalshiBrowserPoller.cs
new file mode 100644
index 00000000..08edd111
--- /dev/null
+++ b/Helpers/KalshiBrowserPoller.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using log4net;
+using VisualHFT.Helpers;
+using VisualHFT.Model;
+
+namespace VisualHFT.Helpers
+{
+ ///
+ /// Singleton poller that watches a *dynamic* set of Kalshi tickers and pushes
+ /// their order books into the same bus the plugin uses
+ /// (HelperOrderBook.Instance.UpdateData). Used by the Events Browser
+ /// when you double-click an event to "watch" it without editing the plugin's
+ /// static ticker list.
+ ///
+ /// Hits prod (richer book). Same Kalshi provider ID/name as the plugin so
+ /// new tickers appear under the existing 'Kalshi' provider in VisualHFT's
+ /// Provider/Symbol dropdown automatically.
+ ///
+ public sealed class KalshiBrowserPoller : IDisposable
+ {
+ private static readonly ILog log = LogManager.GetLogger(typeof(KalshiBrowserPoller));
+
+ // Match the plugin so the Provider/Symbol dropdown groups everything together
+ public const int KalshiProviderId = 100;
+ public const string KalshiProviderName = "Kalshi";
+
+ private static readonly Lazy _instance =
+ new(() => new KalshiBrowserPoller());
+ public static KalshiBrowserPoller Instance => _instance.Value;
+
+ private readonly ConcurrentDictionary _books = new();
+ // Tracks the most-recently-seen trade_id per ticker so we only push new trades.
+ private readonly ConcurrentDictionary> _seenTradeIds = new();
+ private string? _focusedTicker; // Trade tape only fires for the actively viewed ticker
+ private readonly HttpClient _http;
+ private readonly RSA _rsa;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Task _loop;
+ private bool _disposed;
+
+ // 1Hz baseline. Scaled up automatically when many tickers are watched
+ // so total req/s stays within Kalshi's basic-tier ceiling (~10/s read).
+ // See ComputeLoopDelayMs().
+ private const int PollMsBase = 1000;
+ private const int TargetReqPerSec = 8; // headroom under the 10/s nominal cap
+
+ private int ComputeLoopDelayMs()
+ {
+ // Each loop iteration polls every watched ticker once + (optionally) one
+ // trade-tape call. So req/s ≈ (n_books + 1) * (1000 / loopMs).
+ // Keep that under TargetReqPerSec.
+ int n = _books.Count + (string.IsNullOrEmpty(_focusedTicker) ? 0 : 1);
+ if (n <= 0) return PollMsBase;
+ int needed = (int)Math.Ceiling((double)n * 1000.0 / TargetReqPerSec);
+ return Math.Max(PollMsBase, needed);
+ }
+
+ private KalshiBrowserPoller()
+ {
+ _http = new HttpClient { BaseAddress = new Uri(KalshiCredentials.ProdBase) };
+ _rsa = RSA.Create();
+ var pemPath = KalshiCredentials.TryGetProdPemPath();
+ if (!string.IsNullOrEmpty(pemPath) && File.Exists(pemPath))
+ _rsa.ImportFromPem(File.ReadAllText(pemPath));
+ else
+ log.Warn("BrowserPoller: KALSHI_PROD_PEM_PATH unset or file missing — polling will fail. " +
+ "See Helpers/KalshiCredentials.cs for setup.");
+
+ // When the user picks a ticker (Watch List, Strike Ladder, Browser),
+ // remember it so the trade-tape poll fires for that ticker.
+ KalshiViewRequest.OnRequest += (sym, _) => _focusedTicker = sym;
+
+ _loop = Task.Run(LoopAsync);
+ log.Info("KalshiBrowserPoller started");
+ }
+
+ public IReadOnlyCollection WatchedTickers => _books.Keys.ToArray();
+
+ public void Watch(IEnumerable tickers)
+ {
+ int registeredCount = 0;
+ foreach (var t in tickers)
+ {
+ // Kalshi has plenty of non-KX tickers (CONTROLH, GOVPARTY*, EUEXIT, …).
+ // Just require non-empty and reasonably ticker-like.
+ if (string.IsNullOrWhiteSpace(t) || t.Length < 3) continue;
+ _books.TryAdd(t, new OrderBook(t, priceDecimalPlaces: 0, maxDepth: 50)
+ {
+ ProviderID = KalshiProviderId,
+ ProviderName = KalshiProviderName
+ });
+ // Always re-register with HelperSymbol — it dedupes internally and
+ // raises OnCollectionChanged only on the first add. Calling it
+ // unconditionally keeps the dropdown in sync even if the user
+ // double-clicks the same event twice or restarts a session.
+ try
+ {
+ bool wasNew = !HelperSymbol.Instance.Contains(t);
+ HelperSymbol.Instance.UpdateData(t);
+ if (wasNew) registeredCount++;
+ }
+ catch (Exception ex) { log.Warn($"HelperSymbol register {t}: {ex.Message}"); }
+ }
+ log.Info($"Watch: +{registeredCount} new symbol(s); total watched = {_books.Count}");
+ }
+
+ public void Unwatch(string ticker) => _books.TryRemove(ticker, out _);
+ public void UnwatchAll() => _books.Clear();
+
+ private async Task LoopAsync()
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ foreach (var kv in _books.ToArray())
+ {
+ try { await PollOnceAsync(kv.Key, kv.Value); }
+ catch (OperationCanceledException) { return; }
+ catch (Exception ex) { log.Warn($"poll {kv.Key}: {ex.Message}"); }
+ }
+ // Trade tape: fetch recent trades for the currently focused ticker
+ // (the one in the main Provider/Symbol view). Avoids polling trades
+ // for all 100+ watched tickers and blowing past the rate limit.
+ var focus = _focusedTicker;
+ if (!string.IsNullOrEmpty(focus))
+ {
+ try { await PollTradesAsync(focus); }
+ catch (OperationCanceledException) { return; }
+ catch (Exception ex) { log.Warn($"trades {focus}: {ex.Message}"); }
+ }
+
+ int delay = ComputeLoopDelayMs();
+ try { await Task.Delay(delay, _cts.Token); }
+ catch { return; }
+ }
+ }
+
+ private async Task PollTradesAsync(string ticker)
+ {
+ var path = "/trade-api/v2/markets/trades";
+ using var req = BuildRequest(HttpMethod.Get, path, $"?ticker={Uri.EscapeDataString(ticker)}&limit=20");
+ using var resp = await _http.SendAsync(req, _cts.Token).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode) return;
+ var body = await resp.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false);
+
+ using var doc = JsonDocument.Parse(body);
+ if (!doc.RootElement.TryGetProperty("trades", out var arr) || arr.ValueKind != JsonValueKind.Array)
+ return;
+
+ var seen = _seenTradeIds.GetOrAdd(ticker, _ => new HashSet());
+ // Iterate oldest -> newest by reversing (Kalshi returns newest first)
+ var pending = new List();
+ foreach (var t in arr.EnumerateArray())
+ {
+ var tradeId = t.TryGetProperty("trade_id", out var idEl) ? idEl.GetString() ?? "" : "";
+ if (string.IsNullOrEmpty(tradeId)) continue;
+ lock (seen) { if (!seen.Add(tradeId)) continue; }
+
+ double yesPrice = 0;
+ if (t.TryGetProperty("yes_price_dollars", out var ypEl)
+ && double.TryParse(ypEl.GetString(), System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var yp))
+ yesPrice = yp * 100.0;
+
+ double count = 0;
+ if (t.TryGetProperty("count_fp", out var cEl)
+ && double.TryParse(cEl.GetString(), System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var cv))
+ count = cv;
+
+ bool isBuy = t.TryGetProperty("taker_side", out var sEl)
+ && string.Equals(sEl.GetString(), "yes", StringComparison.OrdinalIgnoreCase);
+
+ DateTime ts = DateTime.UtcNow;
+ if (t.TryGetProperty("created_time", out var ctEl)
+ && DateTimeOffset.TryParse(ctEl.GetString(), out var dto))
+ ts = dto.LocalDateTime;
+
+ pending.Add(new Trade
+ {
+ Symbol = ticker,
+ ProviderId = KalshiProviderId,
+ ProviderName = KalshiProviderName,
+ Price = (decimal)yesPrice,
+ Size = (decimal)count,
+ IsBuy = isBuy,
+ Timestamp = ts,
+ });
+ }
+ // pending is newest-first; reverse so the tape grows in time-order
+ pending.Reverse();
+ foreach (var trade in pending)
+ {
+ try { HelperTrade.Instance.UpdateData(trade); }
+ catch (Exception ex) { log.Warn($"HelperTrade.UpdateData {ticker}: {ex.Message}"); }
+ }
+
+ // Cap memory: keep at most last 200 trade-ids per ticker
+ lock (seen)
+ {
+ if (seen.Count > 400)
+ {
+ seen.Clear();
+ foreach (var t in arr.EnumerateArray())
+ {
+ var id = t.TryGetProperty("trade_id", out var idEl) ? idEl.GetString() ?? "" : "";
+ if (!string.IsNullOrEmpty(id)) seen.Add(id);
+ }
+ }
+ }
+ }
+
+ private async Task PollOnceAsync(string ticker, OrderBook book)
+ {
+ var path = $"/trade-api/v2/markets/{ticker}/orderbook";
+ using var req = BuildRequest(HttpMethod.Get, path, $"?depth=50");
+ using var resp = await _http.SendAsync(req, _cts.Token).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode) return;
+ var body = await resp.Content.ReadAsStringAsync(_cts.Token).ConfigureAwait(false);
+
+ using var doc = JsonDocument.Parse(body);
+ var bids = new List();
+ var asks = new List();
+ var now = DateTime.UtcNow;
+
+ if (doc.RootElement.TryGetProperty("orderbook_fp", out var ob))
+ {
+ if (ob.TryGetProperty("yes_dollars", out var yes) && yes.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var lvl in yes.EnumerateArray())
+ {
+ if (lvl.GetArrayLength() < 2) continue;
+ if (!double.TryParse(lvl[0].GetString(), out var p)) continue;
+ if (!double.TryParse(lvl[1].GetString(), out var q)) continue;
+ bids.Add(new BookItem
+ {
+ Symbol = ticker, ProviderID = KalshiProviderId, IsBid = true,
+ Price = Math.Round(p * 100.0, 0), Size = q,
+ EntryID = $"y{p:F4}", LayerName = "MM",
+ LocalTimeStamp = now, ServerTimeStamp = now
+ });
+ }
+ }
+ if (ob.TryGetProperty("no_dollars", out var no) && no.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var lvl in no.EnumerateArray())
+ {
+ if (lvl.GetArrayLength() < 2) continue;
+ if (!double.TryParse(lvl[0].GetString(), out var pNo)) continue;
+ if (!double.TryParse(lvl[1].GetString(), out var q)) continue;
+ asks.Add(new BookItem
+ {
+ Symbol = ticker, ProviderID = KalshiProviderId, IsBid = false,
+ Price = Math.Round((1.0 - pNo) * 100.0, 0), Size = q,
+ EntryID = $"n{pNo:F4}", LayerName = "MM",
+ LocalTimeStamp = now, ServerTimeStamp = now
+ });
+ }
+ }
+ }
+
+ bids.Sort((a, b) => (b.Price ?? 0).CompareTo(a.Price ?? 0));
+ asks.Sort((a, b) => (a.Price ?? 0).CompareTo(b.Price ?? 0));
+ book.LoadData(asks, bids);
+
+ try { HelperOrderBook.Instance.UpdateData(book); }
+ catch (Exception ex) { log.Warn($"UpdateData {ticker}: {ex.Message}"); }
+ }
+
+ ///
+ /// Human-readable metadata for a single Kalshi market. Filled lazily by
+ /// from /markets/{ticker}. All
+ /// fields default to empty so callers can render fallbacks safely.
+ ///
+ public sealed record KalshiMarketInfo(string Title, string Subtitle, string YesSubTitle);
+
+ // Process-wide cache: ladders typically reopen the same handful of
+ // tickers, and these strings don't change for the life of a market.
+ private static readonly ConcurrentDictionary _marketInfoCache = new();
+
+ ///
+ /// Resolve a ticker's title / subtitle / yes-side label. Returns a
+ /// best-effort result (empty fields on auth/network failure) — never
+ /// throws — so the caller can fall back to the raw ticker.
+ ///
+ public async Task GetMarketInfoAsync(string ticker, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(ticker)) return new KalshiMarketInfo("", "", "");
+ if (_marketInfoCache.TryGetValue(ticker, out var cached)) return cached;
+
+ var path = $"/trade-api/v2/markets/{ticker}";
+ try
+ {
+ using var req = BuildRequest(HttpMethod.Get, path, "");
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ {
+ log.Warn($"GetMarketInfo {ticker}: {(int)resp.StatusCode}");
+ return new KalshiMarketInfo("", "", "");
+ }
+ var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ using var doc = JsonDocument.Parse(body);
+ if (!doc.RootElement.TryGetProperty("market", out var m))
+ return new KalshiMarketInfo("", "", "");
+
+ string title = m.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
+ string subtitle = m.TryGetProperty("subtitle", out var s) ? s.GetString() ?? "" : "";
+ string yesSub = m.TryGetProperty("yes_sub_title", out var y) ? y.GetString() ?? "" : "";
+
+ var info = new KalshiMarketInfo(title, subtitle, yesSub);
+ _marketInfoCache[ticker] = info;
+ return info;
+ }
+ catch (OperationCanceledException) { return new KalshiMarketInfo("", "", ""); }
+ catch (Exception ex)
+ {
+ log.Warn($"GetMarketInfo {ticker}: {ex.Message}");
+ return new KalshiMarketInfo("", "", "");
+ }
+ }
+
+ private HttpRequestMessage BuildRequest(HttpMethod method, string pathToSign, string queryString)
+ {
+ var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
+ var msg = Encoding.UTF8.GetBytes(ts + method.Method + pathToSign);
+ var sig = Convert.ToBase64String(_rsa.SignData(msg, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
+ var req = new HttpRequestMessage(method, pathToSign + queryString);
+ req.Headers.Add("KALSHI-ACCESS-KEY", KalshiCredentials.TryGetProdKeyId() ?? "");
+ req.Headers.Add("KALSHI-ACCESS-SIGNATURE", sig);
+ req.Headers.Add("KALSHI-ACCESS-TIMESTAMP", ts);
+ req.Headers.Add("Accept", "application/json");
+ return req;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _cts.Cancel();
+ _http.Dispose();
+ _rsa.Dispose();
+ _disposed = true;
+ }
+ }
+}
diff --git a/Helpers/KalshiCredentials.cs b/Helpers/KalshiCredentials.cs
new file mode 100644
index 00000000..5517a2b6
--- /dev/null
+++ b/Helpers/KalshiCredentials.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace VisualHFT.Helpers
+{
+ ///
+ /// Reads Kalshi API credentials from environment variables so secrets stay
+ /// out of source control. Set these in your shell or system environment
+ /// before running:
+ /// KALSHI_DEMO_KEY_ID — demo environment API access key id (UUID)
+ /// KALSHI_DEMO_PEM_PATH — absolute path to demo RSA private key (.pem)
+ /// KALSHI_PROD_KEY_ID — prod environment API access key id (UUID)
+ /// KALSHI_PROD_PEM_PATH — absolute path to prod RSA private key (.pem)
+ /// Generate keys at https://kalshi.com (or https://demo.kalshi.co for demo)
+ /// → Profile → API Keys.
+ ///
+ internal static class KalshiCredentials
+ {
+ public const string DemoBase = "https://demo-api.kalshi.co";
+ public const string ProdBase = "https://api.elections.kalshi.com";
+
+ public static string DemoKeyId => Require("KALSHI_DEMO_KEY_ID");
+ public static string DemoPemPath => Require("KALSHI_DEMO_PEM_PATH");
+ public static string ProdKeyId => Require("KALSHI_PROD_KEY_ID");
+ public static string ProdPemPath => Require("KALSHI_PROD_PEM_PATH");
+
+ public static string? TryGetProdKeyId() => Environment.GetEnvironmentVariable("KALSHI_PROD_KEY_ID");
+ public static string? TryGetProdPemPath() => Environment.GetEnvironmentVariable("KALSHI_PROD_PEM_PATH");
+
+ private static string Require(string name) =>
+ Environment.GetEnvironmentVariable(name)
+ ?? throw new InvalidOperationException(
+ $"Environment variable '{name}' is not set. " +
+ "See Helpers/KalshiCredentials.cs for setup instructions.");
+ }
+}
diff --git a/Helpers/KalshiEventCatalog.cs b/Helpers/KalshiEventCatalog.cs
new file mode 100644
index 00000000..49723b28
--- /dev/null
+++ b/Helpers/KalshiEventCatalog.cs
@@ -0,0 +1,322 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using log4net;
+
+namespace VisualHFT.Helpers
+{
+ /// One row in the catalog — what /events returns per event,
+ /// plus liquidity aggregates filled in later from /markets.
+ public sealed class KalshiEventInfo : System.ComponentModel.INotifyPropertyChanged
+ {
+ public string EventTicker { get; init; } = "";
+ public string SeriesTicker { get; init; } = "";
+ public string Title { get; init; } = "";
+ public string SubTitle { get; init; } = "";
+ public string Category { get; init; } = "Other";
+ public bool MutuallyExclusive { get; init; }
+ public string LastUpdated { get; init; } = "";
+
+ // Polymarket-only: the first market's YES clobTokenId. Empty for Kalshi rows.
+ // Used by "Watch + Load Chart" to route Polymarket events to providerId 11
+ // (the Polymarket plugin) instead of the Kalshi path.
+ public string PolymarketYesToken { get; init; } = "";
+
+ // Filled in by FetchAllMarketsAsync after events load — aggregated over the event's markets.
+ private double _oi;
+ public double OpenInterest
+ {
+ get => _oi;
+ set { _oi = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(OpenInterest))); PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(OpenInterestText))); }
+ }
+ public string OpenInterestText => Format(OpenInterest);
+
+ private double _volume;
+ public double Volume
+ {
+ get => _volume;
+ set { _volume = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Volume))); PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(VolumeText))); }
+ }
+ public string VolumeText => Format(Volume);
+
+ private static string Format(double v) =>
+ v <= 0 ? ""
+ : v >= 1_000_000 ? $"{v/1_000_000:F1}M"
+ : v >= 1_000 ? $"{v/1_000:F1}K"
+ : $"{v:F0}";
+
+ private int _markets;
+ public int MarketCount
+ {
+ get => _markets;
+ set { _markets = value; PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(MarketCount))); }
+ }
+
+ public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
+ }
+
+ ///
+ /// Fetches the full Kalshi event catalog with pagination, then groups by
+ /// the API's category field. Read-only, uses prod URL because it has
+ /// the richest universe (the polling plugin runs separately on demo).
+ ///
+ public sealed class KalshiEventCatalog : IDisposable
+ {
+ private static readonly ILog log = LogManager.GetLogger(typeof(KalshiEventCatalog));
+
+ private readonly HttpClient _http;
+ private readonly RSA _rsa;
+ private readonly string _keyId;
+ private bool _disposed;
+
+ private KalshiEventCatalog(string baseUrl, string keyId, RSA rsa)
+ {
+ _http = new HttpClient { BaseAddress = new Uri(baseUrl) };
+ _rsa = rsa;
+ _keyId = keyId;
+ }
+
+ public static KalshiEventCatalog ForProd()
+ {
+ var pemPath = KalshiCredentials.ProdPemPath;
+ if (!File.Exists(pemPath))
+ throw new FileNotFoundException($"Prod PEM not found at {pemPath}");
+ var rsa = RSA.Create();
+ rsa.ImportFromPem(File.ReadAllText(pemPath));
+ return new KalshiEventCatalog(KalshiCredentials.ProdBase, KalshiCredentials.ProdKeyId, rsa);
+ }
+
+ // Process-wide cache + lock so reopening the browser is instant and we don't
+ // re-hammer Kalshi. Cleared by the user's Refresh button.
+ private static readonly object _cacheLock = new();
+ private static List? _cachedEvents;
+ private static DateTime _cachedAt;
+
+ public static void InvalidateCache()
+ {
+ lock (_cacheLock) { _cachedEvents = null; }
+ }
+
+ ///
+ /// Fetch every open event, paging until the server returns no cursor.
+ /// Throttled (200ms between pages) and resilient to 429 (exponential
+ /// backoff up to 5 retries per page). Cached process-wide for ~5 min.
+ ///
+ public async Task> FetchAllOpenAsync(int maxPages = 50)
+ {
+ // Serve from cache if it's fresh.
+ lock (_cacheLock)
+ {
+ if (_cachedEvents != null && (DateTime.UtcNow - _cachedAt).TotalMinutes < 5)
+ {
+ log.Info($"event catalog: serving {_cachedEvents.Count} from cache");
+ return new List(_cachedEvents);
+ }
+ }
+
+ var all = new List();
+ string cursor = "";
+ int pages = 0;
+ while (pages < maxPages)
+ {
+ // Throttle BEFORE every request after the first to stay well under
+ // Kalshi's basic-tier limit (~10 req/s). 200ms = 5 req/s ceiling.
+ if (pages > 0) await Task.Delay(200).ConfigureAwait(false);
+
+ var (ok, body) = await FetchPageWithBackoffAsync(cursor).ConfigureAwait(false);
+ if (!ok) { log.Warn($"page {pages}: giving up after retries"); break; }
+
+ using var doc = JsonDocument.Parse(body);
+ if (doc.RootElement.TryGetProperty("events", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var e in arr.EnumerateArray())
+ {
+ all.Add(new KalshiEventInfo
+ {
+ EventTicker = e.TryGetProperty("event_ticker", out var v0) ? v0.GetString() ?? "" : "",
+ SeriesTicker= e.TryGetProperty("series_ticker", out var v1) ? v1.GetString() ?? "" : "",
+ Title = e.TryGetProperty("title", out var v2) ? v2.GetString() ?? "" : "",
+ SubTitle = e.TryGetProperty("sub_title", out var v3) ? v3.GetString() ?? "" : "",
+ Category = e.TryGetProperty("category", out var v4) ? v4.GetString() ?? "Other" : "Other",
+ MutuallyExclusive = e.TryGetProperty("mutually_exclusive", out var v5) && v5.ValueKind == JsonValueKind.True,
+ LastUpdated = e.TryGetProperty("last_updated_ts", out var v6) ? v6.GetString() ?? "" : "",
+ });
+ }
+ }
+ cursor = doc.RootElement.TryGetProperty("cursor", out var cu) ? cu.GetString() ?? "" : "";
+ pages++;
+ if (string.IsNullOrEmpty(cursor)) break;
+ }
+ log.Info($"event catalog: {all.Count} events across {pages} page(s)");
+
+ // Cache only on a complete-ish fetch (>=5 pages or empty cursor).
+ if (all.Count > 200)
+ {
+ lock (_cacheLock) { _cachedEvents = new List(all); _cachedAt = DateTime.UtcNow; }
+ }
+ return all;
+ }
+
+ private async Task<(bool ok, string body)> FetchPageWithBackoffAsync(string cursor)
+ {
+ int retryDelayMs = 1000;
+ const int maxRetries = 5;
+ for (int attempt = 0; attempt < maxRetries; attempt++)
+ {
+ var qs = "/trade-api/v2/events?status=open&limit=200" +
+ (string.IsNullOrEmpty(cursor) ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
+ using var req = BuildRequest(HttpMethod.Get, "/trade-api/v2/events");
+ using var get = new HttpRequestMessage(HttpMethod.Get, qs);
+ CopyAuth(req, get);
+
+ using var resp = await _http.SendAsync(get).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (resp.IsSuccessStatusCode) return (true, body);
+ if ((int)resp.StatusCode == 429)
+ {
+ log.Warn($"429 on attempt {attempt + 1} — backing off {retryDelayMs}ms");
+ await Task.Delay(retryDelayMs).ConfigureAwait(false);
+ retryDelayMs = Math.Min(retryDelayMs * 2, 30_000);
+ continue;
+ }
+ log.Warn($"page failed: {(int)resp.StatusCode} {body[..Math.Min(160, body.Length)]}");
+ return (false, body);
+ }
+ return (false, "");
+ }
+
+ private HttpRequestMessage BuildRequest(HttpMethod method, string path)
+ {
+ var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
+ var msg = Encoding.UTF8.GetBytes(ts + method.Method + path);
+ var sig = Convert.ToBase64String(_rsa.SignData(msg, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
+ var req = new HttpRequestMessage(method, path);
+ req.Headers.Add("KALSHI-ACCESS-KEY", _keyId);
+ req.Headers.Add("KALSHI-ACCESS-SIGNATURE", sig);
+ req.Headers.Add("KALSHI-ACCESS-TIMESTAMP", ts);
+ req.Headers.Add("Accept", "application/json");
+ return req;
+ }
+
+ private static void CopyAuth(HttpRequestMessage from, HttpRequestMessage to)
+ {
+ foreach (var h in from.Headers) to.Headers.TryAddWithoutValidation(h.Key, h.Value);
+ }
+
+ ///
+ /// Fetch the list of market tickers (strikes) inside one event.
+ ///
+ public async Task> GetEventMarketsAsync(string eventTicker)
+ {
+ var basePath = $"/trade-api/v2/events/{eventTicker}";
+ var qs = "?with_nested_markets=true";
+ using var req = BuildRequest(HttpMethod.Get, basePath);
+ using var get = new HttpRequestMessage(HttpMethod.Get, basePath + qs);
+ CopyAuth(req, get);
+
+ using var resp = await _http.SendAsync(get).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ {
+ log.Warn($"GetEventMarkets {eventTicker}: {(int)resp.StatusCode}");
+ return new List();
+ }
+ using var doc = JsonDocument.Parse(body);
+ if (!doc.RootElement.TryGetProperty("event", out var ev))
+ return new List();
+ if (!ev.TryGetProperty("markets", out var arr) || arr.ValueKind != JsonValueKind.Array)
+ return new List();
+ var tickers = new List();
+ foreach (var m in arr.EnumerateArray())
+ {
+ if (m.TryGetProperty("ticker", out var t))
+ {
+ var s = t.GetString();
+ if (!string.IsNullOrEmpty(s)) tickers.Add(s);
+ }
+ }
+ return tickers;
+ }
+
+ ///
+ /// Fetch every active market and aggregate open_interest_fp per event.
+ /// Used by the events browser to sort categories by liquidity.
+ /// Throttled + retry-on-429 like FetchAllOpenAsync.
+ ///
+ public async Task> FetchEventLiquidityAsync(int maxPages = 200)
+ {
+ var byEvent = new Dictionary(StringComparer.Ordinal);
+ string cursor = "";
+ int pages = 0;
+ while (pages < maxPages)
+ {
+ if (pages > 0) await Task.Delay(200).ConfigureAwait(false);
+
+ // status=open is the valid value (Kalshi 400s on 'active'). 'open'
+ // covers active markets — closed/settled markets contribute no live OI.
+ var qs = "/trade-api/v2/markets?status=open&limit=200" +
+ (string.IsNullOrEmpty(cursor) ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
+ using var req = BuildRequest(HttpMethod.Get, "/trade-api/v2/markets");
+ using var get = new HttpRequestMessage(HttpMethod.Get, qs);
+ CopyAuth(req, get);
+
+ using var resp = await _http.SendAsync(get).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if ((int)resp.StatusCode == 429)
+ {
+ log.Warn($"markets page {pages}: 429 — backing off 2s");
+ await Task.Delay(2000).ConfigureAwait(false);
+ continue; // retry same cursor
+ }
+ if (!resp.IsSuccessStatusCode)
+ {
+ log.Warn($"markets page {pages}: {(int)resp.StatusCode}");
+ break;
+ }
+
+ using var doc = JsonDocument.Parse(body);
+ if (doc.RootElement.TryGetProperty("markets", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var m in arr.EnumerateArray())
+ {
+ var ev = m.TryGetProperty("event_ticker", out var et) ? et.GetString() ?? "" : "";
+ if (string.IsNullOrEmpty(ev)) continue;
+ double oi = 0, vol = 0;
+ if (m.TryGetProperty("open_interest_fp", out var oiEl))
+ {
+ var s = oiEl.GetString();
+ if (!string.IsNullOrEmpty(s) && double.TryParse(s, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var p)) oi = p;
+ }
+ if (m.TryGetProperty("volume_fp", out var volEl))
+ {
+ var s = volEl.GetString();
+ if (!string.IsNullOrEmpty(s) && double.TryParse(s, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var p)) vol = p;
+ }
+ (double oi, double vol, int markets) cur = byEvent.TryGetValue(ev, out var v) ? v : (0.0, 0.0, 0);
+ byEvent[ev] = (cur.oi + oi, cur.vol + vol, cur.markets + 1);
+ }
+ }
+ cursor = doc.RootElement.TryGetProperty("cursor", out var cu) ? cu.GetString() ?? "" : "";
+ pages++;
+ if (string.IsNullOrEmpty(cursor)) break;
+ }
+ log.Info($"event liquidity: {byEvent.Count} events covered across {pages} markets-page(s)");
+ return byEvent;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _http.Dispose();
+ _rsa.Dispose();
+ _disposed = true;
+ }
+ }
+}
diff --git a/Helpers/KalshiTradeHelper.cs b/Helpers/KalshiTradeHelper.cs
new file mode 100644
index 00000000..6052a968
--- /dev/null
+++ b/Helpers/KalshiTradeHelper.cs
@@ -0,0 +1,158 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using log4net;
+
+namespace VisualHFT.Helpers
+{
+ ///
+ /// Minimal self-contained Kalshi trading client for the in-app order panel.
+ /// Hard-coded to demo only. Read-only viewing stays on the plugin's prod path.
+ ///
+ /// Mirrors the plugin's KalshiSigner (RSA-PSS-SHA256). Replicated here to
+ /// avoid VisualHFT.csproj depending on the plugin assembly at compile time.
+ ///
+ public sealed class KalshiTradeHelper : IDisposable
+ {
+ private static readonly ILog log = LogManager.GetLogger(typeof(KalshiTradeHelper));
+
+ public const int MAX_COUNT = 5;
+
+ private readonly HttpClient _http;
+ private readonly RSA _rsa;
+ private readonly string _keyId;
+ private bool _disposed;
+
+ private KalshiTradeHelper(string baseUrl, string keyId, RSA rsa)
+ {
+ _http = new HttpClient { BaseAddress = new Uri(baseUrl) };
+ _rsa = rsa;
+ _keyId = keyId;
+ }
+
+ public static KalshiTradeHelper ForDemo()
+ {
+ var pemPath = KalshiCredentials.DemoPemPath;
+ if (!File.Exists(pemPath))
+ throw new FileNotFoundException($"Demo PEM not found at {pemPath}");
+ var rsa = RSA.Create();
+ rsa.ImportFromPem(File.ReadAllText(pemPath));
+ return new KalshiTradeHelper(KalshiCredentials.DemoBase, KalshiCredentials.DemoKeyId, rsa);
+ }
+
+ public sealed class OrderResult
+ {
+ public bool Success { get; init; }
+ public string OrderId { get; init; } = "";
+ public string Status { get; init; } = "";
+ public string Error { get; init; } = "";
+ }
+
+ public async Task PlaceLimitYesBuyAsync(string ticker, int yesCents, int count)
+ => await PlaceLimitAsync(ticker, side: "yes", action: "buy", priceCents: yesCents, count: count);
+ public async Task PlaceLimitYesSellAsync(string ticker, int yesCents, int count)
+ => await PlaceLimitAsync(ticker, side: "yes", action: "sell", priceCents: yesCents, count: count);
+ public async Task PlaceLimitNoBuyAsync(string ticker, int noCents, int count)
+ => await PlaceLimitAsync(ticker, side: "no", action: "buy", priceCents: noCents, count: count);
+ public async Task PlaceLimitNoSellAsync(string ticker, int noCents, int count)
+ => await PlaceLimitAsync(ticker, side: "no", action: "sell", priceCents: noCents, count: count);
+
+ public async Task PlaceLimitAsync(string ticker, string side, string action, int priceCents, int count)
+ {
+ ThrowIfDisposed();
+ if (string.IsNullOrWhiteSpace(ticker) || !ticker.StartsWith("KX", StringComparison.OrdinalIgnoreCase))
+ return new() { Error = "ticker must start with 'KX'" };
+ if (count < 1 || count > MAX_COUNT)
+ return new() { Error = $"count must be 1..{MAX_COUNT}" };
+ if (priceCents < 1 || priceCents > 99)
+ return new() { Error = "price must be 1..99 cents" };
+
+ var path = "/trade-api/v2/portfolio/orders";
+ var clientOrderId = Guid.NewGuid().ToString();
+ var payload = new JsonObject
+ {
+ ["ticker"] = ticker,
+ ["client_order_id"] = clientOrderId,
+ ["type"] = "limit",
+ ["action"] = action,
+ ["side"] = side,
+ ["count"] = count
+ };
+ if (side == "yes") payload["yes_price"] = priceCents;
+ else payload["no_price"] = priceCents;
+
+ using var req = BuildRequest(HttpMethod.Post, path);
+ req.Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json");
+
+ log.Info($"PLACE {ticker} {side} {action} {count}@{priceCents}c cid={clientOrderId}");
+ try
+ {
+ using var resp = await _http.SendAsync(req).ConfigureAwait(false);
+ var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ {
+ var trim = body.Length > 240 ? body.Substring(0, 240) : body;
+ log.Warn($"order rejected: {(int)resp.StatusCode} {trim}");
+ return new() { Error = $"{(int)resp.StatusCode}: {trim}" };
+ }
+ using var doc = JsonDocument.Parse(body);
+ var orderEl = doc.RootElement.GetProperty("order");
+ string id = orderEl.TryGetProperty("order_id", out var i) ? i.GetString() ?? "" : "";
+ string st = orderEl.TryGetProperty("status", out var s) ? s.GetString() ?? "" : "";
+ log.Info($"order placed: id={id} status={st}");
+ return new() { Success = true, OrderId = id, Status = st };
+ }
+ catch (Exception ex)
+ {
+ log.Error("placement failed", ex);
+ return new() { Error = ex.Message };
+ }
+ }
+
+ public async Task CancelAsync(string orderId)
+ {
+ ThrowIfDisposed();
+ if (string.IsNullOrEmpty(orderId)) return false;
+ var path = $"/trade-api/v2/portfolio/orders/{orderId}";
+ using var req = BuildRequest(HttpMethod.Delete, path);
+ log.Info($"CANCEL {orderId}");
+ try
+ {
+ using var resp = await _http.SendAsync(req).ConfigureAwait(false);
+ return resp.IsSuccessStatusCode;
+ }
+ catch (Exception ex) { log.Error("cancel failed", ex); return false; }
+ }
+
+ private HttpRequestMessage BuildRequest(HttpMethod method, string path)
+ {
+ var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
+ var msg = Encoding.UTF8.GetBytes(ts + method.Method + path);
+ var sig = Convert.ToBase64String(
+ _rsa.SignData(msg, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
+ var req = new HttpRequestMessage(method, path);
+ req.Headers.Add("KALSHI-ACCESS-KEY", _keyId);
+ req.Headers.Add("KALSHI-ACCESS-SIGNATURE", sig);
+ req.Headers.Add("KALSHI-ACCESS-TIMESTAMP", ts);
+ req.Headers.Add("Accept", "application/json");
+ return req;
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(KalshiTradeHelper));
+ }
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _http.Dispose();
+ _rsa.Dispose();
+ _disposed = true;
+ }
+ }
+}
diff --git a/Helpers/KalshiViewRequest.cs b/Helpers/KalshiViewRequest.cs
new file mode 100644
index 00000000..bed2d5eb
--- /dev/null
+++ b/Helpers/KalshiViewRequest.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace VisualHFT.Helpers
+{
+ ///
+ /// Cross-window event hub for "show this Kalshi ticker in the main view".
+ /// Subscribed to by vmOrderBook; fired by the Watch List / Strike Ladder /
+ /// Events Browser when the user wants to inspect a specific market.
+ ///
+ public static class KalshiViewRequest
+ {
+ /// (symbol, providerId) — providerId 100 = Kalshi.
+ public static event Action? OnRequest;
+
+ public static void Show(string symbol, int providerId = 100)
+ {
+ if (string.IsNullOrEmpty(symbol)) return;
+ OnRequest?.Invoke(symbol, providerId);
+ }
+ }
+}
diff --git a/Helpers/PolymarketBrowserPoller.cs b/Helpers/PolymarketBrowserPoller.cs
new file mode 100644
index 00000000..58d35328
--- /dev/null
+++ b/Helpers/PolymarketBrowserPoller.cs
@@ -0,0 +1,221 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using log4net;
+
+namespace VisualHFT.Helpers
+{
+ ///
+ /// Catalog fetcher for the Polymarket events browser. Mirrors
+ /// so the existing UI / grouping logic in
+ /// vmKalshiEventBrowser can render Polymarket events without caring
+ /// which venue produced them.
+ ///
+ /// Hits Polymarket's public Gamma API (no auth required):
+ /// https://gamma-api.polymarket.com/events?active=true&closed=false&...
+ ///
+ public static class PolymarketBrowserPoller
+ {
+ private static readonly ILog log = LogManager.GetLogger(typeof(PolymarketBrowserPoller));
+
+ private const string GammaUrl =
+ "https://gamma-api.polymarket.com/events?active=true&closed=false&limit=500&order=volume24hr&ascending=false";
+
+ // One static HttpClient is the recommended pattern for long-lived hosts;
+ // it also keeps the connection pool warm between Refresh clicks.
+ private static readonly HttpClient _http = new()
+ {
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+
+ // Process-wide cache so reopening the browser is instant and we don't
+ // re-hammer Gamma. Cleared by the user's Refresh button (see InvalidateCache).
+ private static readonly object _cacheLock = new();
+ private static List? _cachedEvents;
+ private static DateTime _cachedAt;
+
+ public static void InvalidateCache()
+ {
+ lock (_cacheLock) { _cachedEvents = null; }
+ }
+
+ ///
+ /// Fetch every active, non-closed Polymarket event (single page; the
+ /// Gamma API caps at 500 which covers the entire live universe today).
+ ///
+ public static async Task> FetchAllOpenAsync(CancellationToken ct = default)
+ {
+ // Serve from cache if it's fresh.
+ lock (_cacheLock)
+ {
+ if (_cachedEvents != null && (DateTime.UtcNow - _cachedAt).TotalMinutes < 5)
+ {
+ log.Info($"polymarket catalog: serving {_cachedEvents.Count} from cache");
+ return new List(_cachedEvents);
+ }
+ }
+
+ string body;
+ try
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Get, GammaUrl);
+ req.Headers.Add("Accept", "application/json");
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ {
+ log.Warn($"polymarket catalog: HTTP {(int)resp.StatusCode}");
+ return new List();
+ }
+ body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ log.Warn($"polymarket catalog fetch failed: {ex.Message}");
+ throw;
+ }
+
+ var all = new List();
+ using var doc = JsonDocument.Parse(body);
+
+ // Gamma returns a bare array of event objects.
+ if (doc.RootElement.ValueKind != JsonValueKind.Array)
+ {
+ log.Warn("polymarket catalog: unexpected response shape (expected array)");
+ return all;
+ }
+
+ foreach (var e in doc.RootElement.EnumerateArray())
+ {
+ var slug = StringOrEmpty(e, "slug");
+ if (string.IsNullOrEmpty(slug)) continue;
+
+ var title = StringOrEmpty(e, "title");
+
+ // Sub-title: first market's question, if any.
+ string subTitle = "";
+ string yesToken = "";
+ int marketCount = 0;
+ if (e.TryGetProperty("markets", out var markets) && markets.ValueKind == JsonValueKind.Array)
+ {
+ marketCount = markets.GetArrayLength();
+ bool firstMarket = true;
+ foreach (var m in markets.EnumerateArray())
+ {
+ if (firstMarket)
+ {
+ subTitle = StringOrEmpty(m, "question");
+ yesToken = FirstClobTokenId(m);
+ firstMarket = false;
+ if (!string.IsNullOrEmpty(yesToken)) break;
+ }
+ }
+ }
+
+ // Category: first tag's label (or "Other" if no tags). Also used
+ // for the SeriesTicker column so the existing UI has something
+ // sensible to render in the per-row "Series" cell.
+ string category = "Other";
+ if (e.TryGetProperty("tags", out var tags) && tags.ValueKind == JsonValueKind.Array && tags.GetArrayLength() > 0)
+ {
+ var firstTag = tags[0];
+ var label = StringOrEmpty(firstTag, "label");
+ if (!string.IsNullOrEmpty(label)) category = label;
+ }
+
+ double liquidity = NumberOrZero(e, "liquidity");
+ double vol24 = NumberOrZero(e, "volume24hr");
+
+ all.Add(new KalshiEventInfo
+ {
+ EventTicker = slug,
+ SeriesTicker = category == "Other" ? "" : category,
+ Title = title,
+ SubTitle = subTitle,
+ Category = category,
+ MutuallyExclusive = false,
+ LastUpdated = "",
+ PolymarketYesToken = yesToken,
+ OpenInterest = liquidity,
+ Volume = vol24,
+ MarketCount = marketCount,
+ });
+ }
+
+ log.Info($"polymarket catalog: {all.Count} events");
+
+ // Cache anything non-trivial.
+ if (all.Count > 0)
+ {
+ lock (_cacheLock) { _cachedEvents = new List(all); _cachedAt = DateTime.UtcNow; }
+ }
+ return all;
+ }
+
+ // --- helpers ------------------------------------------------------------
+
+ private static string StringOrEmpty(JsonElement obj, string prop)
+ {
+ if (!obj.TryGetProperty(prop, out var v)) return "";
+ return v.ValueKind switch
+ {
+ JsonValueKind.String => v.GetString() ?? "",
+ JsonValueKind.Number => v.GetRawText(),
+ _ => ""
+ };
+ }
+
+ ///
+ /// Polymarket sometimes returns numeric fields as strings (e.g. "1234.5").
+ /// Handle both shapes defensively.
+ ///
+ private static double NumberOrZero(JsonElement obj, string prop)
+ {
+ if (!obj.TryGetProperty(prop, out var v)) return 0;
+ switch (v.ValueKind)
+ {
+ case JsonValueKind.Number:
+ return v.TryGetDouble(out var d) ? d : 0;
+ case JsonValueKind.String:
+ var s = v.GetString();
+ return double.TryParse(s, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var p) ? p : 0;
+ default:
+ return 0;
+ }
+ }
+
+ ///
+ /// clobTokenIds can be either a JSON array (["yes","no"]) or a stringified
+ /// JSON array ("[\"yes\",\"no\"]"). Return the first element or "".
+ ///
+ private static string FirstClobTokenId(JsonElement market)
+ {
+ if (!market.TryGetProperty("clobTokenIds", out var v)) return "";
+ if (v.ValueKind == JsonValueKind.Array)
+ {
+ if (v.GetArrayLength() == 0) return "";
+ var first = v[0];
+ return first.ValueKind == JsonValueKind.String ? first.GetString() ?? "" : first.GetRawText();
+ }
+ if (v.ValueKind == JsonValueKind.String)
+ {
+ var raw = v.GetString();
+ if (string.IsNullOrEmpty(raw)) return "";
+ try
+ {
+ using var inner = JsonDocument.Parse(raw);
+ if (inner.RootElement.ValueKind == JsonValueKind.Array && inner.RootElement.GetArrayLength() > 0)
+ {
+ var first = inner.RootElement[0];
+ return first.ValueKind == JsonValueKind.String ? first.GetString() ?? "" : first.GetRawText();
+ }
+ }
+ catch { /* fall through */ }
+ }
+ return "";
+ }
+ }
+}
diff --git a/README.Kalshi.md b/README.Kalshi.md
new file mode 100644
index 00000000..d7dda495
--- /dev/null
+++ b/README.Kalshi.md
@@ -0,0 +1,90 @@
+# Kalshi Setup
+
+This fork of [VisualHFT](https://github.com/visualHFT/VisualHFT) adds UI
+windows and helpers for trading prediction markets on
+[Kalshi](https://kalshi.com): strike ladder, per-market ladder, events
+browser, watch list, implied PMF, depth chart, and a demo-only order panel.
+
+The UI lives in **this repo**. The actual data-feed plugin (Kalshi WebSocket
+/ REST → VisualHFT order books and trades) lives in a **separate repo**
+that also bundles a vendored copy of this fork for one-clone setup:
+
+- Bundle (recommended):
+- This UI fork only:
+
+Cloning this repo alone gets you the Kalshi UI, but no data will appear
+until the plugin DLL is built (from the bundle repo) and dropped into
+VisualHFT's plugin folder.
+
+## Screenshots
+
+### Main window
+
+
+
+VisualHFT running with the Kalshi plugin loaded. Top toolbar exposes Kalshi
+entry points (**Multi Venue Prices**, **Kalshi Strikes**, **Events Browser**,
+**Watch List**). The floating ladder shows a per-market view of an MLB strike
+contract (`KXMLBGAME-26APR301305SFPHI-PHI` — Philadelphia, YES 26¢ / NO 72¢,
+2¢ spread) with a cumulative-depth chart and a price ladder rendered with
+the same `OrderBook` bus the rest of the app uses. The center pane shows the
+provider/symbol picker, mid-price tile, and live depth ladder; the right
+pane is the standard VisualHFT depth chart, best-bid/offer time series,
+spread chart, and live trade tape. Bottom strip is a demo-only order panel
+(safety-capped at 5 contracts/order). Kalshi appears in the **Providers'
+Status** row alongside the existing crypto venues.
+
+### Events Browser
+
+
+
+Live catalog of every open Kalshi event grouped by the API's `category`
+field — Sports (2,704), Elections (1,383), Entertainment (646), Politics
+(335), Economics (308), Climate & Weather, Companies, Crypto, Science &
+Tech, etc. Type-ahead search filters across **all** categories
+simultaneously. Each event shows aggregate open interest, volume, and
+market count; double-click an event to start streaming its markets into
+the live ladder without editing the plugin's static ticker list.
+
+## Configure Kalshi credentials
+
+Nothing is hardcoded — supply your own via environment variables. Generate
+a key pair from Kalshi's web UI ( for prod or
+ for demo) → Profile → **API Keys** → **Create new
+API key**. Save the private key Kalshi shows (one-time display) and the
+key id somewhere outside this repo, then set:
+
+```powershell
+# Prod (read-only Events Browser, Watch List, Strike/Per-market ladder data)
+$env:KALSHI_PROD_KEY_ID = ""
+$env:KALSHI_PROD_PEM_PATH = "C:\path\to\your\kalshi-prod.pem"
+
+# Demo (in-app order panel)
+$env:KALSHI_DEMO_KEY_ID = ""
+$env:KALSHI_DEMO_PEM_PATH = "C:\path\to\your\kalshi-demo.pem"
+```
+
+Both scopes are independent — view-only features run without demo creds,
+and the order panel throws a clear error pointing at the missing variable
+if you haven't set demo. Without prod creds the polling helpers log a
+warning and skip work instead of crashing the app.
+
+The credential reader and error messages live in
+[`Helpers/KalshiCredentials.cs`](Helpers/KalshiCredentials.cs).
+
+## What this fork adds vs. upstream
+
+Approximately 3.4k lines added, additive only — no upstream files removed.
+
+- `View/Kalshi*Window.xaml(.cs)` — strike ladder, per-market ladder, events
+ browser, watch list, implied PMF.
+- `ViewModel/vmKalshi*.cs` — view-models backing those windows.
+- `Helpers/Kalshi*.cs` — supplemental browser-poller (richer prod book),
+ event catalog, demo trade helper, plus a small `KalshiCredentials`
+ resolver used by all three.
+- Small tweaks to `View/Dashboard.xaml(.cs)`, `View/ucDepth1.xaml`,
+ `ViewModel/vmOrderBook.cs`, and `VisualHFT.Commons/UserSettings/enums.cs`
+ to wire up the Kalshi UI and persist settings.
+
+See for the
+plugin side and the bundled deliverable.
diff --git a/README.md b/README.md
index 9a1663e5..166b4d02 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,9 @@
+# Kalshi fork
+This is a personal fork of [visualHFT/VisualHFT](https://github.com/visualHFT/VisualHFT)
+that adds UI for trading prediction markets on Kalshi. The data-feed plugin
+lives in a separate repo. **See [README.Kalshi.md](README.Kalshi.md) for the
+two-repo setup.**
+
# Release Notes
See details [here](#release-notes-1)
diff --git a/View/Dashboard.xaml b/View/Dashboard.xaml
index 8b97922a..873f07a7 100644
--- a/View/Dashboard.xaml
+++ b/View/Dashboard.xaml
@@ -141,6 +141,9 @@
+
+
+
diff --git a/View/Dashboard.xaml.cs b/View/Dashboard.xaml.cs
index 3aa5b076..314ae8c7 100644
--- a/View/Dashboard.xaml.cs
+++ b/View/Dashboard.xaml.cs
@@ -54,6 +54,24 @@ private void ButtonMultiVenuePrices_Click(object sender, RoutedEventArgs e)
form.Show();
}
+ private void ButtonKalshiStrikes_Click(object sender, RoutedEventArgs e)
+ {
+ var form = new View.KalshiStrikeLadderWindow();
+ form.Show();
+ }
+
+ private void ButtonKalshiBrowser_Click(object sender, RoutedEventArgs e)
+ {
+ var form = new View.KalshiEventBrowserWindow();
+ form.Show();
+ }
+
+ private void ButtonKalshiWatchList_Click(object sender, RoutedEventArgs e)
+ {
+ var form = new View.KalshiWatchListWindow();
+ form.Show();
+ }
+
private void ButtonPluginManagement_Click(object sender, RoutedEventArgs e)
{
var form = new View.PluginManagerWindow();
diff --git a/View/KalshiEventBrowserWindow.xaml b/View/KalshiEventBrowserWindow.xaml
new file mode 100644
index 00000000..d8174463
--- /dev/null
+++ b/View/KalshiEventBrowserWindow.xaml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/View/KalshiEventBrowserWindow.xaml.cs b/View/KalshiEventBrowserWindow.xaml.cs
new file mode 100644
index 00000000..e8db1e40
--- /dev/null
+++ b/View/KalshiEventBrowserWindow.xaml.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using VisualHFT.Helpers;
+using VisualHFT.ViewModel;
+
+namespace VisualHFT.View
+{
+ ///
+ /// Tabbed browser of every open Kalshi event, grouped by API category.
+ /// Double-click any event row → fetch its markets and add them to the
+ /// dynamic poller (KalshiBrowserPoller) so they show up in the Provider/
+ /// Symbol dropdown and the strike ladder.
+ ///
+ public partial class KalshiEventBrowserWindow : Window
+ {
+ private readonly vmKalshiEventBrowser _vm;
+
+ public KalshiEventBrowserWindow()
+ {
+ InitializeComponent();
+ _vm = new vmKalshiEventBrowser();
+ DataContext = _vm;
+ this.Loaded += async (_, _) => await _vm.RefreshAsync();
+ }
+
+ // Provider ID for the Polymarket plugin. Matches
+ // PolymarketPluginSettings.Provider.ProviderID in the visualhft-polymarket repo.
+ private const int PolymarketProviderId = 11;
+
+ /// True if the row originated from the Polymarket catalog.
+ private static bool IsPolymarketRow(KalshiEventInfo evt) =>
+ !string.IsNullOrEmpty(evt.PolymarketYesToken);
+
+ private async void RefreshBtn_Click(object sender, RoutedEventArgs e)
+ {
+ RefreshBtn.IsEnabled = false;
+ try
+ {
+ // Invalidate whichever venue's cache is currently selected so the
+ // refresh button actually re-fetches.
+ if (_vm.IsPolymarket)
+ PolymarketBrowserPoller.InvalidateCache();
+ else
+ KalshiEventCatalog.InvalidateCache();
+ await _vm.RefreshAsync();
+ }
+ finally { RefreshBtn.IsEnabled = true; }
+ }
+
+ private async void GroupsTabs_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ // Find the DataGridRow that was double-clicked by walking the visual tree
+ var dep = e.OriginalSource as DependencyObject;
+ while (dep != null && dep is not DataGridRow)
+ dep = VisualTreeHelper.GetParent(dep);
+ if (dep is not DataGridRow row || row.Item is not KalshiEventInfo evt) return;
+ if (string.IsNullOrEmpty(evt.EventTicker)) return;
+
+ // Plain double-click keeps current behavior: watch + auto-load chart.
+ await WatchEventAsync(evt, loadChart: true);
+ }
+
+ private async Task WatchEventAsync(KalshiEventInfo evt, bool loadChart)
+ {
+ // Polymarket events route differently: there's no Kalshi-style
+ // event→markets fan-out (each event already carries its first
+ // market's YES clobTokenId). All we do is fire the cross-window
+ // request so the main view can pick it up via the Polymarket
+ // plugin (providerId 11).
+ if (IsPolymarketRow(evt))
+ {
+ if (loadChart)
+ {
+ KalshiViewRequest.Show(evt.PolymarketYesToken, PolymarketProviderId);
+ this.Title = $"Polymarket — Events Browser • Loaded {evt.EventTicker}";
+ }
+ else
+ {
+ // "Add to Watch List (no chart)" — not yet wired for Polymarket;
+ // surface a friendly message rather than silently no-op.
+ MessageBox.Show(
+ "Watch List support for Polymarket events is not yet implemented.\n" +
+ "Use 'Watch + Load Chart' (double-click a row) instead.",
+ "Polymarket — Events Browser",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ return;
+ }
+
+ this.Title = $"Kalshi — Events Browser • Loading markets for {evt.EventTicker}…";
+ try
+ {
+ using var catalog = KalshiEventCatalog.ForProd();
+ var markets = await catalog.GetEventMarketsAsync(evt.EventTicker);
+ if (markets.Count == 0)
+ {
+ this.Title = $"Kalshi — Events Browser • {evt.EventTicker}: no markets returned";
+ return;
+ }
+ KalshiBrowserPoller.Instance.Watch(markets);
+ int total = KalshiBrowserPoller.Instance.WatchedTickers.Count;
+ this.Title = $"Kalshi — Events Browser • Watching {markets.Count} markets from {evt.EventTicker} (total: {total})";
+
+ if (loadChart)
+ {
+ // Auto-load the first market into the main chart.
+ var first = markets.FirstOrDefault(m => !string.IsNullOrEmpty(m));
+ if (first != null)
+ KalshiViewRequest.Show(first, KalshiBrowserPoller.KalshiProviderId);
+ }
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Failed to watch {evt.EventTicker}:\n\n{ex.Message}",
+ "Events Browser", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private async void AddToWatchList_Click(object sender, RoutedEventArgs e)
+ {
+ var evt = SelectedEvent();
+ if (evt == null) return;
+ if (IsPolymarketRow(evt))
+ {
+ MessageBox.Show(
+ "Add to Watch List is not yet supported for Polymarket events.\n" +
+ "Use 'Watch + Load Chart' instead (the chart loads the YES token directly).",
+ "Polymarket — Events Browser",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+ await WatchEventAsync(evt, loadChart: false);
+ }
+
+ private async void WatchAndLoadChart_Click(object sender, RoutedEventArgs e)
+ {
+ var evt = SelectedEvent();
+ if (evt != null) await WatchEventAsync(evt, loadChart: true);
+ }
+
+ private async void ShowInStrikeLadder_Click(object sender, RoutedEventArgs e)
+ {
+ var evt = SelectedEvent();
+ if (evt == null) return;
+ if (IsPolymarketRow(evt))
+ {
+ MessageBox.Show(
+ "Strike Ladder is not yet supported for Polymarket events.\n" +
+ "It's currently wired for Kalshi event markets only.",
+ "Polymarket — Events Browser",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+ await WatchEventAsync(evt, loadChart: false);
+ // Open / focus the strike ladder so the user can see all strikes side-by-side
+ try
+ {
+ var ladder = new View.KalshiStrikeLadderWindow();
+ ladder.Show();
+ }
+ catch { /* best effort */ }
+ }
+
+ /// Find the KalshiEventInfo for the currently-selected row across any tab.
+ private KalshiEventInfo? SelectedEvent()
+ {
+ // The TabControl's SelectedContent is the per-category DataGrid; selected row lives there.
+ var content = GroupsTabs?.SelectedContent;
+ if (content is FrameworkElement fe)
+ {
+ var grid = FindDescendant(fe);
+ if (grid?.SelectedItem is KalshiEventInfo evt) return evt;
+ }
+ return null;
+ }
+
+ private static T? FindDescendant(DependencyObject root) where T : DependencyObject
+ {
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
+ {
+ var c = VisualTreeHelper.GetChild(root, i);
+ if (c is T match) return match;
+ var deeper = FindDescendant(c);
+ if (deeper != null) return deeper;
+ }
+ return null;
+ }
+ }
+}
diff --git a/View/KalshiLadderWindow.xaml b/View/KalshiLadderWindow.xaml
new file mode 100644
index 00000000..418e98ba
--- /dev/null
+++ b/View/KalshiLadderWindow.xaml
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/View/KalshiLadderWindow.xaml.cs b/View/KalshiLadderWindow.xaml.cs
new file mode 100644
index 00000000..37832140
--- /dev/null
+++ b/View/KalshiLadderWindow.xaml.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls.Primitives;
+using System.Windows.Controls;
+using System.Windows.Input;
+using VisualHFT.Helpers;
+using VisualHFT.ViewModel;
+
+namespace VisualHFT.View
+{
+ ///
+ /// Per-ticker depth ladder for a single Kalshi market, styled to mirror
+ /// kalshi.com's view (asks top in red, bids bottom in green, with cumulative
+ /// dollar totals walking away from mid). Includes a demo-only order panel.
+ ///
+ public partial class KalshiLadderWindow : Window
+ {
+ private readonly string _symbol;
+ private readonly vmKalshiLadder _vm;
+ private readonly KalshiTradeHelper _trade;
+ private readonly List _myLiveOrders = new(); // most-recent-last
+
+ public KalshiLadderWindow(string symbol)
+ {
+ InitializeComponent();
+ _symbol = symbol;
+ _vm = new vmKalshiLadder(symbol);
+ DataContext = _vm;
+
+ try { _trade = KalshiTradeHelper.ForDemo(); }
+ catch (Exception ex) { _trade = null!; OrderStatus.Text = $"Trade helper unavailable: {ex.Message}"; }
+
+ _vm.Asks.CollectionChanged += (_, _) => ScrollAsksToBottom();
+ _vm.Bids.CollectionChanged += (_, _) => ScrollBidsToTop();
+
+ this.Closed += (_, _) =>
+ {
+ _vm.Dispose();
+ _trade?.Dispose();
+ };
+ }
+
+ private void ScrollAsksToBottom()
+ {
+ if (AsksGrid?.Items.Count > 0)
+ AsksGrid.ScrollIntoView(AsksGrid.Items[AsksGrid.Items.Count - 1]);
+ }
+
+ private void ScrollBidsToTop()
+ {
+ if (BidsGrid?.Items.Count > 0)
+ BidsGrid.ScrollIntoView(BidsGrid.Items[0]);
+ }
+
+ private async void OrderSubmit_Click(object sender, RoutedEventArgs e)
+ {
+ if (_trade is null) { OrderStatus.Text = "Trade helper not initialized."; return; }
+
+ string side = (OrderSide.SelectedIndex == 0) ? "yes" : "no";
+ string action = (OrderAction.SelectedIndex == 0) ? "buy" : "sell";
+ if (!int.TryParse(OrderPrice.Text.Trim(), out int price) || price < 1 || price > 99)
+ { OrderStatus.Text = "Price must be an integer 1..99 cents."; return; }
+ if (!int.TryParse(OrderCount.Text.Trim(), out int count) || count < 1 || count > KalshiTradeHelper.MAX_COUNT)
+ { OrderStatus.Text = $"Count must be 1..{KalshiTradeHelper.MAX_COUNT}."; return; }
+
+ OrderSubmit.IsEnabled = false;
+ OrderStatus.Text = $"Sending {side.ToUpper()} {action} {count}@{price}¢ on demo for {_symbol}…";
+ try
+ {
+ var r = await _trade.PlaceLimitAsync(_symbol, side, action, price, count);
+ if (r.Success)
+ {
+ _myLiveOrders.Add(r.OrderId);
+ OrderCancelLast.IsEnabled = true;
+ OrderStatus.Text = $"✅ Placed (demo) {side.ToUpper()} {action} {count}@{price}¢ → status={r.Status} id={r.OrderId.Substring(0, Math.Min(8, r.OrderId.Length))}…";
+ }
+ else
+ {
+ OrderStatus.Text = $"❌ {r.Error}";
+ }
+ }
+ finally { OrderSubmit.IsEnabled = true; }
+ }
+
+ private void DepthToggle_Click(object sender, RoutedEventArgs e) => _vm.ToggleDepthChart();
+ private void UnitContracts_Click(object sender, RoutedEventArgs e) => _vm.SetDepthUnit(KalshiDepthUnit.Contracts);
+ private void UnitNotional_Click(object sender, RoutedEventArgs e) => _vm.SetDepthUnit(KalshiDepthUnit.Notional);
+ private void UnitPercent_Click(object sender, RoutedEventArgs e) => _vm.SetDepthUnit(KalshiDepthUnit.Percent);
+
+ // Keyboard shortcut: Ctrl+D toggles the depth chart, but only when focus
+ // is not in a text-editing surface — otherwise the user can't type the
+ // letter D into the order-entry fields.
+ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.D) return;
+ if ((Keyboard.Modifiers & ModifierKeys.Control) != ModifierKeys.Control) return;
+ if (IsTextInputFocused()) return;
+ _vm.ToggleDepthChart();
+ e.Handled = true;
+ }
+
+ private static bool IsTextInputFocused()
+ {
+ // TextBox/RichTextBox both derive from TextBoxBase; PasswordBox is its
+ // own thing. Cover both so order entry typing is never hijacked.
+ var f = Keyboard.FocusedElement;
+ return f is TextBoxBase or PasswordBox;
+ }
+
+ private async void OrderCancelLast_Click(object sender, RoutedEventArgs e)
+ {
+ if (_trade is null || _myLiveOrders.Count == 0) return;
+ string id = _myLiveOrders[^1];
+ OrderStatus.Text = $"Canceling {id.Substring(0, Math.Min(8, id.Length))}…";
+ bool ok = await _trade.CancelAsync(id);
+ if (ok)
+ {
+ _myLiveOrders.RemoveAt(_myLiveOrders.Count - 1);
+ if (_myLiveOrders.Count == 0) OrderCancelLast.IsEnabled = false;
+ OrderStatus.Text = $"✅ Canceled {id.Substring(0, Math.Min(8, id.Length))}";
+ }
+ else
+ {
+ OrderStatus.Text = $"❌ Cancel failed for {id}";
+ }
+ }
+ }
+}
diff --git a/View/KalshiPMFWindow.xaml b/View/KalshiPMFWindow.xaml
new file mode 100644
index 00000000..0a0e07f1
--- /dev/null
+++ b/View/KalshiPMFWindow.xaml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/View/KalshiPMFWindow.xaml.cs b/View/KalshiPMFWindow.xaml.cs
new file mode 100644
index 00000000..4b41868a
--- /dev/null
+++ b/View/KalshiPMFWindow.xaml.cs
@@ -0,0 +1,20 @@
+using System.Windows;
+using VisualHFT.ViewModel;
+
+namespace VisualHFT.View
+{
+ ///
+ /// Implied probability mass function for one event, derived live from the
+ /// strike ladder. Opens for the event of whichever strike row was selected.
+ ///
+ public partial class KalshiPMFWindow : Window
+ {
+ public KalshiPMFWindow(string eventTicker)
+ {
+ InitializeComponent();
+ var vm = new vmKalshiPMF(eventTicker);
+ DataContext = vm;
+ this.Closed += (_, _) => vm.Dispose();
+ }
+ }
+}
diff --git a/View/KalshiStrikeLadderWindow.xaml b/View/KalshiStrikeLadderWindow.xaml
new file mode 100644
index 00000000..6806500e
--- /dev/null
+++ b/View/KalshiStrikeLadderWindow.xaml
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/View/KalshiStrikeLadderWindow.xaml.cs b/View/KalshiStrikeLadderWindow.xaml.cs
new file mode 100644
index 00000000..b92af5da
--- /dev/null
+++ b/View/KalshiStrikeLadderWindow.xaml.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Windows;
+using System.Windows.Input;
+using VisualHFT.Helpers;
+using VisualHFT.ViewModel;
+
+namespace VisualHFT.View
+{
+ ///
+ /// Standalone window showing live Kalshi top-of-book per strike,
+ /// grouped by event. Double-click a row to open the per-ticker ladder.
+ ///
+ public partial class KalshiStrikeLadderWindow : Window
+ {
+ public KalshiStrikeLadderWindow()
+ {
+ InitializeComponent();
+ DataContext = new vmKalshiStrikeLadder();
+ this.Closed += (_, _) =>
+ {
+ if (DataContext is vmKalshiStrikeLadder vm) vm.Dispose();
+ };
+ }
+
+ private void StrikesGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ OpenLadderForSelected();
+ }
+
+ private void OpenLadder_Click(object sender, RoutedEventArgs e) => OpenLadderForSelected();
+
+ private void OpenLadderForSelected()
+ {
+ if (StrikesGrid.SelectedItem is KalshiStrikeRow row && !string.IsNullOrEmpty(row.Ticker))
+ {
+ var ladder = new KalshiLadderWindow(row.Ticker);
+ ladder.Show();
+ }
+ }
+
+ private void ShowPMF_Click(object sender, RoutedEventArgs e)
+ {
+ if (StrikesGrid.SelectedItem is KalshiStrikeRow row && !string.IsNullOrEmpty(row.EventTicker))
+ {
+ var pmf = new KalshiPMFWindow(row.EventTicker);
+ pmf.Show();
+ }
+ }
+
+ private void LoadInMainChart_Click(object sender, RoutedEventArgs e)
+ {
+ if (StrikesGrid.SelectedItem is KalshiStrikeRow row && !string.IsNullOrEmpty(row.Ticker))
+ KalshiViewRequest.Show(row.Ticker, KalshiBrowserPoller.KalshiProviderId);
+ }
+ }
+}
diff --git a/View/KalshiWatchListWindow.xaml b/View/KalshiWatchListWindow.xaml
new file mode 100644
index 00000000..39b69d86
--- /dev/null
+++ b/View/KalshiWatchListWindow.xaml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/View/KalshiWatchListWindow.xaml.cs b/View/KalshiWatchListWindow.xaml.cs
new file mode 100644
index 00000000..be8628ed
--- /dev/null
+++ b/View/KalshiWatchListWindow.xaml.cs
@@ -0,0 +1,67 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using VisualHFT.Helpers;
+using VisualHFT.ViewModel;
+
+namespace VisualHFT.View
+{
+ ///
+ /// Watch List for Kalshi tickers. Mirrors KalshiBrowserPoller's dynamic
+ /// ticker set with live top-of-book and add/remove buttons. Anything
+ /// here also flows into the strike ladder via HelperOrderBook.
+ ///
+ public partial class KalshiWatchListWindow : Window
+ {
+ private readonly vmKalshiWatchList _vm;
+
+ public KalshiWatchListWindow()
+ {
+ InitializeComponent();
+ _vm = new vmKalshiWatchList();
+ DataContext = _vm;
+ this.Closed += (_, _) => _vm.Dispose();
+ }
+
+ private void AddBtn_Click(object sender, RoutedEventArgs e)
+ {
+ var t = AddInput.Text?.Trim() ?? "";
+ if (string.IsNullOrEmpty(t)) return;
+ _vm.AddManual(t);
+ AddInput.Text = "";
+ }
+
+ private void RemoveBtn_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button b && b.DataContext is WatchListRow row)
+ _vm.Remove(row);
+ }
+
+ private void WatchGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (WatchGrid.SelectedItem is WatchListRow row && !string.IsNullOrEmpty(row.Ticker))
+ KalshiViewRequest.Show(row.Ticker, KalshiBrowserPoller.KalshiProviderId);
+ }
+
+ private void OpenLadder_Click(object sender, RoutedEventArgs e)
+ {
+ if (WatchGrid.SelectedItem is WatchListRow row && !string.IsNullOrEmpty(row.Ticker))
+ {
+ var ladder = new KalshiLadderWindow(row.Ticker);
+ ladder.Show();
+ }
+ }
+
+ private void LoadInMainChart_Click(object sender, RoutedEventArgs e)
+ {
+ if (WatchGrid.SelectedItem is WatchListRow row && !string.IsNullOrEmpty(row.Ticker))
+ KalshiViewRequest.Show(row.Ticker, KalshiBrowserPoller.KalshiProviderId);
+ }
+
+ private void RemoveCtx_Click(object sender, RoutedEventArgs e)
+ {
+ if (WatchGrid.SelectedItem is WatchListRow row)
+ _vm.Remove(row);
+ }
+ }
+}
diff --git a/View/ucDepth1.xaml b/View/ucDepth1.xaml
index 104a2e4f..e778d7f6 100644
--- a/View/ucDepth1.xaml
+++ b/View/ucDepth1.xaml
@@ -48,7 +48,7 @@
-
+
-
+