using IBKRTrader.Core.Persistence.Entities;
namespace IBKRTrader.Core.Analytics;
///
/// Ein geschlossener (realisierter) Teil-Trade: eine SELL-Menge, gegen ihre FIFO-gematchten BUY-Lots
/// abgerechnet. Aus den Fills der core_trade_history rein rechnerisch abgeleitet.
///
public sealed record RealizedTrade(
string Module,
string Symbol,
decimal Quantity,
decimal BuyPrice,
decimal SellPrice,
DateTime OpenedAt,
DateTime ClosedAt)
{
/// Realisierte GuV dieser Menge (Erlös − Einstand); Fees sind hier nicht berücksichtigt.
public decimal RealizedPnl => (SellPrice - BuyPrice) * Quantity;
}
///
/// Reines, seiteneffektfreies FIFO-Lot-Matching über Fills (BUY öffnet Lots, SELL realisiert gegen die
/// ältesten offenen Lots) — je (Modul, Symbol). Grundlage sowohl für die Supervisor-KPIs
/// () als auch für die realisierte GuV im Accounting-Modul (kein Duplikat).
/// Geldkritisch → unit-getestet. Long-only-Sicht: SELL-Mengen ohne passendes offenes Lot werden
/// ignoriert (kein Leerverkauf modelliert).
///
public static class RealizedPnlEngine
{
private sealed class Lot
{
public decimal Quantity;
public decimal Price;
public DateTime OpenedAt;
}
/// Matcht alle Fills zu realisierten Teil-Trades (chronologisch, FIFO je Modul+Symbol).
public static IReadOnlyList Match(IEnumerable fills)
{
var result = new List();
var groups = fills
.GroupBy(f => (f.Module, f.Symbol));
foreach (var g in groups)
{
var open = new Queue();
foreach (var f in g.OrderBy(x => x.TradedAt).ThenBy(x => x.Id))
{
bool isBuy = string.Equals(f.Action, "BUY", StringComparison.OrdinalIgnoreCase);
if (isBuy)
{
open.Enqueue(new Lot { Quantity = f.Quantity, Price = f.Price, OpenedAt = f.TradedAt });
continue;
}
// SELL: gegen die ältesten offenen Lots abrechnen.
decimal remaining = f.Quantity;
while (remaining > 0 && open.Count > 0)
{
var lot = open.Peek();
decimal matched = Math.Min(remaining, lot.Quantity);
result.Add(new RealizedTrade(
g.Key.Module, g.Key.Symbol, matched, lot.Price, f.Price, lot.OpenedAt, f.TradedAt));
lot.Quantity -= matched;
remaining -= matched;
if (lot.Quantity <= 0) open.Dequeue();
}
// Überschüssige SELL-Menge ohne offenes Lot: ignoriert (kein Short modelliert).
}
}
return result;
}
/// Summe der realisierten GuV über alle gematchten Teil-Trades.
public static decimal TotalRealized(IEnumerable fills) =>
Match(fills).Sum(t => t.RealizedPnl);
}