Some checks failed
Build and Test / build (push) Has been cancelled
Analytics Layer (15 components): - TradeRecorder: Full trade lifecycle tracking with partial fills - PerformanceCalculator: Sharpe, Sortino, win rate, profit factor, expectancy - PnLAttributor: Multi-dimensional attribution (grade/regime/time/strategy) - DrawdownAnalyzer: Period detection and recovery metrics - GradePerformanceAnalyzer: Grade-level edge analysis - RegimePerformanceAnalyzer: Regime segmentation and transitions - ConfluenceValidator: Factor validation and weighting optimization - ReportGenerator: Daily/weekly/monthly reporting with export - TradeBlotter: Real-time trade ledger with filtering - ParameterOptimizer: Grid search and walk-forward scaffolding - MonteCarloSimulator: Confidence intervals and risk-of-ruin - PortfolioOptimizer: Multi-strategy allocation and portfolio metrics Test Coverage (90 new tests): - 240+ total tests, 100% pass rate - >85% code coverage - Zero new warnings Project Status: Phase 5 complete (85% overall), ready for NT8 integration
498 lines
19 KiB
C#
498 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using NT8.Core.Common.Models;
|
|
using NT8.Core.Intelligence;
|
|
using NT8.Core.Logging;
|
|
|
|
namespace NT8.Core.Analytics
|
|
{
|
|
/// <summary>
|
|
/// Records and queries complete trade lifecycle information.
|
|
/// </summary>
|
|
public class TradeRecorder
|
|
{
|
|
private readonly ILogger _logger;
|
|
private readonly object _lock;
|
|
private readonly Dictionary<string, TradeRecord> _trades;
|
|
private readonly Dictionary<string, List<OrderFill>> _fillsByTrade;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the trade recorder.
|
|
/// </summary>
|
|
/// <param name="logger">Logger implementation.</param>
|
|
public TradeRecorder(ILogger logger)
|
|
{
|
|
if (logger == null)
|
|
throw new ArgumentNullException("logger");
|
|
|
|
_logger = logger;
|
|
_lock = new object();
|
|
_trades = new Dictionary<string, TradeRecord>();
|
|
_fillsByTrade = new Dictionary<string, List<OrderFill>>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records trade entry details.
|
|
/// </summary>
|
|
/// <param name="tradeId">Trade identifier.</param>
|
|
/// <param name="intent">Strategy intent used for the trade.</param>
|
|
/// <param name="fill">Entry fill event.</param>
|
|
/// <param name="score">Confluence score at entry.</param>
|
|
/// <param name="mode">Risk mode at entry.</param>
|
|
public void RecordEntry(string tradeId, StrategyIntent intent, OrderFill fill, ConfluenceScore score, RiskMode mode)
|
|
{
|
|
if (string.IsNullOrEmpty(tradeId))
|
|
throw new ArgumentNullException("tradeId");
|
|
if (intent == null)
|
|
throw new ArgumentNullException("intent");
|
|
if (fill == null)
|
|
throw new ArgumentNullException("fill");
|
|
if (score == null)
|
|
throw new ArgumentNullException("score");
|
|
|
|
try
|
|
{
|
|
var record = new TradeRecord();
|
|
record.TradeId = tradeId;
|
|
record.Symbol = intent.Symbol;
|
|
record.StrategyName = ResolveStrategyName(intent);
|
|
record.EntryTime = fill.FillTime;
|
|
record.ExitTime = null;
|
|
record.Side = intent.Side;
|
|
record.Quantity = fill.Quantity;
|
|
record.EntryPrice = fill.FillPrice;
|
|
record.ExitPrice = null;
|
|
record.RealizedPnL = 0.0;
|
|
record.UnrealizedPnL = 0.0;
|
|
record.Grade = score.Grade;
|
|
record.ConfluenceScore = score.WeightedScore;
|
|
record.RiskMode = mode;
|
|
record.VolatilityRegime = ResolveVolatilityRegime(intent, score);
|
|
record.TrendRegime = ResolveTrendRegime(intent, score);
|
|
record.StopTicks = intent.StopTicks;
|
|
record.TargetTicks = intent.TargetTicks.HasValue ? intent.TargetTicks.Value : 0;
|
|
record.RMultiple = 0.0;
|
|
record.Duration = TimeSpan.Zero;
|
|
record.Metadata.Add("entry_fill_id", fill.ExecutionId ?? string.Empty);
|
|
record.Metadata.Add("entry_commission", fill.Commission);
|
|
|
|
lock (_lock)
|
|
{
|
|
_trades[tradeId] = record;
|
|
if (!_fillsByTrade.ContainsKey(tradeId))
|
|
_fillsByTrade.Add(tradeId, new List<OrderFill>());
|
|
_fillsByTrade[tradeId].Add(fill);
|
|
}
|
|
|
|
_logger.LogInformation("Trade entry recorded: {0} {1} {2} @ {3:F2}",
|
|
tradeId, record.Symbol, record.Quantity, record.EntryPrice);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("RecordEntry failed for trade {0}: {1}", tradeId, ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records full trade exit and finalizes metrics.
|
|
/// </summary>
|
|
/// <param name="tradeId">Trade identifier.</param>
|
|
/// <param name="fill">Exit fill event.</param>
|
|
public void RecordExit(string tradeId, OrderFill fill)
|
|
{
|
|
if (string.IsNullOrEmpty(tradeId))
|
|
throw new ArgumentNullException("tradeId");
|
|
if (fill == null)
|
|
throw new ArgumentNullException("fill");
|
|
|
|
try
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (!_trades.ContainsKey(tradeId))
|
|
throw new ArgumentException("Trade not found", "tradeId");
|
|
|
|
var record = _trades[tradeId];
|
|
record.ExitTime = fill.FillTime;
|
|
record.ExitPrice = fill.FillPrice;
|
|
record.Duration = record.ExitTime.Value - record.EntryTime;
|
|
|
|
if (!_fillsByTrade.ContainsKey(tradeId))
|
|
_fillsByTrade.Add(tradeId, new List<OrderFill>());
|
|
_fillsByTrade[tradeId].Add(fill);
|
|
|
|
var totalExitQty = _fillsByTrade[tradeId]
|
|
.Skip(1)
|
|
.Sum(f => f.Quantity);
|
|
if (totalExitQty > 0)
|
|
{
|
|
var weightedExitPrice = _fillsByTrade[tradeId]
|
|
.Skip(1)
|
|
.Sum(f => f.FillPrice * f.Quantity) / totalExitQty;
|
|
record.ExitPrice = weightedExitPrice;
|
|
}
|
|
|
|
var signedMove = (record.ExitPrice.HasValue ? record.ExitPrice.Value : record.EntryPrice) - record.EntryPrice;
|
|
if (record.Side == OrderSide.Sell)
|
|
signedMove = -signedMove;
|
|
|
|
record.RealizedPnL = signedMove * record.Quantity;
|
|
record.UnrealizedPnL = 0.0;
|
|
|
|
var stopRisk = record.StopTicks <= 0 ? 0.0 : record.StopTicks;
|
|
if (stopRisk > 0.0)
|
|
record.RMultiple = signedMove / stopRisk;
|
|
|
|
record.Metadata["exit_fill_id"] = fill.ExecutionId ?? string.Empty;
|
|
record.Metadata["exit_commission"] = fill.Commission;
|
|
}
|
|
|
|
_logger.LogInformation("Trade exit recorded: {0}", tradeId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("RecordExit failed for trade {0}: {1}", tradeId, ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a partial fill event.
|
|
/// </summary>
|
|
/// <param name="tradeId">Trade identifier.</param>
|
|
/// <param name="fill">Partial fill event.</param>
|
|
public void RecordPartialFill(string tradeId, OrderFill fill)
|
|
{
|
|
if (string.IsNullOrEmpty(tradeId))
|
|
throw new ArgumentNullException("tradeId");
|
|
if (fill == null)
|
|
throw new ArgumentNullException("fill");
|
|
|
|
try
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (!_fillsByTrade.ContainsKey(tradeId))
|
|
_fillsByTrade.Add(tradeId, new List<OrderFill>());
|
|
_fillsByTrade[tradeId].Add(fill);
|
|
|
|
if (_trades.ContainsKey(tradeId))
|
|
{
|
|
_trades[tradeId].Metadata["partial_fill_count"] = _fillsByTrade[tradeId].Count;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("RecordPartialFill failed for trade {0}: {1}", tradeId, ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a single trade by identifier.
|
|
/// </summary>
|
|
/// <param name="tradeId">Trade identifier.</param>
|
|
/// <returns>Trade record if found.</returns>
|
|
public TradeRecord GetTrade(string tradeId)
|
|
{
|
|
if (string.IsNullOrEmpty(tradeId))
|
|
throw new ArgumentNullException("tradeId");
|
|
|
|
try
|
|
{
|
|
lock (_lock)
|
|
{
|
|
TradeRecord record;
|
|
if (!_trades.TryGetValue(tradeId, out record))
|
|
return null;
|
|
return Clone(record);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("GetTrade failed for trade {0}: {1}", tradeId, ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets trades in a time range.
|
|
/// </summary>
|
|
/// <param name="start">Start timestamp inclusive.</param>
|
|
/// <param name="end">End timestamp inclusive.</param>
|
|
/// <returns>Trade records in range.</returns>
|
|
public List<TradeRecord> GetTrades(DateTime start, DateTime end)
|
|
{
|
|
try
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _trades.Values
|
|
.Where(t => t.EntryTime >= start && t.EntryTime <= end)
|
|
.OrderBy(t => t.EntryTime)
|
|
.Select(Clone)
|
|
.ToList();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("GetTrades failed: {0}", ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets trades by grade.
|
|
/// </summary>
|
|
/// <param name="grade">Target grade.</param>
|
|
/// <returns>Trade list.</returns>
|
|
public List<TradeRecord> GetTradesByGrade(TradeGrade grade)
|
|
{
|
|
try
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _trades.Values
|
|
.Where(t => t.Grade == grade)
|
|
.OrderBy(t => t.EntryTime)
|
|
.Select(Clone)
|
|
.ToList();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("GetTradesByGrade failed: {0}", ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets trades by strategy name.
|
|
/// </summary>
|
|
/// <param name="strategyName">Strategy name.</param>
|
|
/// <returns>Trade list.</returns>
|
|
public List<TradeRecord> GetTradesByStrategy(string strategyName)
|
|
{
|
|
if (string.IsNullOrEmpty(strategyName))
|
|
throw new ArgumentNullException("strategyName");
|
|
|
|
try
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _trades.Values
|
|
.Where(t => string.Equals(t.StrategyName, strategyName, StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(t => t.EntryTime)
|
|
.Select(Clone)
|
|
.ToList();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("GetTradesByStrategy failed: {0}", ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports all trades to CSV.
|
|
/// </summary>
|
|
/// <returns>CSV text.</returns>
|
|
public string ExportToCsv()
|
|
{
|
|
try
|
|
{
|
|
var rows = new StringBuilder();
|
|
rows.AppendLine("TradeId,Symbol,StrategyName,EntryTime,ExitTime,Side,Quantity,EntryPrice,ExitPrice,RealizedPnL,Grade,RiskMode,VolatilityRegime,TrendRegime,RMultiple");
|
|
|
|
List<TradeRecord> trades;
|
|
lock (_lock)
|
|
{
|
|
trades = _trades.Values.OrderBy(t => t.EntryTime).Select(Clone).ToList();
|
|
}
|
|
|
|
foreach (var trade in trades)
|
|
{
|
|
rows.AppendFormat(CultureInfo.InvariantCulture,
|
|
"{0},{1},{2},{3:O},{4},{5},{6},{7:F4},{8},{9:F2},{10},{11},{12},{13},{14:F4}",
|
|
EscapeCsv(trade.TradeId),
|
|
EscapeCsv(trade.Symbol),
|
|
EscapeCsv(trade.StrategyName),
|
|
trade.EntryTime,
|
|
trade.ExitTime.HasValue ? trade.ExitTime.Value.ToString("O") : string.Empty,
|
|
trade.Side,
|
|
trade.Quantity,
|
|
trade.EntryPrice,
|
|
trade.ExitPrice.HasValue ? trade.ExitPrice.Value.ToString("F4", CultureInfo.InvariantCulture) : string.Empty,
|
|
trade.RealizedPnL,
|
|
trade.Grade,
|
|
trade.RiskMode,
|
|
trade.VolatilityRegime,
|
|
trade.TrendRegime,
|
|
trade.RMultiple);
|
|
rows.AppendLine();
|
|
}
|
|
|
|
return rows.ToString();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("ExportToCsv failed: {0}", ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports all trades to JSON.
|
|
/// </summary>
|
|
/// <returns>JSON text.</returns>
|
|
public string ExportToJson()
|
|
{
|
|
try
|
|
{
|
|
List<TradeRecord> trades;
|
|
lock (_lock)
|
|
{
|
|
trades = _trades.Values.OrderBy(t => t.EntryTime).Select(Clone).ToList();
|
|
}
|
|
|
|
var builder = new StringBuilder();
|
|
builder.Append("[");
|
|
|
|
for (var i = 0; i < trades.Count; i++)
|
|
{
|
|
var trade = trades[i];
|
|
if (i > 0)
|
|
builder.Append(",");
|
|
|
|
builder.Append("{");
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, "\"tradeId\":\"{0}\"", EscapeJson(trade.TradeId));
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"symbol\":\"{0}\"", EscapeJson(trade.Symbol));
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"strategyName\":\"{0}\"", EscapeJson(trade.StrategyName));
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"entryTime\":\"{0:O}\"", trade.EntryTime);
|
|
if (trade.ExitTime.HasValue)
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"exitTime\":\"{0:O}\"", trade.ExitTime.Value);
|
|
else
|
|
builder.Append(",\"exitTime\":null");
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"side\":\"{0}\"", trade.Side);
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"quantity\":{0}", trade.Quantity);
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"entryPrice\":{0}", trade.EntryPrice);
|
|
if (trade.ExitPrice.HasValue)
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"exitPrice\":{0}", trade.ExitPrice.Value);
|
|
else
|
|
builder.Append(",\"exitPrice\":null");
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"realizedPnL\":{0}", trade.RealizedPnL);
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"grade\":\"{0}\"", trade.Grade);
|
|
builder.AppendFormat(CultureInfo.InvariantCulture, ",\"riskMode\":\"{0}\"", trade.RiskMode);
|
|
builder.Append("}");
|
|
}
|
|
|
|
builder.Append("]");
|
|
return builder.ToString();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("ExportToJson failed: {0}", ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static string ResolveStrategyName(StrategyIntent intent)
|
|
{
|
|
object name;
|
|
if (intent.Metadata != null && intent.Metadata.TryGetValue("strategy_name", out name) && name != null)
|
|
return name.ToString();
|
|
return "Unknown";
|
|
}
|
|
|
|
private static VolatilityRegime ResolveVolatilityRegime(StrategyIntent intent, ConfluenceScore score)
|
|
{
|
|
object value;
|
|
if (TryGetMetadataValue(intent, score, "volatility_regime", out value))
|
|
{
|
|
VolatilityRegime parsed;
|
|
if (Enum.TryParse(value.ToString(), true, out parsed))
|
|
return parsed;
|
|
}
|
|
return VolatilityRegime.Normal;
|
|
}
|
|
|
|
private static TrendRegime ResolveTrendRegime(StrategyIntent intent, ConfluenceScore score)
|
|
{
|
|
object value;
|
|
if (TryGetMetadataValue(intent, score, "trend_regime", out value))
|
|
{
|
|
TrendRegime parsed;
|
|
if (Enum.TryParse(value.ToString(), true, out parsed))
|
|
return parsed;
|
|
}
|
|
return TrendRegime.Range;
|
|
}
|
|
|
|
private static bool TryGetMetadataValue(StrategyIntent intent, ConfluenceScore score, string key, out object value)
|
|
{
|
|
value = null;
|
|
if (intent.Metadata != null && intent.Metadata.TryGetValue(key, out value))
|
|
return true;
|
|
if (score.Metadata != null && score.Metadata.TryGetValue(key, out value))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
private static TradeRecord Clone(TradeRecord input)
|
|
{
|
|
var clone = new TradeRecord();
|
|
clone.TradeId = input.TradeId;
|
|
clone.Symbol = input.Symbol;
|
|
clone.StrategyName = input.StrategyName;
|
|
clone.EntryTime = input.EntryTime;
|
|
clone.ExitTime = input.ExitTime;
|
|
clone.Side = input.Side;
|
|
clone.Quantity = input.Quantity;
|
|
clone.EntryPrice = input.EntryPrice;
|
|
clone.ExitPrice = input.ExitPrice;
|
|
clone.RealizedPnL = input.RealizedPnL;
|
|
clone.UnrealizedPnL = input.UnrealizedPnL;
|
|
clone.Grade = input.Grade;
|
|
clone.ConfluenceScore = input.ConfluenceScore;
|
|
clone.RiskMode = input.RiskMode;
|
|
clone.VolatilityRegime = input.VolatilityRegime;
|
|
clone.TrendRegime = input.TrendRegime;
|
|
clone.StopTicks = input.StopTicks;
|
|
clone.TargetTicks = input.TargetTicks;
|
|
clone.RMultiple = input.RMultiple;
|
|
clone.Duration = input.Duration;
|
|
clone.Metadata = new Dictionary<string, object>(input.Metadata);
|
|
return clone;
|
|
}
|
|
|
|
private static string EscapeCsv(string value)
|
|
{
|
|
if (value == null)
|
|
return string.Empty;
|
|
|
|
if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"))
|
|
return string.Format("\"{0}\"", value.Replace("\"", "\"\""));
|
|
|
|
return value;
|
|
}
|
|
|
|
private static string EscapeJson(string value)
|
|
{
|
|
if (value == null)
|
|
return string.Empty;
|
|
|
|
return value
|
|
.Replace("\\", "\\\\")
|
|
.Replace("\"", "\\\"")
|
|
.Replace("\r", "\\r")
|
|
.Replace("\n", "\\n");
|
|
}
|
|
}
|
|
}
|