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>
1422 lines
60 KiB
C#
1422 lines
60 KiB
C#
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 ? "sì" : "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);
|
||
}
|
||
}
|