diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e5f72fd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,28 @@
+# Trade Viewer (ScottPlot WPF)
+
+This repository now contains a WPF application that mirrors the Python Plotly chart using ScottPlot.
+
+## Requirements
+
+- Windows with .NET 8 SDK
+
+## Run
+
+1. Restore packages:
+
+```bash
+dotnet restore TradeViewer/TradeViewer.csproj
+```
+
+2. Start the app:
+
+```bash
+dotnet run --project TradeViewer/TradeViewer.csproj
+```
+
+3. Click **Load** and select your CSV/log file.
+
+## Notes
+
+- The parser expects the same pipe-delimited log format as the original Python script.
+- Update the default file path in `MainWindow.xaml.cs` if you want a different initial file.
diff --git a/TradeViewer/App.xaml b/TradeViewer/App.xaml
new file mode 100644
index 0000000..23493cc
--- /dev/null
+++ b/TradeViewer/App.xaml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/TradeViewer/App.xaml.cs b/TradeViewer/App.xaml.cs
new file mode 100644
index 0000000..fbbf0d2
--- /dev/null
+++ b/TradeViewer/App.xaml.cs
@@ -0,0 +1,7 @@
+using System.Windows;
+
+namespace TradeViewer;
+
+public partial class App : Application
+{
+}
diff --git a/TradeViewer/MainWindow.xaml b/TradeViewer/MainWindow.xaml
new file mode 100644
index 0000000..ed73ae1
--- /dev/null
+++ b/TradeViewer/MainWindow.xaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TradeViewer/MainWindow.xaml.cs b/TradeViewer/MainWindow.xaml.cs
new file mode 100644
index 0000000..1334f82
--- /dev/null
+++ b/TradeViewer/MainWindow.xaml.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Windows;
+using Microsoft.Win32;
+using ScottPlot;
+using ScottPlot.Plottable;
+using TradeViewer.Models;
+using TradeViewer.Parsing;
+
+namespace TradeViewer;
+
+public partial class MainWindow : Window
+{
+ private const string DefaultFilePath = "C:\\Path\\To\\Cache.csv";
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ ConfigurePlots();
+ LoadFile(DefaultFilePath);
+ }
+
+ private void ConfigurePlots()
+ {
+ ApplyPlotStyle(PricePlot, "Prices & Orders", "Price");
+ ApplyPlotStyle(SpreadPlot, "Spreads & Borders", "Spread");
+ }
+
+ private void ApplyPlotStyle(ScottPlot.WPF.WpfPlot plot, string title, string yLabel)
+ {
+ plot.Plot.Style(Style.Black);
+ plot.Plot.Title(title, color: Color.White);
+ plot.Plot.YLabel(yLabel, color: Color.White);
+ plot.Plot.XAxis.DateTimeFormat(true);
+ plot.Plot.XAxis.Color(Color.White);
+ plot.Plot.YAxis.Color(Color.White);
+ plot.Plot.Legend(location: Alignment.UpperRight);
+ }
+
+ private void OnLoadClicked(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Filter = "CSV or log files (*.csv;*.log)|*.csv;*.log|All files (*.*)|*.*"
+ };
+
+ if (dialog.ShowDialog(this) == true)
+ {
+ LoadFile(dialog.FileName);
+ }
+ }
+
+ private void LoadFile(string path)
+ {
+ FilePathBox.Text = path;
+ var data = LogParser.Parse(path);
+ if (data is null)
+ {
+ MessageBox.Show(this, "File not found or could not be parsed.", "Load error", MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ return;
+ }
+
+ PricePlot.Plot.Clear();
+ SpreadPlot.Plot.Clear();
+
+ ConfigurePlots();
+ AddPriceSeries(data);
+ AddOrderSeries(data);
+ AddTradeSeries(data);
+ AddSpreadSeries(data);
+ AddBorderSeries(data);
+ SyncXAxisLimits(data);
+
+ PricePlot.Refresh();
+ SpreadPlot.Refresh();
+ }
+
+ private void SyncXAxisLimits(ParsedData data)
+ {
+ var allTimes = data.SpotPrices.Select(p => p.Time)
+ .Concat(data.LinearPrices.Select(p => p.Time))
+ .Concat(data.Orders.Select(o => o.StartTime))
+ .Concat(data.Orders.Select(o => o.EndTime))
+ .Concat(data.Trades.Select(t => t.Time))
+ .Concat(data.Spreads.Select(s => s.Time))
+ .Concat(data.Borders.Select(b => b.Time))
+ .ToList();
+
+ if (allTimes.Count == 0)
+ {
+ return;
+ }
+
+ var min = allTimes.Min();
+ var max = allTimes.Max();
+ var minX = min.ToOADate();
+ var maxX = max.ToOADate();
+
+ PricePlot.Plot.SetAxisLimitsX(minX, maxX);
+ SpreadPlot.Plot.SetAxisLimitsX(minX, maxX);
+ }
+
+ private void AddPriceSeries(ParsedData data)
+ {
+ if (data.SpotPrices.Count > 0)
+ {
+ AddLineSeries(PricePlot.Plot, "Spot Bid", data.SpotPrices.Select(p => p.Time).ToArray(),
+ data.SpotPrices.Select(p => p.Bid).ToArray(), Color.FromArgb(34, 139, 34), LineStyle.Dash);
+ AddLineSeries(PricePlot.Plot, "Spot Ask", data.SpotPrices.Select(p => p.Time).ToArray(),
+ data.SpotPrices.Select(p => p.Ask).ToArray(), Color.FromArgb(178, 34, 34), LineStyle.Dash);
+ }
+
+ if (data.LinearPrices.Count > 0)
+ {
+ AddLineSeries(PricePlot.Plot, "Futures Bid", data.LinearPrices.Select(p => p.Time).ToArray(),
+ data.LinearPrices.Select(p => p.Bid).ToArray(), Color.FromArgb(0, 170, 0), LineStyle.Solid);
+ AddLineSeries(PricePlot.Plot, "Futures Ask", data.LinearPrices.Select(p => p.Time).ToArray(),
+ data.LinearPrices.Select(p => p.Ask).ToArray(), Color.FromArgb(255, 0, 0), LineStyle.Solid);
+ }
+ }
+
+ private void AddOrderSeries(ParsedData data)
+ {
+ if (data.Orders.Count == 0)
+ {
+ return;
+ }
+
+ var buyPoints = new List<(DateTime Time, double Value)>();
+ var sellPoints = new List<(DateTime Time, double Value)>();
+
+ foreach (var order in data.Orders)
+ {
+ var target = order.Side == OrderSide.Buy ? buyPoints : sellPoints;
+ target.Add((order.StartTime, order.Price));
+ target.Add((order.EndTime, order.Price));
+ target.Add((order.EndTime, double.NaN));
+ }
+
+ if (buyPoints.Count > 0)
+ {
+ AddLineSeries(PricePlot.Plot, "My Buy", buyPoints.Select(p => p.Time).ToArray(),
+ buyPoints.Select(p => p.Value).ToArray(), Color.FromArgb(0, 128, 0), LineStyle.Solid, 3,
+ MarkerShape.filledCircle, 5);
+ }
+
+ if (sellPoints.Count > 0)
+ {
+ AddLineSeries(PricePlot.Plot, "My Sell", sellPoints.Select(p => p.Time).ToArray(),
+ sellPoints.Select(p => p.Value).ToArray(), Color.FromArgb(204, 0, 0), LineStyle.Solid, 3,
+ MarkerShape.filledCircle, 5);
+ }
+ }
+
+ private void AddTradeSeries(ParsedData data)
+ {
+ var buys = data.Trades.Where(t => t.Side == OrderSide.Buy).ToList();
+ var sells = data.Trades.Where(t => t.Side == OrderSide.Sell).ToList();
+
+ if (buys.Count > 0)
+ {
+ var scatter = PricePlot.Plot.AddScatter(
+ buys.Select(t => t.Time).ToArray(),
+ buys.Select(t => t.Price).ToArray(),
+ color: Color.LimeGreen,
+ lineWidth: 0,
+ markerSize: 10,
+ markerShape: MarkerShape.filledTriangleUp);
+ scatter.Label = "Trade Buy";
+ }
+
+ if (sells.Count > 0)
+ {
+ var scatter = PricePlot.Plot.AddScatter(
+ sells.Select(t => t.Time).ToArray(),
+ sells.Select(t => t.Price).ToArray(),
+ color: Color.OrangeRed,
+ lineWidth: 0,
+ markerSize: 10,
+ markerShape: MarkerShape.filledTriangleDown);
+ scatter.Label = "Trade Sell";
+ }
+ }
+
+ private void AddBorderSeries(ParsedData data)
+ {
+ if (data.Borders.Count == 0)
+ {
+ return;
+ }
+
+ AddLineSeries(SpreadPlot.Plot, "B0", data.Borders.Select(b => b.Time).ToArray(),
+ data.Borders.Select(b => b.B1).ToArray(), Color.FromArgb(139, 69, 0), LineStyle.Solid, 2);
+ AddLineSeries(SpreadPlot.Plot, "B1", data.Borders.Select(b => b.Time).ToArray(),
+ data.Borders.Select(b => b.B2).ToArray(), Color.FromArgb(255, 140, 0), LineStyle.Solid, 2);
+ AddLineSeries(SpreadPlot.Plot, "B2", data.Borders.Select(b => b.Time).ToArray(),
+ data.Borders.Select(b => b.B3).ToArray(), Color.FromArgb(0, 128, 128), LineStyle.Solid, 2);
+ AddLineSeries(SpreadPlot.Plot, "B3", data.Borders.Select(b => b.Time).ToArray(),
+ data.Borders.Select(b => b.B4).ToArray(), Color.FromArgb(0, 0, 139), LineStyle.Solid, 2);
+ }
+
+ private void AddSpreadSeries(ParsedData data)
+ {
+ if (data.Spreads.Count == 0)
+ {
+ return;
+ }
+
+ AddLineSeries(SpreadPlot.Plot, "S0", data.Spreads.Select(s => s.Time).ToArray(),
+ data.Spreads.Select(s => s.S1).ToArray(), Color.FromArgb(31, 119, 180), LineStyle.Solid, 2);
+ AddLineSeries(SpreadPlot.Plot, "S1", data.Spreads.Select(s => s.Time).ToArray(),
+ data.Spreads.Select(s => s.S2).ToArray(), Color.FromArgb(148, 103, 189), LineStyle.Solid, 2);
+ }
+
+ private static ScatterPlot AddLineSeries(
+ Plot plot,
+ string label,
+ DateTime[] xs,
+ double[] ys,
+ Color color,
+ LineStyle lineStyle,
+ double lineWidth = 1,
+ MarkerShape markerShape = MarkerShape.none,
+ double markerSize = 0)
+ {
+ var scatter = plot.AddScatter(xs, ys, color: color, lineWidth: lineWidth, markerSize: markerSize,
+ markerShape: markerShape, lineStyle: lineStyle);
+ scatter.Label = label;
+ return scatter;
+ }
+}
diff --git a/TradeViewer/Models/ParsedData.cs b/TradeViewer/Models/ParsedData.cs
new file mode 100644
index 0000000..672fc09
--- /dev/null
+++ b/TradeViewer/Models/ParsedData.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace TradeViewer.Models;
+
+public enum OrderSide
+{
+ Buy,
+ Sell
+}
+
+public sealed class ParsedData
+{
+ public List SpotPrices { get; } = new();
+ public List LinearPrices { get; } = new();
+ public List Orders { get; } = new();
+ public List Trades { get; } = new();
+ public List Spreads { get; } = new();
+ public List Borders { get; } = new();
+}
+
+public sealed record PriceEntry(DateTime Time, double Ask, double Bid);
+
+public sealed record TradeEntry(DateTime Time, double Price, OrderSide Side, string Symbol);
+
+public sealed record SpreadEntry(DateTime Time, double S1, double S2);
+
+public sealed record BorderEntry(DateTime Time, double B1, double B2, double B3, double B4);
+
+public sealed class OrderEntry
+{
+ public OrderEntry(DateTime startTime, double price, OrderSide side, string symbol, string orderId, string status)
+ {
+ StartTime = startTime;
+ Price = price;
+ Side = side;
+ Symbol = symbol;
+ OrderId = orderId;
+ Status = status;
+ EndTime = startTime;
+ FinalStatus = status;
+ }
+
+ public DateTime StartTime { get; set; }
+ public DateTime EndTime { get; set; }
+ public double Price { get; }
+ public OrderSide Side { get; }
+ public string Symbol { get; }
+ public string OrderId { get; }
+ public string Status { get; set; }
+ public string FinalStatus { get; set; }
+}
diff --git a/TradeViewer/Parsing/LogParser.cs b/TradeViewer/Parsing/LogParser.cs
new file mode 100644
index 0000000..a03792c
--- /dev/null
+++ b/TradeViewer/Parsing/LogParser.cs
@@ -0,0 +1,357 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using TradeViewer.Models;
+
+namespace TradeViewer.Parsing;
+
+public static class LogParser
+{
+ private sealed class TimeState
+ {
+ public DateTime BaseDate { get; } = new(2024, 1, 1);
+ public TimeSpan Offset { get; set; }
+ public DateTime? LastRawDt { get; set; }
+ public DateTime? LastAdjustedDt { get; set; }
+ }
+
+ public static ParsedData? Parse(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ return null;
+ }
+
+ var data = new ParsedData();
+ var activeSlots = new Dictionary<(string Symbol, OrderSide Side), OrderEntry>();
+ var activeOrdersById = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var timeState = new TimeState();
+ DateTime lastTimestamp = timeState.BaseDate;
+
+ foreach (var rawLine in File.ReadLines(filePath))
+ {
+ if (!rawLine.Contains('|'))
+ {
+ continue;
+ }
+
+ var parts = rawLine.Split('|');
+ if (parts.Length < 2)
+ {
+ continue;
+ }
+
+ if (!TryParseTimestamp(parts[0].Trim(), timeState, out var timestamp))
+ {
+ continue;
+ }
+
+ lastTimestamp = timestamp;
+ var eventType = parts[1].Trim();
+
+ try
+ {
+ if (string.Equals(eventType, "Candle", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ switch (eventType)
+ {
+ case "Top":
+ ParseTop(parts, timestamp, data);
+ break;
+ case "UserOrder":
+ ParseOrder(parts, timestamp, data, activeSlots, activeOrdersById);
+ break;
+ case "UserTrade":
+ ParseTrade(parts, timestamp, data);
+ break;
+ case "Border":
+ ParseBorder(parts, timestamp, data);
+ break;
+ case "Spreads":
+ ParseSpread(parts, timestamp, data);
+ break;
+ }
+ }
+ catch
+ {
+ continue;
+ }
+ }
+
+ var endTime = timeState.LastAdjustedDt ?? lastTimestamp;
+ foreach (var order in activeSlots.Values)
+ {
+ order.EndTime = endTime;
+ order.FinalStatus = "ActiveAtEnd";
+ data.Orders.Add(order);
+ }
+
+ data.SpotPrices.Sort((a, b) => a.Time.CompareTo(b.Time));
+ data.LinearPrices.Sort((a, b) => a.Time.CompareTo(b.Time));
+ data.Trades.Sort((a, b) => a.Time.CompareTo(b.Time));
+ data.Borders.Sort((a, b) => a.Time.CompareTo(b.Time));
+ data.Spreads.Sort((a, b) => a.Time.CompareTo(b.Time));
+ data.Orders.Sort((a, b) => a.StartTime.CompareTo(b.StartTime));
+
+ return data;
+ }
+
+ private static void ParseTop(string[] parts, DateTime timestamp, ParsedData data)
+ {
+ if (parts.Length < 5)
+ {
+ return;
+ }
+
+ var symbol = parts[2].Trim();
+ if (!TryParseDouble(parts[3], out var ask) || !TryParseDouble(parts[4], out var bid))
+ {
+ return;
+ }
+
+ var record = new PriceEntry(timestamp, ask, bid);
+ if (symbol.Contains("Spot", StringComparison.OrdinalIgnoreCase))
+ {
+ data.SpotPrices.Add(record);
+ }
+ else if (symbol.Contains("Linear", StringComparison.OrdinalIgnoreCase))
+ {
+ data.LinearPrices.Add(record);
+ }
+ }
+
+ private static void ParseOrder(
+ string[] parts,
+ DateTime timestamp,
+ ParsedData data,
+ Dictionary<(string Symbol, OrderSide Side), OrderEntry> activeSlots,
+ Dictionary activeOrdersById)
+ {
+ if (parts.Length < 9)
+ {
+ return;
+ }
+
+ var symbol = parts[2].Trim();
+ var price = TryParseDouble(parts[3], out var parsedPrice) ? parsedPrice : 0.0;
+ var side = ParseSide(parts[6]);
+ var orderId = parts[7].Trim();
+ var status = parts[8].Trim();
+
+ if (status == "New")
+ {
+ if (side is null)
+ {
+ return;
+ }
+
+ var slotKey = (symbol, side.Value);
+ if (activeSlots.TryGetValue(slotKey, out var previous))
+ {
+ previous.EndTime = timestamp;
+ previous.FinalStatus = "Replaced";
+ data.Orders.Add(previous);
+ if (!string.IsNullOrWhiteSpace(previous.OrderId))
+ {
+ activeOrdersById.Remove(previous.OrderId);
+ }
+ }
+
+ var newOrder = new OrderEntry(timestamp, price, side.Value, symbol, orderId, status);
+ activeSlots[slotKey] = newOrder;
+ if (!string.IsNullOrWhiteSpace(orderId) && orderId != "0")
+ {
+ activeOrdersById[orderId] = newOrder;
+ }
+ }
+ else if (status is "PartiallyFilled" or "Untriggered" or "Triggered")
+ {
+ if (!string.IsNullOrWhiteSpace(orderId) && activeOrdersById.TryGetValue(orderId, out var target))
+ {
+ target.Status = status;
+ }
+ }
+ else if (status is "Filled" or "Cancelled" or "Canceled" or "Rejected")
+ {
+ var target = FindOrder(orderId, side, symbol, activeSlots, activeOrdersById);
+ if (target is null)
+ {
+ return;
+ }
+
+ target.EndTime = timestamp;
+ target.FinalStatus = status;
+ data.Orders.Add(target);
+ if (!string.IsNullOrWhiteSpace(target.OrderId))
+ {
+ activeOrdersById.Remove(target.OrderId);
+ }
+
+ activeSlots.Remove((target.Symbol, target.Side));
+ }
+ }
+
+ private static OrderEntry? FindOrder(
+ string orderId,
+ OrderSide? side,
+ string symbol,
+ Dictionary<(string Symbol, OrderSide Side), OrderEntry> activeSlots,
+ Dictionary activeOrdersById)
+ {
+ if (!string.IsNullOrWhiteSpace(orderId) && activeOrdersById.TryGetValue(orderId, out var target))
+ {
+ return target;
+ }
+
+ if (side.HasValue && activeSlots.TryGetValue((symbol, side.Value), out target))
+ {
+ return target;
+ }
+
+ return null;
+ }
+
+ private static void ParseTrade(string[] parts, DateTime timestamp, ParsedData data)
+ {
+ if (parts.Length < 6)
+ {
+ return;
+ }
+
+ var symbol = parts[2].Trim();
+ if (!TryParseDouble(parts[3], out var price))
+ {
+ return;
+ }
+
+ var side = ParseSide(parts[5]);
+ if (side is null)
+ {
+ return;
+ }
+
+ data.Trades.Add(new TradeEntry(timestamp, price, side.Value, symbol));
+ }
+
+ private static void ParseBorder(string[] parts, DateTime timestamp, ParsedData data)
+ {
+ if (parts.Length < 6)
+ {
+ return;
+ }
+
+ if (!TryParseDouble(parts[2], out var b1) || !TryParseDouble(parts[3], out var b2)
+ || !TryParseDouble(parts[4], out var b3) || !TryParseDouble(parts[5], out var b4))
+ {
+ return;
+ }
+
+ data.Borders.Add(new BorderEntry(timestamp, b1, b2, b3, b4));
+ }
+
+ private static void ParseSpread(string[] parts, DateTime timestamp, ParsedData data)
+ {
+ if (parts.Length < 3)
+ {
+ return;
+ }
+
+ if (!TryParseDouble(parts[2], out var s1))
+ {
+ return;
+ }
+
+ var s2 = s1;
+ if (parts.Length > 3 && TryParseDouble(parts[3], out var parsedS2))
+ {
+ s2 = parsedS2;
+ }
+
+ data.Spreads.Add(new SpreadEntry(timestamp, s1, s2));
+ }
+
+ private static OrderSide? ParseSide(string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ return raw.Trim().ToLowerInvariant() switch
+ {
+ "buy" => OrderSide.Buy,
+ "sell" => OrderSide.Sell,
+ _ => null
+ };
+ }
+
+ private static bool TryParseTimestamp(string raw, TimeState state, out DateTime timestamp)
+ {
+ timestamp = default;
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return false;
+ }
+
+ var timeParts = raw.Split('.');
+ var hms = timeParts[0].Split(':');
+ if (hms.Length != 3)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(hms[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var h)
+ || !int.TryParse(hms[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var m)
+ || !int.TryParse(hms[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var s))
+ {
+ return false;
+ }
+
+ var microSeconds = 0;
+ if (timeParts.Length > 1)
+ {
+ var fraction = timeParts[1];
+ if (fraction.Length > 6)
+ {
+ fraction = fraction[..6];
+ }
+
+ if (!int.TryParse(fraction.PadRight(6, '0'), NumberStyles.Integer, CultureInfo.InvariantCulture, out microSeconds))
+ {
+ return false;
+ }
+ }
+
+ var timeSpan = new TimeSpan(0, h, m, s).Add(TimeSpan.FromTicks(microSeconds * 10L));
+ var currentRawDt = state.BaseDate.Add(timeSpan);
+
+ if (state.LastRawDt.HasValue)
+ {
+ var delta = currentRawDt - state.LastRawDt.Value;
+ var deltaSeconds = delta.TotalSeconds;
+ if (deltaSeconds < -36000)
+ {
+ if (deltaSeconds is > -46800 and < -39600)
+ {
+ state.Offset += TimeSpan.FromHours(12);
+ }
+ else if (deltaSeconds < -80000)
+ {
+ state.Offset += TimeSpan.FromHours(24);
+ }
+ }
+ }
+
+ state.LastRawDt = currentRawDt;
+ timestamp = currentRawDt + state.Offset;
+ state.LastAdjustedDt = timestamp;
+ return true;
+ }
+
+ private static bool TryParseDouble(string raw, out double value)
+ => double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
+}
diff --git a/TradeViewer/TradeViewer.csproj b/TradeViewer/TradeViewer.csproj
new file mode 100644
index 0000000..7f75b5c
--- /dev/null
+++ b/TradeViewer/TradeViewer.csproj
@@ -0,0 +1,13 @@
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+
+
+
+
+
+