From 815b3732aa7a897d0701afb3ab328bdcf43dcbc9 Mon Sep 17 00:00:00 2001 From: Ariel Silahian Date: Wed, 2 Sep 2026 21:28:50 -0400 Subject: [PATCH] chore: comments and test text describe only the code next to them Reworded comments in the trigger engine, the notification helper, the provider helper, the frame coordinator, the basic book-item pool, the tile view model and three market connectors so that each describes the code next to it. Test headers and assertion messages in the trigger-engine, reconnection-engine and order-book guard tests now state the behaviour under test in plain words. The three connector project files drop an unused assembly-visibility entry. The stray README.md.bak is removed. No behaviour change: comments, strings, project metadata and one dead file. --- README.md.bak | 93 ------------------- TriggerEngine/TriggerEngineService.cs | 33 +++---- ViewModel/vmTile.cs | 1 - .../Helper/FrameCoordinator.cs | 4 +- .../Helpers/HelperNotificationManager.cs | 5 +- VisualHFT.Commons/Helpers/HelperProvider.cs | 4 +- .../PluginManager/BasePluginDataRetriever.cs | 4 +- VisualHFT.Commons/Pools/BookItemL2Pool.cs | 10 +- .../BitfinexPlugin.cs | 4 +- .../MarketConnectors.Bitfinex.csproj | 4 - .../CoinbasePlugin.cs | 4 +- .../MarketConnectors.Coinbase.csproj | 4 - .../MarketConnectors.Kraken/KrakenPlugin.cs | 36 +++---- .../MarketConnectors.Kraken.csproj | 4 - .../ReconnectionEngineTests.cs | 6 +- .../FirstFireAndWindowedConditionTests.cs | 35 +++---- .../OnTriggerFiredEventTests.cs | 45 +++++---- .../TriggerEngineTests.cs | 10 +- .../OrderBookDisposedGuardTests.cs | 22 ++--- .../VisualHFT.Commons.Tests.csproj | 2 +- 20 files changed, 104 insertions(+), 226 deletions(-) delete mode 100644 README.md.bak diff --git a/README.md.bak b/README.md.bak deleted file mode 100644 index 4d484f78..00000000 --- a/README.md.bak +++ /dev/null @@ -1,93 +0,0 @@ -# VisualHFT - -GUI for enterprise level high frequency trading systems, making focus on visualizing market microstructure analytics, such Limit Order Book dynamic, latencies, execution quality, and other analytics. - -![Limit Order Book Visualization](https://github.com/silahian/VisualHFT/blob/master/docImages/LOB_fulldepth.gif) -For this example we had the following: -- built with #wpf (desktop app) -- 5 levels of depth on each side -- market data is coming from Binance (btc/usd) -- red/green bubbles are selling/buying orders on the LOB -- light green/red bubbles are our orders (market making) -- bottom chart in blue, is the spread - - -## Long Description -Enterprise level trading systems run in collocated server with no human interaction. With VisualHFT, we can have a look at what’s going on with the markets, risk, exposures, and many other analytics. - -The main focus is to have a dashboard showing market microstructure information. That means that we can see L2 prices, from different venues and the aggregation of it. Also, we can see Limit Order Book dynamic and its resting orders. - -The initial intention of this project was to support a high-frequency trading operation (running with latencies under 2 microseconds), but this project could be used for any type of trading. - -Technologies used are C# and WPF, and ideally, we will be updating to support other platforms too. - -Lot of things need to be improved, so please be patience. And if you have the skills to code, and ideas, happy to include contributions. - -## About me -I’ve been building high-frequency trading software for the past 10 years. Primarily using C++, for the core system, which always runs in a collocated server next to the exchange. - -If you want to learn more about what I’ve been doing, checkout my blog at or follow me in twitter @sisSoftware - -## History -I created this visualization dashboard to allow us to visualize what was going on the main HFT system, residing in a collocated software, and with no human interaction. The goal was to have a quick peek on what was happening, how many orders were being sent and how, how the market was and control some of the strategy parameters. - -## How to Install and Run the project -The server app must send the following websocket messages: - -- [Active Orders](https://github.com/silahian/VisualHFT/blob/master/WS_input_json/ActiveOrders.json) -- [Exposures](https://github.com/silahian/VisualHFT/blob/master/WS_input_json/Exposures.json) -- [Heartbeat](https://github.com/silahian/VisualHFT/blob/master/WS_input_json/HeartBeat.json) -- [Market](https://github.com/silahian/VisualHFT/blob/master/WS_input_json/Market.json) -- [Strategies](https://github.com/silahian/VisualHFT/blob/master/WS_input_json/Strategies.json) - -Also, you will need to create a SQL Server database with the following [script](https://github.com/silahian/VisualHFT/blob/master/SQL%20scripts/table%20creation.sql) - -The system needs to be fed with the defined collection of JSON data. Also, for positions and executions, the information must be read from the database (MS Sql Server database). Also, this system will send some commands back to the core system via REST (start/stop trading, params change, etc) - -Core Trading System: this is the system that will feed VisualHFT. Currently is not included in this repository, however, we have plans to include a small system as an example. It will need to have the following: REST and Websocket server, and persist positions into the database. - -The overall configuration is that the core trading system (again, not included in this repo) will also be a websocket server sending the data, and also persisting the position data into the database so VisualHFT can read from there. - -![Architecture Diagram](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.002.png) - -## Screenshots - -![Trading Statistics](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.003.png) -![Depth LOB](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.004.png) -![Analytics](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.005.png) -![Charts](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.006.png) -![Limit Order Book](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.007.png) -![Stats](/docImages/Aspose.Words.5b849bdf-d96d-4013-ad76-8c3daba3aead.008.png) - -## Why I decided to open the project and the motivations -The focus has always been on the server side, designing high-frequency trading software. Not that much for the UI. Besides, there is no open-source project that provides market microstructure visualization and analysis. That’s why the goal of this project is to be able to display specialized data around microstructures. - -If we can build up a community around this product, it could help to improve the current architecture, make it much more scalable and be able to add nice new features. Ideally, adding more advanced real-time risk metrics, analytics, and TCA (trade cost analysis). - -## Things to improve -- currently using Telerik Charts. Replace them with some good opensource WPF charts. -- documentation and wiki page. -- code architecture. -- be sure to maintain a MVVC pattern. -- be able to add more UI (ie: web) -- throttling websocket input messages (when server send lot of messages) -- performance (real-time charts are taking up too many resources). -- generalization of the strategy parameters and their UI elements. -- scalability (able to be used by multiple users at the same time) -- more real time analytics and risk measurements -- server app for testing purposes (connect to binance, and simulate orders and executions) -- add unit tests -- Ability to have a FIX network sniffer as input data (as alternative to websockets) -- Security: even though these kinds of applications run inside a private network, there is no security at all involved. - -## How to contribute** -### follow the guidelines to contribute -[Setting guidelines for repository contributors - GitHub Docs](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors) - -> Important: We **will not accept** any changes to any of the existing input json message format. This is fixed and cannot be changed. The main reason for this is that we can break all existing installations of this system. Unless there is a “very strong” case that needs to be addressed, and all the community agrees upon that. However, we could accept having new json messages, to be parsed and processed accordingly, without breaking any of the existing ones.* - - -## How to contact me -For project questions use repository’s forums or through any of my social media profiles. -[Twitter](https://twitter.com/sisSoftware) | [LinkedIn](https://www.linkedin.com/in/silahian/) | Forums - diff --git a/TriggerEngine/TriggerEngineService.cs b/TriggerEngine/TriggerEngineService.cs index 2a73708f..b3732ff6 100644 --- a/TriggerEngine/TriggerEngineService.cs +++ b/TriggerEngine/TriggerEngineService.cs @@ -56,13 +56,11 @@ public static class TriggerEngineService private static readonly Channel MetricChannel = Channel.CreateUnbounded(); - // Architecture §2.2.5 — additive fan-in event consumed by the - // MarketDataRecorder TriggerCallbackHandler (T-MDR-046) and any other - // subscriber that needs to react to rule fires. Per ADR-03 this is - // purely additive: zero behavioural change for non-subscribers. The - // raise site lives at the bottom of ProcessMetric (after a fire passes - // the cooldown gate). Subscribers are invoked individually so a - // throwing handler never breaks others (T-MDR-045 case 7). + // Additive fan-in event for any subscriber that needs to react to rule + // fires. Purely additive: zero behavioural change for non-subscribers. + // The raise site lives at the bottom of ProcessMetric (after a fire + // passes the cooldown gate). Subscribers are invoked individually so a + // throwing handler never breaks others. public static event Action? OnTriggerFired; @@ -242,7 +240,7 @@ private static void ProcessMetric(MetricEvent e) if (triggeringCondition == null) triggeringCondition = condition; - // OD-3 / GAP-MDR-14: a rule with a sustained Window must only + // A rule with a sustained Window must only // count as met once the condition has HELD for the full window. // Previously ProcessMetric called EvaluateDirect unconditionally // and IsConditionSatisfiedWithWindow was dead code, so windowed @@ -291,9 +289,9 @@ private static void ProcessMetric(MetricEvent e) if (!ActionLastFiredTimes.TryGetValue(actionKey, out var lastFireTime)) { - // GAP-MDR-01: the FIRST qualifying fire from a clean - // state must fire — the spec (FR-3.3.1 / S-08) treats - // the first breach like any other. Previously this branch + // The FIRST qualifying fire from a clean state must + // fire: the first breach is treated like any other. + // Previously this branch // only recorded the timestamp (ExecuteActionAsync and the // OnTriggerFired raise were commented out), so the first // breach was silently dropped and a fire only happened on @@ -321,12 +319,10 @@ private static void ProcessMetric(MetricEvent e) ActionLastFiredTimes[actionKey] = e.Timestamp; _ = ExecuteActionAsync(rule.Name, triggeringCondition, action, e.Plugin, e.Metric, e.Exchange, e.Symbol, e.Value, e.Timestamp); - // Architecture §2.2.5 — raise OnTriggerFired - // AFTER the cooldown passes (matches the fire - // semantics of ExecuteActionAsync). Per-handler - // try/catch keeps a misbehaving subscriber from - // poisoning the rest of the invocation list - // (T-MDR-045 case 7). + // Raise OnTriggerFired AFTER the cooldown passes + // (matches the fire semantics of ExecuteActionAsync). + // Per-handler try/catch keeps a misbehaving subscriber + // from poisoning the rest of the invocation list. RaiseOnTriggerFired(rule, triggeringCondition, e); } // else: cooldown not passed, do nothing @@ -353,8 +349,7 @@ private static bool ConditionMatchesTick(TriggerCondition condition, MetricEvent // Per-subscriber fan-out for OnTriggerFired. Iterating GetInvocationList // is required so a throwing subscriber does not abort the multicast — the - // default `event(args)` form short-circuits on the first thrown exception - // (Architecture §2.2.5 / T-MDR-045 case 7). + // default `event(args)` form short-circuits on the first thrown exception. private static void RaiseOnTriggerFired(TriggerRule rule, TriggerCondition condition, MetricEvent e) { var snapshot = OnTriggerFired; diff --git a/ViewModel/vmTile.cs b/ViewModel/vmTile.cs index dc78deab..5fdc90b5 100644 --- a/ViewModel/vmTile.cs +++ b/ViewModel/vmTile.cs @@ -346,7 +346,6 @@ private void OpenSettings(object obj) /* * Stop all studies (main and children) asynchronously - * ONLY FOR L3 VERSION */ public async Task StopAllAsync(CancellationToken cancellationToken) { diff --git a/VisualHFT.Commons.WPF/Helper/FrameCoordinator.cs b/VisualHFT.Commons.WPF/Helper/FrameCoordinator.cs index 349d6a84..5bdd01c0 100644 --- a/VisualHFT.Commons.WPF/Helper/FrameCoordinator.cs +++ b/VisualHFT.Commons.WPF/Helper/FrameCoordinator.cs @@ -32,8 +32,8 @@ public sealed class FrameCoordinator : IDisposable // Guards _participants against concurrent mutation. Register is always reached on the UI // thread (UIUpdater's constructor enforces it), but Unregister runs from UIUpdater.Dispose, - // which a plugin can dispose on a BACKGROUND thread (e.g. the replay session clone-swap - // stopping plugins off the dispatcher). Without this lock a concurrent Remove shrinks the + // which a plugin can dispose on a BACKGROUND thread (e.g. a plugin stopped off the + // dispatcher). Without this lock a concurrent Remove shrinks the // list mid-tick and OnFrameTick's indexer throws IndexOutOfRange — crashing the app. private readonly object _sync = new object(); // Reusable per-frame buffer: OnFrameTick copies the live participant set into this UNDER the diff --git a/VisualHFT.Commons/Helpers/HelperNotificationManager.cs b/VisualHFT.Commons/Helpers/HelperNotificationManager.cs index 9dd85535..37a19e79 100644 --- a/VisualHFT.Commons/Helpers/HelperNotificationManager.cs +++ b/VisualHFT.Commons/Helpers/HelperNotificationManager.cs @@ -16,9 +16,8 @@ public enum HelprNorificationManagerTypes ERROR, WARNING, TRIGGER_ACTION, - // GAP-MDR-16 — informational tier (below WARNING) for normal, expected - // outcomes that should be surfaced but are not problems (e.g. a Stop that - // discards an in-flight capture per the FR-03 50%-rule). Appended last so + // Informational tier (below WARNING) for normal, expected outcomes that + // should be surfaced but are not problems. Appended last so // existing ordinals (ERROR=0/WARNING=1/TRIGGER_ACTION=2) are unchanged. INFO } diff --git a/VisualHFT.Commons/Helpers/HelperProvider.cs b/VisualHFT.Commons/Helpers/HelperProvider.cs index ac650467..ddb4f97c 100644 --- a/VisualHFT.Commons/Helpers/HelperProvider.cs +++ b/VisualHFT.Commons/Helpers/HelperProvider.cs @@ -43,8 +43,8 @@ private void _timer_check_heartbeat_Elapsed(object sender, System.Timers.Elapsed /// /// Runs the staleness check over all tracked providers: any provider whose last update is - /// older than the heartbeat window (compared on HelperTimeProvider.Now, so it stays correct - /// under replay) is marked CONNECTED_WITH_WARNINGS, its display cleared via empty order books, + /// older than the heartbeat window (compared on HelperTimeProvider.Now) is marked + /// CONNECTED_WITH_WARNINGS, its display cleared via empty order books, /// and OnProviderStale raised. Public so a deterministic test can drive it by advancing the /// virtual clock instead of waiting for the wall-clock 30s timer. /// diff --git a/VisualHFT.Commons/PluginManager/BasePluginDataRetriever.cs b/VisualHFT.Commons/PluginManager/BasePluginDataRetriever.cs index 14526900..dcbfe8b2 100644 --- a/VisualHFT.Commons/PluginManager/BasePluginDataRetriever.cs +++ b/VisualHFT.Commons/PluginManager/BasePluginDataRetriever.cs @@ -455,8 +455,8 @@ private void LogAndNotify(string reason, Exception? exception = null) /// /// Per-frame freshness guard: if a received frame's exchange timestamp is more than /// behind the (virtual) clock, raise a WARNING notification. - /// Reads HelperTimeProvider.Now so it is deterministic under test and correct under replay (per - /// the replay-clock rule), and shared so every connector's hot-path freshness check is identical. + /// Reads HelperTimeProvider.Now so it is deterministic under test, and shared so every + /// connector's hot-path freshness check is identical. /// Returns true when a warning was raised. /// protected bool CheckFrameFreshnessAndWarn(DateTime frameTimeLocal, double toleranceSeconds = 1.0) diff --git a/VisualHFT.Commons/Pools/BookItemL2Pool.cs b/VisualHFT.Commons/Pools/BookItemL2Pool.cs index 287bb8a4..4cfe0ca4 100644 --- a/VisualHFT.Commons/Pools/BookItemL2Pool.cs +++ b/VisualHFT.Commons/Pools/BookItemL2Pool.cs @@ -30,7 +30,7 @@ public interface IBasicBookItem /// • Zero type checking overhead /// • Lock-free statistics tracking /// • Aggressive inlining for minimal overhead - /// • Open source clean (no L3 contamination) + /// • Handles only basic BookItem instances /// /// PERFORMANCE CHARACTERISTICS: /// ---------------------------- @@ -50,10 +50,8 @@ public interface IBasicBookItem /// /// ARCHITECTURAL PURITY: /// ---------------------- - /// • Zero knowledge of L3 concepts /// • Interface-based type safety /// • Compile-time violation detection - /// • Open source synchronization safe /// • No reflection, no runtime type checking /// /// USAGE EXAMPLE: @@ -112,15 +110,15 @@ public static BookItem Get() /// /// Thread-safe: Returns a basic BookItem to the pool. /// ULTRA-HIGH-PERFORMANCE: Zero overhead return path. - /// OPTIMIZED: Removed reflection - L3 validation moved to BookItemPool smart dispatcher. + /// OPTIMIZED: Removed reflection - no type validation on the return path. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Return(IBasicBookItem item) { if (item == null) return; - // OPTIMIZED: No reflection! L3 validation is handled by BookItemPool's smart dispatcher - // which routes L3 items to BookItemL3Pool before they ever reach here. + // No reflection and no type check here: BookItemPool.Return sends every BookItem to this + // pool unless a return handler has been registered. // CustomObjectPool tracks returns internally, no additional counter needed. _instance.Return((BookItem)item); } diff --git a/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs b/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs index a7e0b6ec..22d23e47 100644 --- a/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs +++ b/VisualHFT.Plugins/MarketConnectors.Bitfinex/BitfinexPlugin.cs @@ -185,8 +185,8 @@ public async Task ClearAsync() // teardown lives here rather than in StopAsync because BOTH stop paths - StopAsync and the // reconnect's InternalStartAsync - must tear the subscriptions down on the live OUTGOING // client and tolerate a dead one. - // Error, not Warn: the desktop telemetry appender ships nothing below Error, so a Warn - // never ships and a fleet-wide teardown failure would be invisible in production telemetry. + // Error, not Warn: a failed socket teardown leaves the venue connection in an unknown state + // and is a failure to surface, not a warning to file. // Not LogException: it also increments OperationalErrorsCount, which the feed-health study // reads as a feed failure, and a tolerated teardown is not one. try diff --git a/VisualHFT.Plugins/MarketConnectors.Bitfinex/MarketConnectors.Bitfinex.csproj b/VisualHFT.Plugins/MarketConnectors.Bitfinex/MarketConnectors.Bitfinex.csproj index d9cb8f63..2da04ab5 100644 --- a/VisualHFT.Plugins/MarketConnectors.Bitfinex/MarketConnectors.Bitfinex.csproj +++ b/VisualHFT.Plugins/MarketConnectors.Bitfinex/MarketConnectors.Bitfinex.csproj @@ -52,8 +52,4 @@ - - - - diff --git a/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs b/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs index d76c1c15..1d539029 100644 --- a/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs +++ b/VisualHFT.Plugins/MarketConnectors.Coinbase/CoinbasePlugin.cs @@ -212,8 +212,8 @@ public async Task ClearAsync() // teardown lives here rather than in StopAsync because BOTH stop paths - StopAsync and the // reconnect's InternalStartAsync - must tear the subscriptions down on the live OUTGOING // client and tolerate a dead one. - // Error, not Warn: the desktop telemetry appender ships nothing below Error, so a Warn - // never ships and a fleet-wide teardown failure would be invisible in production telemetry. + // Error, not Warn: a failed socket teardown leaves the venue connection in an unknown state + // and is a failure to surface, not a warning to file. // Not LogException: it also increments OperationalErrorsCount, which the feed-health study // reads as a feed failure, and a tolerated teardown is not one. try diff --git a/VisualHFT.Plugins/MarketConnectors.Coinbase/MarketConnectors.Coinbase.csproj b/VisualHFT.Plugins/MarketConnectors.Coinbase/MarketConnectors.Coinbase.csproj index 72e9c286..0bc2fd60 100644 --- a/VisualHFT.Plugins/MarketConnectors.Coinbase/MarketConnectors.Coinbase.csproj +++ b/VisualHFT.Plugins/MarketConnectors.Coinbase/MarketConnectors.Coinbase.csproj @@ -17,8 +17,4 @@ - - - - diff --git a/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs b/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs index fd9d4398..84c3934c 100644 --- a/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs +++ b/VisualHFT.Plugins/MarketConnectors.Kraken/KrakenPlugin.cs @@ -42,9 +42,9 @@ public class KrakenPlugin : BasePluginDataRetriever, IDataRetrieverTestable private PlugInSettings _settings; private IKrakenSocketClient _socketClient; private IKrakenRestClient _restClient; - // CONC-1: thread-safe — written on the WS snapshot path, read on the book + trades consumer threads. + // Thread-safe — written on the WS snapshot path, read on the book + trades consumer threads. private readonly ConcurrentDictionary _localOrderBooks = new(); - // ACC-1: per-symbol raw-DECIMAL mirror of the book, maintained solely to validate Kraken's v2 CRC32 + // Per-symbol raw-DECIMAL mirror of the book, maintained solely to validate Kraken's v2 CRC32 // integrity checksum (the double-based display book cannot reproduce the wire precision it needs). // Touched only on the single book-consumer thread (and cleared in ClearAsync after that thread is joined). private readonly ConcurrentDictionary _decimalBooks = new(); @@ -57,8 +57,8 @@ public class KrakenPlugin : BasePluginDataRetriever, IDataRetrieverTestable // Kraken's own book). Desync detection + resync is now armed. Revert to LogOnly if a regression surfaces. 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). + // Value-tuple payloads avoid a per-frame heap Tuple allocation. The isSnapshot flag routes + // snapshots through the SAME single-consumer queue as deltas (ordered, race-free book build). private Dictionary> _eventBuffers = new(); private Dictionary> _tradesBuffers = new(); private readonly object _buffersLock = new object(); // ✅ ADD: Thread-safe buffer access @@ -214,8 +214,8 @@ private async Task ClearAsync() // reconnect's InternalStartAsync - must tear the subscriptions down on the live OUTGOING // client and tolerate a dead one. Both maps are emptied further down, so a completed stop // leaves no stale subscription behind. - // Error, not Warn: the desktop telemetry appender ships nothing below Error, so a Warn - // never ships and a fleet-wide teardown failure would be invisible in production telemetry. + // Error, not Warn: a failed socket teardown leaves the venue connection in an unknown state + // and is a failure to surface, not a warning to file. // Not LogException: it also increments OperationalErrorsCount, which the feed-health study // reads as a feed failure, and a tolerated teardown is not one. try @@ -243,7 +243,7 @@ private async Task ClearAsync() _timerPing?.Stop(); _timerPing?.Dispose(); - // CONC-2: the book/trade consumer threads mutate the OrderBooks (and the shared BookItemPool). + // The book/trade consumer threads mutate the OrderBooks (and the shared BookItemPool). // Stop() only signals cancellation — it does NOT join — so disposing the books while a consumer // is mid-AddOrUpdateLevel risks an ObjectDisposedException or a double-free back into the pool. // Collect + clear the maps under the lock, then Dispose() each queue OUTSIDE the lock (Dispose @@ -310,8 +310,8 @@ private async Task InitializeTradesAsync() foreach (var item in trade.Data) { - // ACC-3: preserve the exchange's trade execution timestamp; do NOT overwrite it - // with the local socket ReceiveTime (that destroys latency/replay fidelity). + // Preserve the exchange's trade execution timestamp; do NOT overwrite it + // with the local socket ReceiveTime (that destroys latency fidelity). buffer.Add((_normalizedSymbol, item)); } } @@ -461,10 +461,10 @@ private async Task InitializeDeltasAsync() { try { - // ACC-4/CONC-4: route BOTH snapshot and update frames through the same + // Route BOTH snapshot and update frames through the same // single-consumer queue, so the book is seeded then mutated strictly in order // on ONE thread (no WS-callback-vs-consumer race, no inline snapshot swap). - // PERF-5: value-tuple payload — no per-frame heap Tuple allocation. + // Value-tuple payload — no per-frame heap Tuple allocation. bool isSnapshot = data.UpdateType != SocketUpdateType.Update; var receiveLocal = data.ReceiveTime.ToLocalTime(); // computed once per frame if (!isSnapshot) @@ -792,7 +792,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. + // so the display book and the decimal ladder are always built before any delta. private void ApplySnapshot(KrakenBookUpdate data, string symbol, DateTime? ts) { var lob = ToOrderBookModel(data, symbol); @@ -815,7 +815,7 @@ private void ApplySnapshot(KrakenBookUpdate data, string symbol, DateTime? ts) /// 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). + /// masquerade as exchange time (same principle as the trades timestamp rule). /// public static DateTime? ResolveBookTimestamp(KrakenBookUpdate lob_update) { @@ -829,7 +829,7 @@ private void UpdateOrderBook(KrakenBookUpdate lob_update, string symbol, DateTim if (!_localOrderBooks.TryGetValue(symbol, out VisualHFT.Model.OrderBook? local_lob) || local_lob == null) return; - // ACC-1/ACC-2: when integrity validation is enabled, mirror each delta into the decimal ladder in + // When integrity validation is enabled, mirror each delta into the decimal ladder in // the SAME pass that updates the display book — zero extra allocation (no ToLevels iterator, no // second enumeration). Kraken provides NO sequence numbers, so the CRC32 is the only desync detector. // The offline test-injection seam never seeds the ladder, so this is inert there. @@ -838,14 +838,14 @@ private void UpdateOrderBook(KrakenBookUpdate lob_update, string symbol, DateTim && _decimalBooks.TryGetValue(symbol, out ladder) && ladder.IsSeeded; - var now = DateTime.Now; // PERF-4: one wall-clock read per frame, reused across every level + var now = DateTime.Now; // one wall-clock read per frame, reused across every level try { foreach (var item in lob_update.Bids) { if (item.Quantity == 0 && item.Price == 0 || item.Quantity < 0) continue; - // PERF-1: allocation-free primitive overloads (no per-level DeltaBookItem), mirroring KuCoin. + // 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 ?? now); else @@ -925,7 +925,7 @@ protected override void Dispose(bool disposing) if (disposing) { // App-exit disposal reaches the same dead subscriptions ClearAsync now tolerates, so - // it needs the same guard: the LIFE-4 try below covers only the Wait, not the unattach + // it needs the same guard: the bounded-wait try below covers only the Wait, not the unattach // loops, and a throw there would skip everything after it - the ping timer, the // start/stop lock, the queues and the order books. Error, not Warn, for the reason // given in ClearAsync. @@ -936,7 +936,7 @@ protected override void Dispose(bool disposing) foreach (var sub in tradesSubscriptions.Values) UnattachEventHandlers(sub?.Data); - // LIFE-4: wait (bounded) for the unsubscribe to actually complete before disposing the + // Wait (bounded) for the unsubscribe to actually complete before disposing the // socket client, instead of fire-and-forget which abandons the unsubscribe. Mirrors KuCoin. try { diff --git a/VisualHFT.Plugins/MarketConnectors.Kraken/MarketConnectors.Kraken.csproj b/VisualHFT.Plugins/MarketConnectors.Kraken/MarketConnectors.Kraken.csproj index b6db1752..615cc0bc 100644 --- a/VisualHFT.Plugins/MarketConnectors.Kraken/MarketConnectors.Kraken.csproj +++ b/VisualHFT.Plugins/MarketConnectors.Kraken/MarketConnectors.Kraken.csproj @@ -46,8 +46,4 @@ - - - - diff --git a/tests/Integration/VisualHFT.DataRetriever.Tests/ReconnectionEngineTests.cs b/tests/Integration/VisualHFT.DataRetriever.Tests/ReconnectionEngineTests.cs index 4534d1b3..c3bc5d9a 100644 --- a/tests/Integration/VisualHFT.DataRetriever.Tests/ReconnectionEngineTests.cs +++ b/tests/Integration/VisualHFT.DataRetriever.Tests/ReconnectionEngineTests.cs @@ -63,7 +63,7 @@ private enum StartOutcome /// /// The plugin deliberately declined to start and stayed idle: Status = LOADED - /// (ReplayEnginePlugin.cs:617-624 — no configured session). + /// (a plugin with nothing configured to run, for example). /// LeaveLoaded, @@ -187,8 +187,8 @@ public async Task Reconnect_WhenStartAsyncFailsTwiceThenSucceeds_EndsStartedAfte } /// - /// A plugin may decline to start on purpose and stay LOADED (ReplayEnginePlugin.cs:617-624, no - /// configured session — deliberately NOT presented as a connected feed). Retrying that is + /// A plugin may decline to start on purpose and stay LOADED (nothing configured to run, so it + /// is deliberately NOT presented as a connected feed). Retrying that is /// pointless and overwriting its status is a lie: the loop must stop and leave LOADED alone. /// [Fact] diff --git a/tests/Integration/VisualHFT.TriggerService.Tests/FirstFireAndWindowedConditionTests.cs b/tests/Integration/VisualHFT.TriggerService.Tests/FirstFireAndWindowedConditionTests.cs index 6b6b756d..d5b8fcd3 100644 --- a/tests/Integration/VisualHFT.TriggerService.Tests/FirstFireAndWindowedConditionTests.cs +++ b/tests/Integration/VisualHFT.TriggerService.Tests/FirstFireAndWindowedConditionTests.cs @@ -1,29 +1,24 @@ -// RED tests (TDD) for two TriggerEngine root-cause defects surfaced by the -// Market Data Recorder gap analysis: +// Tests for two TriggerEngine root-cause defects: // -// GAP-MDR-01 — A qualifying breach from a CLEAN engine state must fire. -// The first-fire branch (TriggerEngineService.cs:196-203) records the -// last-fire timestamp but leaves ExecuteActionAsync commented out and never -// calls RaiseOnTriggerFired, so the very first breach is silently dropped; -// a fire only happens on the second qualifying tick after cooldown. Spec -// FR-3.3.1 / S-08 AC-S08.2 requires the first fire to fire. +// First fire — A qualifying breach from a CLEAN engine state must fire. +// The first-fire branch used to record the last-fire timestamp without +// executing the action or raising OnTriggerFired, so the very first breach +// was silently dropped and a fire only happened on the second qualifying +// tick after cooldown. The first fire must fire. // -// GAP-MDR-14 / OD-3 — A windowed (sustained-condition) rule (Window.Duration +// Windowed condition — A windowed (sustained-condition) rule (Window.Duration // > 0) must only fire after the condition has HELD for the window. -// ProcessMetric:185 calls EvaluateDirect only; IsConditionSatisfiedWithWindow -// is dead code, so a windowed rule fires instantly, ignoring its window. +// ProcessMetric used to call EvaluateDirect only, leaving +// IsConditionSatisfiedWithWindow unwired, so a windowed rule fired instantly. // // WHY THESE TESTS EXIST AT ALL: the original suite tested the windowed logic by // reflecting into the private IsConditionSatisfiedWithWindow (TestHelpers.cs:31), // proving the method works in isolation while never proving it is WIRED into the // live path. And the first-fire defect was codified as expected behavior -// (Pattern TEST-03 "First-Fire-No-Execute"), with tests primed to skip past it. +// ("first fire does not execute"), with tests primed to skip past it. // Every assertion below therefore goes through the real RegisterMetric -> // ProcessMetric pipeline with NO reflection into evaluation internals, so it // fails if the behavior is not actually reachable in production. -// -// These are RED until the L2 TriggerEngine fix lands (first-fire branch fires + -// IsConditionSatisfiedWithWindow wired into ProcessMetric). using System; using System.Collections.Generic; @@ -82,7 +77,7 @@ public void Dispose() } // ==================================================================== - // GAP-MDR-01 — first fire from a clean state + // First fire from a clean state // ==================================================================== /// @@ -114,7 +109,7 @@ public async Task FirstQualifyingBreach_FromCleanState_FiresExactlyOnce() TriggerEngineService.RegisterMetric(Plugin, Metric, Exchange, Symbol, 150.0, DateTime.UtcNow); Assert.True(fired.Wait(TimeSpan.FromSeconds(5)), - "GAP-MDR-01: the first qualifying breach from a clean state did not fire."); + "The first qualifying breach from a clean state did not fire."); await Task.Delay(50); lock (captured) @@ -158,7 +153,7 @@ public async Task FirstFire_ThenSecondBreachWithinCooldown_FiresExactlyOnce() var t0 = DateTime.UtcNow; TriggerEngineService.RegisterMetric(Plugin, Metric, Exchange, Symbol, 150.0, t0); Assert.True(firstFired.Wait(TimeSpan.FromSeconds(5)), - "GAP-MDR-01: first breach did not fire."); + "First breach did not fire."); // Second breach 1s later — well inside the 60s cooldown → must NOT fire. TriggerEngineService.RegisterMetric(Plugin, Metric, Exchange, Symbol, 160.0, t0.AddSeconds(1)); @@ -173,7 +168,7 @@ public async Task FirstFire_ThenSecondBreachWithinCooldown_FiresExactlyOnce() } // ==================================================================== - // GAP-MDR-14 / OD-3 — windowed (sustained) condition + // Windowed (sustained) condition // ==================================================================== /// @@ -251,7 +246,7 @@ public async Task WindowedRule_Fires_AfterConditionHeldBeyondWindow() TriggerEngineService.RegisterMetric(Plugin, Metric, Exchange, Symbol, 152.0, t0.AddSeconds(5)); Assert.True(fired.Wait(TimeSpan.FromSeconds(5)), - "GAP-MDR-14: windowed rule did not fire after the condition held beyond its window."); + "Windowed rule did not fire after the condition held beyond its window."); Assert.True(Volatile.Read(ref fireCount) >= 1); } finally diff --git a/tests/Integration/VisualHFT.TriggerService.Tests/OnTriggerFiredEventTests.cs b/tests/Integration/VisualHFT.TriggerService.Tests/OnTriggerFiredEventTests.cs index 9899f76e..8dac17b2 100644 --- a/tests/Integration/VisualHFT.TriggerService.Tests/OnTriggerFiredEventTests.cs +++ b/tests/Integration/VisualHFT.TriggerService.Tests/OnTriggerFiredEventTests.cs @@ -1,8 +1,7 @@ -// T-MDR-045 (T-S08-2, T-S08-3, T-S08-5) — RED tests for -// TriggerEngineService.OnTriggerFired event. RED until T-MDR-065 lands -// (the event field + invocation site at TriggerEngineService.cs:285). +// Tests for the TriggerEngineService.OnTriggerFired event (the event field +// and its invocation site in TriggerEngineService). // -// Per the wiring manifest forbidden_test_fakes constraint these tests run +// These tests use no fakes: they run // the real TriggerEngineService.RegisterMetric → ProcessMetric → // ExecuteActionAsync pipeline (no mocks). // @@ -18,7 +17,7 @@ // // 8. OnTriggerFired_HandlerLatency_p99_under_1ms // — 10K fires, measure p99 of subscriber wall-clock latency from -// invocation site → assert ≤ 1 ms (FR-10 / T-S08-5). +// invocation site → assert ≤ 1 ms. using System; using System.Collections.Generic; @@ -57,7 +56,7 @@ public OnTriggerFiredEventTests() _originalConfigPath = TriggerEngineService.TriggerEngineConfigFilePath; _testConfigPath = Path.Combine( Path.GetTempPath(), - $"TE_T-MDR-045_{Guid.NewGuid():N}", + $"TE_OnTriggerFired_{Guid.NewGuid():N}", "TriggerEngineConfig.json"); TriggerEngineService.TriggerEngineConfigFilePath = _testConfigPath; @@ -83,11 +82,11 @@ public void Dispose() } /// - /// Case 6 (T-S08-2): a GreaterThan rule fires once when the metric + /// Case 6: a GreaterThan rule fires once when the metric /// breaches its threshold. The subscriber receives exactly one /// whose Timestamp ≈ fire time /// (within a generous 5 s wall-clock window so CI noise does not flake - /// it). Post GAP-MDR-01 fix the FIRST qualifying breach fires, so a single + /// it). The FIRST qualifying breach fires, so a single /// event drives the assertion — no primer cycle. /// [Fact(Timeout = 30_000)] @@ -114,7 +113,7 @@ public async Task OnTriggerFired_FiresOnceWhenRuleSatisfied() TriggerEngineService.AddOrUpdateRule(rule); DateTime fireTime = DateTime.UtcNow; - // GAP-MDR-01 fix: the first qualifying breach fires. A single + // The first qualifying breach fires. A single // GreaterThan event over the threshold is enough — no primer cycle. TriggerEngineService.RegisterMetric(Plugin, Metric, Exchange, Symbol, 150.0, fireTime); @@ -147,11 +146,11 @@ public async Task OnTriggerFired_FiresOnceWhenRuleSatisfied() } /// - /// Case 7 (T-S08-3): a throwing subscriber must not prevent another - /// subscriber from receiving subsequent fires. Architecture §2.2.5: + /// Case 7: a throwing subscriber must not prevent another + /// subscriber from receiving subsequent fires. By design, /// TriggerEngineService catches per-subscriber exceptions and emits /// a WARN. We subscribe both a throwing handler and a counting - /// handler, fire 10× (10 RegisterMetric calls — post GAP-MDR-01 the first + /// handler, fire 10× (10 RegisterMetric calls — the first /// breach fires too, so no primer), and assert the counter reaches 10 + /// a WARN log per failure. /// @@ -184,7 +183,7 @@ public async Task OnTriggerFired_ThrowingSubscriberDoesNotBreakOthers() cooldownSeconds: 0); TriggerEngineService.AddOrUpdateRule(rule); - // Post GAP-MDR-01: every qualifying breach fires, including the first. + // Every qualifying breach fires, including the first. // 10 breaches → 10 fires (cooldown 0). for (int i = 0; i < 10; i++) { @@ -203,7 +202,7 @@ public async Task OnTriggerFired_ThrowingSubscriberDoesNotBreakOthers() Assert.True(Volatile.Read(ref failingCalls) >= 10, "Throwing subscriber must have been called at least 10 times."); - // Architecture §2.2.5: a WARN is emitted per swallowed exception. + // A WARN is emitted per swallowed exception. int warnCount = capture.CountWarns("OnTriggerFired"); Assert.True(warnCount >= 10, $"Expected ≥10 WARNs about OnTriggerFired subscriber; got {warnCount}."); @@ -216,11 +215,11 @@ public async Task OnTriggerFired_ThrowingSubscriberDoesNotBreakOthers() } /// - /// Case 8 (T-S08-5 / FR-10): with a no-op subscriber, the wall-clock + /// Case 8: with a no-op subscriber, the wall-clock /// latency between RegisterMetric → OnTriggerFired must have a p99 /// ≤ 1 ms across 10K fires. Driven inline (no BenchmarkDotNet host) /// because TriggerEngineService is process-static and we're already - /// in a test-runner host. Post GAP-MDR-01 the first breach fires, so every + /// in a test-runner host. The first breach fires, so every /// measured RegisterMetric is a real fire — no primer needed. /// [Fact(Timeout = 120_000)] @@ -253,7 +252,7 @@ public async Task OnTriggerFired_HandlerLatency_p99_under_1ms() cooldownSeconds: 0); TriggerEngineService.AddOrUpdateRule(rule); - // Post GAP-MDR-01 the first breach fires, so no primer — every one + // The first breach fires, so no primer — every one // of the N RegisterMetric calls below is a measured fire. await Task.Delay(100); @@ -281,7 +280,7 @@ public async Task OnTriggerFired_HandlerLatency_p99_under_1ms() double p999 = ns[Math.Min(N - 1, (int)(N * 0.999))]; Assert.True(p99 <= 1_000_000.0, - $"p50={p50:F0} ns, p99={p99:F0} ns, p99.9={p999:F0} ns — p99 must be ≤ 1 ms (FR-10)."); + $"p50={p50:F0} ns, p99={p99:F0} ns, p99.9={p999:F0} ns — p99 must be ≤ 1 ms."); } finally { @@ -291,8 +290,8 @@ public async Task OnTriggerFired_HandlerLatency_p99_under_1ms() // ---------- Reflection helpers ---------------------------------------- // - // Until T-MDR-065 lands, the OnTriggerFired field does not exist and - // every Subscribe/Unsubscribe call below throws — that is the RED gate. + // If the OnTriggerFired field does not exist, every Subscribe/Unsubscribe + // call below throws — that is the gate. // ---------------------------------------------------------------------- private static FieldInfo GetOnTriggerFiredField() @@ -318,7 +317,7 @@ private static FieldInfo GetOnTriggerFiredField() if (field is null) throw new InvalidOperationException( "TriggerEngineService.OnTriggerFired event field not found. " + - "Expected after T-MDR-065 wiring lands (RED until then)."); + "The event must be declared on TriggerEngineService."); return field; } @@ -333,7 +332,7 @@ private static void SubscribeOnTriggerFired(Action handle BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); if (addMethod is null) throw new InvalidOperationException( - "TriggerEngineService.add_OnTriggerFired not found. RED until T-MDR-065."); + "TriggerEngineService.add_OnTriggerFired not found."); addMethod.Invoke(null, new object[] { handler }); } @@ -405,7 +404,7 @@ private static TriggerRule BuildRule( /// /// Captures log4net WARN messages so the throwing-subscriber test can - /// assert the per-failure WARN required by Architecture §2.2.5. + /// assert the per-failure WARN. /// private sealed class CaptureAppender : AppenderSkeleton, IDisposable { diff --git a/tests/Integration/VisualHFT.TriggerService.Tests/TriggerEngineTests.cs b/tests/Integration/VisualHFT.TriggerService.Tests/TriggerEngineTests.cs index ec774ddd..a1bc14d2 100644 --- a/tests/Integration/VisualHFT.TriggerService.Tests/TriggerEngineTests.cs +++ b/tests/Integration/VisualHFT.TriggerService.Tests/TriggerEngineTests.cs @@ -194,9 +194,9 @@ public async Task Should_Trigger_Immediately_When_Condition_Met() var now = DateTime.UtcNow; - // Act: first metric meets the condition — fires immediately. The spec - // (FR-3.3.1 / S-08) treats the first breach like any other; the prior - // "first fire records but does not execute" behavior was GAP-MDR-01. + // Act: first metric meets the condition — fires immediately. The first + // breach is treated like any other; the prior + // "first fire records but does not execute" behavior was a defect. TriggerEngineService.RegisterMetric(PluginID, PluginName, Exchange, Symbol, 120.0, now); await Task.Delay(200); // simulate time for async processing @@ -225,7 +225,7 @@ public async Task Should_Fire_On_First_Breach_And_Again_After_Cooldown() var baseTime = DateTime.UtcNow; - // Act 1: first match — fires immediately (GAP-MDR-01 fix) + // Act 1: first match — fires immediately TriggerEngineService.RegisterMetric(PluginID, PluginName, Exchange, Symbol, 120.0, baseTime); await Task.Delay(100); @@ -254,7 +254,7 @@ public async Task Should_Fire_On_First_Breach_Then_Not_ReFire_When_Condition_Bre var baseTime = DateTime.UtcNow; - // Act: first breach — fires immediately (GAP-MDR-01 fix) + // Act: first breach — fires immediately TriggerEngineService.RegisterMetric(PluginID, PluginName, Exchange, Symbol, 120.0, baseTime); await Task.Delay(100); diff --git a/tests/Unit/VisualHFT.Commons.Tests/OrderBookDisposedGuardTests.cs b/tests/Unit/VisualHFT.Commons.Tests/OrderBookDisposedGuardTests.cs index f7a96870..87956ca4 100644 --- a/tests/Unit/VisualHFT.Commons.Tests/OrderBookDisposedGuardTests.cs +++ b/tests/Unit/VisualHFT.Commons.Tests/OrderBookDisposedGuardTests.cs @@ -47,9 +47,8 @@ namespace VisualHFT.Commons.Tests; /// released. The socket thread resumes PAST its own guard, takes the write lock and dereferences null. /// A pre-lock flag read cannot close that window — only a null re-check INSIDE the lock can, which is /// exactly what DeleteLevel and CalculateMetrics already do. AddLevel and -/// UpdateLevel are worse still: they carry no guard at all and are called directly on possibly -/// disposed books by CoinbaseL3 (MarketConnectors.CoinbaseL3/CoinbasePlugin.cs:688, :701, :1021, :1034) -/// and ReplayEngine (MarketConnectors.ReplayEngine/ReplayEnginePlugin.cs:1296, :1309). +/// UpdateLevel are worse still: they carry no guard at all, so any caller that writes a +/// level directly can reach them on a book a reconnect has already disposed. /// /// The four _WhenTheDisposeLandsAfterTheGuardPassed_ tests reproduce that window /// deterministically, without threads: dispose the book for real (side lists null, flag true), then put @@ -203,10 +202,9 @@ public void AddLevel_WhenTheDisposeLandsAfterTheGuardPassed_IsIgnored_NotAnNre() ServerTimeStamp: DateTime.Now)); Assert.True(thrown == null, - "AddLevel() (OrderBook.cs:699) has NO disposed guard at all and takes no lock of its own; it " - + "dereferenced the nulled side list at list.Count() (OrderBook.cs:717). CoinbaseL3 " - + "(CoinbasePlugin.cs:688, :701, :1021, :1034) and ReplayEngine (ReplayEnginePlugin.cs:1296, " - + ":1309) call this entry point directly on books a reconnect can dispose. It threw: " + "AddLevel() has NO disposed guard at all and takes no lock of its own; it " + + "dereferenced the nulled side list at list.Count(). A caller writing a level " + + "directly can reach it on a book a reconnect has disposed. It threw: " + Describe(thrown)); } @@ -224,9 +222,9 @@ public void UpdateLevel_WhenTheDisposeLandsAfterTheGuardPassed_IsIgnored_NotAnNr ServerTimeStamp: DateTime.Now)); Assert.True(thrown == null, - "UpdateLevel() (OrderBook.cs:776) has NO disposed guard at all and takes no lock of its own; " - + "it dereferenced the nulled side list at .Update(..) (OrderBook.cs:786). Reached through " - + "OrderBookL3.UpdateLevel (Model_L3/OrderBookL3.cs:73-77) on the L3 book path. It threw: " + "UpdateLevel() has NO disposed guard at all and takes no lock of its own; " + + "it dereferenced the nulled side list at .Update(..). Reached through " + + "a direct level update. It threw: " + Describe(thrown)); } @@ -438,8 +436,8 @@ public void LiveBook_StillAppliesClearAddAndDelete() public void LiveBook_StillAppliesAddLevelAndUpdateLevel() { // Same guard on the two entry points that get a null re-check for the first time: a fix that - // returns unconditionally (or on the wrong condition) would silence the whole L3/replay write - // path, which is a far worse defect than the NRE it replaces. + // returns unconditionally (or on the wrong condition) would silence every direct level + // write, which is a far worse defect than the NRE it replaces. using var book = new OrderBook(Symbol, PriceDecimalPlaces, MaxDepth); book.AddLevel( diff --git a/tests/Unit/VisualHFT.Commons.Tests/VisualHFT.Commons.Tests.csproj b/tests/Unit/VisualHFT.Commons.Tests/VisualHFT.Commons.Tests.csproj index c08f3859..8eb1c703 100644 --- a/tests/Unit/VisualHFT.Commons.Tests/VisualHFT.Commons.Tests.csproj +++ b/tests/Unit/VisualHFT.Commons.Tests/VisualHFT.Commons.Tests.csproj @@ -22,7 +22,7 @@