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>
225 lines
9.1 KiB
C#
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&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");
|
|
}
|