Files
Encelado/Encelado/src/Encelado.Core/Baskets/BasketPosition.cs
T
Alby96andClaude Fable 5.1 13d64d1830 5.0 Fase 1: registro persistente degli ordini, stati Pending, orfane adottate e chiuse
Un ordine dall'esito ignoto non viene più abbandonato: entra in
data/state/pending_orders.json prima della chiamata HTTP, l'esito si legge per
orderId (il server non registra il referenceId degli ordini v2) e in mancanza
si ricostruisce dalla posizione comparsa sul conto. Una gamba senza esito porta
il basket in PendingA/PendingB invece di rifiutarlo; alla risoluzione parte la
gamba B, ridimensionata sulle unità eseguite, oppure la gamba A viene richiusa.
Ogni posizione del conto è classificata basket / orfana-bot / esterna: le orfane
del bot vengono adottate e chiuse, le esterne contate e mai toccate. Il picco di
equity ignora i movimenti di cassa. Bonifica da headless, orders.jsonl, contatori
in dashboard, bandit che propone e non applica. ADR-0009, test (m)-(q).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 10:54:50 +02:00

225 lines
9.1 KiB
C#

using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>The lifecycle of one basket. Transitions are checked by <see cref="BasketLifecycle"/>.</summary>
public enum BasketState
{
Idle = 0,
Entering,
Open,
Adding,
Exiting,
Closed,
Error,
/// <summary>Leg A was sent and the venue has not said what became of it; nothing else happens on this basket until it does.</summary>
PendingA,
/// <summary>Leg A is filled, leg B was sent and the venue has not said what became of it.</summary>
PendingB,
}
public static class BasketLifecycle
{
public static bool CanTransition(BasketState from, BasketState to) => (from, to) switch
{
(BasketState.Idle, BasketState.Entering) => true,
(BasketState.Entering, BasketState.Open) => true,
(BasketState.Entering, BasketState.Idle) => true, // leg-risk unwind, both legs flat again
(BasketState.Entering, BasketState.Error) => true,
(BasketState.Entering, BasketState.PendingA) => true, // leg A sent, outcome unknown past the leg timeout
(BasketState.Entering, BasketState.PendingB) => true, // leg A filled, leg B outcome unknown
(BasketState.PendingA, BasketState.Entering) => true, // leg A filled: sending leg B
(BasketState.PendingA, BasketState.Idle) => true, // leg A rejected, or filled and unwound because the signal decayed
(BasketState.PendingA, BasketState.Error) => true,
(BasketState.PendingB, BasketState.Open) => true, // leg B filled
(BasketState.PendingB, BasketState.Idle) => true, // leg B rejected, leg A unwound
(BasketState.PendingB, BasketState.Error) => true,
(BasketState.Open, BasketState.Adding) => true,
(BasketState.Adding, BasketState.Open) => true,
(BasketState.Adding, BasketState.Error) => true,
(BasketState.Open, BasketState.Exiting) => true,
(BasketState.Exiting, BasketState.Closed) => true,
(BasketState.Exiting, BasketState.Error) => true,
(BasketState.Closed, BasketState.Idle) => true,
(BasketState.Error, BasketState.Idle) => true, // after a manual/automatic reconciliation
(BasketState.Error, BasketState.Exiting) => true,
_ => from == to,
};
/// <summary>A basket waiting for the venue: no evaluation, no new order, until the order register resolves it.</summary>
public static bool IsPending(this BasketState state) => state is BasketState.PendingA or BasketState.PendingB;
}
/// <summary>One leg of an open basket, as filled.</summary>
public sealed class BasketLeg
{
public required string Symbol { get; init; }
public required long InstrumentId { get; init; }
public required bool IsBuy { get; init; }
public double Units { get; set; }
/// <summary>Volume-weighted entry across the initial fill and the adds.</summary>
public double EntryPrice { get; set; }
public long PositionId { get; set; }
public string ClientRef { get; set; } = string.Empty;
public DateTime OpenedUtc { get; set; }
public double EntryFeesUsd { get; set; }
public double StopLossRate { get; set; }
/// <summary>Extra positions opened by adds on the same leg (eToro opens a new position per order).</summary>
public List<(long PositionId, double Units, double Price, string ClientRef)> Adds { get; } = [];
public IEnumerable<long> AllPositionIds
{
get
{
if (PositionId != 0)
{
yield return PositionId;
}
foreach ((long id, _, _, _) in Adds)
{
yield return id;
}
}
}
public double TotalUnits => Units + Adds.Sum(static a => a.Units);
/// <summary>Signed pips from entry at the exit price of this leg (bid for a long, ask for a short).</summary>
public double Pips(double exitPrice, double pip) => (IsBuy ? exitPrice - EntryPrice : EntryPrice - exitPrice) / pip;
/// <summary>Writes the leg as a named JSON object (the shape of <c>baskets_state.json</c>).</summary>
public static void Write(System.Text.Json.Utf8JsonWriter w, string name, BasketLeg leg)
{
ArgumentNullException.ThrowIfNull(w);
ArgumentNullException.ThrowIfNull(leg);
w.WriteStartObject(name);
w.WriteString("symbol", leg.Symbol);
w.WriteNumber("instrumentId", leg.InstrumentId);
w.WriteBoolean("isBuy", leg.IsBuy);
w.WriteNumber("units", leg.Units);
w.WriteNumber("entryPrice", leg.EntryPrice);
w.WriteNumber("positionId", leg.PositionId);
w.WriteString("clientRef", leg.ClientRef);
w.WriteString("openedUtc", leg.OpenedUtc.ToString("O", CultureInfo.InvariantCulture));
w.WriteNumber("entryFeesUsd", leg.EntryFeesUsd);
w.WriteNumber("stopLossRate", leg.StopLossRate);
w.WriteStartArray("adds");
foreach ((long id, double units, double price, string clientRef) in leg.Adds)
{
w.WriteStartObject();
w.WriteNumber("positionId", id);
w.WriteNumber("units", units);
w.WriteNumber("price", price);
w.WriteString("clientRef", clientRef);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
public static BasketLeg Read(System.Text.Json.JsonElement e)
{
BasketLeg leg = new()
{
Symbol = e.GetProperty("symbol").GetString() ?? string.Empty,
InstrumentId = e.GetProperty("instrumentId").GetInt64(),
IsBuy = e.GetProperty("isBuy").GetBoolean(),
Units = e.GetProperty("units").GetDouble(),
EntryPrice = e.GetProperty("entryPrice").GetDouble(),
PositionId = e.GetProperty("positionId").GetInt64(),
ClientRef = e.GetProperty("clientRef").GetString() ?? string.Empty,
OpenedUtc = DateTime.Parse(e.GetProperty("openedUtc").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
EntryFeesUsd = e.GetProperty("entryFeesUsd").GetDouble(),
StopLossRate = e.GetProperty("stopLossRate").GetDouble(),
};
if (e.TryGetProperty("adds", out System.Text.Json.JsonElement adds))
{
foreach (System.Text.Json.JsonElement a in adds.EnumerateArray())
{
leg.Adds.Add((a.GetProperty("positionId").GetInt64(), a.GetProperty("units").GetDouble(), a.GetProperty("price").GetDouble(), a.GetProperty("clientRef").GetString() ?? string.Empty));
}
}
return leg;
}
}
/// <summary>An open (or opening/closing) basket: both legs plus what the decision knew at entry.</summary>
public sealed class BasketPosition
{
public required string BasketId { get; init; }
public required string Name { get; init; }
public required bool BuyCross { get; init; }
public required BasketLeg A { get; init; }
public required BasketLeg B { get; init; }
public required DateTime OpenedUtc { get; init; }
public required double EntryZ { get; init; }
public double LastAddZ { get; set; }
public int Adds { get; set; }
public int BarsHeld { get; set; }
/// <summary>Cost estimate written at entry, in pip-equivalents of leg A.</summary>
public double EntryCostPips { get; init; }
public double TpPips { get; init; }
public double MaxLossUsd { get; init; }
public double EquityAtEntry { get; init; }
public int BarsWithBrokenCorrelation { get; set; }
/// <summary>Consecutive bar closes with a spread beyond the anomaly multiple: the forced exit waits for persistence.</summary>
public int BarsWithSpreadAnomaly { get; set; }
/// <summary>Overnight and other fees accrued so far, in USD (positive = cost).</summary>
public double AccruedFeesUsd { get; set; }
public string EntryMotivazione { get; init; } = string.Empty;
/// <summary>Sum of the two legs' pips at the given exit prices — the "Pips" of the Titany screen.</summary>
public double PipsTotal(double exitA, double exitB, double pipA, double pipB) => A.Pips(exitA, pipA) + B.Pips(exitB, pipB);
/// <summary>
/// Net P&amp;L in USD at the given exit prices: both legs converted to the account
/// currency, minus entry fees and accrued overnight.
/// </summary>
public double NetPnlUsd(double exitA, double exitB, Func<string, double?> mid)
{
double pa = PipMath.LegPnlUsd(A.Symbol, A.IsBuy, A.TotalUnits, A.EntryPrice, exitA, mid);
double pb = PipMath.LegPnlUsd(B.Symbol, B.IsBuy, B.TotalUnits, B.EntryPrice, exitB, mid);
if (double.IsNaN(pa) || double.IsNaN(pb))
{
return double.NaN;
}
return pa + pb - A.EntryFeesUsd - B.EntryFeesUsd - AccruedFeesUsd;
}
public string Describe() => string.Create(CultureInfo.InvariantCulture,
$"{Name} {(BuyCross ? "compro" : "vendo")} il cross: {(A.IsBuy ? "long" : "short")} {A.TotalUnits:0.##} {A.Symbol} @ {A.EntryPrice}, {(B.IsBuy ? "long" : "short")} {B.TotalUnits:0.##} {B.Symbol} @ {B.EntryPrice}, z entrata {EntryZ:+0.00;-0.00}, {Adds} aggiunte, {BarsHeld} barre");
}