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 main window with Kalshi integration](docs/screenshots/main-window.png) + +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 + +![Kalshi Events Browser — 5,892 open events across 16 categories](docs/screenshots/events-browser.png) + +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 @@