Files
Encelado/Encelado/src/Encelado.Engine/Baskets/BasketEngine.cs
T
Alby96andClaude Fable 5.1 a69efe7df3 5.0 Fasi 6-7: motore in Encelado.Engine, server Kestrel e interfaccia web al posto di WPF
Il bot deve girare solo nel container (D-28, ADR-0007): la finestra WPF e le chiavi
DPAPI se ne vanno. Il motore diventa la libreria Encelado.Engine; l'eseguibile
Encelado.Server (nessun NuGet) serve un'API JSON scritta a mano, lo stream SSE con uno
snapshot al secondo e l'interfaccia Material 3 incorporata: dashboard con margine e
contatori, storico ordini (ordini, posizioni classificate, profitti per periodo, CSV),
log, impostazioni con chiavi cifrate (AES-GCM + passphrase), ripristino in cinque passi,
ricerca, diagnostica, valuta di visualizzazione, token locale in cookie.

Lo strumento acquista il comando learn; VS Code avvia il server con F5 e la modalità
campione; ~45 membri mai usati e i test WPF sono rimossi. Test dell'HTML incorporato,
del token e dello stream, dello storico: 210 verdi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 13:20:59 +02:00

1422 lines
60 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Encelado.Engine.Configuration;
using Encelado.Engine;
using Encelado.Engine.Logging;
using Encelado.Core.Baskets;
using Encelado.Core.Baskets.Data;
using Encelado.Core.Baskets.History;
using Encelado.Core.Baskets.Learning;
using Encelado.Core.Broker;
using Encelado.Core.Notifications;
using Encelado.Etoro;
namespace Encelado.Engine.Baskets;
/// <summary>What the engine does with a position that carries the bot's signature but belongs to no basket.</summary>
public enum OrphanPolicy
{
/// <summary>Adopt it and close it at once (the default, §5.4 of the 5.0 plan).</summary>
Close = 0,
/// <summary>Report it and wait for the bonifica to decide (the <c>--bonifica</c> start).</summary>
Report,
}
/// <summary>
/// The live engine of the Correlation Baskets module: one broker, eight instruments,
/// five baskets, one decision thread.
/// <para>
/// Quotes arrive by polling (one request for every instrument every few seconds) and
/// build the M15 bars locally; on a bar close each basket is evaluated by the same
/// <see cref="BasketDecider"/> the backtest uses; between bar closes only the exits of
/// open baskets are watched. Every evaluation lands in the ledger before its outcome is
/// known. Every order lands in the order register before it is sent, and a basket whose
/// leg has no outcome waits (<c>PendingA</c>/<c>PendingB</c>) instead of forgetting it.
/// </para>
/// <para>
/// The class is split in partial files: the loop and the decisions here; the order
/// register in <c>BasketEngine.Pending.cs</c>; account, reconciliation and safety in
/// <c>BasketEngine.Reconcile.cs</c>; the state on disk in <c>BasketEngine.State.cs</c>;
/// commands in <c>BasketEngine.Commands.cs</c>; the snapshot in <c>BasketEngine.Snapshot.cs</c>.
/// </para>
/// </summary>
public sealed partial class BasketEngine : IEngine
{
/// <summary>Bars pulled at startup: the 30-day volatility average needs 2880 M15 bars and the windows need room after it.</summary>
private const int WarmupBars = 3000;
/// <summary>How often account and positions are re-read from the venue.</summary>
private const int ReconcileSeconds = 20;
/// <summary>Reject entries when the quote is older than this.</summary>
private const int MaxQuoteAgeSeconds = 15;
/// <summary>One log line per closed bar per instrument: it is how the market-data path is audited.</summary>
private const bool LogEveryBar = true;
private readonly BotConfig _config;
private readonly BasketStrategyConfig _strategy;
private readonly string _configHash;
private readonly ExecutionMode _mode;
private readonly EtoroBroker _feed;
private readonly IBroker _broker;
private readonly PaperBroker? _paper;
private readonly BasketDecider _decider;
private readonly BasketExecutor _executor;
private readonly OrderTracker _tracker;
private readonly INotifier _notifier;
private readonly EquityTracker _equity = new();
private readonly Ledger _ledger;
private readonly LearningState _learning;
private readonly List<double> _volHistory = [];
private readonly IContextProvider _context;
private readonly string _runId;
private readonly string _dataDir;
private readonly string _marketDir;
private readonly string _statePath;
private readonly string _stopFile;
private readonly Lock _gate = new();
private readonly ConcurrentQueue<(EngineCommand Command, TaskCompletionSource<CommandResult> Done)> _commands = new();
private readonly Dictionary<string, SymbolSeries> _series = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<long, SymbolSeries> _seriesById = [];
private readonly Dictionary<long, (DateTime At, CostEstimate Cost, double Units)> _costs = [];
private readonly List<BasketSlot> _slots = [];
private readonly HashSet<string> _conversionOnly = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<long> _foreignPositions = [];
private readonly HashSet<long> _knownPositions = [];
private AccountSnapshot _account = new(DateTime.MinValue, "USD", 0, 0, 0, 0, 0);
private DateOnly _sessionDate;
private double _dayStartEquity;
private double _todayRealized;
private bool _killSwitched;
private bool _equityStopped;
private string? _haltReason;
private string? _entriesBlocked;
private string _apiState = "non connesso";
private double _apiLatencyMs = double.NaN;
private DateTime _lastPollUtc;
private DateTime _lastReconcileUtc;
private DateTime _lastStatusUtc;
private DateTime _lastStopCheckUtc;
private DateTime _lastContextUtc;
private DateTime _lastQuoteUtc;
private int _consecutiveApiErrors;
private string _username = string.Empty;
private DateTime _startedUtc;
private long _lastHourlyBucket = -1;
private DateOnly _lastDailySummary;
private DateOnly _dailyLossNotified;
private bool _pausedByOperator;
/// <summary>Everything the engine knows about one basket.</summary>
private sealed class BasketSlot
{
public required BasketDefinition Definition { get; init; }
public required SyntheticCross Cross { get; init; }
public required SymbolSeries A { get; init; }
public required SymbolSeries B { get; init; }
public string Name => Definition.Name;
public string Id => Definition.Name;
public BasketState State { get; set; } = BasketState.Idle;
public BasketPosition? Position { get; set; }
/// <summary>The entry in flight while the state is <c>PendingA</c> or <c>PendingB</c>.</summary>
public PendingEntry? Pending { get; set; }
public BasketDecision? LastDecision { get; set; }
public BasketEvaluation LastEvaluation { get; set; } = new();
public bool Enabled { get; set; } = true;
public string DisabledReason { get; set; } = string.Empty;
public DateTime DisabledUntilUtc { get; set; }
public long LastBarBucket { get; set; } = -1;
public string Intent { get; set; } = "in attesa della prima barra";
public BasketContextFeatures Features { get; set; } = BasketContextFeatures.Unknown;
public double PMl { get; set; } = double.NaN;
public bool Busy { get; set; }
public string PositionBasketId { get; set; } = string.Empty;
public VolForecaster Vol { get; } = new(horizonBars: 8, ewmaSpan: 100);
public double[]? LastFeatures { get; set; }
public double LastLogX { get; set; } = double.NaN;
/// <summary>Position ids this slot accounts for: both legs of the open basket, or leg A of a pending entry.</summary>
public IEnumerable<long> LegPositionIds()
{
if (Position is { } p)
{
foreach (long id in p.A.AllPositionIds.Concat(p.B.AllPositionIds))
{
yield return id;
}
}
if (Pending?.LegA is { PositionId: > 0 } leg)
{
yield return leg.PositionId;
}
}
}
public BasketEngine(BotConfig config, bool startConfirmed, IContextProvider? context = null, INotifier? notifier = null)
{
ArgumentNullException.ThrowIfNull(config);
_config = config.Validate();
_mode = config.Run.Mode;
_notifier = notifier ?? NullNotifier.Instance;
if (_mode.IsLive() && !startConfirmed)
{
throw new InvalidOperationException($"la modalità {_mode} richiede la conferma esplicita all'avvio");
}
string strategyPath = config.Run.StrategyPath;
if (!File.Exists(strategyPath))
{
AppPaths.SeedStrategyFile(strategyPath);
}
_strategy = BasketStrategyConfig.Load(strategyPath, out List<string> warnings);
foreach (string w in warnings)
{
Log.Warn($"strategy.json: {w}");
}
_strategy.Validate();
_strategy.InvertSignal = false; // research-only flag, never honoured by the bot
_configHash = _strategy.Hash();
_runId = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N")[..6]}");
KeyStores.Resolve(config, out string origin);
Log.Info($"chiavi eToro: {origin}");
_feed = new EtoroBroker(config.Etoro)
{
OnLog = static (message, ex) =>
{
if (ex is null)
{
Log.Warn(message);
}
else
{
Log.Warn($"{message}: {ex.Message}");
}
},
};
_dataDir = config.Run.DataPath;
_marketDir = Path.Combine(_dataDir, "market");
_statePath = Path.Combine(_dataDir, "state", "baskets_state.json");
_stopFile = Path.Combine(config.Run.BaseDirectory, "STOP");
Directory.CreateDirectory(_marketDir);
Directory.CreateDirectory(Path.GetDirectoryName(_statePath)!);
if (_mode == ExecutionMode.Paper)
{
_paper = new PaperBroker(_feed, new PaperBrokerOptions
{
StartingBalance = config.Run.PaperStartingBalance,
SlippagePips = config.Run.PaperSlippagePips,
OvernightPipsPerDay = _strategy.OvernightPipsPerDay,
StatePath = Path.Combine(_dataDir, "state", "paper_state.json"),
});
_paper.PositionClosedByStop += (id, reason) => Log.Warn($"paper: posizione {id} chiusa dal simulatore ({reason})");
_broker = _paper;
}
else
{
_broker = _feed;
}
_ledger = new Ledger(Path.Combine(_dataDir, "ledger"));
_tracker = new OrderTracker(Path.Combine(_dataDir, "state", _mode == ExecutionMode.Paper ? "pending_orders_paper.json" : "pending_orders.json"));
_tracker.Changed += (order, evento) => _ledger.Order(OrderRecord.From(order, _runId, evento));
_decider = new BasketDecider(_strategy);
_executor = new BasketExecutor(_broker, _strategy, MidOf, static m => Log.Warn(m), _tracker, _mode.ToString());
_learning = new LearningState(_dataDir, config.Run.KnowledgePath, _ledger, _strategy);
_context = context ?? new FeedContextProvider(_dataDir, config.Etoro.UserAgent);
}
public string Endpoint => _feed.Endpoint;
public string RunId => _runId;
/// <summary>Set before the start: <c>Report</c> for the <c>--bonifica</c> session, <c>Close</c> otherwise.</summary>
public OrphanPolicy OrphanPolicy { get; set; } = OrphanPolicy.Close;
private string ModeLabel => _mode.ToString();
private string PresetLabel => _decider.Preset.Label;
private double? MidOf(string symbol) => _series.TryGetValue(symbol, out SymbolSeries? s) && s.HasQuote ? s.Mid : null;
private string SymbolOf(long instrumentId) => _seriesById.TryGetValue(instrumentId, out SymbolSeries? s) ? s.Symbol : instrumentId.ToString(CultureInfo.InvariantCulture);
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
public async Task RunAsync(CancellationToken ct)
{
Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}");
_startedUtc = DateTime.UtcNow;
AcquireInstanceLock();
CheckInactivityAtStartup();
await StartupChecksAsync(ct).ConfigureAwait(false);
await LoadInstrumentsAsync(ct).ConfigureAwait(false);
await WarmupAsync(ct).ConfigureAwait(false);
LoadState();
await StartupEquityAsync(ct).ConfigureAwait(false);
// The order register first: an order sent by the previous run and never answered
// must be settled before any decision, or the reconciliation would call its
// position an orphan.
int pending = _tracker.PendingCount;
if (pending > 0)
{
Log.Warn($"registro ordini: {pending} ordine/i senza esito dal run precedente: li risolvo prima di qualsiasi decisione");
await ResolvePendingOrdersAsync(ct, force: true).ConfigureAwait(false);
}
await ReconcileAsync(ct).ConfigureAwait(false);
_sessionDate = DateOnly.FromDateTime(DateTime.UtcNow);
if (_dayStartEquity <= 0)
{
_dayStartEquity = _equity.NetEquity(_account.Equity);
}
SaveState();
WriteHeartbeat(DateTime.UtcNow, "avvio", force: true);
try
{
await _context.RefreshAsync(DateTime.UtcNow, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"contesto (calendario/notizie) non aggiornato: {ex.Message}");
}
Log.Info($"in ascolto: {_slots.Count(static s => s.Enabled)} basket attivi su {_slots.Count}, {_series.Count} strumenti, polling ogni {_config.Run.PollSeconds} s");
_notifier.Notify(NotificationKind.Event, "Bot avviato", string.Create(CultureInfo.InvariantCulture,
$"{_mode} su {TelegramEscape(_broker.Name)}, preset {PresetLabel}, equity {_account.Equity:N2}, {_slots.Count(static s => s.Position is not null)} basket aperti, {_orphanCount} orfane, {_foreignCount} esterne{(_haltReason is not null ? ", BLOCCO: " + TelegramEscape(_haltReason) : string.Empty)}"));
await LoopAsync(ct).ConfigureAwait(false);
}
private async Task StartupChecksAsync(CancellationToken ct)
{
if (!_feed.SupportsTrading)
{
_entriesBlocked = "nessuna chiave eToro: sessione in sola lettura";
Log.Warn("nessuna chiave eToro: il motore parte in sola lettura e non può nemmeno leggere le quotazioni. Inserisci le chiavi dalla finestra di accesso.");
throw new InvalidOperationException("Servono le chiavi eToro (x-api-key e x-user-key) per leggere le quotazioni. Inseriscile dalla finestra di accesso.");
}
Stopwatch sw = Stopwatch.StartNew();
_username = await _feed.VerifyAsync(ct).ConfigureAwait(false);
_apiLatencyMs = sw.Elapsed.TotalMilliseconds;
_apiState = "connesso";
double skew = _feed.ClockSkew.TotalSeconds;
Log.Info(string.Create(CultureInfo.InvariantCulture, $"eToro: utente {_username}, ambiente {(_config.Etoro.IsDemo ? "DEMO" : "REALE")}, latenza {_apiLatencyMs:F0} ms, scarto orologio {skew:+0.0;-0.0} s"));
if (Math.Abs(skew) > _strategy.ClockSkewMaxSeconds)
{
_entriesBlocked = string.Create(CultureInfo.InvariantCulture, $"orologio locale sfasato di {skew:+0.0;-0.0} s rispetto al server (limite {_strategy.ClockSkewMaxSeconds} s)");
Log.Warn(_entriesBlocked);
_notifier.Notify(NotificationKind.Alert, "Scarto orologio", TelegramEscape(_entriesBlocked));
}
if (File.Exists(_stopFile))
{
_killSwitched = true;
_haltReason = "file STOP presente all'avvio";
Log.Warn("file STOP presente: nessuna nuova entrata finché non viene rimosso");
}
}
/// <summary>The pairs that price the display currencies against USD (D-33): polled with the rest, never traded.</summary>
private static readonly string[] ConversionPairs = ["EURUSD", "GBPUSD", "USDCHF", "USDJPY", "AUDUSD", "USDCAD", "NZDUSD"];
private async Task LoadInstrumentsAsync(CancellationToken ct)
{
List<string> symbols = _strategy.Symbols(_strategy.PreferDirectCross);
foreach (string extra in ConversionPairs)
{
if (!symbols.Contains(extra, StringComparer.OrdinalIgnoreCase))
{
symbols.Add(extra);
_conversionOnly.Add(extra);
}
}
IReadOnlyList<Instrument> instruments = await _broker.GetInstrumentsAsync(symbols, ct).ConfigureAwait(false);
Dictionary<string, Instrument> bySymbol = instruments.ToDictionary(static i => i.Symbol, StringComparer.OrdinalIgnoreCase);
foreach (string s in symbols)
{
if (bySymbol.TryGetValue(s, out Instrument? i))
{
bool conversion = _conversionOnly.Contains(s);
SymbolSeries series = new(i, TimeSpan.FromMinutes(15), conversion ? 500 : Math.Max(2000, WarmupBars * 2));
_series[s] = series;
_seriesById[i.Id] = series;
Log.Info($"[{s}] id {i.Id}: {(conversion ? "solo per la conversione della valuta" : i.Notes)}");
}
else if (_conversionOnly.Contains(s))
{
Log.Warn($"[{s}] non quotato da eToro: la valuta che prezza resta senza tasso di conversione");
}
else
{
Log.Warn($"[{s}] non quotato da eToro: i basket che lo usano vengono disattivati");
}
}
WriteInstrumentsFile(instruments);
foreach (BasketDefinition d in _strategy.Baskets)
{
if (!SyntheticCross.TryDerive(d.A, d.B, out SyntheticCross? cross))
{
continue;
}
SymbolSeries? a = _series.GetValueOrDefault(d.A);
SymbolSeries? b = _series.GetValueOrDefault(d.B);
bool available = a is not null && b is not null;
BasketSlot slot = new()
{
Definition = d,
Cross = cross!,
A = a ?? new SymbolSeries(new Instrument(0, d.A, d.A, "Forex", PipMath.Pip(d.A), PipMath.Digits(d.A), 0, 0, 0, [1], false, false, 0, 0, "mancante"), TimeSpan.FromMinutes(15), 500),
B = b ?? new SymbolSeries(new Instrument(0, d.B, d.B, "Forex", PipMath.Pip(d.B), PipMath.Digits(d.B), 0, 0, 0, [1], false, false, 0, 0, "mancante"), TimeSpan.FromMinutes(15), 500),
Enabled = d.Enabled && available,
DisabledReason = !d.Enabled ? "disattivato in strategy.json" : !available ? "uno dei due strumenti non è quotato da eToro" : string.Empty,
};
_slots.Add(slot);
Log.Info($"[{slot.Name}] {cross!.Describe()}{(slot.Enabled ? string.Empty : " DISATTIVATO: " + slot.DisabledReason)}");
}
}
private void WriteInstrumentsFile(IReadOnlyList<Instrument> instruments)
{
try
{
string path = Path.Combine(_config.Run.BaseDirectory, "instruments.json");
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
{
w.WriteStartObject();
w.WriteString("updatedUtc", DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
w.WriteString("environment", _config.Etoro.Environment);
w.WriteStartArray("instruments");
foreach (Instrument i in instruments)
{
w.WriteStartObject();
w.WriteNumber("id", i.Id);
w.WriteString("symbol", i.Symbol);
w.WriteString("displayName", i.DisplayName);
w.WriteString("type", i.Type);
w.WriteNumber("pip", i.Pip);
w.WriteNumber("digits", i.Digits);
w.WriteNumber("minExposureUsd", i.MinExposure);
w.WriteNumber("maxUnitsPerOrder", i.MaxUnitsPerOrder);
w.WriteStartArray("allowedLeverages");
foreach (int l in i.AllowedLeverages)
{
w.WriteNumberValue(l);
}
w.WriteEndArray();
w.WriteBoolean("allowOpen", i.AllowOpen);
w.WriteBoolean("allowShort", i.AllowShort);
w.WriteNumber("minStopLossPct", i.MinStopLossPct);
w.WriteNumber("maxStopLossPct", i.MaxStopLossPct);
w.WriteString("notes", i.Notes);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
File.WriteAllBytes(path + ".tmp", ms.ToArray());
File.Move(path + ".tmp", path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"instruments.json non scritto: {ex.Message}");
}
}
/// <summary>Local candles first, then the venue's last thousand, appended to the file as the delta.</summary>
private async Task WarmupAsync(CancellationToken ct)
{
int want = WarmupBars;
foreach (SymbolSeries s in _series.Values)
{
if (_conversionOnly.Contains(s.Symbol))
{
continue;
}
string path = Path.Combine(_marketDir, BidAskBarCsv.FileName(s.Symbol));
List<BidAskBar> local = BidAskBarCsv.Read(path);
if (local.Count > want * 2)
{
local = local[^(want * 2)..];
}
foreach (BidAskBar b in local)
{
s.Append(b);
}
int fresh = 0;
try
{
IReadOnlyList<BidAskBar> api = await _broker.GetCandlesAsync(s.Instrument.Id, TimeSpan.FromMinutes(15), 1000, ct).ConfigureAwait(false);
// The last candle of the venue may still be forming: keep only the ones
// strictly older than the current interval.
long nowBucket = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(15).Ticks;
List<BidAskBar> closed = [.. api.Where(b => b.TimeUtc.Ticks / TimeSpan.FromMinutes(15).Ticks < nowBucket)];
foreach (BidAskBar b in closed)
{
if (s.Append(b))
{
fresh++;
}
}
BidAskBarCsv.AppendNewer(path, closed);
}
catch (BrokerException ex)
{
Log.Warn($"[{s.Symbol}] candele dall'API non disponibili: {ex.Message}");
}
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[{s.Symbol}] riscaldamento: {local.Count} barre locali + {fresh} dall'API, ultima {(s.Count > 0 ? s.LastTimeUtc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : "nessuna")} UTC"));
}
}
// -----------------------------------------------------------------------
// Main loop
// -----------------------------------------------------------------------
private async Task LoopAsync(CancellationToken ct)
{
using PeriodicTimer timer = new(TimeSpan.FromSeconds(1));
TimeSpan poll = TimeSpan.FromSeconds(_config.Run.PollSeconds);
TimeSpan reconcile = TimeSpan.FromSeconds(ReconcileSeconds);
TimeSpan status = TimeSpan.FromSeconds(_config.Run.StatusSeconds);
try
{
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
DateTime now = DateTime.UtcNow;
CheckInactivityOnTick(now);
WriteHeartbeat(now);
await DrainCommandsAsync(ct).ConfigureAwait(false);
RollSessionIfNeeded(now);
ExpireWarmupBlock(now);
NotifyScheduled(now);
// Orders without an outcome come first: their resolution changes what the
// reconciliation and the decisions below see.
await ResolvePendingOrdersAsync(ct).ConfigureAwait(false);
if (now - _lastStopCheckUtc >= TimeSpan.FromSeconds(5))
{
_lastStopCheckUtc = now;
await CheckStopFileAsync(ct).ConfigureAwait(false);
}
if (now - _lastPollUtc >= poll)
{
_lastPollUtc = now;
await PollQuotesAsync(now, ct).ConfigureAwait(false);
}
await RunRecoveryIfDueAsync(now, ct).ConfigureAwait(false);
if (now - _lastReconcileUtc >= reconcile)
{
_lastReconcileUtc = now;
await ReconcileAsync(ct).ConfigureAwait(false);
await CheckEquityStopAsync(ct).ConfigureAwait(false);
SaveState();
}
// ADR-0006: the weekly cycle runs inside the bot only when the operator turned learning on.
if (_strategy.Learning.Enabled && _strategy.Learning.WeeklyCycle && _learning.CycleDue(now) && now.DayOfWeek == DayOfWeek.Sunday && now.Hour >= 10)
{
try
{
_learning.RunCycle(now);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error("ciclo settimanale di apprendimento fallito", ex);
}
}
if (now - _lastContextUtc >= TimeSpan.FromMinutes(10))
{
_lastContextUtc = now;
try
{
await _context.RefreshAsync(now, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"contesto non aggiornato: {ex.Message}");
}
}
if (now - _lastStatusUtc >= status)
{
_lastStatusUtc = now;
LogStatus();
}
}
}
catch (OperationCanceledException)
{
Log.Info("arresto richiesto");
}
finally
{
SaveState();
WriteHeartbeat(DateTime.UtcNow, "arresto", force: true);
_notifier.Notify(NotificationKind.Event, "Bot fermato", string.Create(CultureInfo.InvariantCulture, $"{_slots.Count(static s => s.Position is not null)} basket restano aperti sul conto (closeOnShutdown = {(_config.Run.CloseOnShutdown ? "" : "no")})"));
}
}
private static string TelegramEscape(string? s) => Core.Notifications.TelegramNotifier.Escape(s);
/// <summary>The hourly status at the top of every UTC hour and the daily summary at the configured hour.</summary>
private void NotifyScheduled(DateTime now)
{
if (_notifier is NullNotifier)
{
return;
}
long bucket = now.Ticks / TimeSpan.TicksPerHour;
if (_lastHourlyBucket < 0)
{
_lastHourlyBucket = bucket;
}
else if (bucket != _lastHourlyBucket)
{
_lastHourlyBucket = bucket;
_notifier.Notify(NotificationKind.Hourly, string.Create(CultureInfo.InvariantCulture, $"Stato delle {now:HH:mm} UTC"), TelegramReports.Hourly(Snapshot(BotState.Running, null, _startedUtc, []), _notifier.Status));
}
int hour = _config.Notifications.Telegram.DailySummaryUtcHour;
DateOnly today = DateOnly.FromDateTime(now);
if (hour >= 0 && now.Hour >= hour && _lastDailySummary != today)
{
_lastDailySummary = today;
if (now.Hour == hour || _lastDailySummary == default)
{
List<BasketOutcomeRow> rows = _ledger.ReadBaskets().Where(r => DateOnly.FromDateTime(r.ClosedUtc) == today).ToList();
_notifier.Notify(NotificationKind.Daily, string.Create(CultureInfo.InvariantCulture, $"Riepilogo del {today:dd/MM}"), TelegramReports.Period("oggi", rows, _equity.Drawdown(_account.Equity)));
}
}
}
private async Task PollQuotesAsync(DateTime now, CancellationToken ct)
{
long[] ids = [.. _seriesById.Keys];
IReadOnlyList<QuoteSnapshot> quotes;
Stopwatch sw = Stopwatch.StartNew();
try
{
quotes = await _broker.GetQuotesAsync(ids, ct).ConfigureAwait(false);
_apiLatencyMs = sw.Elapsed.TotalMilliseconds;
_apiState = "connesso";
_consecutiveApiErrors = 0;
if (_entriesBlocked is not null && _entriesBlocked.StartsWith("API", StringComparison.Ordinal))
{
_entriesBlocked = null;
Log.Info("API di nuovo raggiungibile: entrate riabilitate");
}
}
catch (Exception ex) when (ex is BrokerException or HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
_consecutiveApiErrors++;
_apiState = "errore";
Log.Warn($"quotazioni non lette ({_consecutiveApiErrors}): {ex.Message}");
if (_consecutiveApiErrors >= 5 && _entriesBlocked is null)
{
_entriesBlocked = "API in errore persistente";
Log.Warn("cinque letture consecutive fallite: nuove entrate bloccate finché l'API non risponde");
_notifier.Notify(NotificationKind.Alert, "API eToro in errore", TelegramEscape($"cinque letture consecutive fallite: {ex.Message}; entrate bloccate, uscite attive"));
}
return;
}
double skew = _feed.ClockSkew.TotalSeconds;
if (Math.Abs(skew) > _strategy.ClockSkewMaxSeconds)
{
_entriesBlocked ??= string.Create(CultureInfo.InvariantCulture, $"orologio locale sfasato di {skew:+0.0;-0.0} s");
}
else if (_entriesBlocked is not null && _entriesBlocked.StartsWith("orologio", StringComparison.Ordinal))
{
_entriesBlocked = null;
}
_lastQuoteUtc = now;
HashSet<string> closedNow = [];
foreach (QuoteSnapshot q in quotes)
{
if (!_seriesById.TryGetValue(q.InstrumentId, out SymbolSeries? s))
{
continue;
}
if (s.OnQuote(q, now) is { } bar)
{
OnBarClosed(s, bar);
closedNow.Add(s.Symbol);
}
}
// A symbol without a quote in the new interval still closes its bar on the clock.
foreach (SymbolSeries s in _series.Values)
{
if (!closedNow.Contains(s.Symbol) && s.CloseFormingBar(now) is { } bar)
{
OnBarClosed(s, bar);
closedNow.Add(s.Symbol);
}
}
// Exits are watched on every quote; entries only on bar closes. Entries decided on
// the same bar are collected and executed afterwards, largest |z| first, with the
// account re-read before each one (§10: the second basket sees the margin the
// first one took).
List<(BasketSlot Slot, BasketDecision Decision)> entries = [];
foreach (BasketSlot slot in _slots)
{
if (!slot.Enabled)
{
continue;
}
bool barClosed = closedNow.Contains(slot.A.Symbol) && closedNow.Contains(slot.B.Symbol);
if (barClosed)
{
long bucket = slot.A.LastTimeUtc.Ticks / TimeSpan.FromMinutes(15).Ticks;
if (bucket != slot.LastBarBucket)
{
slot.LastBarBucket = bucket;
await EvaluateAsync(slot, now, isBarClose: true, ct, entries).ConfigureAwait(false);
continue;
}
}
if (slot.Position is not null && slot.State == BasketState.Open)
{
await EvaluateAsync(slot, now, isBarClose: false, ct).ConfigureAwait(false);
}
}
if (entries.Count > 0 && !_recoveryPending)
{
await ExecuteEntriesInOrderAsync(entries, ct).ConfigureAwait(false);
}
}
/// <summary>
/// Runs the entries of one bar close one at a time, |z| descending. Before each, the
/// account is re-read and the decision re-taken on the fresh context: the margin the
/// previous basket locked is now in <c>UsedMargin</c> and out of <c>Available</c>, so
/// a basket that no longer fits is refused here, not by the venue.
/// </summary>
private async Task ExecuteEntriesInOrderAsync(List<(BasketSlot Slot, BasketDecision Decision)> entries, CancellationToken ct)
{
entries.Sort(static (x, y) => Math.Abs(y.Decision.Evaluation.Z).CompareTo(Math.Abs(x.Decision.Evaluation.Z)));
bool first = true;
foreach ((BasketSlot slot, BasketDecision decided) in entries)
{
if (slot.State != BasketState.Idle || slot.Position is not null)
{
continue;
}
if (!first)
{
await RefreshAccountAsync(0, ct).ConfigureAwait(false);
}
first = false;
BasketContext ctx = await BuildContextAsync(slot, DateTime.UtcNow, isBarClose: true, ct).ConfigureAwait(false);
ctx = ctx with { PMl = slot.PMl, MlActive = _learning.Active && double.IsFinite(slot.PMl) };
BasketDecision d = _decider.Evaluate(ctx);
if (d.Kind != DecisionKind.Enter)
{
slot.Intent = $"NON APERTO — {d.Motivazione}";
Log.Warn($"[{slot.Name}] segnale (z {decided.Evaluation.Z:+0.00;-0.00}) non eseguito dopo la rilettura del conto: {d.Motivazione}");
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, "rifiuto", null, "rilettura del conto prima dell'invio");
continue;
}
await ExecuteEntryAsync(slot, ctx, d, ct).ConfigureAwait(false);
}
}
private void OnBarClosed(SymbolSeries s, in BidAskBar bar)
{
if (!s.Append(bar) || _conversionOnly.Contains(s.Symbol))
{
return;
}
try
{
BidAskBarCsv.AppendNewer(Path.Combine(_marketDir, BidAskBarCsv.FileName(s.Symbol)), [bar]);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"[{s.Symbol}] barra non salvata: {ex.Message}");
}
if (s.QualityIssue is { } issue)
{
Log.Warn($"[{s.Symbol}] qualità dati: {issue}");
AppendDataQuality(s.Symbol, bar.TimeUtc, issue);
}
if (LogEveryBar)
{
Log.Info(string.Create(CultureInfo.InvariantCulture, $"[{s.Symbol}] barra {bar.TimeUtc:HH:mm} chiusa: bid {bar.BidClose} ask {bar.AskClose} ({bar.Ticks} quote)"));
}
}
private void AppendDataQuality(string symbol, DateTime when, string issue)
{
try
{
string path = Path.Combine(_config.Run.ReportsPath, "data_quality.csv");
Directory.CreateDirectory(_config.Run.ReportsPath);
bool isNew = !File.Exists(path);
using StreamWriter w = new(path, append: true, new UTF8Encoding(false));
if (isNew)
{
w.WriteLine("timestamp;simbolo;barra;motivazione");
}
w.WriteLine(string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:O};{symbol};{when:O};{issue.Replace(';', ',')}: decisioni sospese su questa barra"));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"data_quality.csv non scritto: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Decisions
// -----------------------------------------------------------------------
private async Task EvaluateAsync(BasketSlot slot, DateTime now, bool isBarClose, CancellationToken ct, List<(BasketSlot Slot, BasketDecision Decision)>? entries = null)
{
if (slot.Busy || slot.State is BasketState.Entering or BasketState.Exiting or BasketState.Adding || slot.State.IsPending())
{
if (slot.State.IsPending())
{
slot.Intent = $"IN ATTESA dell'esito della gamba {(slot.State == BasketState.PendingA ? "A" : "B")} (ordine nel registro dal {slot.Pending?.DecidedUtc:HH:mm:ss} UTC)";
}
return;
}
if (slot.DisabledUntilUtc > now)
{
slot.Intent = $"in pausa fino alle {slot.DisabledUntilUtc:HH:mm} UTC: {slot.DisabledReason}";
return;
}
if (!slot.A.HasQuote || !slot.B.HasQuote)
{
return;
}
if (isBarClose && slot.A.Count > 0 && slot.B.Count > 0)
{
double logX = Math.Log(slot.A.Last.MidClose) + (slot.Cross.SignB * Math.Log(slot.B.Last.MidClose));
if (double.IsFinite(slot.LastLogX))
{
slot.Vol.Observe(logX - slot.LastLogX, now);
}
slot.LastLogX = logX;
}
BasketContext ctx = await BuildContextAsync(slot, now, isBarClose, ct).ConfigureAwait(false);
BasketDecision d;
try
{
// Two passes on a bar close: the features need the evaluation, the decision needs the
// shadow probability computed from those features. Five baskets per quarter hour: cheap.
if (isBarClose && slot.Position is null)
{
BasketEvaluation preview = _decider.Compute(ctx);
double[] features = LearningFeatures.From(ctx, preview, preview.Z < 0);
slot.LastFeatures = features;
slot.PMl = _learning.Predict(features);
ctx = ctx with { PMl = slot.PMl, MlActive = _strategy.Learning.Enabled && _learning.Active && double.IsFinite(slot.PMl) };
}
d = _decider.Evaluate(ctx);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error($"[{slot.Name}] errore nella valutazione", ex);
return;
}
slot.LastDecision = d;
slot.LastEvaluation = d.Evaluation;
if (isBarClose)
{
string evento = d.Kind switch
{
DecisionKind.Enter => "segnale_ingresso",
DecisionKind.Exit => "segnale_uscita",
DecisionKind.Add => "segnale_aggiunta",
DecisionKind.Hold => "posizione",
_ => "skip",
};
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, evento, slot.PositionBasketId.Length > 0 ? slot.PositionBasketId : null);
}
switch (d.Kind)
{
case DecisionKind.Skip:
slot.Intent = d.Motivazione;
break;
case DecisionKind.Hold:
slot.Intent = d.Motivazione;
break;
case DecisionKind.Enter:
slot.Intent = "SEGNALE — " + d.Motivazione;
if (isBarClose)
{
Log.Info($"[{slot.Name}] {slot.Intent}");
if (entries is not null)
{
entries.Add((slot, d));
}
else
{
await ExecuteEntryAsync(slot, ctx, d, ct).ConfigureAwait(false);
}
}
break;
case DecisionKind.Add:
slot.Intent = "AGGIUNTA — " + d.Motivazione;
if (isBarClose)
{
Log.Info($"[{slot.Name}] {slot.Intent}");
await ExecuteAddAsync(slot, ctx, d, ct).ConfigureAwait(false);
}
break;
case DecisionKind.Exit:
slot.Intent = "USCITA — " + d.Motivazione;
if (!isBarClose)
{
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, "segnale_uscita", slot.PositionBasketId);
}
Log.Info($"[{slot.Name}] {slot.Intent}");
await ExecuteExitAsync(slot, ctx, d, d.Motivazione, d.ReasonCodes.FirstOrDefault() ?? "exit", ct).ConfigureAwait(false);
break;
}
}
private async Task<BasketContext> BuildContextAsync(BasketSlot slot, DateTime now, bool isBarClose, CancellationToken ct)
{
(string longCcy, string shortCcy) = slot.Cross.Exposure();
BasketContextFeatures f = _context.For(longCcy, shortCcy, slot.Cross.Common, now);
slot.Features = f;
(double markupA, double overnightA) = await MarkupPipsAsync(slot.A, ct).ConfigureAwait(false);
(double markupB, double overnightB) = await MarkupPipsAsync(slot.B, ct).ConfigureAwait(false);
double overnight = double.IsFinite(overnightA) && double.IsFinite(overnightB) ? (overnightA + overnightB) / 2 : double.NaN;
double pipA = PipMath.PipValueUsd(slot.A.Symbol, 1, MidOf);
double pipB = PipMath.PipValueUsd(slot.B.Symbol, 1, MidOf);
double usdA = PipMath.QuoteToUsd(PipMath.QuoteCurrency(slot.A.Symbol), MidOf) ?? double.NaN;
double usdB = PipMath.QuoteToUsd(PipMath.QuoteCurrency(slot.B.Symbol), MidOf) ?? double.NaN;
bool sameCrossOpen = _slots.Any(o => o != slot && o.Cross.Symbol == slot.Cross.Symbol && (o.Position is not null || o.State == BasketState.Entering || o.State.IsPending()));
double dailyPnl = _todayRealized + _account.UnrealizedPnl;
bool dailyLossHit = _dayStartEquity > 0 && dailyPnl <= -_dayStartEquity * _strategy.DailyLossPct / 100.0;
if (dailyLossHit && _dailyLossNotified != _sessionDate)
{
_dailyLossNotified = _sessionDate;
Log.Warn(string.Create(CultureInfo.InvariantCulture, $"perdita giornaliera raggiunta: {dailyPnl:+0.00;-0.00} USD su {_dayStartEquity:F2} di equity di partenza ({_strategy.DailyLossPct:0.#} %): niente nuove entrate fino a domani"));
_notifier.Notify(NotificationKind.Alert, "Perdita giornaliera raggiunta", string.Create(CultureInfo.InvariantCulture, $"{dailyPnl:+0.00;-0.00} USD oggi su {_dayStartEquity:N2} di equity ({_strategy.DailyLossPct:0.#} %): niente nuove entrate fino alla mezzanotte UTC"));
}
return new BasketContext
{
TimeUtc = now,
BasketId = slot.Id,
Name = slot.Name,
Cross = slot.Cross,
A = slot.A,
B = slot.B,
Equity = _account.Equity,
PeakEquity = _equity.PeakEquity,
DailyPnlUsd = dailyPnl,
OpenBaskets = _slots.Count(static s => s.Position is not null || s.State == BasketState.Entering || s.State.IsPending()),
SameCrossOpen = sameCrossOpen,
DailyLossHit = dailyLossHit,
AvailableMargin = _account.TimeUtc == DateTime.MinValue ? double.NaN : _account.Available,
UsedMargin = _account.UsedMargin,
LeverageA = BasketExecutor.ChooseLeverage(slot.A.Instrument, _strategy.OrderLeverage),
LeverageB = BasketExecutor.ChooseLeverage(slot.B.Instrument, _strategy.OrderLeverage),
EquityStopped = _equityStopped,
KillSwitched = _killSwitched,
EntriesBlockedReason = _entriesBlocked ?? StaleQuoteReason(slot, now),
IsBarClose = isBarClose,
MinutesToNextHigh = f.MinutesToNextHigh,
MinutesSinceLastHigh = f.MinutesSinceLastHigh,
SurpriseLast = f.SurpriseLast,
JustOpened = _context.JustOpened(now, _strategy.OpenDelayMinutes),
SigmaForecast = slot.Vol.Count > 0 ? slot.Vol.Forecast() : double.NaN,
SigmaAverage30d = slot.Vol.AverageVolatility(_strategy.VolAverageDays),
LastOutcomes = _learning.LastOutcomes,
NetSentimentDiff1h = f.NetSentimentDiff1h,
NetSentimentDiff4h = f.NetSentimentDiff4h,
NetSentimentDiff24h = f.NetSentimentDiff24h,
HawkishDiff = f.HawkishDiff,
RiskOff = f.RiskOff,
NewsCount = f.NewsCount,
PMl = slot.PMl,
MlActive = false,
MarkupPipsA = double.IsFinite(markupA) ? markupA : 0,
MarkupPipsB = double.IsFinite(markupB) ? markupB : 0,
OvernightPipsPerDay = overnight,
PipValueUsdA = double.IsNaN(pipA) ? 0 : pipA,
PipValueUsdB = double.IsNaN(pipB) ? 0 : pipB,
UsdPerQuoteA = double.IsNaN(usdA) ? 0 : usdA,
UsdPerQuoteB = double.IsNaN(usdB) ? 0 : usdB,
Mid = MidOf,
Position = slot.Position,
};
}
private string? StaleQuoteReason(BasketSlot slot, DateTime now)
{
int max = MaxQuoteAgeSeconds;
if (max <= 0)
{
return null;
}
double ageA = (now - slot.A.QuoteSeenUtc).TotalSeconds;
double ageB = (now - slot.B.QuoteSeenUtc).TotalSeconds;
return ageA > max || ageB > max ? string.Create(CultureInfo.InvariantCulture, $"quotazioni vecchie ({Math.Max(ageA, ageB):F0} s)") : null;
}
/// <summary>The venue's markup and overnight for a reference order, in pips, cached for fifteen minutes.</summary>
private async Task<(double MarkupPips, double OvernightPipsPerDay)> MarkupPipsAsync(SymbolSeries s, CancellationToken ct)
{
const double referenceUnits = 10_000;
if (_costs.TryGetValue(s.Instrument.Id, out (DateTime At, CostEstimate Cost, double Units) cached) && DateTime.UtcNow - cached.At < TimeSpan.FromMinutes(15))
{
return Pips(cached.Cost, cached.Units);
}
try
{
CostEstimate? cost = await _broker.GetCostAsync(new OrderRequest(Guid.NewGuid().ToString("D"), s.Instrument.Id, s.Symbol, true, referenceUnits, _strategy.OrderLeverage, null, null, "stima costi"), ct).ConfigureAwait(false);
if (cost is null)
{
return (double.NaN, double.NaN);
}
_costs[s.Instrument.Id] = (DateTime.UtcNow, cost, referenceUnits);
return Pips(cost, referenceUnits);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"[{s.Symbol}] stima dei costi non disponibile: {ex.Message}");
return (double.NaN, double.NaN);
}
(double, double) Pips(CostEstimate c, double units)
{
double pipUsd = PipMath.PipValueUsd(s.Symbol, units, MidOf);
if (!double.IsFinite(pipUsd) || pipUsd <= 0)
{
return (double.NaN, double.NaN);
}
// The market spread is already in the quotes; markup and fees are what the venue adds on top.
return ((c.Markup + c.TransactionFee) / pipUsd, c.OvernightPerDay / pipUsd);
}
}
// The bot trades by itself in every mode (D-20): an entry, an add or an exit decided
// by the decider is executed at once. The only human gates left are the start of the
// live mode, the kill-switch and the reset after an equity stop.
private async Task ExecuteEntryAsync(BasketSlot slot, BasketContext ctx, BasketDecision d, CancellationToken ct)
{
if (slot.Position is not null || slot.State != BasketState.Idle)
{
return;
}
Transition(slot, BasketState.Entering);
slot.Busy = true;
try
{
string basketId = string.Create(CultureInfo.InvariantCulture, $"B{DateTime.UtcNow:yyyyMMddHHmmss}-{slot.Name.Replace("/", string.Empty)}");
EntryOutcome outcome = await _executor.OpenAsync(ctx, d, _decider.Preset, basketId, ct).ConfigureAwait(false);
ApplyEntryOutcome(slot, ctx, d, outcome, basketId);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error($"[{slot.Name}] apertura fallita", ex);
Transition(slot, BasketState.Error);
_entriesBlocked = $"errore di esecuzione su {slot.Name}: {ex.Message}";
}
finally
{
slot.Busy = false;
}
}
/// <summary>What an entry attempt (first try, or the continuation after a pending leg) did to the slot.</summary>
private void ApplyEntryOutcome(BasketSlot slot, BasketContext ctx, BasketDecision? d, EntryOutcome outcome, string basketId)
{
if (outcome.Ok && outcome.Position is { } position)
{
slot.Position = position;
slot.Pending = null;
slot.PositionBasketId = basketId;
if (slot.LastFeatures is { } fx)
{
_learning.RememberEntry(basketId, fx);
}
Transition(slot, BasketState.Open);
_knownPositions.UnionWith(position.A.AllPositionIds);
_knownPositions.UnionWith(position.B.AllPositionIds);
slot.Intent = "IN POSIZIONE — " + position.Describe();
string fill = string.Create(CultureInfo.InvariantCulture, $"eseguito: A @ {position.A.EntryPrice} (slippage {outcome.SlippagePipsA:+0.0;-0.0} pip), B @ {position.B.EntryPrice} (slippage {outcome.SlippagePipsB:+0.0;-0.0} pip), latenza {outcome.LatencyMs:F0} ms");
Log.Info($"[{slot.Name}] APERTO {basketId}: {position.Describe()}; {fill}");
_notifier.Notify(NotificationKind.Event, $"Basket aperto: {slot.Name}", TelegramEscape($"{position.Describe()}\n{fill}"));
if (d is not null)
{
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx with { Position = position }, d, "ingresso", basketId, fill);
}
else
{
_ledger.Note(_runId, "ingresso", basketId, $"{position.Describe()} — {fill}", w => w.WriteString("basket", slot.Name));
}
SaveState();
return;
}
if (outcome.IsPending && outcome.Pending is { } plan)
{
slot.Pending = plan;
slot.PositionBasketId = basketId;
Transition(slot, outcome.PendingLeg == PendingLeg.A ? BasketState.PendingA : BasketState.PendingB);
if (plan.LegA is { PositionId: > 0 } legA)
{
_knownPositions.Add(legA.PositionId);
}
slot.Intent = $"IN ATTESA — {outcome.Error}";
Log.Warn($"[{slot.Name}] {slot.Intent}");
_notifier.Notify(NotificationKind.Event, $"Gamba in attesa: {slot.Name}", TelegramEscape(outcome.Error));
_ledger.Note(_runId, "pending", basketId, outcome.Error, w =>
{
w.WriteString("basket", slot.Name);
w.WriteString("leg", outcome.PendingLeg.ToString());
w.WriteString("client_ref", outcome.PendingOrder?.ClientRef ?? string.Empty);
w.WriteNumber("order_id", outcome.PendingOrder?.OrderId ?? 0);
});
SaveState();
return;
}
slot.Pending = null;
Transition(slot, BasketState.Idle);
string evento = outcome.Unwound ? "leg_risk_unwind" : "rifiuto";
slot.Intent = $"NON APERTO — {outcome.Error}";
Log.Warn($"[{slot.Name}] {slot.Intent}");
if (outcome.Unwound || outcome.Error.Contains("NON richiusa", StringComparison.Ordinal))
{
_notifier.Notify(NotificationKind.Alert, $"Unwind: {slot.Name}", TelegramEscape(outcome.Error));
}
if (d is not null)
{
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, evento, basketId, outcome.Error);
}
else
{
_ledger.Note(_runId, evento, basketId, outcome.Error, w => w.WriteString("basket", slot.Name));
}
if (outcome.Unwound || outcome.Error.Contains("NON richiusa", StringComparison.Ordinal))
{
slot.DisabledUntilUtc = DateTime.UtcNow.AddHours(1);
slot.DisabledReason = "leg-risk: seconda gamba non eseguita";
if (outcome.Error.Contains("NON richiusa", StringComparison.Ordinal))
{
Transition(slot, BasketState.Error);
_entriesBlocked = $"gamba orfana su {slot.Name}: serve una riconciliazione";
}
}
SaveState();
}
private async Task ExecuteAddAsync(BasketSlot slot, BasketContext ctx, BasketDecision d, CancellationToken ct)
{
if (slot.Position is null || slot.State != BasketState.Open)
{
return;
}
Transition(slot, BasketState.Adding);
slot.Busy = true;
try
{
EntryOutcome outcome = await _executor.AddAsync(ctx, d, ct).ConfigureAwait(false);
Transition(slot, BasketState.Open);
if (outcome.Ok)
{
_knownPositions.UnionWith(slot.Position.A.AllPositionIds);
_knownPositions.UnionWith(slot.Position.B.AllPositionIds);
Log.Info($"[{slot.Name}] AGGIUNTA eseguita: {slot.Position.Describe()}");
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, "aggiunta", slot.PositionBasketId, "eseguita");
SaveState();
}
else
{
Log.Warn($"[{slot.Name}] aggiunta non eseguita: {outcome.Error}");
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, outcome.Unwound ? "leg_risk_unwind" : "rifiuto", slot.PositionBasketId, outcome.Error);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error($"[{slot.Name}] aggiunta fallita", ex);
Transition(slot, BasketState.Open);
}
finally
{
slot.Busy = false;
}
}
private async Task ExecuteExitAsync(BasketSlot slot, BasketContext ctx, BasketDecision? d, string reason, string reasonCode, CancellationToken ct)
{
if (slot.Position is not { } p || slot.State is not (BasketState.Open or BasketState.Error))
{
return;
}
Transition(slot, BasketState.Exiting);
slot.Busy = true;
try
{
ExitOutcome x = await _executor.CloseAsync(ctx, p, reason, ct).ConfigureAwait(false);
if (x.Ok)
{
CloseBasket(slot, p, x, reasonCode, reason, ctx, d);
}
else
{
Transition(slot, BasketState.Error);
_entriesBlocked = $"chiusura incompleta su {slot.Name}: {x.Error}";
slot.Intent = $"ERRORE — {x.Error}";
Log.Error($"[{slot.Name}] chiusura incompleta: {x.Error}. Nuove entrate bloccate finché non è risolta (riconciliazione periodica)", null);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error($"[{slot.Name}] chiusura fallita", ex);
Transition(slot, BasketState.Error);
}
finally
{
slot.Busy = false;
}
}
private void CloseBasket(BasketSlot slot, BasketPosition p, ExitOutcome x, string reasonCode, string reason, BasketContext ctx, BasketDecision? d)
{
double costUsd = (p.EntryCostPips * ctx.PipValueUsdA * p.A.TotalUnits) + p.AccruedFeesUsd;
double gross = x.RealizedPnlUsd + p.A.EntryFeesUsd + p.B.EntryFeesUsd + p.AccruedFeesUsd;
BasketOutcomeRow row = new(
slot.PositionBasketId, _runId, slot.Name, ModeLabel, PresetLabel, p.OpenedUtc, x.ClosedUtc, p.BuyCross, p.EntryZ,
d?.Evaluation.Z ?? slot.LastEvaluation.Z, gross, x.RealizedPnlUsd, x.PipsTotal, p.EntryCostPips, costUsd,
x.SlippagePipsA + x.SlippagePipsB, p.Adds, p.BarsHeld, reasonCode, p.EquityAtEntry, slot.PMl,
$"{reason} | ingresso: {p.EntryMotivazione}");
_ledger.Basket(row);
_todayRealized += x.RealizedPnlUsd;
ObserveOutcome(slot, row.Label);
foreach (long id in p.A.AllPositionIds.Concat(p.B.AllPositionIds))
{
_knownPositions.Remove(id);
}
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[{slot.Name}] CHIUSO {slot.PositionBasketId}: netto {x.RealizedPnlUsd:+0.00;-0.00} USD, {x.PipsTotal:+0.0;-0.0} pip, {p.BarsHeld} barre, motivo {reasonCode}"));
_notifier.Notify(NotificationKind.Event, $"Basket chiuso: {slot.Name}", string.Create(CultureInfo.InvariantCulture,
$"netto <b>{x.RealizedPnlUsd:+0.00;-0.00}</b> USD, {x.PipsTotal:+0.0;-0.0} pip, {p.BarsHeld} barre, motivo {reasonCode}\n{TelegramEscape(reason)}"));
if (d is not null)
{
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, "uscita", slot.PositionBasketId,
string.Create(CultureInfo.InvariantCulture, $"chiuso: netto {x.RealizedPnlUsd:+0.00;-0.00} USD, {x.PipsTotal:+0.0;-0.0} pip, slippage {x.SlippagePipsA + x.SlippagePipsB:+0.0;-0.0} pip"));
}
else
{
_ledger.Note(_runId, "uscita", slot.PositionBasketId, string.Create(CultureInfo.InvariantCulture, $"{reason}: netto {x.RealizedPnlUsd:+0.00;-0.00} USD, {x.PipsTotal:+0.0;-0.0} pip"), w => w.WriteString("basket", slot.Name));
}
slot.Position = null;
slot.PositionBasketId = string.Empty;
Transition(slot, BasketState.Closed);
Transition(slot, BasketState.Idle);
slot.Intent = $"chiuso ({reasonCode}): {x.RealizedPnlUsd:+0.00;-0.00} USD";
SaveState();
}
/// <summary>
/// The learning stack sees every close: the shadow model learns, the bandit is
/// rewarded and its proposal is logged. The proposal is <b>not</b> applied (5.0,
/// D-30): a parameter changed by the bot on its own is exactly what the rules forbid;
/// the path to a different preset is a proposal in <c>knowledge/proposals.csv</c>.
/// </summary>
private void ObserveOutcome(BasketSlot slot, int label)
{
double sigma = slot.Vol.Count > 0 ? slot.Vol.Forecast() : double.NaN;
if (double.IsFinite(sigma))
{
_volHistory.Add(sigma);
if (_volHistory.Count > 500)
{
_volHistory.RemoveAt(0);
}
}
int context = ThompsonBandit.VolatilityContext(sigma, _volHistory);
_learning.Observe(slot.PositionBasketId, label, context, _decider.Preset.Name);
(PresetName proposed, string text) = _learning.Propose(context);
Log.Info($"bandit (solo proposta, non applicata): {text}{(proposed != _decider.Preset.Name ? $" il preset resta {PresetLabel}" : string.Empty)}");
}
private void Transition(BasketSlot slot, BasketState to)
{
if (!BasketLifecycle.CanTransition(slot.State, to))
{
Log.Warn($"[{slot.Name}] transizione di stato non prevista {slot.State} → {to}");
}
slot.State = to;
}
private void RollSessionIfNeeded(DateTime now)
{
DateOnly today = DateOnly.FromDateTime(now);
if (today == _sessionDate)
{
return;
}
_sessionDate = today;
_dayStartEquity = _equity.NetEquity(_account.Equity);
_todayRealized = 0;
Log.Info(string.Create(CultureInfo.InvariantCulture, $"── nuova giornata {today:yyyy-MM-dd}: equity di partenza {_dayStartEquity:F2} (al netto dei movimenti di cassa) ──"));
SaveState();
}
private void LogStatus()
{
double dd = _equity.Drawdown(_account.Equity);
string line = string.Create(CultureInfo.InvariantCulture,
$"[stato] {_mode} · equity {_account.Equity:F2} (saldo {_account.Balance:F2}, aperto {_account.UnrealizedPnl:+0.00;-0.00}, oggi {_todayRealized:+0.00;-0.00}) · DD {dd:P2} dal picco {_equity.PeakEquity:F2} · basket aperti {_slots.Count(static s => s.Position is not null)}/{_decider.Preset.MaxBaskets}, in attesa {_slots.Count(static s => s.State.IsPending())}, ordini pendenti {_tracker.PendingCount}, orfane {_orphanCount}, esterne {_foreignCount} · margine {_account.UsedMargin:F0}/{_account.Available:F0} · API {_apiState} {_apiLatencyMs:F0} ms");
if (_haltReason is not null)
{
line += $" · BLOCCO: {_haltReason}";
}
if (_entriesBlocked is not null)
{
line += $" · entrate bloccate: {_entriesBlocked}";
}
if (_unreconciledSince is not null)
{
line += $" · NON RICONCILIATO: {_unreconciledReason}";
}
Log.Info(line);
foreach (BasketSlot s in _slots)
{
BasketEvaluation e = s.LastEvaluation;
Log.Info(string.Create(CultureInfo.InvariantCulture,
$" {s.Name,-14} {s.State,-8} z {(double.IsFinite(e.Z) ? e.Z.ToString("+0.00;-0.00") : ""),6} ρ {(double.IsFinite(e.RhoW) ? e.RhoW.ToString("+0.00;-0.00") : ""),6} HL {(double.IsFinite(e.HalfLife) ? e.HalfLife.ToString("0") : ""),4} {s.Intent}"));
}
}
public async ValueTask DisposeAsync()
{
SaveState();
ReleaseInstanceLock();
_learning.Dispose();
_ledger.Dispose();
if (_paper is not null)
{
await _paper.DisposeAsync().ConfigureAwait(false);
}
await _feed.DisposeAsync().ConfigureAwait(false);
}
}