using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace XFEEDToolBox.Core.Execution; public enum TradeCashKind { Purchase, Sale, Fuel, Repair, Rearm, OtherExpense, CollectedReward } public sealed record TradeCashEntry(string OperationKey, TradeCashKind Kind, decimal Amount, long MarketId, DateTimeOffset At, string Commodity = "", int Quantity = 0); public sealed record TradeProfitSnapshot(decimal Purchases, decimal Sales, decimal SoldCargoCost, decimal Fuel, decimal Repairs, decimal Rearm, decimal OtherExpenses, decimal CollectedRewards) { public static TradeProfitSnapshot Empty { get; } = new(0, 0, 0, 0, 0, 0, 0, 0); public decimal Expenses => Fuel + Repairs + Rearm + OtherExpenses; public decimal CargoInvestment => Purchases - SoldCargoCost; public decimal NetProfit => Sales - SoldCargoCost - Expenses + CollectedRewards; public decimal CashChange => Sales - Purchases - Expenses + CollectedRewards; } /// Confirmed money movements, with FIFO acquisition costs for cargo actually sold. public sealed class TradeProfitLedger : IDisposable { private sealed record StoredEntry(long Sequence, TradeCashEntry Entry); private sealed record Lot(int Quantity, decimal Cost); private readonly object sync = new(); private readonly Dictionary entries = new(StringComparer.Ordinal); private readonly Dictionary> inventory = new(StringComparer.Ordinal); private readonly string? directory; private readonly FileStream? lease; private TradeProfitSnapshot snapshot = TradeProfitSnapshot.Empty; private long sequence; private bool disposed; public TradeProfitSnapshot Snapshot { get { lock (sync) return snapshot; } } public bool ContainsOperation(string operationKey) { lock(sync)return entries.ContainsKey(operationKey); } // Use a distinct account directory for each continuous trading session. // Reopening that directory restores the same accumulated profit after a restart. public TradeProfitLedger(string? directory = null) { this.directory = directory; if (directory is null) return; Directory.CreateDirectory(directory); lease = new FileStream(Path.Combine(directory, "writer.lock"), FileMode.OpenOrCreate, FileAccess.Write, FileShare.None); try { foreach (var path in Directory.EnumerateFiles(directory, "*.entry.json").Order(StringComparer.Ordinal)) { var stored = JsonSerializer.Deserialize(File.ReadAllText(path)) ?? throw new InvalidDataException("跑商收支记录为空。"); if (stored.Sequence != sequence + 1 || entries.ContainsKey(stored.Entry.OperationKey)) throw new InvalidDataException("跑商收支记录顺序或订单编号不一致。"); Validate(stored.Entry); Apply(stored.Entry); entries.Add(stored.Entry.OperationKey, stored.Entry); sequence = stored.Sequence; } } catch { lease.Dispose(); throw; } } public bool RecordTrade(ConfirmedTradeReceipt receipt) { if (!receipt.ServerConfirmed || !receipt.ClientApplied || receipt.QuantityDelta == 0 || receipt.UnitPrice <= 0 || !Guid.TryParseExact(receipt.OperationKey, "N", out _) || receipt.CreditsAfter < 0 || receipt.CreditsBefore < 0 || (decimal)receipt.CreditsAfter - receipt.CreditsBefore != -(decimal)receipt.UnitPrice * receipt.QuantityDelta || (long)receipt.CargoAfter - receipt.CargoBefore != receipt.QuantityDelta) throw new InvalidDataException("未经核对的交易不能计入实际利润。"); return Record(new(receipt.OperationKey, receipt.QuantityDelta > 0 ? TradeCashKind.Purchase : TradeCashKind.Sale, Math.Abs((decimal)receipt.QuantityDelta * receipt.UnitPrice), receipt.MarketId, receipt.ConfirmedAt, receipt.Commodity, checked((int)Math.Abs((long)receipt.QuantityDelta)))); } public bool Record(TradeCashEntry entry) { lock (sync) { ObjectDisposedException.ThrowIf(disposed, this); if (entries.TryGetValue(entry.OperationKey, out var previous)) { if (entry != previous) throw new InvalidDataException("同一原生订单出现不同的收支金额。"); return false; } Validate(entry); // Write the complete record before publishing it. An interrupted temp // file is never mistaken for a confirmed ledger entry on reopening. if (directory is not null) { var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(entry.OperationKey))); var path = Path.Combine(directory, $"{sequence + 1:D12}-{hash}.entry.json"); var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough)) { JsonSerializer.Serialize(output, new StoredEntry(sequence + 1, entry)); output.Flush(true); } File.Move(temporary, path, overwrite: false); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } Apply(entry); entries.Add(entry.OperationKey, entry); sequence++; return true; } } private void Validate(TradeCashEntry entry) { if (string.IsNullOrWhiteSpace(entry.OperationKey) || entry.OperationKey.Length > 512 || !Enum.IsDefined(entry.Kind) || entry.Amount <= 0 || entry.MarketId <= 0 || entry.At == default) throw new InvalidDataException("跑商收支记录缺少已确认的金额、订单或站点。"); var cargo = entry.Kind is TradeCashKind.Purchase or TradeCashKind.Sale; if (cargo ? string.IsNullOrWhiteSpace(entry.Commodity) || entry.Quantity <= 0 : entry.Quantity != 0) throw new InvalidDataException("跑商收支记录中的货物数量无效。"); if (entry.Kind == TradeCashKind.Sale && (!inventory.TryGetValue(entry.Commodity, out var lots) || lots.Sum(x => (long)x.Quantity) < entry.Quantity)) throw new InvalidDataException("卖出货物的原始买入成本尚未核实,不能计作已赚取利润。"); } private void Apply(TradeCashEntry entry) { switch (entry.Kind) { case TradeCashKind.Purchase: if (!inventory.TryGetValue(entry.Commodity, out var lots)) inventory.Add(entry.Commodity, lots = []); lots.Add(new(entry.Quantity, entry.Amount)); snapshot = snapshot with { Purchases = snapshot.Purchases + entry.Amount }; break; case TradeCashKind.Sale: var remaining = entry.Quantity; decimal cost = 0; var held = inventory[entry.Commodity]; while (remaining > 0) { var lot = held[0]; var quantity = Math.Min(remaining, lot.Quantity); var usedCost = quantity == lot.Quantity ? lot.Cost : lot.Cost * quantity / lot.Quantity; cost += usedCost; if (quantity == lot.Quantity) held.RemoveAt(0); else held[0] = lot with { Quantity = lot.Quantity - quantity, Cost = lot.Cost - usedCost }; remaining -= quantity; } snapshot = snapshot with { Sales = snapshot.Sales + entry.Amount, SoldCargoCost = snapshot.SoldCargoCost + cost }; break; case TradeCashKind.Fuel: snapshot = snapshot with { Fuel = snapshot.Fuel + entry.Amount }; break; case TradeCashKind.Repair: snapshot = snapshot with { Repairs = snapshot.Repairs + entry.Amount }; break; case TradeCashKind.Rearm: snapshot = snapshot with { Rearm = snapshot.Rearm + entry.Amount }; break; case TradeCashKind.OtherExpense: snapshot = snapshot with { OtherExpenses = snapshot.OtherExpenses + entry.Amount }; break; case TradeCashKind.CollectedReward: snapshot = snapshot with { CollectedRewards = snapshot.CollectedRewards + entry.Amount }; break; } } public void Dispose() { lock (sync) { if (disposed) return; disposed = true; lease?.Dispose(); } } } public sealed record ConfirmedDockedServiceReceipt(string OperationKey, string Action, long MarketId, long CreditsBefore, long CreditsAfter, DateTimeOffset ConfirmedAt) { public TradeCashEntry ToCashEntry() { if (!Guid.TryParseExact(OperationKey, "N", out _) || CreditsAfter < 0 || CreditsBefore <= CreditsAfter) throw new InvalidDataException("维护服务的实际扣款尚未确认。"); var kind = Action switch { "refuel" => TradeCashKind.Fuel, "repair" or "integrity" => TradeCashKind.Repair, "rearm" => TradeCashKind.Rearm, _ => throw new InvalidDataException("未知的维护服务回执。") }; return new(OperationKey, kind, (decimal)CreditsBefore - CreditsAfter, MarketId, ConfirmedAt); } } public interface IConfirmedDockedServiceDriver { event Action? ServiceConfirmed; }