diff --git a/TriggerEngine/TriggerEngineService.cs b/TriggerEngine/TriggerEngineService.cs
index 25af06f0..2a73708f 100644
--- a/TriggerEngine/TriggerEngineService.cs
+++ b/TriggerEngine/TriggerEngineService.cs
@@ -448,7 +448,12 @@ private static Task ExecuteActionAsync(string ruleName, TriggerCondition conditi
{
var body = action.RestApi.BodyTemplate
.Replace("{{rulename}}", ruleName)
+ // {{plugin}} historically receives the METRIC name (the display name does
+ // not exist at fire time); kept as-is so existing saved templates keep
+ // their current output. {{metric}} is the documented, correctly-named
+ // placeholder for the same value.
.Replace("{{plugin}}", metric)
+ .Replace("{{metric}}", metric)
.Replace("{{condition}}", condition.Operator.ToString())
.Replace("{{threshold}}", condition.Threshold.ToString())
.Replace("{{value}}", value.ToString())
diff --git a/TriggerEngine/View/AddAPISetting.xaml.cs b/TriggerEngine/View/AddAPISetting.xaml.cs
index c826afa9..87d095cf 100644
--- a/TriggerEngine/View/AddAPISetting.xaml.cs
+++ b/TriggerEngine/View/AddAPISetting.xaml.cs
@@ -43,7 +43,7 @@ public AddAPISetting(RestApiActionViewModel _rule)
}
else
{
- restApiAction.BodyTemplate = "{\r\n\r\n\"plugin\":\"{{plugin}}\",\r\n\"value\":\"{{value}}\",\r\n\"timestamp\":\"{{timestamp}}\", \r\n}";
+ restApiAction.BodyTemplate = "{\r\n\r\n\"metric\":\"{{metric}}\",\r\n\"value\":\"{{value}}\",\r\n\"timestamp\":\"{{timestamp}}\", \r\n}";
}
this.DataContext = restApiAction;
}
diff --git a/TriggerEngine/View/TriggerSettingAddOrUpdate.xaml b/TriggerEngine/View/TriggerSettingAddOrUpdate.xaml
index d31b9f2c..81168234 100644
--- a/TriggerEngine/View/TriggerSettingAddOrUpdate.xaml
+++ b/TriggerEngine/View/TriggerSettingAddOrUpdate.xaml
@@ -266,10 +266,18 @@ TextOptions.TextRenderingMode="Auto"
•
Body Template:
- JSON payload with dynamic placeholders like
+ JSON payload with dynamic placeholders:
+ {{rulename}},
+ {{metric}}
+ (the metric that fired),
+ {{condition}},
+ {{threshold}},
+ {{value}}
+ and
+ {{timestamp}}
+ .
{{plugin}}
- or
- {{condition}} etc.
+ is kept for older templates and receives the same metric name.
•
Headers:
diff --git a/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs b/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs
index bd448fab..cbaa99b7 100644
--- a/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs
+++ b/VisualHFT.Plugins/MarketConnectors.Binance/BinancePlugin.cs
@@ -288,10 +288,12 @@ private async Task InitializeDeltasAsync()
{
try
{
- data.Data.EventTime = data.ReceiveTime;
- if (Math.Abs(DateTime.Now.Subtract(data.Data.EventTime.ToLocalTime()).TotalSeconds) > 1)
+ // EventTime is the venue's own event timestamp ("E") — never overwrite it.
+ // The freshness warn keys on RECEIVE time: it measures in-process dispatch
+ // delay, and must not fire on venue/local clock offset.
+ if (Math.Abs(DateTime.Now.Subtract(data.ReceiveTime.ToLocalTime()).TotalSeconds) > 1)
{
- var _msg = $"Rates are coming late at {Math.Abs(DateTime.Now.Subtract(data.Data.EventTime.ToLocalTime()).TotalSeconds)} seconds.";
+ var _msg = $"Rates are coming late at {Math.Abs(DateTime.Now.Subtract(data.ReceiveTime.ToLocalTime()).TotalSeconds)} seconds.";
log.Warn(_msg);
HelperNotificationManager.Instance.AddNotification(this.Name, _msg, HelprNorificationManagerTypes.WARNING, HelprNorificationManagerCategories.PLUGINS);
}
@@ -662,6 +664,20 @@ private void deltaSubscription_Exception(Exception obj)
#endregion
// ✅ FIX: Thread-safe UpdateOrderBook with TryGetValue
+ ///
+ /// The single decision point for the venue's book timestamp. Binance's diff-depth
+ /// stream carries the exchange event time (wire field "E"); return it in local
+ /// kind. A frame without it returns null — a default(DateTime) stamp would
+ /// fabricate a colossal latency spike, and receive time must never masquerade
+ /// as exchange time.
+ ///
+ public static DateTime? ResolveBookTimestamp(IBinanceEventOrderBook lob_update)
+ {
+ if (lob_update == null || lob_update.EventTime == default)
+ return null;
+ return lob_update.EventTime.ToLocalTime();
+ }
+
private void UpdateOrderBook(IBinanceEventOrderBook lob_update, string normalizedSymbol)
{
// ✅ Use TryGetValue for thread safety
@@ -674,7 +690,7 @@ private void UpdateOrderBook(IBinanceEventOrderBook lob_update, string normalize
return;
}
- DateTime ts = lob_update.EventTime.ToLocalTime();
+ DateTime? exchangeTs = ResolveBookTimestamp(lob_update);
if (lob_update.LastUpdateId <= local_lob.Sequence)
return;
@@ -685,6 +701,7 @@ private void UpdateOrderBook(IBinanceEventOrderBook lob_update, string normalize
// ✅ Cache DateTime.Now once
var now = DateTime.Now;
+ DateTime ts = exchangeTs ?? now;
foreach (var item in lob_update.Bids)
{
@@ -741,7 +758,9 @@ private void UpdateOrderBook(IBinanceEventOrderBook lob_update, string normalize
}
local_lob.Sequence = lob_update.LastUpdateId;
- local_lob.LastUpdated = ts;
+ // Venue event time when the frame carries one; null otherwise (never
+ // receive time — Mkt Lat measures the venue clock, not our decode delay).
+ local_lob.LastUpdated = exchangeTs;
RaiseOnDataReceived(local_lob);
}
diff --git a/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs b/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs
index 17e0db34..5134c309 100644
--- a/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs
+++ b/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs
@@ -39,7 +39,7 @@ public class BitfinexPlugin : BasePluginDataRetriever, IDataRetrieverTestable
private BitfinexSocketClient _socketClient;
private BitfinexRestClient _restClient;
private Dictionary _localOrderBooks = new Dictionary();
- private Dictionary>> _eventBuffers = new();
+ private Dictionary>> _eventBuffers = new();
private Dictionary>> _tradesBuffers = new();
private readonly object _buffersLock = new object(); // ✅ ADD: Thread-safe buffer access
@@ -115,7 +115,7 @@ private async Task InternalStartAsync()
// Initialize event buffer for each symbol
foreach (var symbol in GetAllNormalizedSymbols())
{
- _eventBuffers.Add(symbol, new HelperCustomQueue>($">_{this.Name.Replace(" Plugin", "")}", eventBuffers_onReadAction, eventBuffers_onErrorAction));
+ _eventBuffers.Add(symbol, new HelperCustomQueue>($">_{this.Name.Replace(" Plugin", "")}", eventBuffers_onReadAction, eventBuffers_onErrorAction));
_tradesBuffers.Add(symbol, new HelperCustomQueue>($">_{this.Name.Replace(" Plugin", "")}", tradesBuffers_onReadAction, tradesBuffers_onErrorAction));
}
@@ -356,18 +356,21 @@ private async Task InitializeDeltasAsync()
else
{
// ✅ FIX: Thread-safe buffer access
- HelperCustomQueue> buffer;
+ HelperCustomQueue> buffer;
lock (_buffersLock)
{
if (!_eventBuffers.TryGetValue(normalizedSymbol, out buffer))
return; // Buffer was cleared during reconnection
}
+ // The BOOK stamp is the venue's server timestamp carried on the
+ // wrapper (DataTime, null when absent) — never local receive time.
+ var exchangeTs = ResolveBookTimestamp(data.DataTime);
foreach (var item in data.Data)
{
buffer.Add(
- new Tuple(
- data.ReceiveTime.ToLocalTime(), normalizedSymbol, item));
+ new Tuple(
+ exchangeTs, normalizedSymbol, item));
}
}
}
@@ -436,7 +439,7 @@ private async Task InitializePingTimerAsync()
_timerPing.Enabled = true; // Start the timer
}
- private void eventBuffers_onReadAction(Tuple eventData)
+ private void eventBuffers_onReadAction(Tuple eventData)
{
UpdateOrderBook(eventData.Item3, eventData.Item2, eventData.Item1);
}
@@ -659,7 +662,21 @@ private void UpdateOrderBookSnapshot(IEnumerable data, s
});
});
}
- private void UpdateOrderBook(BitfinexOrderBookEntry lob_update, string symbol, DateTime ts)
+ ///
+ /// The single decision point for the venue's book timestamp. Bitfinex book
+ /// entries carry no timestamp, but the socket library enables the exchange's
+ /// TIMESTAMP conf flag and delivers the server's event time on
+ /// DataEvent.DataTime for every book frame; return it in local kind. A frame
+ /// without it returns null — receive time must never masquerade as exchange time.
+ ///
+ public static DateTime? ResolveBookTimestamp(DateTime? dataTime)
+ {
+ if (!dataTime.HasValue || dataTime.Value == default)
+ return null;
+ return dataTime.Value.ToLocalTime();
+ }
+
+ private void UpdateOrderBook(BitfinexOrderBookEntry lob_update, string symbol, DateTime? ts)
{
if (!_localOrderBooks.ContainsKey(symbol))
return;
@@ -680,7 +697,7 @@ private void UpdateOrderBook(BitfinexOrderBookEntry lob_update, string symbol, D
Size = (double)Math.Abs(lob_update.Quantity),
IsBid = isBid,
LocalTimeStamp = DateTime.Now,
- ServerTimeStamp = ts,
+ ServerTimeStamp = ts ?? DateTime.Now,
Symbol = local_lob.Symbol,
MDUpdateAction = eMDUpdateAction.Delete,
};
@@ -694,7 +711,7 @@ private void UpdateOrderBook(BitfinexOrderBookEntry lob_update, string symbol, D
Size = (double)Math.Abs(lob_update.Quantity),
IsBid = isBid,
LocalTimeStamp = DateTime.Now,
- ServerTimeStamp = ts,
+ ServerTimeStamp = ts ?? DateTime.Now,
Symbol = local_lob.Symbol,
MDUpdateAction = eMDUpdateAction.Change,
};
diff --git a/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs b/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs
index a3111b7f..631dad83 100644
--- a/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs
+++ b/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs
@@ -35,8 +35,8 @@ public class CoinbasePlugin : BasePluginDataRetriever
private readonly ConcurrentDictionary _localOrderBooks =
new ConcurrentDictionary();
- private readonly ConcurrentDictionary>> _eventBuffers =
- new ConcurrentDictionary>>();
+ private readonly ConcurrentDictionary>> _eventBuffers =
+ new ConcurrentDictionary>>();
private readonly ConcurrentDictionary>> _tradesBuffers =
new ConcurrentDictionary>>();
@@ -139,7 +139,7 @@ private async Task InternalStartAsync()
foreach (var symbol in GetAllNormalizedSymbols())
{
_eventBuffers.TryAdd(symbol,
- new HelperCustomQueue>(
+ new HelperCustomQueue>(
$">_{this.Name.Replace(" Plugin", "")}",
eventBuffers_onReadAction, eventBuffers_onErrorAction));
_tradesBuffers.TryAdd(symbol,
@@ -335,9 +335,12 @@ private async Task InitializeDeltasAsync()
HelprNorificationManagerCategories.PLUGINS);
}
+ // The BOOK stamp is the venue's own event time (newest entry
+ // "event_time", null when absent) — ReceiveTime stays the
+ // freshness-warn input only, never the book stamp.
_eventBuffers[normalizedSymbol].Add(
- new Tuple(
- data.ReceiveTime.ToLocalTime(), normalizedSymbol, data.Data));
+ new Tuple(
+ ResolveBookTimestamp(data.Data), normalizedSymbol, data.Data));
}
}
catch (Exception ex)
@@ -417,7 +420,7 @@ private async Task InitializePingTimerAsync()
_timerPing.Enabled = true; // Start the timer
}
- private void eventBuffers_onReadAction(Tuple eventData)
+ private void eventBuffers_onReadAction(Tuple eventData)
{
UpdateOrderBook(eventData.Item3, eventData.Item2, eventData.Item1);
}
@@ -530,7 +533,37 @@ private void deltaSubscription_Exception(Exception obj)
#endregion
// ✅ FIX: Thread-safe UpdateOrderBook
- private void UpdateOrderBook(CoinbaseOrderBookUpdate lob_update, string symbol, DateTime ts)
+ ///
+ /// The single decision point for the venue's book timestamp. Coinbase's level2
+ /// stream carries the exchange event time PER ENTRY (wire field "event_time");
+ /// the frame timestamp is the newest entry event time across bids and asks,
+ /// returned in local kind. Entries without it are ignored; a frame with no
+ /// usable entry timestamps returns null — receive time must never masquerade
+ /// as exchange time.
+ ///
+ public static DateTime? ResolveBookTimestamp(CoinbaseOrderBookUpdate lob_update)
+ {
+ if (lob_update == null)
+ return null;
+ var newest = default(DateTime);
+ if (lob_update.Bids != null)
+ {
+ foreach (var item in lob_update.Bids)
+ {
+ if (item.EventTime > newest) newest = item.EventTime;
+ }
+ }
+ if (lob_update.Asks != null)
+ {
+ foreach (var item in lob_update.Asks)
+ {
+ if (item.EventTime > newest) newest = item.EventTime;
+ }
+ }
+ return newest == default ? null : newest.ToLocalTime();
+ }
+
+ private void UpdateOrderBook(CoinbaseOrderBookUpdate lob_update, string symbol, DateTime? ts)
{
if (lob_update == null)
return;
@@ -559,7 +592,7 @@ private void UpdateOrderBook(CoinbaseOrderBookUpdate lob_update, string symbol,
Size = (double)item.Quantity,
IsBid = true,
LocalTimeStamp = now,
- ServerTimeStamp = ts,
+ ServerTimeStamp = ts ?? DateTime.Now,
Symbol = symbol
});
}
@@ -571,7 +604,7 @@ private void UpdateOrderBook(CoinbaseOrderBookUpdate lob_update, string symbol,
Price = (double)item.Price,
IsBid = true,
LocalTimeStamp = now,
- ServerTimeStamp = ts,
+ ServerTimeStamp = ts ?? DateTime.Now,
Symbol = symbol
});
}
@@ -588,7 +621,7 @@ private void UpdateOrderBook(CoinbaseOrderBookUpdate lob_update, string symbol,
Size = (double)item.Quantity,
IsBid = false,
LocalTimeStamp = now,
- ServerTimeStamp = ts,
+ ServerTimeStamp = ts ?? DateTime.Now,
Symbol = symbol
});
}
@@ -600,7 +633,7 @@ private void UpdateOrderBook(CoinbaseOrderBookUpdate lob_update, string symbol,
Price = (double)item.Price,
IsBid = false,
LocalTimeStamp = now,
- ServerTimeStamp = ts,
+ ServerTimeStamp = ts ?? DateTime.Now,
Symbol = symbol
});
}
diff --git a/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs b/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs
index 9ea06ab8..878d5448 100644
--- a/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs
+++ b/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs
@@ -58,7 +58,7 @@ private enum ChecksumValidationMode { Off, LogOnly, Enforce }
private ChecksumValidationMode _checksumMode = ChecksumValidationMode.Enforce;
// PERF-5: value-tuple payloads avoid a per-frame heap Tuple allocation. The isSnapshot flag routes
// snapshots through the SAME single-consumer queue as deltas (ACC-4/CONC-4: ordered, race-free book build).
- private Dictionary> _eventBuffers = new();
+ private Dictionary> _eventBuffers = new();
private Dictionary> _tradesBuffers = new();
private readonly object _buffersLock = new object(); // ✅ ADD: Thread-safe buffer access
@@ -141,7 +141,7 @@ private async Task InternalStartAsync()
// Initialize event buffer for each symbol
foreach (var symbol in GetAllNormalizedSymbols())
{
- _eventBuffers.Add(symbol, new HelperCustomQueue<(DateTime ts, string symbol, KrakenBookUpdate data, bool isSnapshot)>($"_{this.Name.Replace(" Plugin", "")}", eventBuffers_onReadAction, eventBuffers_onErrorAction));
+ _eventBuffers.Add(symbol, new HelperCustomQueue<(DateTime? ts, string symbol, KrakenBookUpdate data, bool isSnapshot)>($"_{this.Name.Replace(" Plugin", "")}", eventBuffers_onReadAction, eventBuffers_onErrorAction));
_tradesBuffers.Add(symbol, new HelperCustomQueue<(string symbol, KrakenTradeUpdate trade)>($"_{this.Name.Replace(" Plugin", "")}", tradesBuffers_onReadAction, tradesBuffers_onErrorAction));
}
@@ -435,14 +435,18 @@ private async Task InitializeDeltasAsync()
if (!isSnapshot)
CheckFrameFreshnessAndWarn(receiveLocal);
- HelperCustomQueue<(DateTime ts, string symbol, KrakenBookUpdate data, bool isSnapshot)> buffer;
+ // The BOOK stamp is the venue's own frame timestamp (null when
+ // absent) — receiveLocal stays the freshness-guard input only.
+ var exchangeTs = ResolveBookTimestamp(data.Data);
+
+ HelperCustomQueue<(DateTime? ts, string symbol, KrakenBookUpdate data, bool isSnapshot)> buffer;
lock (_buffersLock)
{
if (!_eventBuffers.TryGetValue(normalizedSymbol, out buffer))
return; // Buffer was cleared during reconnection
}
- buffer.Add((receiveLocal, normalizedSymbol, data.Data, isSnapshot));
+ buffer.Add((exchangeTs, normalizedSymbol, data.Data, isSnapshot));
}
catch (Exception ex)
{
@@ -511,7 +515,7 @@ private async Task InitializePingTimerAsync()
_timerPing.Enabled = true; // Start the timer
}
- private void eventBuffers_onReadAction((DateTime ts, string symbol, KrakenBookUpdate data, bool isSnapshot) e)
+ private void eventBuffers_onReadAction((DateTime? ts, string symbol, KrakenBookUpdate data, bool isSnapshot) e)
{
if (e.isSnapshot)
ApplySnapshot(e.data, e.symbol, e.ts);
@@ -754,7 +758,7 @@ private void UpdateOrderBookSnapshot(KrakenBookUpdate data, string symbol)
}
// Live snapshot handler. Runs on the single book-consumer thread, strictly in order with deltas
// (ACC-4/CONC-4), so the display book and the decimal ladder are always built before any delta.
- private void ApplySnapshot(KrakenBookUpdate data, string symbol, DateTime ts)
+ private void ApplySnapshot(KrakenBookUpdate data, string symbol, DateTime? ts)
{
var lob = ToOrderBookModel(data, symbol);
lob.LastUpdated = ts;
@@ -771,7 +775,21 @@ private void ApplySnapshot(KrakenBookUpdate data, string symbol, DateTime ts)
RaiseOnDataReceived(lob);
}
- private void UpdateOrderBook(KrakenBookUpdate lob_update, string symbol, DateTime ts)
+ ///
+ /// The single decision point for the venue's book timestamp. Kraken book-v2
+ /// carries a frame-level exchange timestamp (wire field "timestamp"); return it
+ /// in local kind. A frame without it returns null — a default(DateTime) stamp
+ /// would fabricate a colossal latency spike, and receive time must never
+ /// masquerade as exchange time (same principle as the ACC-3 trades rule).
+ ///
+ public static DateTime? ResolveBookTimestamp(KrakenBookUpdate lob_update)
+ {
+ if (lob_update == null || lob_update.Timestamp == default)
+ return null;
+ return lob_update.Timestamp.ToLocalTime();
+ }
+
+ private void UpdateOrderBook(KrakenBookUpdate lob_update, string symbol, DateTime? ts)
{
if (!_localOrderBooks.TryGetValue(symbol, out VisualHFT.Model.OrderBook? local_lob) || local_lob == null)
return;
@@ -794,7 +812,7 @@ private void UpdateOrderBook(KrakenBookUpdate lob_update, string symbol, DateTim
continue;
// PERF-1: allocation-free primitive overloads (no per-level DeltaBookItem), mirroring KuCoin.
if (item.Quantity != 0)
- local_lob.AddOrUpdateLevel(true, string.Empty, (double)item.Price, (double)item.Quantity, now, ts);
+ local_lob.AddOrUpdateLevel(true, string.Empty, (double)item.Price, (double)item.Quantity, now, ts ?? now);
else
local_lob.DeleteLevel(true, string.Empty, (double)item.Price, (double)item.Quantity);
if (validate)
@@ -805,7 +823,7 @@ private void UpdateOrderBook(KrakenBookUpdate lob_update, string symbol, DateTim
if (item.Quantity == 0 && item.Price == 0 || item.Quantity < 0)
continue;
if (item.Quantity != 0)
- local_lob.AddOrUpdateLevel(false, string.Empty, (double)item.Price, (double)item.Quantity, now, ts);
+ local_lob.AddOrUpdateLevel(false, string.Empty, (double)item.Price, (double)item.Quantity, now, ts ?? now);
else
local_lob.DeleteLevel(false, string.Empty, (double)item.Price, (double)item.Quantity);
if (validate)
diff --git a/VisualHFT.Plugins/MarketConnectors.KuCoin/KuCoinPlugin.cs b/VisualHFT.Plugins/MarketConnectors.KuCoin/KuCoinPlugin.cs
index 49d9f197..37ff71b8 100644
--- a/VisualHFT.Plugins/MarketConnectors.KuCoin/KuCoinPlugin.cs
+++ b/VisualHFT.Plugins/MarketConnectors.KuCoin/KuCoinPlugin.cs
@@ -45,8 +45,8 @@ public class KuCoinPlugin : BasePluginDataRetriever, IDataRetrieverTestable
private readonly ReaderWriterLockSlim _orderBooksLock = new ReaderWriterLockSlim();
private readonly object _buffersLock = new object();
- private Dictionary>> _eventBuffers =
- new Dictionary>>();
+ private Dictionary>> _eventBuffers =
+ new Dictionary>>();
private Dictionary>> _tradesBuffers =
new Dictionary>>();
@@ -172,7 +172,7 @@ private async Task InternalStartAsync()
foreach (var symbol in GetAllNormalizedSymbols())
{
_eventBuffers.Add(symbol,
- new HelperCustomQueue>(
+ new HelperCustomQueue>(
$">_{this.Name.Replace(" Plugin", "")}",
eventBuffers_onReadAction, eventBuffers_onErrorAction));
_tradesBuffers.Add(symbol,
@@ -449,16 +449,19 @@ private async Task InitializeDeltasAsync()
{
// Per-frame freshness guard (shared + virtual-clock — see BasePluginDataRetriever).
CheckFrameFreshnessAndWarn(data.ReceiveTime.ToLocalTime());
- HelperCustomQueue> buffer;
+ HelperCustomQueue> buffer;
lock (_buffersLock) // ✅ Protect access
{
if (!_eventBuffers.TryGetValue(normalizedSymbol, out buffer))
return; // Buffer was cleared
}
+ // The BOOK stamp is the venue's own frame timestamp ("time",
+ // null when absent) — ReceiveTime stays the freshness-guard
+ // input only, never the book stamp.
buffer.Add(
- new Tuple(
- data.ReceiveTime.ToLocalTime(), normalizedSymbol, data.Data));
+ new Tuple(
+ ResolveBookTimestamp(data.Data), normalizedSymbol, data.Data));
}
}
catch (Exception ex)
@@ -573,7 +576,7 @@ private async Task InitializePingTimerAsync()
_timerPing.Enabled = true; // Start the timer
}
- private void eventBuffers_onReadAction(Tuple eventData)
+ private void eventBuffers_onReadAction(Tuple eventData)
{
UpdateOrderBook(eventData.Item3, eventData.Item2, eventData.Item1);
}
@@ -704,7 +707,21 @@ private void deltaSubscription_Exception(Exception obj)
#endregion
- private void UpdateOrderBook(KucoinStreamOrderBook lob_update, string symbol, DateTime ts)
+ ///
+ /// The single decision point for the venue's book timestamp. KuCoin's aggregated
+ /// level-2 stream carries a frame-level exchange timestamp (wire field "time");
+ /// return it in local kind. A frame without it returns null — a default(DateTime)
+ /// stamp would fabricate a colossal latency spike, and receive time must never
+ /// masquerade as exchange time.
+ ///
+ public static DateTime? ResolveBookTimestamp(KucoinStreamOrderBook lob_update)
+ {
+ if (lob_update == null || lob_update.Timestamp == default)
+ return null;
+ return lob_update.Timestamp.ToLocalTime();
+ }
+
+ private void UpdateOrderBook(KucoinStreamOrderBook lob_update, string symbol, DateTime? ts)
{
_orderBooksLock.EnterWriteLock();
@@ -747,7 +764,7 @@ private void UpdateOrderBook(KucoinStreamOrderBook lob_update, string symbol, Da
if (item.Quantity == 0)
local_lob.DeleteLevel(true, string.Empty, (double)item.Price, (double)item.Quantity);
else
- local_lob.AddOrUpdateLevel(true, string.Empty, (double)item.Price, (double)item.Quantity, DateTime.Now, ts);
+ local_lob.AddOrUpdateLevel(true, string.Empty, (double)item.Price, (double)item.Quantity, DateTime.Now, ts ?? DateTime.Now);
}
foreach (var item in lob_update.Changes.Asks)
@@ -758,7 +775,7 @@ private void UpdateOrderBook(KucoinStreamOrderBook lob_update, string symbol, Da
if (item.Quantity == 0)
local_lob.DeleteLevel(false, string.Empty, (double)item.Price, (double)item.Quantity);
else
- local_lob.AddOrUpdateLevel(false, string.Empty, (double)item.Price, (double)item.Quantity, DateTime.Now, ts);
+ local_lob.AddOrUpdateLevel(false, string.Empty, (double)item.Price, (double)item.Quantity, DateTime.Now, ts ?? DateTime.Now);
}
local_lob.Sequence = lob_update.SequenceEnd;