API documentation
Level2Entry
Subscribe to Level2 (order book) updates for an instrument
/// <summary>
/// Subscribe to Level2 (order book) updates for an instrument
/// </summary>
public void SubscribeLevel2(int instrumentId)
{
if (this.tickUpdatesPaused)
{
return;
}
// Already subscribed to this instrument - skip to avoid duplicate subscriptions
if (this.level2SubscribedInstrumentId.HasValue && this.level2SubscribedInstrumentId == instrumentId)
{
return;
}
// Unsubscribe from previous instrument if any
if (this.level2SubscribedInstrumentId.HasValue)
{
this.UnsubscribeLevel2();
}
this.level2SubscribedInstrumentId = instrumentId;
this.level2Bids.Clear();
this.level2Asks.Clear();
this.level2SentTimestamp = 0;
Logger.Information("User {UserId} subscribed to Level2 for instrument {InstrumentId}", this.UserId, instrumentId);
}
/// <summary>
/// Unsubscribe from Level2 updates
/// </summary>
public void UnsubscribeLevel2()
{
if (this.level2SubscribedInstrumentId.HasValue)
{
Logger.Information("User {UserId} unsubscribed from Level2 for instrument {InstrumentId}", this.UserId, this.level2SubscribedInstrumentId);
this.level2SubscribedInstrumentId = null;
this.level2Bids.Clear();
this.level2Asks.Clear();
}
}
private async Task On(Level2Occurred @event, CancellationToken cancellationToken)
{
var level2 = @event.Level2;
if (this.tickUpdatesPaused || !this.HasActiveConnections)
{
return;
}
// Only process if subscribed to this instrument
if (!this.level2SubscribedInstrumentId.HasValue ||
this.level2SubscribedInstrumentId.Value != level2.InstrumentId)
{
return;
}
// Update internal order book state
this.UpdateLevel2Book(level2);
// Throttle sending to 500ms
var currentTicks = DateTime.UtcNow.Ticks;
if (currentTicks - this.level2SentTimestamp < Level2SendPeriod.Ticks)
{
return;
}
this.level2SentTimestamp = currentTicks;
try
{
// Check for backpressure
var currentPendingCount = Interlocked.Read(ref this.pendingMessageCount);
if (currentPendingCount > 100)
{
return;
}
Instrument? instrument = null;
this.instrumentsById?.TryGetValue(level2.InstrumentId, out instrument);
// Build and send order book snapshot
var snapshot = new Level2BookSnapshot(
level2.InstrumentId,
this.level2Bids.Take(Level2MaxLevels).ToArray(),
this.level2Asks.Take(Level2MaxLevels).ToArray(),
DateTime.UtcNow.ToString("o"),
instrument?.PriceFormat);
Interlocked.Increment(ref this.pendingMessageCount);
await this.clientProxy.SendAsync("UpdateLevel2", snapshot, cancellationToken)
.ContinueWith(task =>
{
Interlocked.Decrement(ref this.pendingMessageCount);
if (task.IsFaulted)
{
Logger.Warning(task.Exception, "Level2 send failed for user {UserId}, instrument {InstrumentId}",
this.UserId, level2.InstrumentId);
}
}, TaskContinuationOptions.ExecuteSynchronously);
}
catch (Exception exception) when (exception.IsNotCritical())
{
Logger.Error(exception, "Level2 SignalR error for user {UserId}", this.UserId);
}
}
private void UpdateLevel2Book(Level2 level2)
{
var targetList = level2.Side == Level2Side.Bid ? this.level2Bids : this.level2Asks;
var position = level2.Position;
switch (level2.Action)
{
case Level2UpdateAction.New:
if (position >= 0 && position <= targetList.Count && targetList.Count < Level2MaxLevels * 2)
{
targetList.Insert(position, new Level2Entry(level2.Price, level2.Size, 0));
}
break;
case Level2UpdateAction.Change:
if (position >= 0 && position < targetList.Count)
{
targetList[position] = new Level2Entry(level2.Price, level2.Size, 0);
}
break;
case Level2UpdateAction.Delete:
if (position >= 0 && position < targetList.Count)
{
targetList.RemoveAt(position);
}
break;
case Level2UpdateAction.Reset:
this.level2Bids.Clear();
this.level2Asks.Clear();
break;
}
// Recalculate totals (cumulative size)
this.RecalculateTotals(this.level2Bids);
this.RecalculateTotals(this.level2Asks);
}
private void RecalculateTotals(List<Level2Entry> entries)
{
decimal total = 0;
for (int i = 0; i < entries.Count; i++)
{
total += entries[i].Size;
entries[i].Total = total;
}
}
private async Task On(Level2SnapshotOccurred @event, CancellationToken cancellationToken)
{
var snapshot = @event.Snapshot;
if (this.tickUpdatesPaused || !this.HasActiveConnections)
{
return;
}
// Only process if subscribed to this instrument
if (!this.level2SubscribedInstrumentId.HasValue ||
this.level2SubscribedInstrumentId.Value != snapshot.InstrumentId)
{
return;
}
// Replace the internal order book with the snapshot data
this.level2Bids.Clear();
this.level2Asks.Clear();
if (snapshot.Bids != null)
{
decimal total = 0;
foreach (var bid in snapshot.Bids.Take(Level2MaxLevels))
{
total += bid.Size;
this.level2Bids.Add(new Level2Entry(bid.Price, bid.Size, total));
}
}
if (snapshot.Asks != null)
{
decimal total = 0;
foreach (var ask in snapshot.Asks.Take(Level2MaxLevels))
{
total += ask.Size;
this.level2Asks.Add(new Level2Entry(ask.Price, ask.Size, total));
}
}
// Throttle sending to 500ms
var currentTicks = DateTime.UtcNow.Ticks;
if (currentTicks - this.level2SentTimestamp < Level2SendPeriod.Ticks)
{
return;
}
this.level2SentTimestamp = currentTicks;
await this.SendLevel2ToClientAsync(snapshot.InstrumentId, cancellationToken);
}
private async Task On(Level2UpdateOccurred @event, CancellationToken cancellationToken)
{
var update = @event.Update;
if (this.tickUpdatesPaused || !this.HasActiveConnections)
{
return;
}
// Only process if subscribed to this instrument
if (!this.level2SubscribedInstrumentId.HasValue ||
this.level2SubscribedInstrumentId.Value != update.InstrumentId)
{
return;
}
// Apply incremental updates
if (update.Entries != null)
{
foreach (var entry in update.Entries)
{
this.UpdateLevel2Book(entry);
}
}
// Throttle sending to 500ms
var currentTicks = DateTime.UtcNow.Ticks;
if (currentTicks - this.level2SentTimestamp < Level2SendPeriod.Ticks)
{
return;
}
this.level2SentTimestamp = currentTicks;
await this.SendLevel2ToClientAsync(update.InstrumentId, cancellationToken);
}
private async Task SendLevel2ToClientAsync(int instrumentId, CancellationToken cancellationToken)
{
try
{
// Check for backpressure
var currentPendingCount = Interlocked.Read(ref this.pendingMessageCount);
if (currentPendingCount > 100)
{
return;
}
Instrument? instrument = null;
this.instrumentsById?.TryGetValue(instrumentId, out instrument);
// Build and send order book snapshot
var bookSnapshot = new Level2BookSnapshot(
instrumentId,
this.level2Bids.Take(Level2MaxLevels).ToArray(),
this.level2Asks.Take(Level2MaxLevels).ToArray(),
DateTime.UtcNow.ToString("o"),
instrument?.PriceFormat);
Interlocked.Increment(ref this.pendingMessageCount);
await this.clientProxy.SendAsync("UpdateLevel2", bookSnapshot, cancellationToken)
.ContinueWith(task =>
{
Interlocked.Decrement(ref this.pendingMessageCount);
if (task.IsFaulted)
{
Logger.Warning(task.Exception, "Level2 send failed for user {UserId}, instrument {InstrumentId}",
this.UserId, instrumentId);
}
}, TaskContinuationOptions.ExecuteSynchronously);
}
catch (Exception exception) when (exception.IsNotCritical())
{
Logger.Error(exception, "Level2 SignalR error for user {UserId}", this.UserId);
}
}
#endregion
#region Trade (Time & Sales) Subscription
/// <summary>
/// Subscribe to trade updates for an instrument (Time & Sales)
/// </summary>
public void SubscribeTrades(int instrumentId)
{
if (this.tickUpdatesPaused)
{
return;
}
// Already subscribed to this instrument - skip to avoid duplicate subscriptions
if (this.tradeSubscribedInstrumentId.HasValue && this.tradeSubscribedInstrumentId == instrumentId)
{
return;
}
// Unsubscribe from previous instrument if any
if (this.tradeSubscribedInstrumentId.HasValue)
{
this.UnsubscribeTrades();
}
this.tradeSubscribedInstrumentId = instrumentId;
Logger.Information("User {UserId} subscribed to trades for instrument {InstrumentId}", this.UserId, instrumentId);
}
/// <summary>
/// Unsubscribe from trade updates
/// </summary>
public void UnsubscribeTrades()
{
if (this.tradeSubscribedInstrumentId.HasValue)
{
Logger.Information("User {UserId} unsubscribed from trades for instrument {InstrumentId}", this.UserId, this.tradeSubscribedInstrumentId);
this.tradeSubscribedInstrumentId = null;
}
}
#endregion
private Task On(LastPriceReceived @event, CancellationToken cancellationToken)
{
var lastPrice = @event.LastPrice;
Instrument? instrument = null;
this.instrumentsById?.TryGetValue(lastPrice.InstrumentId, out instrument);
this.instrumentLastPrices[lastPrice.InstrumentId] = new LastPriceInfo(
lastPrice.InstrumentId,
lastPrice.Timestamp.ToString(),
lastPrice.Bid,
lastPrice.BidSize,
lastPrice.Ask,
lastPrice.AskSize,
lastPrice.BarTimestamp.ToString(),
lastPrice.Open,
lastPrice.High,
lastPrice.Low,
lastPrice.Close,
instrument.PriceFormat);
return Task.CompletedTask;
}
protected virtual Task SendPortfolioUpdatedAsync(CancellationToken cancellationToken)
{
return this.portfolioManager is null
? Task.CompletedTask
: this.SendPortfolioUpdatedAsync(this.portfolioManager.Portfolio, cancellationToken);
}
private void UpdateInstrumentFromTick(Tick tick)
{
if (this.instrumentsById?.TryGetValue(tick.InstrumentId, out var instrument) == true)
{
switch (tick.TypeId)
{
case DataObjectType.Bid:
{
instrument.Bid = (Bid)tick;
break;
}
case DataObjectType.Ask:
{
instrument.Ask = (Ask)tick;
break;
}
case DataObjectType.Trade:
{
instrument.Trade = (Trade)tick;
break;
}
}
}
}
#endregion
private void ActivateIdleTimer()
{
this.idleTimer.Change(this.idleTimeout, Timeout.InfiniteTimeSpan);
}
private void DisableIdleTimer()
{
this.idleTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
private void PostStatusChange(StreamConnectionStatus status, ErrorInfo? errorInfo = null)
{
this.Post(new StreamClientStatusChanged(status, errorInfo));
}
public void SendClientInfo(string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
this.clientProxy.SendAsync("Info", message)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
public void SendClientError(ErrorCodes code, string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
this.clientProxy.SendAsync("Error", (int)code, message)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
public void SendLoadingMessage(string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
this.clientProxy.SendAsync("LoadingMessage", message)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
public void LogoutUserOnFrontEnd(string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
this.clientProxy.SendAsync("ForceLogoutUser", message)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private async Task<bool> RecreateStreamClientAsync(CancellationToken cancellationToken)
{
if (DateTime.UtcNow - this.streamClientCreatedAt < TimeSpan.FromSeconds(10))
{
return false;
}
if (this.streamClient.Status == ProviderStatus.Connected)
{
try
{
var wrapper = new StreamClientAsyncWrapper(this.streamClient);
await wrapper.DisconnectAsync(reason: "Recreating stream client instance.", cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception)
{
Logger.Error(
exception,
"Stream client failed to disconnect for user {UserId}",
this.UserId);
}
}
this.streamClient = new ObermindStreamClient();
this.SubscribeToStreamClientEvents();
this.streamClientCreatedAt = DateTime.UtcNow;
this.orderCounter = OrderIdStartValue;
Logger.Information("Stream client is recreated for user {UserId}", this.UserId);
return true;
}
private async Task LaunchStreamClientAsync(string? account, CancellationToken cancellationToken)
{
// this.SendClientInfo("Connecting to STREAM...");
try
{
var wrapper = new StreamClientAsyncWrapper(this.streamClient);
await wrapper.ConnectAsync(this.options, account, cancellationToken);
}
catch (Exception exception)
{
this.SendClientError(ErrorCodes.StreamConnection, "Failed to connect to STREAM");
throw;
}
}
private void OnPortfolioUpdated(object sender, PersistentPortfolioEventArgs args)
{
if (this.portfolioUpdatesPaused)
{
return;
}
if (DateTime.UtcNow.Ticks - this.portfolioSentTimestamp < PortfolioSendPeriod.Ticks) // limit sending portfolio update
{
return;
}
this.portfolioSentTimestamp = DateTime.UtcNow.Ticks;
this.SendPortfolioUpdatedAsync(args.Portfolio)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private Task SendPortfolioUpdatedAsync(PortfolioModel model, CancellationToken cancellationToken = default)
{
var dto = PortfolioModelMapper.Map(model);
if (dto.Positions.Any(pos => pos.Qty == 0))
{
dto.Positions = dto.Positions.Where(pos => pos.Qty > 0).ToArray();
}
return this.clientProxy.SendAsync("PortfolioUpdated", dto, cancellationToken);
}
private void OnStreamHearbeat(object? sender, OnHeartbeat args)
{
if (args.HeartbeatInterval.HasValue && args.HeartbeatInterval.Value != 0)
{
this.clientProxy.SendAsync("StreamHeartbeatResponse", args.DateTime, args.HeartbeatInterval.Value)
.SafeFireAndForget(exception => Logger.Error(exception, "Failed to send stream heartbeat"));
}
else
{
this.clientProxy.SendAsync("StreamHeartbeatResponse", args.DateTime)
.SafeFireAndForget(exception => Logger.Error(exception, "Failed to send stream heartbeat"));
}
}
private void OnTransactionListLoaded(object sender, TransactionListEventArgs args)
{
this.clientProxy.SendAsync("TransactionsLoaded", args.Transactions)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private void OnAccountTransactionListLoaded(object sender, AccountTransactionListEventArgs args)
{
this.clientProxy.SendAsync("AccountTransactionsLoaded", args.AccountTransactions)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private void OnPortfolioStatisticsLoaded(object sender, PortfolioStatisticsResultListEventArgs args)
{
this.clientProxy.SendAsync("PortfolioStatisticsLoaded", args.PortfolioStatisticsResults)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private void OnPortfolioOrdersListUpdated(object sender, OrderListEventArgs args)
{
this.clientProxy.SendAsync("PortfolioOrdersListUpdated", args.Orders)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private void OnPortfolioPositionsListUpdated(object sender, PositionListEventArgs args)
{
// Throttle position updates to avoid flooding the websocket
// Positions update on every tick, so we need some throttling (but less than portfolio)
if (DateTime.UtcNow.Ticks - this.positionsSentTimestamp < PositionsSendPeriod.Ticks)
{
return;
}
this.positionsSentTimestamp = DateTime.UtcNow.Ticks;
// Only send open positions (amount != 0), filter out closed positions
var openPositions = args.Positions.Where(pos => pos.Amount != 0).ToArray();
var positionDtos = PositionModelMapper.MapArray(openPositions);
this.clientProxy.SendAsync("PortfolioPositionsListUpdated", positionDtos)
.SafeFireAndForget((exception) => Logger.Error(exception, "Client proxy send failed"));
}
private void SubscribeToStreamClientEvents()
{
this.streamClient.Error += this.OnStreamClientError;
this.streamClient.ForcedLogout += this.OnStreamClientForcedLogout;
this.streamClient.StatusChanged += this.OnStreamClientStatusChanged;
this.streamClient.ExecutionReport += this.OnStreamClientExecutionReport;
this.streamClient.ExecutionMessagesList += this.OnStreamClientExecutionMessagesList;
this.streamClient.Tick += this.OnStreamClientTick;
this.streamClient.Level2Snapshot += this.OnStreamClientLevel2Snapshot;
this.streamClient.Level2Update += this.OnStreamClientLevel2Update;
this.streamClient.AccountData += this.OnStreamClientAccountData;
this.streamClient.TransactionList += this.OnStreamClientTransactionList;
this.streamClient.AccountTransactionList += this.OnStreamClientAccountTransactionList;
// this.streamClient.PortfolioPerformanceRecordList += this.OnStreamClientPortfolioPerformanceRecordList;
this.streamClient.PortfolioStatisticsResultList += this.OnStreamClientPortfolioStatisticsResultList;
this.streamClient.LastPrice += this.OnStreamClientLastPrice;
this.streamClient.Heartbeat += this.OnStreamHearbeat;
}
private void UnsubscribeFromStreamClientEvents()
{
this.streamClient.Tick -= this.OnStreamClientTick;
this.streamClient.Level2Snapshot -= this.OnStreamClientLevel2Snapshot;
this.streamClient.Level2Update -= this.OnStreamClientLevel2Update;
this.streamClient.Error -= this.OnStreamClientError;
this.streamClient.StatusChanged -= this.OnStreamClientStatusChanged;
this.streamClient.ExecutionReport -= this.OnStreamClientExecutionReport;
this.streamClient.ExecutionMessagesList -= this.OnStreamClientExecutionMessagesList;
this.streamClient.AccountData -= this.OnStreamClientAccountData;
this.streamClient.TransactionList -= this.OnStreamClientTransactionList;
this.streamClient.AccountTransactionList -= this.OnStreamClientAccountTransactionList;
// this.streamClient.PortfolioPerformanceRecordList -= this.OnStreamClientPortfolioPerformanceRecordList;
this.streamClient.PortfolioStatisticsResultList -= this.OnStreamClientPortfolioStatisticsResultList;
this.streamClient.LastPrice -= this.OnStreamClientLastPrice; // missed?
this.streamClient.Heartbeat -= this.OnStreamHearbeat;
}
private void CreateAndSubscribeToPortfolioManagerEvents()
{
this.portfolioManager = new PersistentPortfolioManager(this.framework.Framework, true);
this.portfolioManager.PersistentPortfolio += this.OnPortfolioUpdated;
this.portfolioManager.TransactionList += this.OnTransactionListLoaded;
this.portfolioManager.AccountTransactionList += this.OnAccountTransactionListLoaded;
this.portfolioManager.PortfolioStatistics += this.OnPortfolioStatisticsLoaded;
this.portfolioManager.OrderList += this.OnPortfolioOrdersListUpdated;
this.portfolioManager.PositionList += this.OnPortfolioPositionsListUpdated;
}
#region Stream client event handlers
private void OnStreamClientError(object? sender, DataEventArgs<Exception> args)
{
Logger.Error(args.Data, "Stream client error for user {UserId}", this.UserId);
if (args.Data.Message == "Unable to read beyond the end of the stream.")
return;
this.PostStatusChange(
StreamConnectionStatus.Error,
new ErrorInfo(
args.Data,
ErrorCodes.StreamConnection,
$"STREAM connection error: {args.Data.Message}"));
}
private void OnStreamClientForcedLogout(object? sender, EventArgs e)
{
Logger.Information("OnStreamClientForcedLogout {UserId}", this.UserId);
this.Dispose();
this.AdminForcedLogout?.Invoke(this, EventArgs.Empty);
}
private void OnStreamClientStatusChanged(object? sender, EventArgs args)
{
var newStatus = this.streamClient.Status switch
{
ProviderStatus.Connecting => StreamConnectionStatus.Connecting,
ProviderStatus.Connected => StreamConnectionStatus.Connected,
ProviderStatus.Disconnected => StreamConnectionStatus.Disconnected,
_ => this.StreamStatus
};
this.PostStatusChange(newStatus);
}
private void OnStreamClientExecutionReport(object? sender, ExecutionReportEventArgs args)
{
this.Post(new ExecutionReportReceived(args.Report));
}
private void OnStreamClientExecutionMessagesList(object? sender, StreamOrderInfoListEventArgs args)
{
this.Post(new ExecutionMessagesListReceived(args.OrderInfoList));
}
private void OnStreamClientTick(object? sender, TickEventArgs args)
{
this.Post(new TickOccurred(args.Tick));
// Also handle Level2 data if it comes through as a tick
if (args.Tick.TypeId == DataObjectType.Level2 && args.Tick is Level2 level2)
{
this.Post(new Level2Occurred(level2));
}
}
private void OnStreamClientLevel2Snapshot(object? sender, Level2SnapshotEventArgs args)
{
this.Post(new Level2SnapshotOccurred(args.Snapshot));
}
private void OnStreamClientLevel2Update(object? sender, Level2UpdateEventArgs args)
{
this.Post(new Level2UpdateOccurred(args.Update));
}
private void OnStreamClientAccountData(object sender, AccountDataEventArgs args)
{
this.Post(new AccountDataReceived(args.Data));
}
private void OnStreamClientTransactionList(object sender, TransactionListEventArgs args)
{
this.Post(new TransactionListReceived(args.Transactions));
}
private void OnStreamClientAccountTransactionList(object sender, AccountTransactionListEventArgs args)
{
this.Post(new AccountTransactionListReceived(args.AccountTransactions));
}
private void OnStreamClientPortfolioPerformanceRecordList(object sender, PortfolioPerformanceRecordListEventArgs args)
{
this.Post(new PortfolioPerformanceRecordListReceived(args.Records));
}
private void OnStreamClientPortfolioStatisticsResultList(object sender, PortfolioStatisticsResultListEventArgs args)
{
this.Post(new PortfolioStatisticsResultListReceived(args.PortfolioStatisticsResults));
}
private void OnStreamClientLastPrice(object? sender, LastPriceEventArgs e)
{
this.Post(new LastPriceReceived(e.LastPrice));
}
#endregion
}
public sealed class LastPriceInfo
{
public LastPriceInfo(
int instrumentId,
string? timeStamp,
double? bid,
decimal? bidSize,
double? ask,
decimal? askSize,
string? barTimeStamp,
double? open,
double? high,
double? low,
double? close,
string? priceFormat)
{
this.InstrumentId = instrumentId;
this.TimeStamp = timeStamp;
this.Bid = bid;
this.BidSize = bidSize;
this.Ask = ask;
this.AskSize = askSize;
this.BarTimeStamp = barTimeStamp;
this.Open = open;
this.High = high;
this.Low = low;
this.Close = close;
this.PriceFormat = priceFormat;
}
public int InstrumentId { get; }
public string? TimeStamp { get; }
public double? Bid { get; }
public decimal? BidSize { get; }
public double? Ask { get; }
public decimal? AskSize { get; }
public string? BarTimeStamp { get; }
public double? Open { get; }
public double? High { get; }
public double? Low { get; }
public double? Close { get; }
public string? PriceFormat { get; }
}
/// <summary>
/// Represents a single price level in the order book (Level2)
/// </summary>
public sealed class Level2Entry
{
public Level2Entry(double price, decimal size, decimal total)
{
this.Price = price;
this.Size = size;
this.Total = total;
}