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;
}