diff --git a/Encelado/.gitignore b/Encelado/.gitignore new file mode 100644 index 0000000..f29207e --- /dev/null +++ b/Encelado/.gitignore @@ -0,0 +1,13 @@ +# Build output +bin/ +obj/ +artifacts/ + +# Runtime output — never commit logs or the trade journal +logs/ +*.log +*.jsonl + +# Local configuration: credentials and machine-specific overrides live here +*.local.json +.env diff --git a/Encelado/.vscode/extensions.json b/Encelado/.vscode/extensions.json new file mode 100644 index 0000000..8e23111 --- /dev/null +++ b/Encelado/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + // Fornisce il debugger "coreclr" richiesto da launch.json. + "ms-dotnettools.csharp", + "ms-dotnettools.csdevkit", + + // Colora installer\Encelado.iss e ne conosce direttive e costanti. Serve solo a + // leggere e scrivere quel file: l'installer si costruisce con il task + // "installer", che non dipende da nessuna estensione. + "idleberg.innosetup" + ] +} diff --git a/Encelado/.vscode/launch.json b/Encelado/.vscode/launch.json new file mode 100644 index 0000000..cc95b9f --- /dev/null +++ b/Encelado/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // One way to launch, on purpose. Encelado is a desktop application: F5 here starts + // the same window you get by double-clicking Encelado.exe. Everything else — login, + // start/stop, backtest, settings — lives inside that window. + "version": "0.2.0", + "configurations": [ + { + "name": "Encelado", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows/Encelado.exe", + "cwd": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows", + "console": "internalConsole", + "stopAtEntry": false + } + ] +} diff --git a/Encelado/.vscode/tasks.json b/Encelado/.vscode/tasks.json new file mode 100644 index 0000000..7d80c7d --- /dev/null +++ b/Encelado/.vscode/tasks.json @@ -0,0 +1,83 @@ +{ + "version": "2.0.0", + "tasks": [ + { + // Referenced by the launch configuration as preLaunchTask. + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/Encelado.slnx", + "-p:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary" + ], + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": "$msCompile", + "presentation": { "reveal": "silent", "clear": true } + }, + { + "label": "test", + "type": "process", + "command": "dotnet", + "args": ["test", "${workspaceFolder}/Encelado.slnx", "--nologo"], + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": "$msCompile" + }, + { + // Produces the distributable build under src/Encelado.Bot/publish. + "label": "publish (Release)", + "type": "process", + "command": "dotnet", + "args": [ + "publish", + "${workspaceFolder}/src/Encelado.Bot/Encelado.Bot.csproj", + "-c", + "Release", + "-o", + "${workspaceFolder}/src/Encelado.Bot/publish" + ], + "problemMatcher": "$msCompile" + }, + { + // Produce artifacts/installer/Encelado-Setup-.exe: un unico file da + // consegnare. Installa Inno Setup al primo utilizzo se non lo trova. + "label": "installer", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/build/make-installer.ps1" + ], + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated", "clear": true } + }, + { + // Come sopra ma senza il runtime .NET incorporato: ~2 MB invece di ~65, al + // prezzo di dover avere il .NET 10 Desktop Runtime sulla macchina di arrivo. + "label": "installer (senza runtime)", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/build/make-installer.ps1", + "-FrameworkDependent" + ], + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated", "clear": true } + }, + { + "label": "clean", + "type": "process", + "command": "dotnet", + "args": ["clean", "${workspaceFolder}/Encelado.slnx"], + "problemMatcher": [] + } + ] +} diff --git a/Encelado/Directory.Build.props b/Encelado/Directory.Build.props new file mode 100644 index 0000000..26c7be0 --- /dev/null +++ b/Encelado/Directory.Build.props @@ -0,0 +1,40 @@ + + + + net10.0 + latest + enable + enable + true + false + latest + en + true + false + Encelado + Encelado + 3.2.0 + + + + + true + true + true + true + true + true + false + + + + + true + true + + + diff --git a/Encelado/Encelado.slnx b/Encelado/Encelado.slnx new file mode 100644 index 0000000..ea35d18 --- /dev/null +++ b/Encelado/Encelado.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Encelado/build/make-installer.ps1 b/Encelado/build/make-installer.ps1 new file mode 100644 index 0000000..45496e8 --- /dev/null +++ b/Encelado/build/make-installer.ps1 @@ -0,0 +1,233 @@ +<# +.SYNOPSIS + Costruisce Encelado-Setup-.exe, l'installer distribuibile. + +.DESCRIPTION + Tre passaggi: pubblica l'applicazione, verifica che il compilatore di Inno Setup + ci sia (e se manca lo installa), compila installer\Encelado.iss. + + Il risultato finisce in artifacts\installer\ ed è un singolo eseguibile: chi lo + riceve fa doppio clic, non gli viene chiesto nulla dall'UAC e si ritrova Encelado + nel menu Start. + +.PARAMETER FrameworkDependent + Pubblica senza il runtime .NET incorporato. L'installer scende da ~65 MB a ~2 MB, + ma sulla macchina di destinazione deve già esserci il .NET 10 Desktop Runtime, + altrimenti l'applicazione non parte e Windows mostra un errore poco chiaro. + Ha senso solo per aggiornare una macchina che hai già preparato tu. + +.PARAMETER SkipTests + Salta la suite di test prima di pubblicare. Sconsigliato: l'unica ragione per cui + i numeri della strategia sono affidabili è che qualcosa li verifica. + +.PARAMETER Configuration + Debug o Release. Il default è Release e non c'è motivo di cambiarlo. + +.EXAMPLE + .\build\make-installer.ps1 + +.EXAMPLE + .\build\make-installer.ps1 -FrameworkDependent -SkipTests +#> +[CmdletBinding()] +param( + [switch] $FrameworkDependent, + [switch] $SkipTests, + [ValidateSet('Release', 'Debug')] + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Root = Split-Path -Parent $PSScriptRoot +$Project = Join-Path $Root 'src\Encelado.Bot\Encelado.Bot.csproj' +$Solution = Join-Path $Root 'Encelado.slnx' +$IssScript = Join-Path $Root 'installer\Encelado.iss' +$PublishDir = Join-Path $Root 'artifacts\publish' +$OutputDir = Join-Path $Root 'artifacts\installer' + +function Write-Step([string] $Message) { + Write-Host '' + Write-Host "==> $Message" -ForegroundColor Cyan +} + +function Write-Note([string] $Message) { + Write-Host " $Message" -ForegroundColor DarkGray +} + +# -------------------------------------------------------------------------------- +# Versione +# -------------------------------------------------------------------------------- +# Una sola fonte di verità: Directory.Build.props. Se la versione fosse scritta anche +# nello script .iss, prima o poi le due divergerebbero e verrebbero distribuiti due +# installer diversi con lo stesso numero sopra. + +function Get-ProductVersion { + $propsPath = Join-Path $Root 'Directory.Build.props' + if (-not (Test-Path -LiteralPath $propsPath)) { + throw "Directory.Build.props non trovato in $Root." + } + + $version = ([xml](Get-Content -LiteralPath $propsPath -Raw)). + SelectSingleNode('//PropertyGroup/Version') + + if ($null -eq $version -or [string]::IsNullOrWhiteSpace($version.InnerText)) { + throw "Nessun elemento in $propsPath." + } + + $text = $version.InnerText.Trim() + if ($text -notmatch '^\d+(\.\d+){1,3}$') { + throw "Versione '$text' non utilizzabile: Inno Setup vuole da 2 a 4 numeri separati da punti." + } + + return $text +} + +# -------------------------------------------------------------------------------- +# Compilatore Inno Setup +# -------------------------------------------------------------------------------- + +function Find-Iscc { + $command = Get-Command 'ISCC.exe' -ErrorAction SilentlyContinue + if ($null -ne $command) { return $command.Source } + + # Inno registra qui la propria cartella; è più affidabile di indovinare il percorso. + $keys = @( + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1' + ) + + foreach ($key in $keys) { + try { + $location = (Get-ItemProperty -LiteralPath $key -ErrorAction Stop).InstallLocation + } catch { + continue + } + + if ([string]::IsNullOrWhiteSpace($location)) { continue } + + $candidate = Join-Path $location 'ISCC.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + + foreach ($candidate in @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) { + + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + + return $null +} + +function Install-InnoSetup { + if ($null -eq (Get-Command 'winget' -ErrorAction SilentlyContinue)) { + throw @' +Inno Setup non è installato e winget non è disponibile per installarlo. +Scaricalo da https://jrsoftware.org/isdl.php e rilancia questo script. +'@ + } + + Write-Note 'Inno Setup non trovato: lo installo con winget (una tantum).' + & winget install --id JRSoftware.InnoSetup --exact --silent ` + --accept-package-agreements --accept-source-agreements + + # winget restituisce 0 anche in casi che non ci interessano, quindi la vera + # verifica è ricercare l'eseguibile. + $iscc = Find-Iscc + if ($null -eq $iscc) { + throw 'Installazione di Inno Setup non riuscita. Installalo a mano da https://jrsoftware.org/isdl.php' + } + + return $iscc +} + +# -------------------------------------------------------------------------------- + +$version = Get-ProductVersion +Write-Host '' +Write-Host "Encelado $version — costruzione dell'installer" -ForegroundColor White + +if (-not $SkipTests) { + Write-Step 'Test' + & dotnet test $Solution --configuration $Configuration --nologo --verbosity quiet + if ($LASTEXITCODE -ne 0) { + throw 'Test non superati: mi fermo qui invece di impacchettare qualcosa di rotto.' + } +} + +Write-Step 'Pubblicazione' + +# Una cartella pulita a ogni giro. Senza questo, i resti di una pubblicazione +# precedente — una DLL rinominata, un runtime cambiato — finirebbero dentro +# l'installer, e sarebbero il tipo di problema che si manifesta solo sulla macchina +# di qualcun altro. +if (Test-Path -LiteralPath $PublishDir) { + Remove-Item -LiteralPath $PublishDir -Recurse -Force +} + +$publishArgs = @( + 'publish', $Project, + '--configuration', $Configuration, + '--runtime', 'win-x64', + '--output', $PublishDir, + '--nologo', + "-p:SelfContained=$(if ($FrameworkDependent) { 'false' } else { 'true' })", + '-p:DebugType=none', + '-p:GenerateDocumentationFile=false' +) + +if ($FrameworkDependent) { + Write-Note 'Senza runtime incorporato: sulla macchina di destinazione serve il .NET 10 Desktop Runtime.' +} else { + Write-Note 'Con runtime incorporato: nessun prerequisito sulla macchina di destinazione.' +} + +& dotnet @publishArgs +if ($LASTEXITCODE -ne 0) { throw 'Pubblicazione non riuscita.' } + +$exePath = Join-Path $PublishDir 'Encelado.exe' +if (-not (Test-Path -LiteralPath $exePath)) { + throw "La pubblicazione è andata a buon fine ma Encelado.exe non c'è in $PublishDir." +} + +$configPath = Join-Path $PublishDir 'encelado.json' +if (-not (Test-Path -LiteralPath $configPath)) { + throw "encelado.json manca dal publish: senza configurazione l'applicazione non parte." +} + +$publishSize = (Get-ChildItem -LiteralPath $PublishDir -Recurse -File | + Measure-Object -Property Length -Sum).Sum +Write-Note ('{0:N0} file, {1:N1} MB' -f ` + (Get-ChildItem -LiteralPath $PublishDir -Recurse -File).Count, ($publishSize / 1MB)) + +Write-Step 'Installer' + +$iscc = Find-Iscc +if ($null -eq $iscc) { + $iscc = Install-InnoSetup +} +Write-Note $iscc + +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + +& $iscc $IssScript "/DAppVersion=$version" "/DPublishDir=$PublishDir" '/Qp' +if ($LASTEXITCODE -ne 0) { throw 'Compilazione dell''installer non riuscita.' } + +$setup = Join-Path $OutputDir "Encelado-Setup-$version.exe" +if (-not (Test-Path -LiteralPath $setup)) { + throw "Inno Setup non ha segnalato errori ma $setup non esiste." +} + +$setupSize = (Get-Item -LiteralPath $setup).Length + +Write-Host '' +Write-Host 'Fatto.' -ForegroundColor Green +Write-Host " $setup" +Write-Host (' {0:N1} MB' -f ($setupSize / 1MB)) +Write-Host '' +Write-Host ' Si installa in %LOCALAPPDATA%\Programs\Encelado senza richiesta di privilegi.' -ForegroundColor DarkGray +Write-Host ' Un encelado.json già presente non viene sovrascritto.' -ForegroundColor DarkGray diff --git a/Encelado/config/encelado.json b/Encelado/config/encelado.json new file mode 100644 index 0000000..150eee6 --- /dev/null +++ b/Encelado/config/encelado.json @@ -0,0 +1,150 @@ +{ + "_comment": "Encelado — configurazione unica. Ogni numero qui sotto è stato verificato su due dataset indipendenti: 4.756 barre giornaliere Bitstamp (2012-2025, ripiegate da 6,8 milioni di barre da un minuto) e 3.260 barre Binance (2017-2026). Vedi il README.", + + "alpaca": { + "paper": true, + "dataFeed": "iex", + "requestsPerMinute": 180, + "httpTimeoutSeconds": 15, + "maxRetries": 4 + }, + + "engine": { + "assetClass": "crypto", + + "_timeFrame": "Giornaliero. Le stesse regole su barre orarie perdono il 99% del capitale: con ~50 bps di costo per giro completo la frequenza uccide prima della direzione.", + "timeFrame": "1Day", + + "_warmup": "La media è a 100 giorni. 220 barre danno margine.", + "warmupBars": 220, + + "tradeOnlyRegularHours": false, + "flattenBeforeCloseMinutes": 0, + + "_crypto": "Alpaca sulle crypto vuole quantità frazionarie e non supporta i bracket order: lo stop lo tiene l'engine e lo verifica a ogni quotazione.", + "allowFractionalShares": true, + "useBracketOrders": false, + + "entryOrderType": "limit", + "limitOffsetBps": 8, + + "dryRun": false, + + "_reconcile": "Ogni quanto il bot ricontrolla conto, posizioni e ordini contro il broker, e chiede le barre già chiuse. È anche il momento in cui si accorge che una barra nuova è disponibile da valutare, quindi abbassarlo lo rende più reattivo all'apertura di una barra.", + "reconcileSeconds": 30, + + "_status": "Riepilogo periodico nel log: contatori, latenze, stato delle connessioni.", + "statusSeconds": 60, + + "_explain": "Ogni quanto il bot rilegge cosa farebbe al prezzo attuale e lo scrive nel log, se è cambiato rispetto a prima. Su barre giornaliere il bot è legittimamente silenzioso per settimane, e da fuori il silenzio è indistinguibile da un blocco: questa riga lo trasforma in una frase.", + "explainSeconds": 5, + + "maxQuoteAgeSeconds": 120, + "closeOnShutdown": false + }, + + "risk": { + "_sizing": "Questa strategia compete con il comprare e tenere, quindi quando è dentro deve esserci per intero: qualunque frazione inferiore perde la gara in partenza. stakePct 1.0 impegna tutto il saldo disponibile; il risk engine si ferma comunque al 98% per lasciare spazio alle commissioni.", + "stakePct": 1.0, + "stakeAmount": 0, + + "_risk": "Non usato finché stakePct è impostato, ma deve restare valido: è il criterio di riserva se un giorno azzeri stakePct.", + "maxRiskPerTradePct": 0.05, + + "_caps": "A 1.0 perché con un solo asset e stake pieno la posizione È il portafoglio. Abbassarli qui significa restare parzialmente liquidi e perdere rendimento senza guadagnare protezione: la protezione la dà l'uscita sotto la media.", + "maxPositionNotionalPct": 1.0, + "maxGrossExposurePct": 1.0, + + "_openPositions": "0 = nessun limite. Nota però che con un solo simbolo il numero di posizioni contemporanee resta 1 comunque: il risk engine rifiuta un secondo ingresso sullo stesso strumento con 'already in position'. E con stakePct 1.0 la prima posizione impegna tutto il saldo, quindi una seconda non avrebbe con cosa aprirsi. Questo limite torna a contare quando aggiungi simboli.", + "maxOpenPositions": 0, + + "_frequency": "0 = nessun limite. Il bot può aprire quante posizioni vuole e fare quante operazioni vuole: a fermarlo è la strategia, non un contatore. Attenzione: erano una rete contro un bug (un ciclo che riapre la stessa posizione mille volte costa mille commissioni). Con 0 quella rete non c'è più.", + "maxTradesPerDay": 0, + "maxTradesPerSymbolPerDay": 0, + "minSecondsBetweenEntries": 0, + + "_dailyLoss": "Kill switch giornaliero. Al 25% perché su BTC un -20% in un giorno è successo più volte e non è una ragione per smettere: la strategia esce quando cede la media, non quando fa male. Troppo stretto qui significa liquidare sul minimo.", + "maxDailyLossPct": 0.25, + "maxDailyProfitPct": 0, + + "maxRelativeSpread": 0.0015, + "minPrice": 0.01, + "maxPrice": 10000000, + "minOrderNotional": 25, + "maxOrderNotional": 0, + + "_shorting": "Alpaca non consente lo short sulle crypto. La strategia è long/flat.", + "allowShorting": false, + + "_stop": "Rete di sicurezza per un gap, non il controllo del rischio. Quello vero è l'uscita sotto la media: uno stop stretto venderebbe e poi aspetterebbe un nuovo incrocio per rientrare, che è esattamente come il modello precedente trasformava le oscillazioni in perdite realizzate.", + "defaultStopPct": 0.35, + "maxStopDistancePct": 0.60 + }, + + "logging": { + "_level": "trace | debug | info | warn | error | none. 'debug' registra anche ogni segnale scartato e ogni rifiuto del risk engine: utile per capire perché il bot NON ha fatto qualcosa.", + "level": "debug", + + "_directory": "Dove salvare tutti gli output. Relativa all'eseguibile, oppure un percorso assoluto tipo D:\\encelado-logs. Si cambia anche da Impostazioni → Log, che verifica di potervi scrivere prima di salvare.", + "directory": "logs", + + "console": false, + "file": "encelado.log", + + "_rotation": "Ruota encelado.log in encelado.1.log e così via, tenendo gli ultimi 10.", + "maxFileSizeMb": 32, + "maxFiles": 10, + + "_analysis": "decisions.csv ha una riga per ogni barra valutata con tutti gli indicatori; executions.csv ha una riga per ogni segnale arrivato agli ordini, con il verdetto del risk engine. Si uniscono su decisionId. Sono il materiale per migliorare il modello.", + "tradeJournal": "trades.jsonl", + "decisionLog": "decisions.csv", + "executionLog": "executions.csv", + + "_verbose": "Con logMarketData attivo e level=trace registra ogni singola quotazione e ogni print. File enormi: serve solo per diagnosticare il flusso dati.", + "logMarketData": false, + + "_everyBar": "Scrive una riga per ogni barra da un minuto che arriva dallo stream, non solo per quelle che chiudono una barra della strategia. Su barre giornaliere 1439 minuti su 1440 vengono assorbiti in silenzio: senza questo il log non mostra nulla per ventiquattr'ore e il bot sembra fermo.", + "logEveryBar": true, + + "_inApp": "Quante righe tiene la striscia ATTIVITÀ nella pagina Stato e quante ne tiene la scheda Log. La seconda è il tetto di memoria del log in-app. Il file su disco resta completo comunque.", + "statusLines": 200, + "bufferedLines": 5000 + }, + + "ui": { + "url": "http://localhost:5088", + "autoStartBot": false, + "openBrowser": false + }, + + "_symbols": "Solo BTC/USD. ETH è stato tolto: la strategia è tarata e verificata su BTC, e con stakePct 1.0 un secondo asset dimezzerebbe l'esposizione al primo senza che nessun backtest lo giustifichi.", + "symbols": [ + { + "symbol": "BTC/USD", + "strategy": "trend-filter", + "enabled": true, + "parameters": { + "_period": "Media a 100 giorni. È l'unico valore che batte il comprare e tenere su ENTRAMBI i dataset: 120 rende di più su Bitstamp ma perde su Binance, 200 perde su tutti e due. La riga dei 100 giorni vince su entrambi a qualunque banda.", + "period": 100, + + "_band": "Isteresi, non un filtro: si entra il 2% sopra la media e si esce il 2% sotto, così un prezzo appoggiato alla media non genera un'operazione ogni due giorni. Dimezza gli scambi lasciando il rendimento dov'era.", + "band": 0.02, + + "_stop": "Rete per un gap. La vera uscita è la media.", + "stopPct": 0.35, + + "_cvd": "Gate di order flow, disattivato. Misurato su Binance con il volume taker: alzandolo il Calmar scende da 0,71 a 0,66 a 0,64. Serviva al modello precedente, che operava di rado e poteva permettersi di aspettare conferma; qui ogni barra passata ad aspettare è una barra che non compone. Il valore resta calcolato e registrato nei log.", + "cvdThreshold": 0, + "cvdPeriod": 10, + "cvdNormPeriod": 60, + + "_diagnostics": "Solo per il pannello e i log, non entrano in nessuna decisione.", + "volPeriod": 30, + "barsPerYear": 365, + "atrPeriod": 14, + + "allowShort": 0 + } + } + ] +} diff --git a/Encelado/config/encelado.local.json.example b/Encelado/config/encelado.local.json.example new file mode 100644 index 0000000..7838d4f --- /dev/null +++ b/Encelado/config/encelado.local.json.example @@ -0,0 +1,12 @@ +{ + "_comment": "Copy this file to encelado.local.json (gitignored) next to encelado.json. It is merged on top of the main config, so it only needs the keys you want to override. Environment variables still win over both.", + + "alpaca": { + "keyId": "PK...........", + "secretKey": "................................" + }, + + "engine": { + "dryRun": true + } +} diff --git a/Encelado/installer/Encelado.iss b/Encelado/installer/Encelado.iss new file mode 100644 index 0000000..d8f148f --- /dev/null +++ b/Encelado/installer/Encelado.iss @@ -0,0 +1,141 @@ +; Encelado — script di installazione (Inno Setup 6). +; +; Non compilarlo a mano: usa build\make-installer.ps1 (o il task "installer" di VS +; Code), che pubblica l'applicazione, ricava la versione da Directory.Build.props e +; passa qui sotto i due #define che mancano. +; +; ISCC.exe installer\Encelado.iss /DAppVersion=2.0.0 /DPublishDir=...\publish +; +; Scelta di fondo: l'installazione è PER UTENTE, in %LOCALAPPDATA%\Programs\Encelado. +; Non è una semplificazione per evitare l'UAC. Encelado scrive i log, il diario +; operazioni e i CSV di analisi accanto al proprio eseguibile (LoggingOptions. +; ResolveDirectory risolve i percorsi relativi su AppContext.BaseDirectory): dentro +; C:\Program Files quelle scritture fallirebbero silenziosamente e l'utente si +; ritroverebbe senza i log proprio quando gli servono per capire cosa ha fatto il bot. +; Per questo PrivilegesRequired resta "lowest" e non è consentito forzarlo. + +#ifndef AppVersion + #error AppVersion non definito: lancia build\make-installer.ps1 +#endif + +#ifndef PublishDir + #error PublishDir non definito: lancia build\make-installer.ps1 +#endif + +#define AppName "Encelado" +#define AppPublisher "Encelado" +#define AppExeName "Encelado.exe" +#define AppDescription "Bot di trading automatico su Alpaca" + +[Setup] +; Questo GUID identifica il prodotto per sempre: cambiarlo significa che un +; aggiornamento verrà installato di fianco al vecchio invece che sopra. +AppId={{7C4F1E62-2B8A-4D19-9C55-3E0A6B1D8F44} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +VersionInfoVersion={#AppVersion} +VersionInfoDescription={#AppDescription} +AppPublisher={#AppPublisher} +UninstallDisplayName={#AppName} {#AppVersion} +UninstallDisplayIcon={app}\{#AppExeName} + +DefaultDirName={autopf}\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +DisableDirPage=auto + +; Vedi la nota in testa al file: l'app deve poter scrivere nella propria cartella. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed= + +; Il publish è win-x64. +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +OutputDir={#SourcePath}\..\artifacts\installer +OutputBaseFilename={#AppName}-Setup-{#AppVersion} +SetupIconFile={#SourcePath}\..\src\Encelado.Bot\Assets\encelado.ico +WizardStyle=modern +Compression=lzma2/max +SolidCompression=yes + +; Se Encelado è in esecuzione, il Restart Manager lo chiude invece di lasciare +; l'installazione a metà con i file bloccati. +CloseApplications=yes +RestartApplications=no + +[Languages] +Name: "it"; MessagesFile: "compiler:Languages\Italian.isl" +Name: "en"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +; Tutto il publish tranne la configurazione, che ha una regola sua, e i simboli di +; debug, che non servono a chi installa. +Source: "{#PublishDir}\*"; DestDir: "{app}"; \ + Excludes: "encelado.json,*.pdb,*.xml,logs\*"; \ + Flags: ignoreversion recursesubdirs createallsubdirs + +; La configurazione è il prodotto — ogni numero dentro encelado.json è tarato su nove +; anni di backtest — ma è anche l'unico posto dove l'utente mette mano (verbosità dei +; log, rischio per operazione). "onlyifdoesntexist" fa sì che un aggiornamento non +; cancelli quelle modifiche; "uninsneveruninstall" che una disinstallazione non le +; butti via. Le chiavi nuove introdotte da una versione successiva non rompono nulla: +; il loader usa i valori di default per quelle che non trova. +Source: "{#PublishDir}\encelado.json"; DestDir: "{app}"; \ + Flags: onlyifdoesntexist uninsneveruninstall + +; Copia sempre aggiornata dei valori di fabbrica, per poter vedere cosa è cambiato +; rispetto al proprio encelado.json dopo un aggiornamento. +Source: "{#PublishDir}\encelado.json"; DestDir: "{app}"; \ + DestName: "encelado.default.json"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Comment: "{#AppDescription}" +Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; \ + Comment: "{#AppDescription}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#AppName}}"; \ + Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; Prodotti a runtime, quindi non tracciati dall'installer: senza questo resterebbero +; una cartella vuota e dei file orfani. +Type: filesandordirs; Name: "{app}\logs" + +[Code] +{ Le credenziali Alpaca vivono in %LOCALAPPDATA%\Encelado, fuori dalla cartella di + installazione, quindi una disinstallazione normale non le toccherebbe. Lasciarle lì + in silenzio però significa lasciare sul disco una chiave API cifrata di cui l'utente + si è dimenticato. Glielo chiediamo, con il "no" come risposta predefinita: chi + disinstalla per reinstallare una versione nuova non deve ritrovarsi a reinserire le + chiavi solo perché ha premuto Invio di fretta. } +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + DataDir: String; +begin + if CurUninstallStep <> usPostUninstall then + Exit; + + { In modalità silenziosa non c'è nessuno a cui chiedere, e la risposta che non si + può disfare è quella che cancella. Nel dubbio le credenziali restano dove sono. } + if UninstallSilent then + Exit; + + DataDir := ExpandConstant('{localappdata}\Encelado'); + if not DirExists(DataDir) then + Exit; + + if MsgBox( + 'Vuoi eliminare anche le credenziali Alpaca salvate?' + #13#10#13#10 + + DataDir + #13#10#13#10 + + 'Scegli No se hai intenzione di reinstallare Encelado: le credenziali ' + + 'verranno riconosciute dalla nuova installazione.', + mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then + DelTree(DataDir, True, True, True); +end; diff --git a/Encelado/src/Encelado.Alpaca/AlpacaOptions.cs b/Encelado/src/Encelado.Alpaca/AlpacaOptions.cs new file mode 100644 index 0000000..25c93f6 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/AlpacaOptions.cs @@ -0,0 +1,114 @@ +using Encelado.Core.Market; + +namespace Encelado.Alpaca; + +/// Connection settings for every Alpaca endpoint the bot talks to. +public sealed class AlpacaOptions +{ + public const string PaperTradingBase = "https://paper-api.alpaca.markets"; + public const string LiveTradingBase = "https://api.alpaca.markets"; + public const string MarketDataBase = "https://data.alpaca.markets"; + public const string MarketDataStreamBase = "wss://stream.data.alpaca.markets"; + + public string KeyId { get; set; } = string.Empty; + + public string SecretKey { get; set; } = string.Empty; + + /// Paper trading is the default. Flipping this to risks real money. + public bool Paper { get; set; } = true; + + /// + /// Equity data feed: iex (free), sip (full tape, paid), + /// delayed_sip, or test (Alpaca's synthetic FAKEPACA stream). + /// + public string DataFeed { get; set; } = "iex"; + + /// Overrides the trading REST base URL. Leave empty to derive it from . + public string TradingBaseUrlOverride { get; set; } = string.Empty; + + /// Overrides the market-data REST base URL. + public string DataBaseUrlOverride { get; set; } = string.Empty; + + /// Client-side throttle. Alpaca allows 200 requests/minute per account on the basic plan. + public int RequestsPerMinute { get; set; } = 180; + + public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15); + + /// Number of retries for transient failures (429 / 5xx / socket errors). + public int MaxRetries { get; set; } = 4; + + public string TradingBaseUrl => + string.IsNullOrWhiteSpace(TradingBaseUrlOverride) + ? (Paper ? PaperTradingBase : LiveTradingBase) + : TradingBaseUrlOverride.TrimEnd('/'); + + public string DataBaseUrl => + string.IsNullOrWhiteSpace(DataBaseUrlOverride) + ? MarketDataBase + : DataBaseUrlOverride.TrimEnd('/'); + + /// Order/position event stream. Lives on the trading host, not the data host. + public Uri TradeUpdatesStreamUri => + new(TradingBaseUrl.Replace("https://", "wss://", StringComparison.Ordinal) + "/stream"); + + public Uri MarketDataStreamUri(AssetClass assetClass) => assetClass switch + { + AssetClass.Crypto => new Uri($"{MarketDataStreamBase}/v1beta3/crypto/us"), + _ => new Uri($"{MarketDataStreamBase}/v2/{DataFeed}"), + }; + + public AlpacaOptions Validate() + { + if (string.IsNullOrWhiteSpace(KeyId) || string.IsNullOrWhiteSpace(SecretKey)) + { + throw new InvalidOperationException( + "Alpaca credentials are missing. Set APCA_API_KEY_ID and APCA_API_SECRET_KEY " + + "(or alpaca.keyId / alpaca.secretKey in the config file)."); + } + + // Credentials travel as HTTP headers. A stray non-ASCII character (a smart quote + // from a copy/paste, a BOM, a UTF-16 artefact from a pipe) would otherwise + // surface much later as an opaque "invalid char encoding" transport failure. + RequirePrintableAscii(KeyId, nameof(KeyId)); + RequirePrintableAscii(SecretKey, nameof(SecretKey)); + + if (DataFeed is not ("iex" or "sip" or "delayed_sip" or "otc" or "test")) + { + throw new InvalidOperationException( + $"alpaca.dataFeed '{DataFeed}' is not one of: iex, sip, delayed_sip, otc, test."); + } + + if (RequestsPerMinute is < 1 or > 1000) + { + throw new InvalidOperationException("alpaca.requestsPerMinute must be between 1 and 1000."); + } + + return this; + } + + private static void RequirePrintableAscii(string value, string field) + { + foreach (char c in value) + { + if (c is < ' ' or > '~') + { + throw new InvalidOperationException( + $"alpaca.{char.ToLowerInvariant(field[0])}{field[1..]} contains a character that is not " + + $"printable ASCII (U+{(int)c:X4}). Re-copy the key from the Alpaca dashboard — " + + "invisible characters are usually picked up by copy/paste."); + } + } + } +} + +/// Raised when Alpaca answers with a non-success status or an unusable payload. +public sealed class AlpacaApiException(string message, int statusCode = 0, string? body = null) + : Exception(message) +{ + public int StatusCode { get; } = statusCode; + + public string? Body { get; } = body; + + /// Transient conditions worth retrying. + public bool IsTransient => StatusCode is 429 or >= 500; +} diff --git a/Encelado/src/Encelado.Alpaca/Encelado.Alpaca.csproj b/Encelado/src/Encelado.Alpaca/Encelado.Alpaca.csproj new file mode 100644 index 0000000..3823770 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Encelado.Alpaca.csproj @@ -0,0 +1,12 @@ + + + + Encelado.Alpaca + Encelado.Alpaca + + + + + + + diff --git a/Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs b/Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs new file mode 100644 index 0000000..e927ab2 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text.Json; + +namespace Encelado.Alpaca.Internal; + +/// +/// Reading helpers for Alpaca's REST payloads. Alpaca encodes most numeric fields as +/// JSON strings ("qty": "10"), and omits or nulls fields liberally, so +/// every accessor tolerates both shapes and a missing property. +/// +public static class JsonRead +{ + public static string? StringOrNull(this JsonElement e, string name) => + e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; + + public static string StringOrEmpty(this JsonElement e, string name) => + e.StringOrNull(name) ?? string.Empty; + + public static double Double(this JsonElement e, string name, double fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.GetDouble(), + JsonValueKind.String => double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) + ? d + : fallback, + _ => fallback, + }; + } + + public static decimal Decimal(this JsonElement e, string name, decimal fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.GetDecimal(), + JsonValueKind.String => decimal.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d) + ? d + : fallback, + _ => fallback, + }; + } + + public static int Int32(this JsonElement e, string name, int fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.TryGetInt32(out int i) ? i : (int)v.GetDouble(), + JsonValueKind.String => int.TryParse(v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int i) + ? i + : fallback, + _ => fallback, + }; + } + + public static bool Bool(this JsonElement e, string name, bool fallback = false) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(v.GetString(), out bool b) ? b : fallback, + _ => fallback, + }; + } + + public static DateTime Timestamp(this JsonElement e, string name) => + e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String + ? Rfc3339.ParseUtc(v.GetString()) + : DateTime.MinValue; + + public static DateTime? TimestampOrNull(this JsonElement e, string name) + { + DateTime dt = e.Timestamp(name); + return dt == DateTime.MinValue ? null : dt; + } +} diff --git a/Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs b/Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs new file mode 100644 index 0000000..0682b77 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs @@ -0,0 +1,121 @@ +using System.Globalization; + +namespace Encelado.Alpaca.Internal; + +/// +/// Hand-rolled RFC3339 parser for the shape Alpaca actually emits +/// (2024-05-17T13:04:56.334262119Z). It runs on every tick of the market-data +/// stream, so it avoids the general-purpose date parser and its culture lookups. +/// Falls back to +/// for anything unusual (offsets, missing fractions, non-UTC). +/// +public static class Rfc3339 +{ + /// Parses a UTC timestamp from UTF-8 bytes. Returns on failure. + public static DateTime ParseUtc(ReadOnlySpan utf8) + { + // Fast path: exactly "YYYY-MM-DDTHH:MM:SS" plus optional ".fraction" and a "Z". + if (utf8.Length >= 20 && utf8[^1] == (byte)'Z' && + utf8[4] == (byte)'-' && utf8[7] == (byte)'-' && + (utf8[10] == (byte)'T' || utf8[10] == (byte)' ') && + utf8[13] == (byte)':' && utf8[16] == (byte)':') + { + if (TryDigits(utf8, 0, 4, out int year) && + TryDigits(utf8, 5, 2, out int month) && + TryDigits(utf8, 8, 2, out int day) && + TryDigits(utf8, 11, 2, out int hour) && + TryDigits(utf8, 14, 2, out int minute) && + TryDigits(utf8, 17, 2, out int second)) + { + long fractionTicks = 0; + if (utf8.Length > 20 && utf8[19] == (byte)'.') + { + // Consume up to 7 fractional digits (100 ns resolution); ignore the rest. + int i = 20; + int digits = 0; + long value = 0; + while (i < utf8.Length - 1 && utf8[i] >= (byte)'0' && utf8[i] <= (byte)'9') + { + if (digits < 7) + { + value = (value * 10) + (utf8[i] - (byte)'0'); + digits++; + } + + i++; + } + + while (digits < 7) + { + value *= 10; + digits++; + } + + fractionTicks = value; + } + + try + { + return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc) + .AddTicks(fractionTicks); + } + catch (ArgumentOutOfRangeException) + { + return DateTime.MinValue; + } + } + } + + return SlowParse(utf8); + } + + public static DateTime ParseUtc(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return DateTime.MinValue; + } + + return DateTime.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out DateTime dt) + ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) + : DateTime.MinValue; + } + + private static DateTime SlowParse(ReadOnlySpan utf8) + { + Span chars = utf8.Length <= 64 ? stackalloc char[utf8.Length] : new char[utf8.Length]; + for (int i = 0; i < utf8.Length; i++) + { + chars[i] = (char)utf8[i]; + } + + return DateTime.TryParse( + chars, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out DateTime dt) + ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) + : DateTime.MinValue; + } + + private static bool TryDigits(ReadOnlySpan utf8, int offset, int count, out int value) + { + value = 0; + for (int i = offset; i < offset + count; i++) + { + byte b = utf8[i]; + if (b < (byte)'0' || b > (byte)'9') + { + return false; + } + + value = (value * 10) + (b - (byte)'0'); + } + + return true; + } +} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs new file mode 100644 index 0000000..2314702 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs @@ -0,0 +1,197 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Encelado.Alpaca.Internal; +using Encelado.Core.Market; + +namespace Encelado.Alpaca.Rest; + +/// +/// Historical and latest-snapshot market data. Used to warm indicators up before the +/// live stream takes over, and by the replay/backtest mode. +/// +public sealed class AlpacaDataClient(AlpacaOptions options) : IDisposable +{ + private readonly AlpacaHttp _http = new(options.Validate(), options.DataBaseUrl); + private readonly string _feed = options.DataFeed; + + /// Alpaca caps a single bars page at 10 000 rows. + private const int PageLimit = 10_000; + + public Task WarmupAsync(CancellationToken ct) => + _http.WarmupAsync("v2/stocks/bars?symbols=SPY&timeframe=1Day&limit=1", ct); + + /// + /// Fetches historical bars for one or more symbols in chronological order, + /// following pagination across the whole requested range. + /// + /// keeps the most recent N bars, which is + /// what indicator warm-up needs — trimming while paging would keep the oldest ones + /// and leave the strategies primed with stale state. + /// + /// + public async Task>> GetBarsAsync( + IReadOnlyList symbols, + TimeFrame timeFrame, + DateTime startUtc, + DateTime? endUtc, + AssetClass assetClass, + int maxBarsPerSymbol, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(symbols); + Dictionary> result = new(StringComparer.OrdinalIgnoreCase); + if (symbols.Count == 0 || maxBarsPerSymbol <= 0) + { + return result; + } + + string basePath = assetClass == AssetClass.Crypto + ? "v1beta3/crypto/us/bars" + : "v2/stocks/bars"; + + string? pageToken = null; + int guard = 0; + + do + { + StringBuilder path = new(256); + path.Append(basePath) + .Append("?symbols=").Append(Uri.EscapeDataString(string.Join(',', symbols))) + .Append("&timeframe=").Append(timeFrame.ToAlpaca()) + .Append("&limit=").Append(PageLimit) + .Append("&sort=asc") + .Append("&start=").Append(Uri.EscapeDataString(FormatInstant(startUtc))); + + if (endUtc is { } end) + { + path.Append("&end=").Append(Uri.EscapeDataString(FormatInstant(end))); + } + + if (assetClass != AssetClass.Crypto) + { + path.Append("&adjustment=raw&feed=").Append(_feed == "test" ? "iex" : _feed); + } + + if (pageToken is not null) + { + path.Append("&page_token=").Append(Uri.EscapeDataString(pageToken)); + } + + using JsonDocument doc = await _http.GetAsync(path.ToString(), ct).ConfigureAwait(false); + JsonElement root = doc.RootElement; + + if (root.TryGetProperty("bars", out JsonElement barsBySymbol) && + barsBySymbol.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty symbolBars in barsBySymbol.EnumerateObject()) + { + if (!result.TryGetValue(symbolBars.Name, out List? list)) + { + list = new List(Math.Min(maxBarsPerSymbol, 1024)); + result[symbolBars.Name] = list; + } + + foreach (JsonElement b in symbolBars.Value.EnumerateArray()) + { + list.Add(ParseBar(b)); + } + } + } + + pageToken = root.TryGetProperty("next_page_token", out JsonElement token) && + token.ValueKind == JsonValueKind.String + ? token.GetString() + : null; + } + while (pageToken is not null && ++guard < 500); + + // Keep only the newest slice, preserving chronological order. + foreach (string key in result.Keys) + { + List bars = result[key]; + if (bars.Count > maxBarsPerSymbol) + { + result[key] = bars.GetRange(bars.Count - maxBarsPerSymbol, maxBarsPerSymbol); + } + } + + return result; + } + + public async Task> GetLatestQuotesAsync( + IReadOnlyList symbols, + AssetClass assetClass, + CancellationToken ct) + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + if (symbols.Count == 0) + { + return result; + } + + string path = assetClass == AssetClass.Crypto + ? $"v1beta3/crypto/us/latest/quotes?symbols={Uri.EscapeDataString(string.Join(',', symbols))}" + : $"v2/stocks/quotes/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}"; + + using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); + if (doc.RootElement.TryGetProperty("quotes", out JsonElement quotes) && + quotes.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty p in quotes.EnumerateObject()) + { + result[p.Name] = new Quote( + p.Value.Timestamp("t"), + p.Value.Double("bp"), + p.Value.Double("bs"), + p.Value.Double("ap"), + p.Value.Double("as")); + } + } + + return result; + } + + public async Task> GetLatestTradesAsync( + IReadOnlyList symbols, + AssetClass assetClass, + CancellationToken ct) + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + if (symbols.Count == 0) + { + return result; + } + + string path = assetClass == AssetClass.Crypto + ? $"v1beta3/crypto/us/latest/trades?symbols={Uri.EscapeDataString(string.Join(',', symbols))}" + : $"v2/stocks/trades/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}"; + + using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); + if (doc.RootElement.TryGetProperty("trades", out JsonElement trades) && + trades.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty p in trades.EnumerateObject()) + { + result[p.Name] = new Tick(p.Value.Timestamp("t"), p.Value.Double("p"), p.Value.Double("s")); + } + } + + return result; + } + + internal static Bar ParseBar(JsonElement e) => new( + e.Timestamp("t"), + e.Double("o"), + e.Double("h"), + e.Double("l"), + e.Double("c"), + e.Double("v"), + e.Double("vw"), + e.Int32("n")); + + private static string FormatInstant(DateTime utc) => + DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); + + public void Dispose() => _http.Dispose(); +} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs new file mode 100644 index 0000000..756c088 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs @@ -0,0 +1,225 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Encelado.Alpaca.Rest; + +/// +/// Shared HTTP transport for the Alpaca REST APIs: one pooled, pre-warmed HTTP/2 +/// connection per host, a client-side rate limiter that keeps us under Alpaca's +/// 200 req/min, and bounded retries for transient failures. +/// +public sealed class AlpacaHttp : IDisposable +{ + private readonly HttpClient _http; + private readonly MinuteRateLimiter _limiter; + private readonly int _maxRetries; + + public AlpacaHttp(AlpacaOptions options, string baseUrl) + { + ArgumentNullException.ThrowIfNull(options); + + SocketsHttpHandler handler = new() + { + // Long-lived pooled connections: TLS handshakes are the single biggest + // source of order latency, so we never want to pay one on the hot path. + PooledConnectionLifetime = TimeSpan.FromMinutes(10), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), + MaxConnectionsPerServer = 16, + EnableMultipleHttp2Connections = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + ConnectTimeout = TimeSpan.FromSeconds(10), + KeepAlivePingDelay = TimeSpan.FromSeconds(30), + KeepAlivePingTimeout = TimeSpan.FromSeconds(10), + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests, + }; + + _http = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), + Timeout = options.HttpTimeout, + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower, + }; + + _http.DefaultRequestHeaders.Add("APCA-API-KEY-ID", options.KeyId); + _http.DefaultRequestHeaders.Add("APCA-API-SECRET-KEY", options.SecretKey); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _http.DefaultRequestHeaders.UserAgent.ParseAdd("Encelado/2.0"); + + _limiter = new MinuteRateLimiter(options.RequestsPerMinute); + _maxRetries = Math.Max(0, options.MaxRetries); + } + + /// + /// Opens the TLS connection ahead of the first real request so the first order + /// does not pay for the handshake. + /// + public async Task WarmupAsync(string probePath, CancellationToken ct) + { + try + { + using JsonDocument _ = await GetAsync(probePath, ct).ConfigureAwait(false); + } + catch (AlpacaApiException) + { + // A 4xx still means the socket is up, which is all warm-up needs. + } + } + + public Task GetAsync(string path, CancellationToken ct) => + SendAsync(HttpMethod.Get, path, null, ct); + + public Task PostAsync(string path, ReadOnlyMemory json, CancellationToken ct) => + SendAsync(HttpMethod.Post, path, json, ct); + + public Task PatchAsync(string path, ReadOnlyMemory json, CancellationToken ct) => + SendAsync(HttpMethod.Patch, path, json, ct); + + public Task DeleteAsync(string path, CancellationToken ct) => + SendAsync(HttpMethod.Delete, path, null, ct); + + /// Like but maps HTTP 404 to . + public async Task GetOrNullAsync(string path, CancellationToken ct) + { + try + { + return await GetAsync(path, ct).ConfigureAwait(false); + } + catch (AlpacaApiException ex) when (ex.StatusCode == 404) + { + return null; + } + } + + private async Task SendAsync( + HttpMethod method, + string path, + ReadOnlyMemory? body, + CancellationToken ct) + { + AlpacaApiException? last = null; + + for (int attempt = 0; attempt <= _maxRetries; attempt++) + { + await _limiter.WaitAsync(ct).ConfigureAwait(false); + + using HttpRequestMessage request = new(method, path); + if (body is { } payload) + { + request.Content = new ReadOnlyMemoryContent(payload); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + + HttpResponseMessage? response = null; + try + { + response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0) + { + return JsonDocument.Parse("{}"u8.ToArray()); + } + + return await JsonDocument.ParseAsync(stream, default, ct).ConfigureAwait(false); + } + + string errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + last = new AlpacaApiException( + $"{method} {path} -> {(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(errorBody)}", + (int)response.StatusCode, + errorBody); + + if (!last.IsTransient || attempt == _maxRetries) + { + throw last; + } + + await BackoffAsync(attempt, response.Headers.RetryAfter, ct).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (attempt < _maxRetries) + { + last = new AlpacaApiException($"{method} {path} -> transport failure: {ex.Message}"); + await BackoffAsync(attempt, null, ct).ConfigureAwait(false); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < _maxRetries) + { + last = new AlpacaApiException($"{method} {path} -> timed out after {_http.Timeout.TotalSeconds:F0}s"); + await BackoffAsync(attempt, null, ct).ConfigureAwait(false); + } + finally + { + response?.Dispose(); + } + } + + throw last ?? new AlpacaApiException($"{method} {path} failed without a response."); + } + + private static async Task BackoffAsync(int attempt, RetryConditionHeaderValue? retryAfter, CancellationToken ct) + { + TimeSpan delay; + if (retryAfter?.Delta is { } delta && delta > TimeSpan.Zero) + { + delay = delta; + } + else + { + double baseMs = 200 * Math.Pow(2, attempt); + delay = TimeSpan.FromMilliseconds(baseMs + Random.Shared.Next(0, 150)); + } + + await Task.Delay(delay > TimeSpan.FromSeconds(30) ? TimeSpan.FromSeconds(30) : delay, ct) + .ConfigureAwait(false); + } + + private static string Truncate(string s) => s.Length <= 400 ? s : s[..400] + "…"; + + public void Dispose() => _http.Dispose(); +} + +/// +/// Sliding-window limiter: remembers when each of the last N requests went out and +/// blocks until the oldest falls out of the 60 second window. +/// +internal sealed class MinuteRateLimiter(int permitsPerMinute) +{ + private static readonly long WindowTicks = Stopwatch.Frequency * 60; + + private readonly long[] _sentAt = new long[Math.Max(1, permitsPerMinute)]; + private readonly SemaphoreSlim _gate = new(1, 1); + private int _index; + + public async ValueTask WaitAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + long now = Stopwatch.GetTimestamp(); + long oldest = _sentAt[_index]; + + if (oldest != 0) + { + long elapsed = now - oldest; + if (elapsed < WindowTicks) + { + double waitSeconds = (WindowTicks - elapsed) / (double)Stopwatch.Frequency; + await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct).ConfigureAwait(false); + now = Stopwatch.GetTimestamp(); + } + } + + _sentAt[_index] = now; + _index = _index + 1 == _sentAt.Length ? 0 : _index + 1; + } + finally + { + _gate.Release(); + } + } +} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs new file mode 100644 index 0000000..c165c8b --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs @@ -0,0 +1,350 @@ +using System.Text.Json; +using Encelado.Alpaca.Internal; +using Encelado.Core.Market; + +namespace Encelado.Alpaca.Rest; + +public enum OrderStatus : byte +{ + Unknown = 0, + New, + PendingNew, + Accepted, + AcceptedForBidding, + PartiallyFilled, + Filled, + DoneForDay, + Canceled, + PendingCancel, + Expired, + Replaced, + PendingReplace, + Rejected, + Suspended, + Stopped, + Calculated, + Held, +} + +public static class OrderStatusParser +{ + public static OrderStatus Parse(string? s) => s switch + { + "new" => OrderStatus.New, + "pending_new" => OrderStatus.PendingNew, + "accepted" => OrderStatus.Accepted, + "accepted_for_bidding" => OrderStatus.AcceptedForBidding, + "partially_filled" => OrderStatus.PartiallyFilled, + "filled" => OrderStatus.Filled, + "done_for_day" => OrderStatus.DoneForDay, + "canceled" => OrderStatus.Canceled, + "pending_cancel" => OrderStatus.PendingCancel, + "expired" => OrderStatus.Expired, + "replaced" => OrderStatus.Replaced, + "pending_replace" => OrderStatus.PendingReplace, + "rejected" => OrderStatus.Rejected, + "suspended" => OrderStatus.Suspended, + "stopped" => OrderStatus.Stopped, + "calculated" => OrderStatus.Calculated, + "held" => OrderStatus.Held, + _ => OrderStatus.Unknown, + }; + + /// True once the order can no longer change state. + public static bool IsTerminal(this OrderStatus s) => + s is OrderStatus.Filled or OrderStatus.Canceled or OrderStatus.Expired + or OrderStatus.Rejected or OrderStatus.Replaced or OrderStatus.DoneForDay; + + public static bool IsWorking(this OrderStatus s) => + s is OrderStatus.New or OrderStatus.PendingNew or OrderStatus.Accepted + or OrderStatus.AcceptedForBidding or OrderStatus.PartiallyFilled + or OrderStatus.PendingCancel or OrderStatus.PendingReplace or OrderStatus.Held; +} + +public sealed record AlpacaAccount( + string Id, + string AccountNumber, + string Status, + string Currency, + decimal Cash, + decimal Equity, + decimal LastEquity, + decimal BuyingPower, + decimal DaytradingBuyingPower, + decimal PortfolioValue, + decimal Multiplier, + int DaytradeCount, + bool PatternDayTrader, + bool TradingBlocked, + bool AccountBlocked, + bool TransfersBlocked, + bool TradeSuspendedByUser, + bool ShortingEnabled) +{ + /// True when the broker will refuse new orders for any reason. + public bool CanTrade => !TradingBlocked && !AccountBlocked && !TradeSuspendedByUser && + string.Equals(Status, "ACTIVE", StringComparison.OrdinalIgnoreCase); + + public static AlpacaAccount FromJson(JsonElement e) => new( + e.StringOrEmpty("id"), + e.StringOrEmpty("account_number"), + e.StringOrEmpty("status"), + e.StringOrEmpty("currency"), + e.Decimal("cash"), + e.Decimal("equity"), + e.Decimal("last_equity"), + e.Decimal("buying_power"), + e.Decimal("daytrading_buying_power"), + e.Decimal("portfolio_value"), + e.Decimal("multiplier", 1), + e.Int32("daytrade_count"), + e.Bool("pattern_day_trader"), + e.Bool("trading_blocked"), + e.Bool("account_blocked"), + e.Bool("transfers_blocked"), + e.Bool("trade_suspended_by_user"), + e.Bool("shorting_enabled")); +} + +public sealed record AlpacaPosition( + string Symbol, + string AssetClass, + double Quantity, + double AverageEntryPrice, + double CurrentPrice, + double MarketValue, + double UnrealizedPnl, + double UnrealizedPnlPct) +{ + public static AlpacaPosition FromJson(JsonElement e) + { + double qty = e.Double("qty"); + + // Alpaca reports short positions with a negative qty already, but be explicit. + if (string.Equals(e.StringOrNull("side"), "short", StringComparison.OrdinalIgnoreCase) && qty > 0) + { + qty = -qty; + } + + return new AlpacaPosition( + e.StringOrEmpty("symbol"), + e.StringOrEmpty("asset_class"), + qty, + e.Double("avg_entry_price"), + e.Double("current_price"), + e.Double("market_value"), + e.Double("unrealized_pl"), + e.Double("unrealized_plpc")); + } +} + +public sealed record AlpacaOrder( + string Id, + string ClientOrderId, + string Symbol, + Side Side, + string Type, + string OrderClass, + OrderStatus Status, + double Quantity, + double FilledQuantity, + double FilledAveragePrice, + double LimitPrice, + double StopPrice, + DateTime SubmittedAtUtc, + DateTime? FilledAtUtc, + IReadOnlyList Legs) +{ + private static readonly AlpacaOrder[] NoLegs = []; + + public bool IsWorking => Status.IsWorking(); + + public static AlpacaOrder FromJson(JsonElement e) + { + AlpacaOrder[] legs = NoLegs; + if (e.TryGetProperty("legs", out JsonElement legsElement) && legsElement.ValueKind == JsonValueKind.Array) + { + int n = legsElement.GetArrayLength(); + if (n > 0) + { + legs = new AlpacaOrder[n]; + int i = 0; + foreach (JsonElement leg in legsElement.EnumerateArray()) + { + legs[i++] = FromJson(leg); + } + } + } + + return new AlpacaOrder( + e.StringOrEmpty("id"), + e.StringOrEmpty("client_order_id"), + e.StringOrEmpty("symbol"), + string.Equals(e.StringOrNull("side"), "sell", StringComparison.OrdinalIgnoreCase) ? Side.Sell : Side.Buy, + e.StringOrEmpty("type"), + e.StringOrEmpty("order_class"), + OrderStatusParser.Parse(e.StringOrNull("status")), + e.Double("qty"), + e.Double("filled_qty"), + e.Double("filled_avg_price"), + e.Double("limit_price", double.NaN), + e.Double("stop_price", double.NaN), + e.Timestamp("submitted_at"), + e.TimestampOrNull("filled_at"), + legs); + } +} + +public sealed record AlpacaClock( + DateTime TimestampUtc, + bool IsOpen, + DateTime NextOpenUtc, + DateTime NextCloseUtc) +{ + public static AlpacaClock FromJson(JsonElement e) => new( + e.Timestamp("timestamp"), + e.Bool("is_open"), + e.Timestamp("next_open"), + e.Timestamp("next_close")); +} + +public sealed record AlpacaAsset( + string Symbol, + string Name, + string Exchange, + string Class, + string Status, + bool Tradable, + bool Marginable, + bool Shortable, + bool EasyToBorrow, + bool Fractionable, + double MinOrderSize, + double MinTradeIncrement, + double PriceIncrement) +{ + public bool IsActive => Tradable && string.Equals(Status, "active", StringComparison.OrdinalIgnoreCase); + + public static AlpacaAsset FromJson(JsonElement e) => new( + e.StringOrEmpty("symbol"), + e.StringOrEmpty("name"), + e.StringOrEmpty("exchange"), + e.StringOrEmpty("class"), + e.StringOrEmpty("status"), + e.Bool("tradable"), + e.Bool("marginable"), + e.Bool("shortable"), + e.Bool("easy_to_borrow"), + e.Bool("fractionable"), + e.Double("min_order_size"), + e.Double("min_trade_increment"), + e.Double("price_increment")); +} + +/// +/// Account equity over time, straight from Alpaca. is the +/// equity at the start of the requested window, so lifetime P&L is +/// Equity[^1] - BaseValue — with the usual caveat that deposits and withdrawals +/// move equity without being profit. +/// +public sealed record AlpacaPortfolioHistory( + IReadOnlyList TimestampsUnix, + IReadOnlyList Equity, + IReadOnlyList ProfitLoss, + double BaseValue, + string Timeframe) +{ + public static readonly AlpacaPortfolioHistory Empty = + new([], [], [], 0, string.Empty); + + public bool HasData => Equity.Count > 0; + + public double LastEquity => Equity.Count > 0 ? Equity[^1] : 0; + + /// Change over the whole window in absolute terms. + public double TotalProfitLoss => HasData && BaseValue > 0 ? LastEquity - BaseValue : 0; + + public double TotalProfitLossPct => BaseValue > 0 ? TotalProfitLoss / BaseValue : 0; + + public static AlpacaPortfolioHistory FromJson(JsonElement e) + { + return new AlpacaPortfolioHistory( + ReadLongs(e, "timestamp"), + ReadDoubles(e, "equity"), + ReadDoubles(e, "profit_loss"), + e.Double("base_value"), + e.StringOrEmpty("timeframe")); + + static double[] ReadDoubles(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array) + { + return []; + } + + double[] values = new double[array.GetArrayLength()]; + int i = 0; + foreach (JsonElement item in array.EnumerateArray()) + { + values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetDouble() : 0; + } + + return values; + } + + static long[] ReadLongs(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array) + { + return []; + } + + long[] values = new long[array.GetArrayLength()]; + int i = 0; + foreach (JsonElement item in array.EnumerateArray()) + { + values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetInt64() : 0; + } + + return values; + } + } +} + +/// An order about to be submitted. Built by the execution router, never by strategies. +public sealed record NewOrder +{ + public required string Symbol { get; init; } + + public required Side Side { get; init; } + + public required double Quantity { get; init; } + + public OrderType Type { get; init; } = OrderType.Market; + + public TimeInForce TimeInForce { get; init; } = TimeInForce.Day; + + public double LimitPrice { get; init; } = double.NaN; + + public double StopPrice { get; init; } = double.NaN; + + /// Idempotency key. Alpaca rejects duplicates, which is exactly what we want on a retry. + public string? ClientOrderId { get; init; } + + public bool ExtendedHours { get; init; } + + /// Attached protective stop. Turns the order into a bracket/OTO order. + public double TakeProfitLimitPrice { get; init; } = double.NaN; + + public double StopLossStopPrice { get; init; } = double.NaN; + + public double StopLossLimitPrice { get; init; } = double.NaN; + + public bool HasBracket => !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice); + + /// Alpaca's order_class implied by the attached legs. + public string OrderClass => + !double.IsNaN(TakeProfitLimitPrice) && !double.IsNaN(StopLossStopPrice) ? "bracket" + : !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice) ? "oto" + : "simple"; +} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs new file mode 100644 index 0000000..341f54c --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs @@ -0,0 +1,307 @@ +using System.Buffers; +using System.Globalization; +using System.Text.Json; +using Encelado.Core.Market; + +namespace Encelado.Alpaca.Rest; + +/// +/// Typed wrapper over Alpaca's trading REST API (/v2/account, /v2/orders, +/// /v2/positions, …). Request bodies are written straight to UTF-8 with +/// — no serializer, no reflection, no per-order allocation +/// beyond a pooled buffer. +/// +public sealed class AlpacaTradingClient(AlpacaOptions options) : IDisposable +{ + private readonly AlpacaHttp _http = new(options.Validate(), options.TradingBaseUrl); + + public string BaseUrl { get; } = options.TradingBaseUrl; + + public bool IsPaper { get; } = options.Paper; + + /// Opens the TLS/HTTP2 connection before the session starts. + public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync("v2/clock", ct); + + public async Task GetAccountAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.GetAsync("v2/account", ct).ConfigureAwait(false); + return AlpacaAccount.FromJson(doc.RootElement); + } + + public async Task GetClockAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.GetAsync("v2/clock", ct).ConfigureAwait(false); + return AlpacaClock.FromJson(doc.RootElement); + } + + /// + /// Equity curve for the account. uses Alpaca's notation + /// (1D, 1M, 1A, all) and the + /// bucket size (1Min, 15Min, 1H, 1D). + /// + public async Task GetPortfolioHistoryAsync( + string period, + string timeframe, + CancellationToken ct) + { + string path = $"v2/account/portfolio/history?period={Uri.EscapeDataString(period)}" + + $"&timeframe={Uri.EscapeDataString(timeframe)}&intraday_reporting=continuous"; + + try + { + using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); + return AlpacaPortfolioHistory.FromJson(doc.RootElement); + } + catch (AlpacaApiException) + { + // History is a nice-to-have for the dashboard, never a reason to stop trading. + return AlpacaPortfolioHistory.Empty; + } + } + + public async Task GetAssetAsync(string symbol, CancellationToken ct) + { + using JsonDocument? doc = await _http.GetOrNullAsync($"v2/assets/{Uri.EscapeDataString(symbol)}", ct) + .ConfigureAwait(false); + return doc is null ? null : AlpacaAsset.FromJson(doc.RootElement); + } + + public async Task> ListPositionsAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.GetAsync("v2/positions", ct).ConfigureAwait(false); + List positions = []; + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement e in doc.RootElement.EnumerateArray()) + { + positions.Add(AlpacaPosition.FromJson(e)); + } + } + + return positions; + } + + public async Task GetPositionAsync(string symbol, CancellationToken ct) + { + using JsonDocument? doc = await _http.GetOrNullAsync($"v2/positions/{Uri.EscapeDataString(symbol)}", ct) + .ConfigureAwait(false); + return doc is null ? null : AlpacaPosition.FromJson(doc.RootElement); + } + + /// Liquidates a position at market. Alpaca cancels the open legs for us. + public async Task ClosePositionAsync(string symbol, double? quantity, CancellationToken ct) + { + string path = $"v2/positions/{Uri.EscapeDataString(symbol)}"; + if (quantity is > 0) + { + path += $"?qty={FormatQuantity(quantity.Value)}"; + } + + try + { + using JsonDocument doc = await _http.DeleteAsync(path, ct).ConfigureAwait(false); + return doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("id", out _) + ? AlpacaOrder.FromJson(doc.RootElement) + : null; + } + catch (AlpacaApiException ex) when (ex.StatusCode == 404) + { + // Already flat: treat as success so the caller's exit path is idempotent. + return null; + } + } + + public async Task CloseAllPositionsAsync(bool cancelOrders, CancellationToken ct) + { + using JsonDocument _ = await _http + .DeleteAsync($"v2/positions?cancel_orders={(cancelOrders ? "true" : "false")}", ct) + .ConfigureAwait(false); + } + + public async Task> ListOrdersAsync( + string status, + int limit, + string? symbols, + CancellationToken ct) + { + string path = $"v2/orders?status={status}&limit={limit}&nested=true"; + if (!string.IsNullOrWhiteSpace(symbols)) + { + path += $"&symbols={Uri.EscapeDataString(symbols)}"; + } + + using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); + List orders = []; + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement e in doc.RootElement.EnumerateArray()) + { + orders.Add(AlpacaOrder.FromJson(e)); + } + } + + return orders; + } + + public Task> ListOpenOrdersAsync(CancellationToken ct) => + ListOrdersAsync("open", 500, null, ct); + + public async Task SubmitOrderAsync(NewOrder order, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(order); + + byte[] body = WriteOrderJson(order); + using JsonDocument doc = await _http.PostAsync("v2/orders", body, ct).ConfigureAwait(false); + return AlpacaOrder.FromJson(doc.RootElement); + } + + /// Moves an open order's stop/limit — used to trail protective stops. + public async Task ReplaceOrderAsync( + string orderId, + double? quantity, + double? limitPrice, + double? stopPrice, + string? clientOrderId, + CancellationToken ct) + { + ArrayBufferWriter buffer = new(192); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + if (quantity is > 0) + { + w.WriteString("qty", FormatQuantity(quantity.Value)); + } + + if (limitPrice is > 0) + { + w.WriteString("limit_price", FormatPrice(limitPrice.Value)); + } + + if (stopPrice is > 0) + { + w.WriteString("stop_price", FormatPrice(stopPrice.Value)); + } + + if (!string.IsNullOrEmpty(clientOrderId)) + { + w.WriteString("client_order_id", clientOrderId); + } + + w.WriteEndObject(); + } + + using JsonDocument doc = await _http + .PatchAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", buffer.WrittenMemory, ct) + .ConfigureAwait(false); + return AlpacaOrder.FromJson(doc.RootElement); + } + + public async Task CancelOrderAsync(string orderId, CancellationToken ct) + { + try + { + using JsonDocument _ = await _http + .DeleteAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", ct) + .ConfigureAwait(false); + return true; + } + catch (AlpacaApiException ex) when (ex.StatusCode is 404 or 422) + { + // 404 = gone, 422 = already in a terminal state. Both mean "not working any more". + return false; + } + } + + public async Task CancelAllOrdersAsync(CancellationToken ct) + { + using JsonDocument _ = await _http.DeleteAsync("v2/orders", ct).ConfigureAwait(false); + } + + /// Serialises an order to Alpaca's wire format. Public so it can be asserted on in tests. + public static byte[] WriteOrderJson(NewOrder order) + { + ArrayBufferWriter buffer = new(384); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("symbol", order.Symbol); + w.WriteString("qty", FormatQuantity(order.Quantity)); + w.WriteString("side", order.Side.ToAlpaca()); + w.WriteString("type", order.Type.ToAlpaca()); + w.WriteString("time_in_force", order.TimeInForce.ToAlpaca()); + + if (order.Type is OrderType.Limit or OrderType.StopLimit && !double.IsNaN(order.LimitPrice)) + { + w.WriteString("limit_price", FormatPrice(order.LimitPrice)); + } + + if (order.Type is OrderType.Stop or OrderType.StopLimit && !double.IsNaN(order.StopPrice)) + { + w.WriteString("stop_price", FormatPrice(order.StopPrice)); + } + + if (!string.IsNullOrEmpty(order.ClientOrderId)) + { + w.WriteString("client_order_id", order.ClientOrderId); + } + + if (order.ExtendedHours) + { + w.WriteBoolean("extended_hours", true); + } + + if (order.HasBracket) + { + w.WriteString("order_class", order.OrderClass); + + if (!double.IsNaN(order.TakeProfitLimitPrice)) + { + w.WriteStartObject("take_profit"); + w.WriteString("limit_price", FormatPrice(order.TakeProfitLimitPrice)); + w.WriteEndObject(); + } + + if (!double.IsNaN(order.StopLossStopPrice)) + { + w.WriteStartObject("stop_loss"); + w.WriteString("stop_price", FormatPrice(order.StopLossStopPrice)); + if (!double.IsNaN(order.StopLossLimitPrice)) + { + w.WriteString("limit_price", FormatPrice(order.StopLossLimitPrice)); + } + + w.WriteEndObject(); + } + } + + w.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + /// + /// Alpaca rejects prices that are not a valid sub-penny increment: two decimals at + /// or above $1, four decimals below it. + /// + public static string FormatPrice(double price) + { + double rounded = price >= 1.0 + ? Math.Round(price, 2, MidpointRounding.AwayFromZero) + : Math.Round(price, 4, MidpointRounding.AwayFromZero); + + return rounded.ToString(price >= 1.0 ? "0.##" : "0.####", CultureInfo.InvariantCulture); + } + + /// Whole shares stay integral; fractional sizes get at most 9 decimals. + public static string FormatQuantity(double quantity) + { + double abs = Math.Abs(quantity); + return abs == Math.Floor(abs) + ? abs.ToString("0", CultureInfo.InvariantCulture) + : Math.Round(abs, 9, MidpointRounding.ToZero).ToString("0.#########", CultureInfo.InvariantCulture); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs b/Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs new file mode 100644 index 0000000..926ad28 --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs @@ -0,0 +1,486 @@ +using System.Buffers; +using System.Globalization; +using System.Text.Json; +using Encelado.Alpaca.Internal; +using Encelado.Core.Market; + +namespace Encelado.Alpaca.Streaming; + +/// Which side crossed the spread on a print. Unknown when the feed omits it. +public enum Aggressor : byte +{ + Unknown = 0, + Buy = 1, + Sell = 2, +} + +public delegate void TickHandler(int symbolId, string symbol, in Tick tick, Aggressor aggressor); + +public delegate void QuoteHandler(int symbolId, string symbol, in Quote quote); + +public delegate void BarHandler(int symbolId, string symbol, in Bar bar); + +/// +/// Alpaca's real-time market data socket. Frames are decoded straight out of the +/// receive buffer with and symbols are resolved through +/// a , so a live tape produces no garbage per tick. +/// +public sealed class MarketDataStream : WebSocketChannel +{ + private enum MsgKind : byte + { + Unknown = 0, + Trade, + Quote, + Bar, + UpdatedBar, + DailyBar, + Status, + Success, + Error, + Subscription, + } + + private readonly byte[] _authPayload; + private readonly byte[] _subscribePayload; + private readonly SymbolTable _symbols; + private CancellationToken _channelToken; + + public MarketDataStream( + AlpacaOptions options, + IReadOnlyList symbols, + AssetClass assetClass, + bool subscribeTrades = true, + bool subscribeQuotes = true, + bool subscribeBars = true) + : base(options.MarketDataStreamUri(assetClass), $"data:{(assetClass == AssetClass.Crypto ? "crypto" : options.DataFeed)}") + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(symbols); + + _symbols = new SymbolTable(symbols); + AssetClass = assetClass; + _authPayload = BuildAuth(options.KeyId, options.SecretKey); + _subscribePayload = BuildSubscribe(symbols, subscribeTrades, subscribeQuotes, subscribeBars); + } + + public AssetClass AssetClass { get; } + + public SymbolTable Symbols => _symbols; + + /// Fired for every print on the tape. + public TickHandler? OnTrade { get; set; } + + /// Fired on every top-of-book change. + public QuoteHandler? OnQuote { get; set; } + + /// Fired when a minute bar closes — the engine's main decision trigger. + public BarHandler? OnBar { get; set; } + + /// Fired for Alpaca's rolling daily bar. + public BarHandler? OnDailyBar { get; set; } + + public long TradesReceived { get; private set; } + + public long QuotesReceived { get; private set; } + + public long BarsReceived { get; private set; } + + protected override async ValueTask OnOpenAsync(CancellationToken ct) + { + _channelToken = ct; + + // Alpaca accepts the auth frame immediately; the "connected" greeting and the + // "authenticated" acknowledgement both arrive on the receive loop. + await SendAsync(_authPayload, ct).ConfigureAwait(false); + } + + protected override void OnMessage(ReadOnlySpan payload, bool isText) + { + if (!isText) + { + Log($"[{Name}] ignoring a binary frame ({payload.Length} bytes); expected JSON."); + return; + } + + Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default); + if (!reader.Read()) + { + return; + } + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + DecodeObject(ref reader); + } + else + { + reader.Skip(); + } + } + } + else if (reader.TokenType == JsonTokenType.StartObject) + { + DecodeObject(ref reader); + } + } + + private void DecodeObject(ref Utf8JsonReader r) + { + MsgKind kind = MsgKind.Unknown; + int symbolId = -1; + double open = 0, high = 0, low = 0, close = 0, volume = 0, vwap = 0; + double price = 0, size = 0, bidPrice = 0, bidSize = 0, askPrice = 0, askSize = 0; + int tradeCount = 0; + DateTime timestamp = default; + string? message = null; + int code = 0; + Aggressor aggressor = Aggressor.Unknown; + + while (r.Read() && r.TokenType != JsonTokenType.EndObject) + { + if (r.TokenType != JsonTokenType.PropertyName) + { + r.Skip(); + continue; + } + + if (r.ValueTextEquals("T"u8)) + { + r.Read(); + kind = ParseKind(r.ValueSpan); + } + else if (r.ValueTextEquals("S"u8)) + { + r.Read(); + symbolId = _symbols.Resolve(r.ValueSpan); + } + else if (r.ValueTextEquals("t"u8)) + { + r.Read(); + timestamp = Rfc3339.ParseUtc(r.ValueSpan); + } + else if (r.ValueTextEquals("p"u8)) + { + r.Read(); + price = ReadNumber(ref r); + } + else if (r.ValueTextEquals("s"u8)) + { + r.Read(); + size = ReadNumber(ref r); + } + else if (r.ValueTextEquals("bp"u8)) + { + r.Read(); + bidPrice = ReadNumber(ref r); + } + else if (r.ValueTextEquals("bs"u8)) + { + r.Read(); + bidSize = ReadNumber(ref r); + } + else if (r.ValueTextEquals("ap"u8)) + { + r.Read(); + askPrice = ReadNumber(ref r); + } + else if (r.ValueTextEquals("as"u8)) + { + r.Read(); + askSize = ReadNumber(ref r); + } + else if (r.ValueTextEquals("o"u8)) + { + r.Read(); + open = ReadNumber(ref r); + } + else if (r.ValueTextEquals("h"u8)) + { + r.Read(); + high = ReadNumber(ref r); + } + else if (r.ValueTextEquals("l"u8)) + { + r.Read(); + low = ReadNumber(ref r); + } + else if (r.ValueTextEquals("c"u8)) + { + r.Read(); + + // On a bar "c" is the close; on a trade/quote it is the condition array. + if (r.TokenType == JsonTokenType.Number) + { + close = r.GetDouble(); + } + else + { + r.Skip(); + } + } + else if (r.ValueTextEquals("v"u8)) + { + r.Read(); + volume = ReadNumber(ref r); + } + else if (r.ValueTextEquals("vw"u8)) + { + r.Read(); + vwap = ReadNumber(ref r); + } + else if (r.ValueTextEquals("n"u8)) + { + r.Read(); + tradeCount = (int)ReadNumber(ref r); + } + else if (r.ValueTextEquals("tks"u8)) + { + r.Read(); + + // Alpaca's crypto feed reports the taker side as "B" or "S". It is the + // only way to know whether a print lifted an offer or hit a bid, which + // is what the volume delta is built from. + if (r.TokenType == JsonTokenType.String && r.ValueSpan.Length > 0) + { + aggressor = r.ValueSpan[0] switch + { + (byte)'B' or (byte)'b' => Aggressor.Buy, + (byte)'S' or (byte)'s' => Aggressor.Sell, + _ => Aggressor.Unknown, + }; + } + } + else if (r.ValueTextEquals("msg"u8)) + { + r.Read(); + message = r.TokenType == JsonTokenType.String ? r.GetString() : null; + } + else if (r.ValueTextEquals("code"u8)) + { + r.Read(); + code = (int)ReadNumber(ref r); + } + else + { + r.Read(); + r.Skip(); + } + } + + Dispatch(kind, symbolId, timestamp, message, code, + open, high, low, close, volume, vwap, tradeCount, + price, size, bidPrice, bidSize, askPrice, askSize, aggressor); + } + + private void Dispatch( + MsgKind kind, int symbolId, DateTime timestamp, string? message, int code, + double open, double high, double low, double close, double volume, double vwap, int tradeCount, + double price, double size, double bidPrice, double bidSize, double askPrice, double askSize, + Aggressor aggressor) + { + switch (kind) + { + case MsgKind.Trade when symbolId >= 0: + { + TradesReceived++; + Tick tick = new(timestamp, price, size); + OnTrade?.Invoke(symbolId, _symbols.Name(symbolId), in tick, aggressor); + break; + } + + case MsgKind.Quote when symbolId >= 0: + { + QuotesReceived++; + Quote quote = new(timestamp, bidPrice, bidSize, askPrice, askSize); + OnQuote?.Invoke(symbolId, _symbols.Name(symbolId), in quote); + break; + } + + case MsgKind.Bar when symbolId >= 0: + { + BarsReceived++; + Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount); + OnBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar); + break; + } + + case MsgKind.DailyBar when symbolId >= 0: + { + Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount); + OnDailyBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar); + break; + } + + case MsgKind.Success: + if (string.Equals(message, "authenticated", StringComparison.Ordinal)) + { + Log($"[{Name}] authenticated; subscribing to {_symbols.Count} symbol(s)"); + _ = SendSubscribeAsync(); + } + else + { + Log($"[{Name}] {message}"); + } + + break; + + case MsgKind.Subscription: + SetState(ChannelState.Live); + Log($"[{Name}] subscription confirmed"); + break; + + case MsgKind.Error: + OnServerError(code, message); + break; + + case MsgKind.Status: + case MsgKind.UpdatedBar: + case MsgKind.Unknown: + default: + break; + } + } + + /// + /// Turns an Alpaca stream error into either a refusal or a note. + /// + /// The distinction is the whole point. Codes in the 400s here mean the server has + /// decided about this session: reconnecting straight away cannot change its mind, + /// and — because an unauthenticated socket keeps the account's single market-data + /// slot busy for ten seconds — trying again quickly is what keeps the refusal true. + /// Treating these as informational is what produced an endless connect / 406 / + /// auth-timeout loop that never recovered on its own. + /// + /// + private void OnServerError(int code, string? message) + { + string? refusal = code switch + { + 406 => "un'altra connessione sta già usando i dati di mercato di questo conto " + + "(Alpaca ne consente una sola). Chiudi l'altra istanza di Encelado, oppure " + + "attendi: una sessione interrotta male viene liberata dal server dopo poco.", + 401 or 403 => "credenziali rifiutate dallo stream dati. Controlla le chiavi in " + + "Impostazioni e che siano quelle dell'ambiente giusto (paper o live).", + 409 => "abbonamento dati insufficiente per i simboli richiesti.", + _ => null, + }; + + if (refusal is null) + { + Log($"[{Name}] server error {code}: {message}"); + return; + } + + Log($"[{Name}] {code}: {refusal}"); + Reject(refusal); + } + + private async Task SendSubscribeAsync() + { + try + { + await SendAsync(_subscribePayload, _channelToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log($"[{Name}] subscribe failed: {ex.Message}", ex); + } + } + + private static double ReadNumber(ref Utf8JsonReader r) => r.TokenType switch + { + JsonTokenType.Number => r.GetDouble(), + JsonTokenType.String => double.TryParse( + r.ValueSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : 0, + _ => 0, + }; + + private static MsgKind ParseKind(ReadOnlySpan value) + { + if (value.Length == 1) + { + return value[0] switch + { + (byte)'t' => MsgKind.Trade, + (byte)'q' => MsgKind.Quote, + (byte)'b' => MsgKind.Bar, + (byte)'u' => MsgKind.UpdatedBar, + (byte)'d' => MsgKind.DailyBar, + (byte)'s' => MsgKind.Status, + _ => MsgKind.Unknown, + }; + } + + if (value.SequenceEqual("success"u8)) + { + return MsgKind.Success; + } + + if (value.SequenceEqual("error"u8)) + { + return MsgKind.Error; + } + + return value.SequenceEqual("subscription"u8) ? MsgKind.Subscription : MsgKind.Unknown; + } + + private static byte[] BuildAuth(string key, string secret) + { + ArrayBufferWriter buffer = new(160); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("action", "auth"); + w.WriteString("key", key); + w.WriteString("secret", secret); + w.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + private static byte[] BuildSubscribe(IReadOnlyList symbols, bool trades, bool quotes, bool bars) + { + ArrayBufferWriter buffer = new(256); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("action", "subscribe"); + + if (trades) + { + WriteArray(w, "trades", symbols); + } + + if (quotes) + { + WriteArray(w, "quotes", symbols); + } + + if (bars) + { + WriteArray(w, "bars", symbols); + } + + w.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + + static void WriteArray(Utf8JsonWriter w, string name, IReadOnlyList values) + { + w.WriteStartArray(name); + foreach (string v in values) + { + w.WriteStringValue(v); + } + + w.WriteEndArray(); + } + } +} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/SymbolTable.cs b/Encelado/src/Encelado.Alpaca/Streaming/SymbolTable.cs new file mode 100644 index 0000000..89b0b2b --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Streaming/SymbolTable.cs @@ -0,0 +1,62 @@ +namespace Encelado.Alpaca.Streaming; + +/// +/// Maps a symbol's UTF-8 bytes to a stable integer id and a single interned string +/// instance. The market-data decoder resolves symbols straight from the receive +/// buffer, so a busy tape does not allocate one string per tick. +/// +public sealed class SymbolTable +{ + private readonly Dictionary _ids; + private readonly Dictionary.AlternateLookup> _lookup; + private readonly List _names = []; + + public SymbolTable(IEnumerable symbols) + { + ArgumentNullException.ThrowIfNull(symbols); + _ids = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string s in symbols) + { + string symbol = s.Trim(); + if (symbol.Length > 0 && _ids.TryAdd(symbol, _names.Count)) + { + _names.Add(symbol); + } + } + + _lookup = _ids.GetAlternateLookup>(); + } + + public int Count => _names.Count; + + public IReadOnlyList Names => _names; + + /// Resolves a symbol from raw UTF-8. Returns -1 when it is not subscribed. + public int Resolve(ReadOnlySpan utf8) + { + // Symbols are short ASCII (crypto pairs like BTC/USD included), so a stack + // buffer covers every real case without touching the heap. + if (utf8.Length is 0 or > 32) + { + return -1; + } + + Span chars = stackalloc char[32]; + for (int i = 0; i < utf8.Length; i++) + { + byte b = utf8[i]; + if (b > 127) + { + return -1; + } + + chars[i] = (char)b; + } + + return _lookup.TryGetValue(chars[..utf8.Length], out int id) ? id : -1; + } + + public int Resolve(string symbol) => _ids.TryGetValue(symbol, out int id) ? id : -1; + + public string Name(int id) => (uint)id < (uint)_names.Count ? _names[id] : string.Empty; +} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs b/Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs new file mode 100644 index 0000000..7f50a6b --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs @@ -0,0 +1,258 @@ +using System.Buffers; +using System.Text.Json; +using Encelado.Alpaca.Internal; +using Encelado.Alpaca.Rest; +using Encelado.Core.Market; + +namespace Encelado.Alpaca.Streaming; + +/// One order lifecycle event pushed by Alpaca. +public sealed record TradeUpdate( + string Event, + DateTime TimestampUtc, + string Symbol, + Side Side, + double Price, + double Quantity, + double PositionQuantity, + AlpacaOrder Order) +{ + /// True when shares actually changed hands. + public bool IsExecution => Event is "fill" or "partial_fill"; + + public bool IsTerminal => Event is "fill" or "canceled" or "expired" or "rejected" or "done_for_day"; +} + +/// +/// Order and position events straight from the broker, so the bot learns about fills +/// in milliseconds instead of polling. The reconciler still sweeps REST periodically: +/// this stream is the fast path, not the source of truth. +/// +public sealed class TradeUpdateStream : WebSocketChannel +{ + private readonly byte[] _authPrimary; + private readonly byte[] _authAlternate; + private readonly byte[] _listenPayload; + private CancellationToken _channelToken; + private volatile bool _authorized; + private bool _warnedBinary; + + public TradeUpdateStream(AlpacaOptions options) + : base(options.TradeUpdatesStreamUri, "trade-updates") + { + ArgumentNullException.ThrowIfNull(options); + + _authPrimary = BuildEnvelopeAuth(options.KeyId, options.SecretKey); + _authAlternate = BuildFlatAuth(options.KeyId, options.SecretKey); + _listenPayload = BuildListen(); + } + + /// Raised for every order lifecycle event. Runs on the receive thread. + public Action? OnTradeUpdate { get; set; } + + public long UpdatesReceived { get; private set; } + + protected override async ValueTask OnOpenAsync(CancellationToken ct) + { + _channelToken = ct; + _authorized = false; + + // The documented handshake for the trading /stream endpoint. + await SendAsync(_authPrimary, ct).ConfigureAwait(false); + + // Alpaca has shipped two auth shapes for this endpoint over the years. If the + // first one is not acknowledged shortly, try the other before giving up. + _ = FallbackAuthAsync(); + } + + private async Task FallbackAuthAsync() + { + try + { + await Task.Delay(TimeSpan.FromSeconds(3), _channelToken).ConfigureAwait(false); + if (!_authorized) + { + Log($"[{Name}] no auth acknowledgement yet; retrying with the alternate handshake"); + await SendAsync(_authAlternate, _channelToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException) + { + // Socket closed while we were waiting; the reconnect loop takes over. + } + catch (Exception ex) + { + Log($"[{Name}] fallback auth failed: {ex.Message}", ex); + } + } + + protected override void OnMessage(ReadOnlySpan payload, bool isText) + { + if (!isText) + { + if (!_warnedBinary) + { + _warnedBinary = true; + Log($"[{Name}] received a binary (msgpack) frame; falling back to REST reconciliation for fills."); + } + + return; + } + + JsonDocument doc; + try + { + Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default); + doc = JsonDocument.ParseValue(ref reader); + } + catch (JsonException ex) + { + Log($"[{Name}] undecodable frame: {ex.Message}"); + return; + } + + using (doc) + { + JsonElement root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return; + } + + string stream = root.StringOrEmpty("stream"); + if (!root.TryGetProperty("data", out JsonElement data)) + { + return; + } + + switch (stream) + { + case "authorization": + HandleAuthorization(data); + break; + + case "listening": + SetState(ChannelState.Live); + Log($"[{Name}] listening for trade updates"); + break; + + case "trade_updates": + HandleTradeUpdate(data); + break; + + default: + break; + } + } + } + + private void HandleAuthorization(JsonElement data) + { + string status = data.StringOrEmpty("status"); + if (string.Equals(status, "authorized", StringComparison.OrdinalIgnoreCase)) + { + _authorized = true; + Log($"[{Name}] authorized"); + _ = SendListenAsync(); + } + else + { + Log($"[{Name}] authorization refused: {status}"); + } + } + + private void HandleTradeUpdate(JsonElement data) + { + UpdatesReceived++; + + AlpacaOrder order = data.TryGetProperty("order", out JsonElement orderElement) && + orderElement.ValueKind == JsonValueKind.Object + ? AlpacaOrder.FromJson(orderElement) + : new AlpacaOrder(string.Empty, string.Empty, string.Empty, Side.Buy, string.Empty, string.Empty, + OrderStatus.Unknown, 0, 0, 0, double.NaN, double.NaN, DateTime.MinValue, null, []); + + DateTime timestamp = data.Timestamp("timestamp"); + TradeUpdate update = new( + data.StringOrEmpty("event"), + timestamp == DateTime.MinValue ? DateTime.UtcNow : timestamp, + order.Symbol, + order.Side, + data.Double("price", order.FilledAveragePrice), + data.Double("qty"), + data.Double("position_qty"), + order); + + try + { + OnTradeUpdate?.Invoke(update); + } + catch (Exception ex) + { + Log($"[{Name}] trade-update handler threw: {ex.Message}", ex); + } + } + + private async Task SendListenAsync() + { + try + { + await SendAsync(_listenPayload, _channelToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log($"[{Name}] listen failed: {ex.Message}", ex); + } + } + + /// {"action":"authenticate","data":{"key_id":…,"secret_key":…}} + private static byte[] BuildEnvelopeAuth(string key, string secret) + { + ArrayBufferWriter buffer = new(192); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("action", "authenticate"); + w.WriteStartObject("data"); + w.WriteString("key_id", key); + w.WriteString("secret_key", secret); + w.WriteEndObject(); + w.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + /// {"action":"auth","key":…,"secret":…} + private static byte[] BuildFlatAuth(string key, string secret) + { + ArrayBufferWriter buffer = new(160); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("action", "auth"); + w.WriteString("key", key); + w.WriteString("secret", secret); + w.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } + + private static byte[] BuildListen() + { + ArrayBufferWriter buffer = new(96); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("action", "listen"); + w.WriteStartObject("data"); + w.WriteStartArray("streams"); + w.WriteStringValue("trade_updates"); + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndObject(); + } + + return buffer.WrittenSpan.ToArray(); + } +} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/WebSocketChannel.cs b/Encelado/src/Encelado.Alpaca/Streaming/WebSocketChannel.cs new file mode 100644 index 0000000..b92ad2f --- /dev/null +++ b/Encelado/src/Encelado.Alpaca/Streaming/WebSocketChannel.cs @@ -0,0 +1,378 @@ +using System.Buffers; +using System.Net.WebSockets; + +namespace Encelado.Alpaca.Streaming; + +public enum ChannelState : byte +{ + Disconnected = 0, + Connecting, + Authenticating, + Live, + Faulted, +} + +/// +/// Long-lived WebSocket with authentication, resubscribe-on-reconnect and capped +/// exponential backoff. Subclasses only implement the handshake and the message +/// decoder; the reconnect loop, frame reassembly and buffer pooling live here. +/// +public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable +{ + private const int InitialBufferSize = 64 * 1024; + private const int MaxBufferSize = 8 * 1024 * 1024; + + private readonly SemaphoreSlim _sendGate = new(1, 1); + private ClientWebSocket? _socket; + private CancellationTokenSource? _cts; + private Task? _loop; + private int _consecutiveFailures; + private int _rejections; + private volatile string? _rejection; + + public string Name { get; } = name; + + public Uri Uri { get; } = uri; + + public ChannelState State { get; private set; } = ChannelState.Disconnected; + + public bool IsLive => State == ChannelState.Live; + + /// Number of times the channel has (re)established a live session. + public int ConnectCount { get; private set; } + + public DateTime LastMessageUtc { get; private set; } + + /// Diagnostics sink. Set by the host so channel events land in the bot log. + public Action? OnLog { get; set; } + + /// Raised whenever the channel transitions to or away from . + public Action? OnLiveChanged { get; set; } + + /// + /// Why the server is refusing this channel, or null when nothing has refused it. + /// Survives across reconnects so the UI can explain a channel that keeps bouncing. + /// + public string? RejectionReason => _rejection; + + /// + /// Records a server-side refusal that reconnecting cannot fix on its own, and tears + /// the socket down now rather than waiting for the server to time it out. + /// + /// The timing matters more than it looks. Alpaca permits one market-data connection + /// per account and closes an unauthenticated socket after ten seconds; a client that + /// reconnects on a three-second backoff therefore opens the next socket while the + /// refused one is still occupying the only slot, and refuses itself forever. Closing + /// immediately, and backing off past the server's own timeout, is what breaks that. + /// + /// + protected void Reject(string reason) + { + _rejection = reason; + Interlocked.Increment(ref _rejections); + + // Aborting rather than closing politely: a graceful close needs a round trip the + // server has already decided not to complete. + try + { + _socket?.Abort(); + } + catch (ObjectDisposedException) + { + // Raced with the reconnect loop disposing it. Nothing left to abort. + } + } + + /// Clears the refusal once a session actually comes up. + private void Accept() + { + _rejection = null; + Interlocked.Exchange(ref _rejections, 0); + Interlocked.Exchange(ref _consecutiveFailures, 0); + } + + public Task StartAsync(CancellationToken ct) + { + if (_loop is not null) + { + return Task.CompletedTask; + } + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _loop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + public async Task StopAsync() + { + if (_cts is not null) + { + await _cts.CancelAsync().ConfigureAwait(false); + } + + if (_loop is not null) + { + try + { + await _loop.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + + _loop = null; + } + + SetState(ChannelState.Disconnected); + } + + private async Task RunAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + SetState(ChannelState.Connecting); + + _socket = new ClientWebSocket(); + _socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); + ConfigureSocket(_socket.Options); + + await _socket.ConnectAsync(Uri, ct).ConfigureAwait(false); + OnLog?.Invoke($"[{Name}] socket open -> {Uri}", null); + + SetState(ChannelState.Authenticating); + await OnOpenAsync(ct).ConfigureAwait(false); + + ConnectCount++; + + // The failure counter is NOT reset here. Opening a socket and sending + // the handshake proves nothing: the server can still refuse the session + // a moment later. Resetting at this point was the bug behind an endless + // reconnect loop — every attempt looked like a success, so the backoff + // never grew past its first step and the client hammered a connection + // limit every three seconds indefinitely. It is reset in Accept(), + // called when the channel actually reaches Live. + await ReceiveLoopAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _consecutiveFailures++; + SetState(ChannelState.Faulted); + + // A socket the server aborted after refusing us is the expected outcome + // of Reject(), not a separate fault worth its own alarming line. + if (_rejection is null) + { + OnLog?.Invoke($"[{Name}] connection failed ({_consecutiveFailures}): {ex.Message}", ex); + } + } + finally + { + SetState(ChannelState.Disconnected); + DisposeSocket(); + + // The session ended without ever going live, so the attempt failed even + // if no exception was thrown — a refusal followed by a clean server + // close looks exactly like that. + if (_rejection is not null) + { + _consecutiveFailures++; + } + } + + if (ct.IsCancellationRequested) + { + break; + } + + TimeSpan delay = BackoffDelay(_consecutiveFailures); + + if (_rejection is { } reason) + { + OnLog?.Invoke( + $"[{Name}] rifiutato dal server ({_rejections}x): {reason} — nuovo tentativo fra {delay.TotalSeconds:F0}s", + null); + } + else + { + OnLog?.Invoke($"[{Name}] reconnecting in {delay.TotalSeconds:F1}s", null); + } + + try + { + await Task.Delay(delay, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + byte[] buffer = ArrayPool.Shared.Rent(InitialBufferSize); + try + { + while (!ct.IsCancellationRequested && _socket is { State: WebSocketState.Open }) + { + int offset = 0; + ValueWebSocketReceiveResult result; + + do + { + if (offset == buffer.Length) + { + if (buffer.Length >= MaxBufferSize) + { + throw new InvalidOperationException( + $"[{Name}] message exceeded {MaxBufferSize / (1024 * 1024)} MB."); + } + + byte[] bigger = ArrayPool.Shared.Rent(buffer.Length * 2); + Buffer.BlockCopy(buffer, 0, bigger, 0, offset); + ArrayPool.Shared.Return(buffer); + buffer = bigger; + } + + result = await _socket.ReceiveAsync(buffer.AsMemory(offset), ct).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + OnLog?.Invoke( + $"[{Name}] server closed: {_socket.CloseStatus} {_socket.CloseStatusDescription}", null); + return; + } + + offset += result.Count; + } + while (!result.EndOfMessage); + + LastMessageUtc = DateTime.UtcNow; + + try + { + OnMessage(buffer.AsSpan(0, offset), result.MessageType == WebSocketMessageType.Text); + } + catch (Exception ex) + { + // A malformed frame must never take the channel down. + OnLog?.Invoke($"[{Name}] message handler threw: {ex.Message}", ex); + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + protected async ValueTask SendAsync(ReadOnlyMemory payload, CancellationToken ct) + { + ClientWebSocket? socket = _socket; + if (socket is not { State: WebSocketState.Open }) + { + throw new InvalidOperationException($"[{Name}] cannot send: socket is {socket?.State.ToString() ?? "null"}."); + } + + await _sendGate.WaitAsync(ct).ConfigureAwait(false); + try + { + await socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, ct).ConfigureAwait(false); + } + finally + { + _sendGate.Release(); + } + } + + /// Sends the auth (and subscribe) handshake right after the socket opens. + protected abstract ValueTask OnOpenAsync(CancellationToken ct); + + /// Decodes one complete frame. Runs on the receive thread — keep it allocation free. + protected abstract void OnMessage(ReadOnlySpan payload, bool isText); + + protected virtual void ConfigureSocket(ClientWebSocketOptions socketOptions) + { + } + + protected void SetState(ChannelState state) + { + if (State == state) + { + return; + } + + bool wasLive = State == ChannelState.Live; + State = state; + bool isLive = state == ChannelState.Live; + + // Reaching Live is the only evidence that a connection attempt worked, so it is + // the only place the backoff is allowed to reset. + if (isLive) + { + Accept(); + } + + if (wasLive != isLive) + { + OnLiveChanged?.Invoke(isLive); + } + } + + protected void Log(string message, Exception? ex = null) => OnLog?.Invoke(message, ex); + + /// + /// Server-side timeout for an unauthenticated socket. Any backoff shorter than this + /// risks opening the next connection while the previous one still holds the + /// account's single market-data slot. + /// + private static readonly TimeSpan ServerAuthTimeout = TimeSpan.FromSeconds(10); + + /// + /// 1s, 2s, 4s … capped at 60s, with jitter. Alpaca allows a single market-data + /// connection per account, so hammering reconnects just earns a 406. + /// + /// Once the server has actually refused us, the floor rises above its own ten-second + /// timeout. Otherwise the client competes with its own dying socket for the one slot + /// available and can never win. + /// + /// + private TimeSpan BackoffDelay(int failures) + { + if (failures <= 0) + { + return TimeSpan.FromSeconds(1); + } + + double seconds = Math.Min(60, Math.Pow(2, Math.Min(failures, 6))); + + if (_rejection is not null) + { + seconds = Math.Max(seconds, ServerAuthTimeout.TotalSeconds * 1.5); + } + + return TimeSpan.FromSeconds(seconds + (Random.Shared.NextDouble() * 1.5)); + } + + private void DisposeSocket() + { + ClientWebSocket? socket = Interlocked.Exchange(ref _socket, null); + socket?.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + _cts?.Dispose(); + _sendGate.Dispose(); + DisposeSocket(); + GC.SuppressFinalize(this); + } +} diff --git a/Encelado/src/Encelado.Bot/App.xaml b/Encelado/src/Encelado.Bot/App.xaml new file mode 100644 index 0000000..953b251 --- /dev/null +++ b/Encelado/src/Encelado.Bot/App.xaml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/Encelado/src/Encelado.Bot/App.xaml.cs b/Encelado/src/Encelado.Bot/App.xaml.cs new file mode 100644 index 0000000..19e77cd --- /dev/null +++ b/Encelado/src/Encelado.Bot/App.xaml.cs @@ -0,0 +1,81 @@ +using System.IO; +using System.Windows; +using System.Windows.Threading; +using Encelado.Bot.Configuration; +using Encelado.Bot.Logging; + +namespace Encelado.Bot; + +public partial class App : Application +{ + /// Loaded once at startup and shared by every window. + public static BotConfig Config { get; private set; } = new(); + + public static IReadOnlyList ConfigWarnings { get; private set; } = []; + + public static string ConfigPath { get; private set; } = string.Empty; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // A crash in a background task must show a dialog, not vanish silently. + DispatcherUnhandledException += OnDispatcherException; + AppDomain.CurrentDomain.UnhandledException += (_, args) => + Log.Error("unhandled exception", args.ExceptionObject as Exception); + TaskScheduler.UnobservedTaskException += (_, args) => + { + Log.Error("unobserved task exception", args.Exception); + args.SetObserved(); + }; + + try + { + ConfigPath = ResolveConfigPath(); + Config = ConfigLoader.Load(ConfigPath, out List warnings); + ConfigWarnings = warnings; + } + catch (Exception ex) + { + MessageBox.Show( + $"Impossibile leggere la configurazione:\n\n{ex.Message}", + "Encelado", MessageBoxButton.OK, MessageBoxImage.Error); + Shutdown(2); + return; + } + + Log.Initialize(Config.Logging); + + // Created here rather than via StartupUri: the config must load first, and a + // failure above has to be able to abort startup before any window exists. + MainWindow window = new MainWindow(); + MainWindow = window; + window.Show(); + } + + /// + /// The config lives next to the executable. Working directories vary (debugger, + /// shortcut, taskbar), so resolving it relative to the assembly is the only choice + /// that always finds the file. + /// + private static string ResolveConfigPath() + { + string beside = Path.Combine(AppContext.BaseDirectory, "encelado.json"); + return File.Exists(beside) ? beside : Path.GetFullPath("encelado.json"); + } + + private static void OnDispatcherException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + Log.Error("UI exception", e.Exception); + MessageBox.Show( + $"Errore imprevisto:\n\n{e.Exception.Message}", + "Encelado", MessageBoxButton.OK, MessageBoxImage.Error); + e.Handled = true; + } + + protected override void OnExit(ExitEventArgs e) + { + Log.ShutdownAsync().GetAwaiter().GetResult(); + base.OnExit(e); + } +} diff --git a/Encelado/src/Encelado.Bot/Assets/encelado.ico b/Encelado/src/Encelado.Bot/Assets/encelado.ico new file mode 100644 index 0000000..99f380b Binary files /dev/null and b/Encelado/src/Encelado.Bot/Assets/encelado.ico differ diff --git a/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs b/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs new file mode 100644 index 0000000..eca1843 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs @@ -0,0 +1,328 @@ +using Encelado.Alpaca; +using Encelado.Core.Market; +using Encelado.Core.Risk; +using Encelado.Core.Strategies; + +namespace Encelado.Bot.Configuration; + +/// Where the Alpaca credentials in use actually came from. +public enum CredentialSource +{ + None = 0, + ConfigFile, + Environment, + SavedStore, + Interactive, +} + +public sealed class BotConfig +{ + public AlpacaOptions Alpaca { get; set; } = new(); + + /// + /// Provenance of . Set by the loader and by the + /// login flow so startup can report it without ever echoing the secret. + /// + public CredentialSource CredentialOrigin { get; set; } = CredentialSource.None; + + public EngineOptions Engine { get; set; } = new(); + + public RiskLimits Risk { get; set; } = new(); + + public LoggingOptions Logging { get; set; } = new(); + + public UiOptions Ui { get; set; } = new(); + + public List Symbols { get; set; } = []; + + public IEnumerable EnabledSymbols => Symbols.Where(s => s.Enabled); + + public BotConfig Validate() + { + Alpaca.Validate(); + Risk.Validate(); + Engine.Validate(); + Ui.Validate(); + Logging.Validate(); + + List enabled = [.. EnabledSymbols]; + if (enabled.Count == 0) + { + throw new InvalidOperationException("No enabled symbols in the configuration."); + } + + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + foreach (SymbolConfig s in enabled) + { + if (string.IsNullOrWhiteSpace(s.Symbol)) + { + throw new InvalidOperationException("A symbol entry has an empty 'symbol'."); + } + + if (!seen.Add(s.Symbol)) + { + throw new InvalidOperationException($"Symbol '{s.Symbol}' is configured more than once."); + } + + if (!StrategyFactory.IsKnown(s.Strategy)) + { + throw new InvalidOperationException( + $"Symbol '{s.Symbol}' uses unknown strategy '{s.Strategy}'. " + + $"Available: {string.Join(", ", StrategyFactory.Available)}."); + } + } + + return this; + } +} + +public sealed class EngineOptions +{ + /// us_equity or crypto. Crypto trades 24/7 and requires fractional sizes. + public string AssetClass { get; set; } = "us_equity"; + + /// Decision timeframe. Bars are consumed straight from the stream at 1Min. + public string TimeFrame { get; set; } = "1Min"; + + /// Historical bars pulled at startup to warm the indicators. + public int WarmupBars { get; set; } = 300; + + /// Refuse new entries outside 09:30–16:00 ET. + public bool TradeOnlyRegularHours { get; set; } = true; + + /// Flatten everything this many minutes before the close. 0 disables. + public int FlattenBeforeCloseMinutes { get; set; } = 10; + + public bool AllowFractionalShares { get; set; } + + /// Attach take-profit/stop-loss legs server-side so exits survive a bot crash. + public bool UseBracketOrders { get; set; } = true; + + /// market or limit. A marketable limit caps slippage. + public string EntryOrderType { get; set; } = "limit"; + + /// How far through the touch a marketable limit is priced, in basis points. + public double LimitOffsetBps { get; set; } = 5; + + /// Log decisions but never send an order. The safest way to observe a new config. + public bool DryRun { get; set; } + + public int ReconcileSeconds { get; set; } = 30; + + public int StatusSeconds { get; set; } = 60; + + /// + /// How often the engine re-reads what each strategy would do at the current price and + /// writes it to the log when it has changed. This is the heartbeat that makes a + /// patient bot distinguishable from a stuck one. + /// + public int ExplainSeconds { get; set; } = 5; + + /// Reject entries when top-of-book is older than this. 0 disables the check. + public int MaxQuoteAgeSeconds { get; set; } = 30; + + /// Liquidate everything when the bot shuts down. + public bool CloseOnShutdown { get; set; } + + public AssetClass ResolvedAssetClass => + AssetClass.Trim().ToLowerInvariant() is "crypto" or "us_crypto" + ? Core.Market.AssetClass.Crypto + : Core.Market.AssetClass.UsEquity; + + public TimeFrame ResolvedTimeFrame => + TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf) ? tf : Core.Market.TimeFrame.OneMinute; + + public bool UseLimitEntries => + EntryOrderType.Trim().Equals("limit", StringComparison.OrdinalIgnoreCase); + + public void Validate() + { + if (!TimeFrameExtensions.TryParse(TimeFrame, out _)) + { + throw new InvalidOperationException($"engine.timeFrame '{TimeFrame}' is not supported."); + } + + if (EntryOrderType.Trim() is not ("limit" or "market")) + { + throw new InvalidOperationException("engine.entryOrderType must be 'limit' or 'market'."); + } + + if (WarmupBars is < 0 or > 10_000) + { + throw new InvalidOperationException("engine.warmupBars must be between 0 and 10000."); + } + + if (LimitOffsetBps is < 0 or > 500) + { + throw new InvalidOperationException("engine.limitOffsetBps must be between 0 and 500."); + } + + if (ReconcileSeconds < 5) + { + throw new InvalidOperationException("engine.reconcileSeconds must be at least 5."); + } + + if (ResolvedAssetClass == Core.Market.AssetClass.Crypto && !AllowFractionalShares) + { + throw new InvalidOperationException( + "engine.allowFractionalShares must be true when engine.assetClass is 'crypto'."); + } + } +} + +/// Settings for the web dashboard served by the ui command. +public sealed class UiOptions +{ + /// + /// Where the dashboard listens. Use http://0.0.0.0:5088 to reach it from + /// another machine — there is no authentication, so only do that on a trusted LAN. + /// + public string Url { get; set; } = "http://localhost:5088"; + + /// Begin trading as soon as the dashboard starts, without pressing START. + public bool AutoStartBot { get; set; } + + public bool OpenBrowser { get; set; } = true; + + public void Validate() + { + if (!Uri.TryCreate(Url, UriKind.Absolute, out Uri? parsed) || + (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException($"ui.url '{Url}' is not a valid http(s) URL."); + } + } +} + +public sealed class LoggingOptions +{ + /// + /// Verbosity: trace, debug, info, warn, error or + /// none. debug adds every rejected signal and risk refusal; + /// trace adds per-quote detail and is very noisy. + /// + public string Level { get; set; } = "info"; + + public bool Console { get; set; } + + /// + /// Folder that holds every output file. Relative paths resolve against the + /// executable's directory, so the app writes to the same place regardless of where + /// it was launched from. An absolute path is used as given. + /// + public string Directory { get; set; } = "logs"; + + /// Application log file name. Empty disables file logging. + public string File { get; set; } = "encelado.log"; + + /// One JSON line per order event. Empty disables it. + public string TradeJournal { get; set; } = "trades.jsonl"; + + /// + /// One CSV row per evaluated bar, per symbol, with the full market state, every + /// indicator the strategy exposes, the position, and the resulting signal. This is + /// the dataset to analyse when tuning the model. Empty disables it. + /// + public string DecisionLog { get; set; } = "decisions.csv"; + + /// + /// One CSV row per signal that reached the order path, with the risk verdict and + /// the order outcome. Joins to on decisionId. + /// + public string ExecutionLog { get; set; } = "executions.csv"; + + /// Rotate the application log once it passes this size. 0 disables rotation. + public int MaxFileSizeMb { get; set; } = 32; + + /// How many rotated application logs to keep. + public int MaxFiles { get; set; } = 10; + + /// + /// Log every quote and trade tick. Produces enormous files and is only useful when + /// diagnosing the market-data path itself. + /// + public bool LogMarketData { get; set; } + + /// + /// Lines kept in the activity strip on the status page. Small on purpose: that panel + /// is glanced at, not read, and every line held there is a live WPF visual. + /// + /// + /// Log every incoming bar from the stream, not only the ones that close a strategy + /// bucket. On a daily timeframe this is the difference between a log that shows the + /// market moving and one that shows nothing for twenty-four hours. + /// + public bool LogEveryBar { get; set; } = true; + + public int StatusLines { get; set; } = 200; + + /// + /// Lines kept by the log page. This is the memory ceiling for the in-app log: at the + /// default it is a few megabytes. It is deliberately not unbounded — a bot left + /// running for a week at debug would otherwise grow without limit. The file + /// on disk stays complete regardless, and the page can open it. + /// + public int BufferedLines { get; set; } = 5_000; + + /// Absolute path of the log directory, created on demand. + public string ResolveDirectory() + { + string directory = string.IsNullOrWhiteSpace(Directory) ? "logs" : Directory; + return Path.IsPathRooted(directory) + ? directory + : Path.Combine(AppContext.BaseDirectory, directory); + } + + /// Absolute path of a file inside the log directory, or null when disabled. + public string? ResolvePath(string? fileName) => + string.IsNullOrWhiteSpace(fileName) + ? null + : Path.IsPathRooted(fileName) + ? fileName + : Path.Combine(ResolveDirectory(), fileName); + + public void Validate() + { + if (MaxFileSizeMb is < 0 or > 4096) + { + throw new InvalidOperationException("logging.maxFileSizeMb must be between 0 and 4096."); + } + + if (MaxFiles is < 1 or > 500) + { + throw new InvalidOperationException("logging.maxFiles must be between 1 and 500."); + } + + if (StatusLines is < 20 or > 5_000) + { + throw new InvalidOperationException("logging.statusLines must be between 20 and 5000."); + } + + // The ceiling is a memory guard, not a preference: each buffered line is a live + // object plus, once scrolled into view, a WPF visual. + if (BufferedLines is < 100 or > 200_000) + { + throw new InvalidOperationException("logging.bufferedLines must be between 100 and 200000."); + } + + if (BufferedLines < StatusLines) + { + throw new InvalidOperationException( + "logging.bufferedLines must be >= logging.statusLines: the log page cannot hold " + + "less history than the status strip."); + } + } +} + +public sealed class SymbolConfig +{ + public string Symbol { get; set; } = string.Empty; + + public string Strategy { get; set; } = "ema-cross"; + + public bool Enabled { get; set; } = true; + + public Dictionary Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public StrategyParameters ToStrategyParameters() => new(Parameters); +} diff --git a/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs b/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs new file mode 100644 index 0000000..67736d4 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs @@ -0,0 +1,392 @@ +using System.Globalization; +using System.Text.Json; + +namespace Encelado.Bot.Configuration; + +/// +/// Reads encelado.json by hand with . No reflection +/// binder means no trimming surprises and no silent type coercion — an unknown key is +/// reported instead of ignored. +/// +/// Precedence: file < environment variables. Credentials should live in the +/// environment (or a gitignored local file), never in the committed config. +/// +/// +public static class ConfigLoader +{ + private static readonly JsonDocumentOptions ParseOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + public static BotConfig Load(string path, out List warnings) + { + warnings = []; + BotConfig config = new(); + + if (File.Exists(path)) + { + using FileStream stream = File.OpenRead(path); + using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions); + ApplyJson(config, doc.RootElement, warnings); + } + else + { + warnings.Add($"config file '{path}' not found; using defaults plus environment variables"); + } + + // A sibling *.local.json overlays secrets and machine-specific overrides. + string localPath = Path.ChangeExtension(path, null) + ".local.json"; + if (File.Exists(localPath)) + { + using FileStream stream = File.OpenRead(localPath); + using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions); + ApplyJson(config, doc.RootElement, warnings); + } + + ApplyEnvironment(config); + return config; + } + + private static void ApplyJson(BotConfig config, JsonElement root, List warnings) + { + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("The configuration root must be a JSON object."); + } + + foreach (JsonProperty section in root.EnumerateObject()) + { + if (section.Name.StartsWith('_')) + { + continue; + } + + switch (section.Name.ToLowerInvariant()) + { + case "alpaca": + ReadAlpaca(config, section.Value, warnings); + break; + case "engine": + ReadEngine(config, section.Value, warnings); + break; + case "risk": + ReadRisk(config, section.Value, warnings); + break; + case "logging": + ReadLogging(config, section.Value, warnings); + break; + case "ui": + ReadUi(config, section.Value, warnings); + break; + case "symbols": + ReadSymbols(config, section.Value, warnings); + break; + case "$schema": + case "_comment": + break; + default: + warnings.Add($"unknown config section '{section.Name}'"); + break; + } + } + } + + private static void ReadAlpaca(BotConfig config, JsonElement e, List warnings) + { + foreach (JsonProperty p in Properties(e, "alpaca", warnings)) + { + switch (p.Name.ToLowerInvariant()) + { + case "keyid": config.Alpaca.KeyId = Str(p); break; + case "secretkey": config.Alpaca.SecretKey = Str(p); break; + case "paper": config.Alpaca.Paper = Bool(p); break; + case "datafeed": config.Alpaca.DataFeed = Str(p); break; + case "tradingbaseurl": config.Alpaca.TradingBaseUrlOverride = Str(p); break; + case "databaseurl": config.Alpaca.DataBaseUrlOverride = Str(p); break; + case "requestsperminute": config.Alpaca.RequestsPerMinute = Int(p); break; + case "httptimeoutseconds": config.Alpaca.HttpTimeout = TimeSpan.FromSeconds(Num(p)); break; + case "maxretries": config.Alpaca.MaxRetries = Int(p); break; + default: warnings.Add($"unknown key 'alpaca.{p.Name}'"); break; + } + } + } + + private static void ReadEngine(BotConfig config, JsonElement e, List warnings) + { + EngineOptions o = config.Engine; + foreach (JsonProperty p in Properties(e, "engine", warnings)) + { + switch (p.Name.ToLowerInvariant()) + { + case "assetclass": o.AssetClass = Str(p); break; + case "timeframe": o.TimeFrame = Str(p); break; + case "warmupbars": o.WarmupBars = Int(p); break; + case "tradeonlyregularhours": o.TradeOnlyRegularHours = Bool(p); break; + case "flattenbeforeclosminutes": + case "flattenbeforecloseminutes": o.FlattenBeforeCloseMinutes = Int(p); break; + case "allowfractionalshares": o.AllowFractionalShares = Bool(p); break; + case "usebracketorders": o.UseBracketOrders = Bool(p); break; + case "entryordertype": o.EntryOrderType = Str(p); break; + case "limitoffsetbps": o.LimitOffsetBps = Num(p); break; + case "dryrun": o.DryRun = Bool(p); break; + case "reconcileseconds": o.ReconcileSeconds = Int(p); break; + case "statusseconds": o.StatusSeconds = Int(p); break; + case "explainseconds": o.ExplainSeconds = Int(p); break; + case "maxquoteageseconds": o.MaxQuoteAgeSeconds = Int(p); break; + case "closeonshutdown": o.CloseOnShutdown = Bool(p); break; + default: warnings.Add($"unknown key 'engine.{p.Name}'"); break; + } + } + } + + private static void ReadRisk(BotConfig config, JsonElement e, List warnings) + { + Core.Risk.RiskLimits r = config.Risk; + foreach (JsonProperty p in Properties(e, "risk", warnings)) + { + switch (p.Name.ToLowerInvariant()) + { + case "maxriskpertradepct": r.MaxRiskPerTradePct = Num(p); break; + case "stakepct": r.StakePct = Num(p); break; + case "stakeamount": r.StakeAmount = Num(p); break; + case "maxpositionnotionalpct": r.MaxPositionNotionalPct = Num(p); break; + case "maxgrossexposurepct": r.MaxGrossExposurePct = Num(p); break; + case "maxopenpositions": r.MaxOpenPositions = Int(p); break; + case "maxtradesperday": r.MaxTradesPerDay = Int(p); break; + case "maxtradespersymbolperday": r.MaxTradesPerSymbolPerDay = Int(p); break; + case "maxdailylosspct": r.MaxDailyLossPct = Num(p); break; + case "maxdailyprofitpct": r.MaxDailyProfitPct = Num(p); break; + case "minsecondsbetweenentries": r.MinSecondsBetweenEntries = Int(p); break; + case "maxrelativespread": r.MaxRelativeSpread = Num(p); break; + case "minprice": r.MinPrice = Num(p); break; + case "maxprice": r.MaxPrice = Num(p); break; + case "minordernotional": r.MinOrderNotional = Num(p); break; + case "maxordernotional": r.MaxOrderNotional = Num(p); break; + case "allowshorting": r.AllowShorting = Bool(p); break; + case "defaultstoppct": r.DefaultStopPct = Num(p); break; + case "maxstopdistancepct": r.MaxStopDistancePct = Num(p); break; + default: warnings.Add($"unknown key 'risk.{p.Name}'"); break; + } + } + } + + private static void ReadLogging(BotConfig config, JsonElement e, List warnings) + { + LoggingOptions o = config.Logging; + foreach (JsonProperty p in Properties(e, "logging", warnings)) + { + switch (p.Name.ToLowerInvariant()) + { + case "level": o.Level = Str(p); break; + case "console": o.Console = Bool(p); break; + case "directory": o.Directory = Str(p); break; + case "file": o.File = Str(p); break; + case "tradejournal": o.TradeJournal = Str(p); break; + case "decisionlog": o.DecisionLog = Str(p); break; + case "executionlog": o.ExecutionLog = Str(p); break; + case "maxfilesizemb": o.MaxFileSizeMb = Int(p); break; + case "maxfiles": o.MaxFiles = Int(p); break; + case "logmarketdata": o.LogMarketData = Bool(p); break; + case "logeverybar": o.LogEveryBar = Bool(p); break; + case "statuslines": o.StatusLines = Int(p); break; + case "bufferedlines": o.BufferedLines = Int(p); break; + default: warnings.Add($"unknown key 'logging.{p.Name}'"); break; + } + } + } + + private static void ReadUi(BotConfig config, JsonElement e, List warnings) + { + UiOptions o = config.Ui; + foreach (JsonProperty p in Properties(e, "ui", warnings)) + { + switch (p.Name.ToLowerInvariant()) + { + case "url": o.Url = Str(p); break; + case "autostartbot": o.AutoStartBot = Bool(p); break; + case "openbrowser": o.OpenBrowser = Bool(p); break; + default: warnings.Add($"unknown key 'ui.{p.Name}'"); break; + } + } + } + + private static void ReadSymbols(BotConfig config, JsonElement e, List warnings) + { + if (e.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("'symbols' must be an array."); + } + + config.Symbols.Clear(); + foreach (JsonElement item in e.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + config.Symbols.Add(new SymbolConfig { Symbol = item.GetString() ?? string.Empty }); + continue; + } + + if (item.ValueKind != JsonValueKind.Object) + { + warnings.Add("ignoring a non-object entry in 'symbols'"); + continue; + } + + SymbolConfig sc = new(); + foreach (JsonProperty p in item.EnumerateObject()) + { + if (p.Name.StartsWith('_')) + { + continue; + } + + switch (p.Name.ToLowerInvariant()) + { + case "symbol": sc.Symbol = Str(p); break; + case "strategy": sc.Strategy = Str(p); break; + case "enabled": sc.Enabled = Bool(p); break; + case "parameters" or "params": + if (p.Value.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty kv in p.Value.EnumerateObject()) + { + if (kv.Name.StartsWith('_')) + { + continue; + } + + sc.Parameters[kv.Name] = kv.Value.ValueKind switch + { + JsonValueKind.Number => kv.Value.GetDouble(), + JsonValueKind.True => 1, + JsonValueKind.False => 0, + JsonValueKind.String when double.TryParse( + kv.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, + out double parsed) => parsed, + _ => 0, + }; + } + } + + break; + default: warnings.Add($"unknown key 'symbols[].{p.Name}'"); break; + } + } + + config.Symbols.Add(sc); + } + } + + private static void ApplyEnvironment(BotConfig config) + { + // Alpaca's own variable names, so existing tooling keeps working. + bool fromEnvironment = false; + + string? key = Environment.GetEnvironmentVariable("APCA_API_KEY_ID"); + if (!string.IsNullOrWhiteSpace(key)) + { + config.Alpaca.KeyId = key.Trim(); + fromEnvironment = true; + } + + string? secret = Environment.GetEnvironmentVariable("APCA_API_SECRET_KEY"); + if (!string.IsNullOrWhiteSpace(secret)) + { + config.Alpaca.SecretKey = secret.Trim(); + fromEnvironment = true; + } + + bool haveBoth = + !string.IsNullOrWhiteSpace(config.Alpaca.KeyId) && + !string.IsNullOrWhiteSpace(config.Alpaca.SecretKey); + + config.CredentialOrigin = haveBoth + ? fromEnvironment ? CredentialSource.Environment : CredentialSource.ConfigFile + : CredentialSource.None; + + if (TryEnvBool("ENCELADO_PAPER", out bool paper)) + { + config.Alpaca.Paper = paper; + } + + if (TryEnvBool("ENCELADO_DRY_RUN", out bool dryRun)) + { + config.Engine.DryRun = dryRun; + } + + string? feed = Environment.GetEnvironmentVariable("ENCELADO_DATA_FEED"); + if (!string.IsNullOrWhiteSpace(feed)) + { + config.Alpaca.DataFeed = feed.Trim(); + } + + string? level = Environment.GetEnvironmentVariable("ENCELADO_LOG_LEVEL"); + if (!string.IsNullOrWhiteSpace(level)) + { + config.Logging.Level = level.Trim(); + } + } + + private static bool TryEnvBool(string name, out bool value) + { + string? raw = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(raw)) + { + value = false; + return false; + } + + raw = raw.Trim(); + value = raw is "1" or "true" or "True" or "TRUE" or "yes" or "YES" or "on"; + return true; + } + + private static IEnumerable Properties(JsonElement e, string section, List warnings) + { + if (e.ValueKind != JsonValueKind.Object) + { + warnings.Add($"'{section}' must be an object; ignored"); + yield break; + } + + foreach (JsonProperty p in e.EnumerateObject()) + { + // Keys beginning with '_' are inline documentation. JSON has no comments, + // and a config full of trading assumptions badly needs them. + if (!p.Name.StartsWith('_')) + { + yield return p; + } + } + } + + private static string Str(JsonProperty p) => p.Value.ValueKind switch + { + JsonValueKind.String => p.Value.GetString() ?? string.Empty, + JsonValueKind.Number => p.Value.GetDouble().ToString(CultureInfo.InvariantCulture), + _ => string.Empty, + }; + + private static double Num(JsonProperty p) => p.Value.ValueKind switch + { + JsonValueKind.Number => p.Value.GetDouble(), + JsonValueKind.String when double.TryParse( + p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d, + JsonValueKind.True => 1, + JsonValueKind.False => 0, + _ => throw new InvalidOperationException($"'{p.Name}' must be a number."), + }; + + private static int Int(JsonProperty p) => (int)Math.Round(Num(p)); + + private static bool Bool(JsonProperty p) => p.Value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => p.Value.GetDouble() != 0, + JsonValueKind.String => bool.TryParse(p.Value.GetString(), out bool b) && b, + _ => throw new InvalidOperationException($"'{p.Name}' must be a boolean."), + }; +} diff --git a/Encelado/src/Encelado.Bot/Configuration/ConfigWriter.cs b/Encelado/src/Encelado.Bot/Configuration/ConfigWriter.cs new file mode 100644 index 0000000..1d2f5bd --- /dev/null +++ b/Encelado/src/Encelado.Bot/Configuration/ConfigWriter.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Encelado.Bot.Configuration; + +/// +/// Targeted edits to encelado.json made from the settings screen. +/// +/// The file is parsed into a tree, one value is replaced, and +/// the tree is written back. Serialising a instead would be +/// simpler and wrong: it would silently delete every key the loader does not model — +/// including the _-prefixed lines that document what each number is for and why +/// it has that value — and reorder everything else. +/// +/// +/// The write goes to a temporary file first and is then moved into place, so a failure +/// halfway through leaves the previous configuration intact rather than a truncated +/// file the application cannot start from. +/// +/// +public static class ConfigWriter +{ + private static readonly JsonWriterOptions WriteOptions = new() { Indented = true }; + + private static readonly JsonDocumentOptions ReadOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// Sets logging.directory and saves. + public static void SetLogDirectory(string configPath, string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configPath); + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + Apply(configPath, new Dictionary { ["logging.directory"] = directory }); + } + + /// + /// Writes a batch of values addressed by dotted path, in one atomic save. + /// + /// Paths look like risk.stakePct, engine.timeFrame or + /// symbols[0].parameters.period. Missing intermediate objects are created; + /// missing array elements are an error, because inventing a symbol out of a typo + /// would be worse than refusing. + /// + /// + /// A batch is all-or-nothing on purpose. Applying half a settings screen would leave + /// a configuration that no one chose — for instance a stake raised without the + /// position cap that has to accompany it, which the validator would then reject at + /// the next start. + /// + /// + public static void Apply(string configPath, IReadOnlyDictionary changes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configPath); + ArgumentNullException.ThrowIfNull(changes); + + if (changes.Count == 0) + { + return; + } + + Update(configPath, root => + { + foreach ((string path, JsonNode? value) in changes) + { + SetPath(root, path, value); + } + }); + } + + private static void SetPath(JsonObject root, string path, JsonNode? value) + { + string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw new ArgumentException($"Percorso vuoto.", nameof(path)); + } + + JsonNode current = root; + + for (int i = 0; i < segments.Length - 1; i++) + { + current = Descend(current, segments[i], path); + } + + (string name, int? index) = Parse(segments[^1]); + + if (index is { } arrayIndex) + { + JsonArray array = Array(current, name, path); + if (arrayIndex >= array.Count) + { + throw new InvalidOperationException( + $"'{path}': l'elemento {arrayIndex} non esiste in '{name}'."); + } + + array[arrayIndex] = value; + return; + } + + if (current is not JsonObject target) + { + throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto."); + } + + target[name] = value; + } + + private static JsonNode Descend(JsonNode current, string segment, string path) + { + (string name, int? index) = Parse(segment); + + if (index is { } arrayIndex) + { + JsonArray array = Array(current, name, path); + if (arrayIndex >= array.Count) + { + throw new InvalidOperationException( + $"'{path}': l'elemento {arrayIndex} non esiste in '{name}'."); + } + + return array[arrayIndex] + ?? throw new InvalidOperationException($"'{path}': '{name}[{arrayIndex}]' è null."); + } + + if (current is not JsonObject parent) + { + throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto."); + } + + if (parent[name] is not JsonObject child) + { + child = []; + parent[name] = child; + } + + return child; + } + + private static JsonArray Array(JsonNode current, string name, string path) + { + if (current is not JsonObject parent) + { + throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto."); + } + + return parent[name] as JsonArray + ?? throw new InvalidOperationException($"'{path}': '{name}' non è un array."); + } + + /// Splits symbols[0] into its name and index. + private static (string Name, int? Index) Parse(string segment) + { + int bracket = segment.IndexOf('[', StringComparison.Ordinal); + if (bracket < 0) + { + return (segment, null); + } + + if (!segment.EndsWith(']') || + !int.TryParse(segment.AsSpan(bracket + 1, segment.Length - bracket - 2), out int index) || + index < 0) + { + throw new ArgumentException($"Indice non valido in '{segment}'."); + } + + return (segment[..bracket], index); + } + + private static void Update(string path, Action edit) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Configurazione non trovata: {path}", path); + } + + JsonNode? parsed = JsonNode.Parse(File.ReadAllText(path), documentOptions: ReadOptions); + if (parsed is not JsonObject root) + { + throw new InvalidOperationException($"{path} non contiene un oggetto JSON."); + } + + edit(root); + + string temporary = path + ".tmp"; + + using (FileStream stream = File.Create(temporary)) + using (Utf8JsonWriter writer = new(stream, WriteOptions)) + { + root.WriteTo(writer); + } + + File.Move(temporary, path, overwrite: true); + } +} diff --git a/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs b/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs new file mode 100644 index 0000000..436bd98 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs @@ -0,0 +1,123 @@ +using Encelado.Alpaca; +using Encelado.Alpaca.Rest; + +namespace Encelado.Bot.Configuration; + +/// Outcome of trying to find usable Alpaca credentials. +public readonly record struct CredentialLookup(bool Found, CredentialSource Source, string MaskedKey) +{ + public string Describe() => Source switch + { + CredentialSource.ConfigFile => $"file di configurazione ({MaskedKey})", + CredentialSource.Environment => $"variabili d'ambiente ({MaskedKey})", + CredentialSource.SavedStore => $"chiavi salvate ({MaskedKey})", + CredentialSource.Interactive => $"inserite a mano ({MaskedKey})", + _ => "nessuna credenziale", + }; +} + +/// +/// Decides which credentials the app should use, and verifies candidates against the +/// broker before they are trusted. Deliberately UI-free: the window owns the dialog, +/// this owns the policy. +/// +public static class CredentialResolver +{ + /// + /// Resolves in order of explicitness: environment variables (automation), then keys + /// saved by the user, then the config file. Mutates with + /// whatever it settles on. + /// + public static CredentialLookup Resolve(BotConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + if (config.CredentialOrigin == CredentialSource.Environment) + { + return new CredentialLookup(true, CredentialSource.Environment, + CredentialStore.Mask(config.Alpaca.KeyId)); + } + + if (CredentialStore.Load(config.Alpaca.Paper) is { } saved) + { + config.Alpaca.KeyId = saved.KeyId; + config.Alpaca.SecretKey = saved.SecretKey; + config.CredentialOrigin = CredentialSource.SavedStore; + return new CredentialLookup(true, CredentialSource.SavedStore, CredentialStore.Mask(saved.KeyId)); + } + + if (config.CredentialOrigin == CredentialSource.ConfigFile) + { + return new CredentialLookup(true, CredentialSource.ConfigFile, + CredentialStore.Mask(config.Alpaca.KeyId)); + } + + return new CredentialLookup(false, CredentialSource.None, string.Empty); + } + + /// + /// Confirms a key pair actually works by asking Alpaca for the account. Returns the + /// account on success so the caller can show who just logged in. + /// + public static async Task<(bool Ok, string Message, AlpacaAccount? Account)> VerifyAsync( + string keyId, + string secretKey, + bool paper, + CancellationToken ct) + { + AlpacaOptions probe = new() + { + KeyId = keyId, + SecretKey = secretKey, + Paper = paper, + HttpTimeout = TimeSpan.FromSeconds(20), + MaxRetries = 1, + }; + + try + { + probe.Validate(); + } + catch (InvalidOperationException ex) + { + return (false, ex.Message, null); + } + + try + { + using AlpacaTradingClient client = new(probe); + AlpacaAccount account = await client.GetAccountAsync(ct).ConfigureAwait(false); + return (true, $"Conto {account.AccountNumber} — {account.Status}", account); + } + catch (AlpacaApiException ex) when (ex.StatusCode is 401 or 403) + { + string hint = paper && keyId.StartsWith("AK", StringComparison.OrdinalIgnoreCase) + ? " Sembra una chiave LIVE ma l'app è impostata su paper." + : !paper && keyId.StartsWith("PK", StringComparison.OrdinalIgnoreCase) + ? " Sembra una chiave PAPER ma l'app è impostata su live." + : string.Empty; + + return (false, $"Alpaca ha rifiutato le credenziali (HTTP {ex.StatusCode}).{hint}", null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return (false, $"Impossibile contattare Alpaca: {ex.Message}", null); + } + } + + /// Stores a verified key pair and points the config at it. + public static void Apply(BotConfig config, string keyId, string secretKey, bool save) + { + ArgumentNullException.ThrowIfNull(config); + + config.Alpaca.KeyId = keyId; + config.Alpaca.SecretKey = secretKey; + config.CredentialOrigin = CredentialSource.Interactive; + + if (save) + { + CredentialStore.Save(config.Alpaca.Paper, keyId, secretKey); + config.CredentialOrigin = CredentialSource.SavedStore; + } + } +} diff --git a/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs b/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs new file mode 100644 index 0000000..b423ff3 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs @@ -0,0 +1,282 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text.Json; + +namespace Encelado.Bot.Configuration; + +/// Credentials plus a human-readable note about where they came from. +public readonly record struct StoredCredentials(string KeyId, string SecretKey); + +/// +/// Persists Alpaca API keys outside the repository, per user and per environment +/// (paper keys and live keys are different keys, so they are stored separately). +/// +/// On Windows the file is encrypted with DPAPI bound to the current user account: +/// another user on the same machine cannot read it, and it needs no passphrase, which +/// matters for a bot that has to restart unattended. On other platforms DPAPI does not +/// exist, so the file is written as plain JSON with owner-only permissions and +/// reports so callers can warn. +/// +/// +public static class CredentialStore +{ + private const string PaperKey = "paper"; + private const string LiveKey = "live"; + + /// True when the file at rest is encrypted rather than merely permission-restricted. + public static bool IsEncrypted => OperatingSystem.IsWindows(); + + /// + /// Where the store lives. ENCELADO_HOME overrides it, which keeps portable + /// installs and containers self-contained — and lets the tests run without ever + /// touching the real user profile. Read on every access so it stays overridable. + /// + public static string DirectoryPath => + Environment.GetEnvironmentVariable("ENCELADO_HOME") is { Length: > 0 } custom + ? custom + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Encelado"); + + public static string FilePath => Path.Combine(DirectoryPath, "credentials.dat"); + + public static bool Exists => File.Exists(FilePath); + + /// Reads the credentials saved for the given environment, or null when there are none. + public static StoredCredentials? Load(bool paper) + { + Dictionary all = LoadAll(); + return all.TryGetValue(paper ? PaperKey : LiveKey, out StoredCredentials found) ? found : null; + } + + public static void Save(bool paper, string keyId, string secretKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); + + Dictionary all = LoadAll(); + all[paper ? PaperKey : LiveKey] = new StoredCredentials(keyId.Trim(), secretKey.Trim()); + Write(all); + } + + /// Removes the credentials for one environment. Returns whether anything was removed. + public static bool Clear(bool paper) + { + Dictionary all = LoadAll(); + if (!all.Remove(paper ? PaperKey : LiveKey)) + { + return false; + } + + if (all.Count == 0) + { + Delete(); + } + else + { + Write(all); + } + + return true; + } + + public static bool ClearAll() + { + if (!Exists) + { + return false; + } + + Delete(); + return true; + } + + /// + /// Strips control characters, byte-order marks and stray spacing from a pasted + /// credential. Keys copied out of a browser routinely carry a zero-width space or a + /// BOM, which would surface much later as an opaque "invalid char encoding" failure + /// deep inside the HTTP stack. + /// + public static string? Clean(string? raw) + { + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + Span buffer = raw.Length <= 256 ? stackalloc char[raw.Length] : new char[raw.Length]; + int length = 0; + + foreach (char c in raw) + { + if (!char.IsControl(c) && c != '\uFEFF' && c != '\u200B' && c != '\u00A0') + { + buffer[length++] = c; + } + } + + string cleaned = new string(buffer[..length]).Trim(); + return cleaned.Length == 0 ? null : cleaned; + } + + /// + /// Masks a key for display. Only the first four characters survive — enough to tell + /// a paper key (PK…) from a live one (AK…) and to recognise which key + /// is loaded, without putting anything reusable into a log file that may be shared. + /// + public static string Mask(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return "(empty)"; + } + + if (value.Length <= 4) + { + return new string('*', value.Length); + } + + return value[..4] + new string('*', Math.Min(12, value.Length - 4)); + } + + private static Dictionary LoadAll() + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + if (!File.Exists(FilePath)) + { + return result; + } + + byte[] raw; + try + { + raw = File.ReadAllBytes(FilePath); + } + catch (IOException) + { + return result; + } + catch (UnauthorizedAccessException) + { + return result; + } + + byte[] plaintext; + try + { + plaintext = Unprotect(raw); + } + catch (CryptographicException) + { + // Written by a different Windows user, or the file is corrupt. Treat it as + // absent so the caller falls back to prompting instead of crashing. + return result; + } + + try + { + using JsonDocument doc = JsonDocument.Parse(plaintext); + foreach (JsonProperty entry in doc.RootElement.EnumerateObject()) + { + string? keyId = entry.Value.TryGetProperty("keyId", out JsonElement k) ? k.GetString() : null; + string? secret = entry.Value.TryGetProperty("secretKey", out JsonElement s) ? s.GetString() : null; + + if (!string.IsNullOrWhiteSpace(keyId) && !string.IsNullOrWhiteSpace(secret)) + { + result[entry.Name] = new StoredCredentials(keyId, secret); + } + } + } + catch (JsonException) + { + return []; + } + finally + { + CryptographicOperations.ZeroMemory(plaintext); + } + + return result; + } + + private static void Write(Dictionary all) + { + ArrayBufferWriter buffer = new(256); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + foreach ((string environment, StoredCredentials credentials) in all) + { + w.WriteStartObject(environment); + w.WriteString("keyId", credentials.KeyId); + w.WriteString("secretKey", credentials.SecretKey); + w.WriteEndObject(); + } + + w.WriteEndObject(); + } + + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + + byte[] payload = Protect(buffer.WrittenSpan); + try + { + File.WriteAllBytes(FilePath, payload); + RestrictPermissions(FilePath); + } + finally + { + CryptographicOperations.ZeroMemory(payload); + } + } + + private static void Delete() + { + try + { + File.Delete(FilePath); + } + catch (IOException) + { + // Nothing more we can do; the caller reports the path. + } + } + + private static byte[] Protect(ReadOnlySpan plaintext) + { + if (OperatingSystem.IsWindows()) + { + return ProtectedData.Protect(plaintext.ToArray(), optionalEntropy: null, DataProtectionScope.CurrentUser); + } + + return plaintext.ToArray(); + } + + private static byte[] Unprotect(byte[] stored) + { + if (OperatingSystem.IsWindows()) + { + return ProtectedData.Unprotect(stored, optionalEntropy: null, DataProtectionScope.CurrentUser); + } + + return stored; + } + + /// Owner-only access. On Windows DPAPI already scopes the data to the user. + private static void RestrictPermissions(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + // Best effort: the caller already warns that the file is not encrypted here. + } + } +} diff --git a/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs b/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs new file mode 100644 index 0000000..97e3cae --- /dev/null +++ b/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs @@ -0,0 +1,358 @@ +using System.Globalization; +using System.Text; +using Encelado.Bot.Configuration; +using Encelado.Bot.Logging; +using Encelado.Core.Market; +using Encelado.Core.Portfolio; +using Encelado.Core.Strategies; + +namespace Encelado.Bot.Diagnostics; + +/// +/// Structured, machine-readable record of everything the engine decided and why. +/// +/// Two CSV files, joined on decisionId: +/// +/// +/// decisions — one row per evaluated bar per symbol: the bar itself +/// including the aggressor breakdown, every indicator the strategy publishes, the +/// position at the time, and the signal that came out. This is the dataset to load +/// into pandas when asking "why did it do that" or "would a different threshold have +/// helped". +/// executions — one row per signal that reached the order path: the risk +/// verdict, the size that survived it, and the broker's answer. +/// +/// +/// CSV rather than JSON on purpose: it opens in Excel, loads in one line of pandas, and +/// stays readable when a run produces tens of thousands of rows. Writes are buffered and +/// flushed on a timer, so the decision path never waits on the disk. +/// +/// +public sealed class AnalyticsLog : IDisposable +{ + private readonly StreamWriter? _decisions; + private readonly StreamWriter? _executions; + private readonly Lock _gate = new(); + private readonly StringBuilder _row = new(512); + + private string[] _metricNames = []; + private bool _decisionHeaderWritten; + private long _nextId; + private DateTime _lastFlush = DateTime.UtcNow; + + public AnalyticsLog(LoggingOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _decisions = Open(options.ResolvePath(options.DecisionLog)); + _executions = Open(options.ResolvePath(options.ExecutionLog)); + + if (_executions is not null && _executions.BaseStream.Length == 0) + { + _executions.WriteLine( + "timestampUtc,decisionId,symbol,side,phase,approved,riskReason,riskDetail," + + "quantity,referencePrice,stopPrice,targetPrice,notional,equity,buyingPower," + + "grossExposure,openPositions,orderId,error,latencyMs"); + } + } + + public bool IsEnabled => _decisions is not null || _executions is not null; + + public string? DecisionPath { get; private init; } + + /// Allocates the id that ties a decision row to its execution row. + public long NextDecisionId() => Interlocked.Increment(ref _nextId); + + /// + /// Records one bar evaluation. Called on the market-data thread once per closed + /// bar per symbol — a handful of times a day on this configuration, so the cost is + /// irrelevant, but it stays buffered anyway. + /// + public void Decision( + long decisionId, + string symbol, + in Bar bar, + IStrategy strategy, + in PositionView position, + in Signal signal, + in Quote quote, + double quoteAgeSeconds, + double equity, + bool sessionOpen, + bool halted) + { + if (_decisions is null) + { + return; + } + + IReadOnlyList metrics = strategy.Diagnostics; + + lock (_gate) + { + if (!_decisionHeaderWritten) + { + WriteDecisionHeader(metrics); + } + + _row.Clear(); + + Add(bar.TimeUtc.ToString("O", CultureInfo.InvariantCulture)); + Add(decisionId); + Add(symbol); + Add(strategy.Name); + Add(strategy.IsReady ? 1 : 0); + + Add(bar.Open); + Add(bar.High); + Add(bar.Low); + Add(bar.Close); + Add(bar.Volume); + Add(bar.TakerBuyVolume); + Add(bar.Delta); + Add(bar.TradeCount); + + // Indicator values, in the same order the header declared. + foreach (string name in _metricNames) + { + double value = 0; + foreach (StrategyMetric m in metrics) + { + if (m.Name == name) + { + value = double.IsFinite(m.Value) ? m.Value : 0; + break; + } + } + + Add(value); + } + + Add(position.Quantity); + Add(position.AverageEntryPrice); + Add(position.UnrealizedPnl); + Add(position.BarsHeld); + Add(position.StopPrice); + Add(position.TargetPrice); + + Add(signal.Kind.ToString()); + Add(signal.Strength); + Add(signal.StopPrice); + Add(signal.TargetPrice); + + Add(quote.IsValid ? quote.BidPrice : 0); + Add(quote.IsValid ? quote.AskPrice : 0); + Add(quote.IsValid ? quote.RelativeSpread : 0); + Add(quoteAgeSeconds); + + Add(equity); + Add(sessionOpen ? 1 : 0); + Add(halted ? 1 : 0); + Add(signal.Reason, last: true); + + _decisions.WriteLine(_row.ToString()); + MaybeFlush(); + } + } + + /// Records what the order path did with a signal. + public void Execution( + long decisionId, + string symbol, + Side side, + string phase, + bool approved, + string riskReason, + string riskDetail, + double quantity, + double referencePrice, + double stopPrice, + double targetPrice, + double equity, + double buyingPower, + double grossExposure, + int openPositions, + string? orderId, + string? error, + double latencyMs) + { + if (_executions is null) + { + return; + } + + lock (_gate) + { + _row.Clear(); + + Add(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + Add(decisionId); + Add(symbol); + Add(side.ToString()); + Add(phase); + Add(approved ? 1 : 0); + Add(riskReason); + Add(riskDetail); + Add(quantity); + Add(referencePrice); + Add(stopPrice); + Add(targetPrice); + Add(quantity * referencePrice); + Add(equity); + Add(buyingPower); + Add(grossExposure); + Add(openPositions); + Add(orderId ?? string.Empty); + Add(error ?? string.Empty); + Add(latencyMs, last: true); + + _executions.WriteLine(_row.ToString()); + MaybeFlush(); + } + } + + private void WriteDecisionHeader(IReadOnlyList metrics) + { + string[] names = new string[metrics.Count]; + for (int i = 0; i < metrics.Count; i++) + { + names[i] = metrics[i].Name; + } + + _metricNames = names; + _decisionHeaderWritten = true; + + if (_decisions!.BaseStream.Length > 0) + { + // Appending to an existing file: keep its header rather than writing a + // second one in the middle. + return; + } + + StringBuilder header = new(400); + header.Append("barTimeUtc,decisionId,symbol,strategy,ready,") + .Append("open,high,low,close,volume,takerBuyVolume,delta,trades,"); + + foreach (string name in names) + { + header.Append(name).Append(','); + } + + header.Append("positionQty,positionEntry,positionPnl,barsHeld,positionStop,positionTarget,") + .Append("signal,signalStrength,signalStop,signalTarget,") + .Append("bid,ask,spreadPct,quoteAgeSec,equity,sessionOpen,halted,reason"); + + _decisions.WriteLine(header.ToString()); + } + + private void Add(double value, bool last = false) + { + if (double.IsFinite(value)) + { + _row.Append(value.ToString("G10", CultureInfo.InvariantCulture)); + } + + if (!last) + { + _row.Append(','); + } + } + + private void Add(long value, bool last = false) + { + _row.Append(value.ToString(CultureInfo.InvariantCulture)); + if (!last) + { + _row.Append(','); + } + } + + private void Add(string? value, bool last = false) + { + if (!string.IsNullOrEmpty(value)) + { + // Quote only when necessary; a reason string routinely contains commas. + if (value.AsSpan().IndexOfAny(',', '"', '\n') >= 0) + { + _row.Append('"').Append(value.Replace("\"", "\"\"", StringComparison.Ordinal)).Append('"'); + } + else + { + _row.Append(value); + } + } + + if (!last) + { + _row.Append(','); + } + } + + private void MaybeFlush() + { + if (DateTime.UtcNow - _lastFlush < TimeSpan.FromSeconds(5)) + { + return; + } + + _lastFlush = DateTime.UtcNow; + Flush(); + } + + public void Flush() + { + lock (_gate) + { + try + { + _decisions?.Flush(); + _executions?.Flush(); + } + catch (IOException ex) + { + Log.Warn($"analytics flush failed: {ex.Message}"); + } + } + } + + private static StreamWriter? Open(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + return new StreamWriter( + new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 16384), + Encoding.UTF8) + { AutoFlush = false }; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warn($"cannot open analytics file {path}: {ex.Message}"); + return null; + } + } + + public void Dispose() + { + lock (_gate) + { + try + { + _decisions?.Flush(); + _executions?.Flush(); + } + catch (IOException) + { + // Best effort on shutdown. + } + + _decisions?.Dispose(); + _executions?.Dispose(); + } + } +} diff --git a/Encelado/src/Encelado.Bot/Diagnostics/Metrics.cs b/Encelado/src/Encelado.Bot/Diagnostics/Metrics.cs new file mode 100644 index 0000000..ad5ca83 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Diagnostics/Metrics.cs @@ -0,0 +1,158 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace Encelado.Bot.Diagnostics; + +/// +/// Fixed-bucket latency histogram. Recording is a single interlocked increment, so it +/// can sit directly on the decision path without perturbing what it measures. +/// +public sealed class LatencyHistogram(string name) +{ + private static readonly long[] BoundsMicros = + [50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 1_000_000, long.MaxValue]; + + private readonly long[] _buckets = new long[BoundsMicros.Length]; + private long _count; + private long _sumMicros; + private long _maxMicros; + + public string Name { get; } = name; + + public long Count => Interlocked.Read(ref _count); + + public void Record(long micros) + { + if (micros < 0) + { + return; + } + + int index = 0; + while (index < BoundsMicros.Length - 1 && micros > BoundsMicros[index]) + { + index++; + } + + Interlocked.Increment(ref _buckets[index]); + Interlocked.Increment(ref _count); + Interlocked.Add(ref _sumMicros, micros); + + long observedMax = Interlocked.Read(ref _maxMicros); + while (micros > observedMax) + { + long previous = Interlocked.CompareExchange(ref _maxMicros, micros, observedMax); + if (previous == observedMax) + { + break; + } + + observedMax = previous; + } + } + + /// Records the elapsed time since a reading. + public void RecordSince(long startTimestamp) => + Record((long)Stopwatch.GetElapsedTime(startTimestamp).TotalMicroseconds); + + public string Summary() + { + long total = Interlocked.Read(ref _count); + if (total == 0) + { + return $"{Name}: no samples"; + } + + double mean = Interlocked.Read(ref _sumMicros) / (double)total; + return string.Create(CultureInfo.InvariantCulture, + $"{Name}: n={total} avg={Format(mean)} p50={Format(Percentile(0.50, total))} " + + $"p95={Format(Percentile(0.95, total))} p99={Format(Percentile(0.99, total))} " + + $"max={Format(Interlocked.Read(ref _maxMicros))}"); + } + + /// Upper bound of the bucket containing the requested percentile. + private double Percentile(double percentile, long total) + { + long target = (long)Math.Ceiling(percentile * total); + long running = 0; + for (int i = 0; i < _buckets.Length; i++) + { + running += Interlocked.Read(ref _buckets[i]); + if (running >= target) + { + return BoundsMicros[i] == long.MaxValue ? BoundsMicros[^2] : BoundsMicros[i]; + } + } + + return BoundsMicros[^2]; + } + + private static string Format(double micros) => + micros >= 1000 + ? string.Create(CultureInfo.InvariantCulture, $"{micros / 1000:F1}ms") + : string.Create(CultureInfo.InvariantCulture, $"{micros:F0}us"); +} + +/// Process-wide counters and latency traces for the trading loop. +public sealed class Metrics +{ + private long _trades; + private long _quotes; + private long _bars; + private long _signals; + private long _ordersSubmitted; + private long _ordersFilled; + private long _orderErrors; + private long _riskRejects; + private long _exits; + + public LatencyHistogram BarToSignal { get; } = new("bar->signal"); + + public LatencyHistogram SignalToOrder { get; } = new("signal->order"); + + public long Trades => Interlocked.Read(ref _trades); + + public long Quotes => Interlocked.Read(ref _quotes); + + public long Bars => Interlocked.Read(ref _bars); + + public long Signals => Interlocked.Read(ref _signals); + + public long OrdersSubmitted => Interlocked.Read(ref _ordersSubmitted); + + public long OrdersFilled => Interlocked.Read(ref _ordersFilled); + + public long OrderErrors => Interlocked.Read(ref _orderErrors); + + public long RiskRejects => Interlocked.Read(ref _riskRejects); + + public long Exits => Interlocked.Read(ref _exits); + + public void CountTrade() => Interlocked.Increment(ref _trades); + + public void CountQuote() => Interlocked.Increment(ref _quotes); + + public void CountBar() => Interlocked.Increment(ref _bars); + + public void CountSignal() => Interlocked.Increment(ref _signals); + + public void CountOrderSubmitted() => Interlocked.Increment(ref _ordersSubmitted); + + public void CountOrderFilled() => Interlocked.Increment(ref _ordersFilled); + + public void CountOrderError() => Interlocked.Increment(ref _orderErrors); + + public void CountRiskReject() => Interlocked.Increment(ref _riskRejects); + + public void CountExit() => Interlocked.Increment(ref _exits); + + public string Summary() + { + StringBuilder sb = new(256); + sb.Append(CultureInfo.InvariantCulture, $"ticks={Trades} quotes={Quotes} bars={Bars} ") + .Append(CultureInfo.InvariantCulture, $"signals={Signals} orders={OrdersSubmitted} fills={OrdersFilled} ") + .Append(CultureInfo.InvariantCulture, $"exits={Exits} riskRejects={RiskRejects} errors={OrderErrors}"); + return sb.ToString(); + } +} diff --git a/Encelado/src/Encelado.Bot/Encelado.Bot.csproj b/Encelado/src/Encelado.Bot/Encelado.Bot.csproj new file mode 100644 index 0000000..5dc8bc9 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Encelado.Bot.csproj @@ -0,0 +1,50 @@ + + + + + net10.0-windows + WinExe + true + + + enable + enable + latest + + + false + false + Encelado.Bot + Encelado + Assets\encelado.ico + true + false + false + false + + true + + + + + + + + + + + + + + + + diff --git a/Encelado/src/Encelado.Bot/Engine/AccountState.cs b/Encelado/src/Encelado.Bot/Engine/AccountState.cs new file mode 100644 index 0000000..f99924b --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/AccountState.cs @@ -0,0 +1,68 @@ +using Encelado.Alpaca.Rest; + +namespace Encelado.Bot.Engine; + +/// +/// Latest known account snapshot, refreshed by the reconciler and read from the +/// order path. Fields are written as a unit under a lock and read without one, which +/// is fine: sizing only needs a recent value, not a transactionally consistent one. +/// +public sealed class AccountState +{ + private double _equity; + private double _buyingPower; + private double _cash; + private int _daytradeCount; + private bool _patternDayTrader; + private bool _canTrade; + private bool _shortingEnabled; + + // Broker-side detail the account page shows verbatim. None of it is on the decision + // path, so a reference swap under the same update is all the consistency needed. + private AlpacaAccount? _raw; + + public double Equity => Volatile.Read(ref _equity); + + public double BuyingPower => Volatile.Read(ref _buyingPower); + + public double Cash => Volatile.Read(ref _cash); + + public int DaytradeCount => Volatile.Read(ref _daytradeCount); + + public bool PatternDayTrader => Volatile.Read(ref _patternDayTrader); + + public bool CanTrade => Volatile.Read(ref _canTrade); + + public bool ShortingEnabled => Volatile.Read(ref _shortingEnabled); + + public DateTime LastUpdateUtc { get; private set; } + + public bool HasData => Equity > 0; + + /// + /// The last full account payload from the broker, or before + /// the first reconcile. Dashboard only — the trading path reads the fields above. + /// + public AlpacaAccount? Raw => Volatile.Read(ref _raw); + + public void Update(AlpacaAccount account) + { + ArgumentNullException.ThrowIfNull(account); + + Volatile.Write(ref _raw, account); + Volatile.Write(ref _equity, (double)account.Equity); + Volatile.Write(ref _buyingPower, (double)account.BuyingPower); + Volatile.Write(ref _cash, (double)account.Cash); + Volatile.Write(ref _daytradeCount, account.DaytradeCount); + Volatile.Write(ref _patternDayTrader, account.PatternDayTrader); + Volatile.Write(ref _canTrade, account.CanTrade); + Volatile.Write(ref _shortingEnabled, account.ShortingEnabled); + LastUpdateUtc = DateTime.UtcNow; + } + + /// + /// Under the PDT rule an account flagged as a pattern day trader with less than + /// $25 000 in equity cannot open a new day trade. + /// + public bool IsDayTradeBlocked => PatternDayTrader && Equity < 25_000; +} diff --git a/Encelado/src/Encelado.Bot/Engine/BarAggregator.cs b/Encelado/src/Encelado.Bot/Engine/BarAggregator.cs new file mode 100644 index 0000000..5101105 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/BarAggregator.cs @@ -0,0 +1,83 @@ +using Encelado.Core.Market; + +namespace Encelado.Bot.Engine; + +/// +/// Folds Alpaca's one-minute stream bars into the strategy timeframe. A bucket is +/// emitted as soon as the first bar of the next bucket arrives, which is the earliest +/// moment the previous one is provably complete. +/// +public sealed class BarAggregator(int minutesPerBar) +{ + private readonly long _bucketTicks = TimeSpan.TicksPerMinute * Math.Max(1, minutesPerBar); + private readonly bool _passthrough = minutesPerBar <= 1; + + private Bar _current; + private long _bucket = -1; + private bool _has; + + public bool IsPassthrough => _passthrough; + + /// + /// Feeds a one-minute bar. Returns when a higher-timeframe + /// bar closed, with the completed bar in . + /// + public bool TryAdd(in Bar minuteBar, out Bar closed) + { + if (_passthrough) + { + closed = minuteBar; + return true; + } + + long bucket = minuteBar.TimeUtc.Ticks / _bucketTicks; + + if (!_has) + { + _current = minuteBar; + _bucket = bucket; + _has = true; + closed = default; + return false; + } + + if (bucket != _bucket) + { + closed = _current; + _current = minuteBar; + _bucket = bucket; + return true; + } + + _current = Merge(_current, minuteBar); + closed = default; + return false; + } + + public void Reset() + { + _has = false; + _bucket = -1; + _current = default; + } + + private static Bar Merge(in Bar acc, in Bar next) + { + double volume = acc.Volume + next.Volume; + double vwap = volume > 0 + ? ((acc.Vwap > 0 ? acc.Vwap : acc.TypicalPrice) * acc.Volume + + (next.Vwap > 0 ? next.Vwap : next.TypicalPrice) * next.Volume) / volume + : next.Close; + + return new Bar( + acc.TimeUtc, + acc.Open, + Math.Max(acc.High, next.High), + Math.Min(acc.Low, next.Low), + next.Close, + volume, + vwap, + acc.TradeCount + next.TradeCount, + acc.TakerBuyVolume + next.TakerBuyVolume); + } +} diff --git a/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs b/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs new file mode 100644 index 0000000..213a609 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs @@ -0,0 +1,295 @@ +namespace Encelado.Bot.Engine; + +public enum BotState +{ + Stopped = 0, + Starting, + Running, + Stopping, + Faulted, +} + +/// One open position, as the positions grid shows it. +public sealed record PositionRow( + string Symbol, + string Side, + double Quantity, + double EntryPrice, + double LastPrice, + double MarketValue, + double UnrealizedPnl, + double UnrealizedPnlPct, + double StopPrice, + double TargetPrice, + int BarsHeld, + DateTime OpenedAtUtc); + +/// Per-symbol strategy state, including whatever the strategy chooses to expose. +public sealed record SymbolRow( + string Symbol, + string Strategy, + bool Ready, + int BarsSeen, + int WarmupBars, + double LastPrice, + double BidPrice, + double AskPrice, + double SpreadPct, + double QuoteAgeSeconds, + bool InPosition, + IReadOnlyList Metrics) +{ + public double WarmupProgress => WarmupBars > 0 ? Math.Min(1, BarsSeen / (double)WarmupBars) : 1; + + /// The blended conviction, when the strategy publishes one. + public double? Score + { + get + { + foreach (MetricRow m in Metrics) + { + if (m.Name == "score") + { + return m.Value; + } + } + + return null; + } + } +} + +public sealed record MetricRow(string Name, double Value, string Format) +{ + public string Display => Format switch + { + "P1" => (Value * 100).ToString("F1", System.Globalization.CultureInfo.CurrentCulture) + "%", + "F0" => Value.ToString("F0", System.Globalization.CultureInfo.CurrentCulture), + _ => Value.ToString("F2", System.Globalization.CultureInfo.CurrentCulture), + }; +} + +/// A point on the session equity curve. +public sealed record EquityPoint(DateTime TimeUtc, double Equity); + +public sealed record EventRow(string Time, string Level, string Message); + +/// +/// The broker's own view of the account, shown verbatim on the account page. Kept as a +/// separate record rather than folded into so it can be +/// absent — before the first reconcile there is nothing truthful to display, and +/// showing zeros would look like a funded account that lost everything. +/// +public sealed record AccountRow( + string AccountNumber, + string Status, + string Currency, + double Equity, + double LastEquity, + double Cash, + double PortfolioValue, + double BuyingPower, + double DaytradingBuyingPower, + double Multiplier, + int DaytradeCount, + bool PatternDayTrader, + bool TradingBlocked, + bool AccountBlocked, + bool TransfersBlocked, + bool ShortingEnabled, + DateTime UpdatedUtc) +{ + public double ChangeToday => Equity - LastEquity; + + public double ChangeTodayPct => LastEquity > 0 ? (Equity - LastEquity) / LastEquity : 0; + + /// Everything that would make the broker refuse an order, in one line. + public string Restrictions + { + get + { + List issues = []; + if (TradingBlocked) { issues.Add("trading bloccato"); } + if (AccountBlocked) { issues.Add("conto bloccato"); } + if (TransfersBlocked) { issues.Add("trasferimenti bloccati"); } + if (PatternDayTrader && Equity < 25_000) { issues.Add("PDT sotto i 25.000"); } + return issues.Count == 0 ? "nessuna" : string.Join(", ", issues); + } + } +} + +/// One row of the orders page. +public sealed record OrderRow( + string OrderId, + string Symbol, + string Side, + string Type, + string Status, + double Quantity, + double FilledQuantity, + double FilledAveragePrice, + double LimitPrice, + DateTime SubmittedUtc, + DateTime? FilledUtc) +{ + public bool IsWorking { get; init; } + + public double Notional => FilledQuantity > 0 && FilledAveragePrice > 0 + ? FilledQuantity * FilledAveragePrice + : Quantity * (double.IsFinite(LimitPrice) && LimitPrice > 0 ? LimitPrice : 0); + + public string SubmittedLocal => SubmittedUtc.ToLocalTime().ToString("dd/MM HH:mm:ss", + System.Globalization.CultureInfo.CurrentCulture); +} + +/// Price memory for one charted symbol. +public sealed record PriceSeriesRow( + string Symbol, + double LastPrice, + double SessionOpen, + double SessionHigh, + double SessionLow, + double SessionChangePct, + IReadOnlyList BarCloses, + IReadOnlyList BarHighs, + IReadOnlyList BarLows, + IReadOnlyList BarOpens, + IReadOnlyList LivePrices) +{ + public bool HasBars => BarCloses.Count > 1; + + public bool HasLive => LivePrices.Count > 1; +} + +/// +/// Everything the UI renders, in one immutable object built without holding any engine +/// lock. Handing off a value rather than exposing live state means repainting the +/// window can never perturb or block the trading path. +/// +public sealed record BotSnapshot +{ + public required BotState State { get; init; } + + public string? Error { get; init; } + + public DateTime? StartedAtUtc { get; init; } + + public TimeSpan Uptime { get; init; } + + public required string Mode { get; init; } + + public bool Paper { get; init; } + + public bool DryRun { get; init; } + + public required string AssetClass { get; init; } + + public required string TimeFrame { get; init; } + + public required string Endpoint { get; init; } + + // ---- money ----------------------------------------------------------- + public double Equity { get; init; } + + public double Cash { get; init; } + + public double BuyingPower { get; init; } + + public double PnlToday { get; init; } + + public double PnlTodayPct { get; init; } + + public double PnlSession { get; init; } + + public double PnlSessionPct { get; init; } + + public double PnlAllTime { get; init; } + + public double PnlAllTimePct { get; init; } + + public bool HasAllTime { get; init; } + + public double UnrealizedPnl { get; init; } + + public double RealizedToday { get; init; } + + public double GrossExposure { get; init; } + + public double ExposurePct { get; init; } + + // ---- session & risk --------------------------------------------------- + public required string SessionStatus { get; init; } + + public bool MarketOpen { get; init; } + + public bool Halted { get; init; } + + public string? HaltReason { get; init; } + + public int TradesToday { get; init; } + + public int MaxTradesPerDay { get; init; } + + public int OpenPositions { get; init; } + + public int MaxOpenPositions { get; init; } + + public double RiskPerTradePct { get; init; } + + public double MaxDailyLossPct { get; init; } + + // ---- plumbing --------------------------------------------------------- + public required string MarketDataState { get; init; } + + public required string TradeStreamState { get; init; } + + /// + /// Why a stream is being refused by the broker, when one is. A reconnect loop is + /// otherwise invisible from the window: the state just reads "disconnected" and the + /// explanation sits in the log file, which is the last place anyone looks. + /// + public string? StreamRejection { get; init; } + + public int Reconnects { get; init; } + + public long Ticks { get; init; } + + public long Quotes { get; init; } + + public long Bars { get; init; } + + public long Signals { get; init; } + + public long Orders { get; init; } + + public long Fills { get; init; } + + public long Exits { get; init; } + + public long RiskRejects { get; init; } + + public long Errors { get; init; } + + public required string BarToSignal { get; init; } + + public required string SignalToOrder { get; init; } + + public IReadOnlyList Positions { get; init; } = []; + + public IReadOnlyList Symbols { get; init; } = []; + + public IReadOnlyList EquityCurve { get; init; } = []; + + public IReadOnlyList Events { get; init; } = []; + + /// Null until the first successful reconcile against the broker. + public AccountRow? Account { get; init; } + + /// Named to stay clear of , which counts submissions. + public IReadOnlyList OrderHistory { get; init; } = []; + + public IReadOnlyList Prices { get; init; } = []; +} + +/// Result of a start/stop/close request from the UI. +public sealed record CommandResult(bool Ok, string Message); diff --git a/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs b/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs new file mode 100644 index 0000000..de2a5d5 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs @@ -0,0 +1,669 @@ +using System.Globalization; +using Encelado.Alpaca.Rest; +using Encelado.Bot.Configuration; +using Encelado.Bot.Logging; +using Encelado.Core.Portfolio; +using Encelado.Core.Strategies; + +namespace Encelado.Bot.Engine; + +/// +/// Owns the engine's lifecycle so the window can start and stop trading without +/// restarting the process, and assembles the snapshot the UI renders. +/// +/// Each start creates a fresh . Reusing one would +/// mean resurrecting websockets, warm-up state and risk counters that were built to +/// live exactly as long as a session does; a new instance is simpler and cannot leak +/// stale state into the next run. +/// +/// +public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable +{ + private const int EquityCurveCapacity = 1440; + + private readonly int _eventCapacity = Math.Max(20, config.Logging.StatusLines); + + private readonly Lock _gate = new(); + private readonly Queue _equityCurve = new(EquityCurveCapacity); + private readonly Queue _events = new(); + + private TradingEngine? _engine; + private CancellationTokenSource? _engineCts; + private Task? _engineTask; + private BotState _state = BotState.Stopped; + private string? _error; + private DateTime _lastEquitySample; + + public BotConfig Config => config; + + public BotState State + { + get { lock (_gate) { return _state; } } + } + + /// Mirrors the log into the activity feed shown in the window. + public void AttachLogSink() => Log.Sink = RecordEvent; + + public void DetachLogSink() => Log.Sink = null; + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + public async Task StartAsync() + { + lock (_gate) + { + if (_state is BotState.Running or BotState.Starting) + { + return new CommandResult(false, "the bot is already running"); + } + + if (_state == BotState.Stopping) + { + return new CommandResult(false, "the previous run is still shutting down"); + } + + _state = BotState.Starting; + _error = null; + } + + Log.Info("── start requested ──"); + + TradingEngine engine; + try + { + engine = new TradingEngine(config); + } + catch (Exception ex) + { + lock (_gate) + { + _state = BotState.Faulted; + _error = ex.Message; + } + + Log.Error("could not build the engine", ex); + return new CommandResult(false, ex.Message); + } + + CancellationTokenSource cts = new(); + + lock (_gate) + { + _engine = engine; + _engineCts = cts; + _equityCurve.Clear(); + } + + // RunAsync blocks for the whole session, so it owns a background task and the + // caller gets control back immediately so the UI stays responsive. + Task task = Task.Run(async () => + { + try + { + await engine.RunAsync(cts.Token).ConfigureAwait(false); + lock (_gate) + { + _state = BotState.Stopped; + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + _state = BotState.Stopped; + } + } + catch (Exception ex) + { + Log.Error("engine stopped with an error", ex); + lock (_gate) + { + _state = BotState.Faulted; + _error = ex.Message; + } + } + finally + { + try + { + await engine.ShutdownAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Warn($"shutdown reported: {ex.Message}"); + } + } + }); + + lock (_gate) + { + _engineTask = task; + } + + // Give startup a moment so an immediate failure (bad credentials, blocked + // account) surfaces as a returned error instead of silently on the feed. + await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(3))).ConfigureAwait(false); + + lock (_gate) + { + if (_state == BotState.Faulted) + { + return new CommandResult(false, _error ?? "the engine failed to start"); + } + + if (_state == BotState.Starting) + { + _state = BotState.Running; + } + } + + return new CommandResult(true, "bot started"); + } + + public async Task StopAsync() + { + CancellationTokenSource? cts; + Task? task; + + lock (_gate) + { + if (_state is BotState.Stopped or BotState.Stopping) + { + return new CommandResult(false, "the bot is not running"); + } + + _state = BotState.Stopping; + cts = _engineCts; + task = _engineTask; + } + + Log.Info("── stop requested ──"); + + if (cts is not null) + { + await cts.CancelAsync().ConfigureAwait(false); + } + + if (task is not null) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(45)).ConfigureAwait(false); + } + catch (TimeoutException) + { + Log.Warn("the engine did not stop within 45s"); + } + } + + TradingEngine? engine; + lock (_gate) + { + engine = _engine; + _engine = null; + _engineTask = null; + _engineCts = null; + _state = BotState.Stopped; + } + + if (engine is not null) + { + await engine.DisposeAsync().ConfigureAwait(false); + } + + cts?.Dispose(); + Log.Info("bot stopped"); + return new CommandResult(true, "bot stopped"); + } + + /// Closes one position on demand from the positions grid. + public async Task ClosePositionAsync(string symbol, CancellationToken ct) + { + TradingEngine? engine; + lock (_gate) + { + engine = _engine; + } + + if (engine is null) + { + return new CommandResult(false, "the bot is not running"); + } + + if (config.Engine.DryRun) + { + return new CommandResult(false, "dry-run mode: no order was sent"); + } + + try + { + await engine.Router.FlattenSymbolAsync(symbol, "closed manually from the app", ct) + .ConfigureAwait(false); + return new CommandResult(true, $"{symbol} close requested"); + } + catch (Exception ex) + { + Log.Error($"manual close of {symbol} failed", ex); + return new CommandResult(false, ex.Message); + } + } + + // ----------------------------------------------------------------------- + // Snapshot + // ----------------------------------------------------------------------- + + public BotSnapshot Snapshot() + { + TradingEngine? engine; + BotState state; + string? error; + + lock (_gate) + { + engine = _engine; + state = _state; + error = _error; + } + + return engine is null || state is BotState.Stopped or BotState.Faulted + ? IdleSnapshot(state, error) + : LiveSnapshot(engine, state, error); + } + + private BotSnapshot IdleSnapshot(BotState state, string? error) => new() + { + State = state, + Error = error, + Mode = DescribeMode(), + Paper = config.Alpaca.Paper, + DryRun = config.Engine.DryRun, + AssetClass = config.Engine.AssetClass, + TimeFrame = config.Engine.TimeFrame, + Endpoint = config.Alpaca.TradingBaseUrl, + SessionStatus = "engine stopped", + MarketDataState = "disconnected", + TradeStreamState = "disconnected", + BarToSignal = "—", + SignalToOrder = "—", + MaxOpenPositions = config.Risk.MaxOpenPositions, + MaxTradesPerDay = config.Risk.MaxTradesPerDay, + RiskPerTradePct = config.Risk.MaxRiskPerTradePct, + MaxDailyLossPct = config.Risk.MaxDailyLossPct, + Symbols = IdleSymbols(), + EquityCurve = SnapshotEquityCurve(), + Events = SnapshotEvents(), + Prices = IdlePrices(), + }; + + /// + /// Empty price rows for the configured symbols while the engine is stopped. Without + /// them the prices panel says "nessun asset configurato", which is false and reads + /// as a configuration problem rather than as "the bot is not running". + /// + private IReadOnlyList IdlePrices() + { + List rows = []; + foreach (SymbolConfig sc in config.EnabledSymbols) + { + rows.Add(new PriceSeriesRow(sc.Symbol, 0, 0, 0, 0, 0, [], [], [], [], [])); + } + + return rows; + } + + private IReadOnlyList IdleSymbols() + { + List views = []; + foreach (SymbolConfig sc in config.EnabledSymbols) + { + int warmup = 0; + try + { + warmup = StrategyFactory.Create(sc.Strategy, sc.ToStrategyParameters()).WarmupBars; + } + catch (ArgumentException) + { + // A misconfigured strategy is reported by validation, not here. + } + + views.Add(new SymbolRow(sc.Symbol, sc.Strategy, false, 0, warmup, 0, 0, 0, 0, 0, false, [])); + } + + return views; + } + + private BotSnapshot LiveSnapshot(TradingEngine engine, BotState state, string? error) + { + AccountState account = engine.Account; + double equity = account.Equity; + + SampleEquity(equity); + + double sessionBase = engine.StartEquity; + double todayBase = engine.PreviousCloseEquity; + AlpacaPortfolioHistory history = engine.PortfolioHistory; + + List positions = []; + foreach (Position p in engine.Book.Positions) + { + if (!p.IsOpen) + { + continue; + } + + positions.Add(new PositionRow( + p.Symbol, + p.Side == Core.Market.Side.Buy ? "long" : "short", + p.Quantity, + p.AverageEntryPrice, + p.LastPrice, + p.MarketValue, + p.UnrealizedPnl, + p.UnrealizedPnlPct, + p.StopPrice, + p.TargetPrice, + p.BarsHeld, + p.OpenedAtUtc)); + } + + positions.Sort(static (a, b) => Math.Abs(b.MarketValue).CompareTo(Math.Abs(a.MarketValue))); + + List symbols = []; + foreach (SymbolPipeline pipe in engine.Pipelines) + { + List metrics = []; + foreach (StrategyMetric m in pipe.Strategy.Diagnostics) + { + metrics.Add(new MetricRow(m.Name, double.IsFinite(m.Value) ? m.Value : 0, m.Format)); + } + + Core.Market.Quote quote = pipe.LastQuote; + double age = pipe.QuoteAge == TimeSpan.MaxValue ? -1 : pipe.QuoteAge.TotalSeconds; + + symbols.Add(new SymbolRow( + pipe.Symbol, + pipe.Strategy.Name, + pipe.Strategy.IsReady, + pipe.BarsSeen, + pipe.Strategy.WarmupBars, + pipe.LastPrice, + quote.IsValid ? quote.BidPrice : 0, + quote.IsValid ? quote.AskPrice : 0, + quote.IsValid ? quote.RelativeSpread : 0, + age, + !engine.Book.View(pipe.Symbol).IsFlat, + metrics)); + } + + double unrealized = engine.Book.TotalUnrealizedPnl; + double exposure = engine.Book.GrossExposure; + + return new BotSnapshot + { + State = state, + Error = error, + StartedAtUtc = engine.StartedAtUtc, + Uptime = engine.StartedAtUtc == default + ? TimeSpan.Zero + : DateTime.UtcNow - engine.StartedAtUtc, + + Mode = DescribeMode(), + Paper = config.Alpaca.Paper, + DryRun = config.Engine.DryRun, + AssetClass = config.Engine.AssetClass, + TimeFrame = config.Engine.TimeFrame, + Endpoint = config.Alpaca.TradingBaseUrl, + + Equity = equity, + Cash = account.Cash, + BuyingPower = account.BuyingPower, + PnlToday = todayBase > 0 ? equity - todayBase : 0, + PnlTodayPct = todayBase > 0 ? (equity - todayBase) / todayBase : 0, + PnlSession = sessionBase > 0 ? equity - sessionBase : 0, + PnlSessionPct = sessionBase > 0 ? (equity - sessionBase) / sessionBase : 0, + PnlAllTime = history.TotalProfitLoss, + PnlAllTimePct = history.TotalProfitLossPct, + HasAllTime = history.HasData && history.BaseValue > 0, + UnrealizedPnl = unrealized, + RealizedToday = engine.Risk.DailyRealizedPnl, + GrossExposure = exposure, + ExposurePct = equity > 0 ? exposure / equity : 0, + + SessionStatus = engine.Session.Describe(), + MarketOpen = engine.Session.IsOpen, + Halted = engine.Risk.IsHalted, + HaltReason = engine.Risk.IsHalted ? engine.Risk.HaltReason : null, + TradesToday = engine.Risk.TradesToday, + MaxTradesPerDay = config.Risk.MaxTradesPerDay, + OpenPositions = positions.Count, + MaxOpenPositions = config.Risk.MaxOpenPositions, + RiskPerTradePct = config.Risk.MaxRiskPerTradePct, + MaxDailyLossPct = config.Risk.MaxDailyLossPct, + + MarketDataState = engine.MarketData.State.ToString().ToLowerInvariant(), + TradeStreamState = engine.TradeUpdates.State.ToString().ToLowerInvariant(), + StreamRejection = engine.MarketData.RejectionReason ?? engine.TradeUpdates.RejectionReason, + Reconnects = Math.Max(0, engine.MarketData.ConnectCount - 1), + Ticks = engine.Metrics.Trades, + Quotes = engine.Metrics.Quotes, + Bars = engine.Metrics.Bars, + Signals = engine.Metrics.Signals, + Orders = engine.Metrics.OrdersSubmitted, + Fills = engine.Metrics.OrdersFilled, + Exits = engine.Metrics.Exits, + RiskRejects = engine.Metrics.RiskRejects, + Errors = engine.Metrics.OrderErrors, + BarToSignal = engine.Metrics.BarToSignal.Summary(), + SignalToOrder = engine.Metrics.SignalToOrder.Summary(), + + Positions = positions, + Symbols = symbols, + EquityCurve = SnapshotEquityCurve(), + Events = SnapshotEvents(), + Account = BuildAccount(account), + OrderHistory = BuildOrders(engine), + Prices = BuildPrices(engine), + }; + } + + private static AccountRow? BuildAccount(AccountState state) + { + // Before the first reconcile there is no truthful account to show. Returning + // null lets the page say "in attesa" instead of rendering a zeroed-out account + // that reads like a wiped-out one. + if (state.Raw is not { } a) + { + return null; + } + + return new AccountRow( + a.AccountNumber, + a.Status, + a.Currency, + (double)a.Equity, + (double)a.LastEquity, + (double)a.Cash, + (double)a.PortfolioValue, + (double)a.BuyingPower, + (double)a.DaytradingBuyingPower, + (double)a.Multiplier, + a.DaytradeCount, + a.PatternDayTrader, + a.TradingBlocked, + a.AccountBlocked, + a.TransfersBlocked, + a.ShortingEnabled, + state.LastUpdateUtc); + } + + private static IReadOnlyList BuildOrders(TradingEngine engine) + { + IReadOnlyList source = engine.RecentOrders; + List rows = new(source.Count); + + foreach (AlpacaOrder o in source) + { + rows.Add(new OrderRow( + o.Id, + o.Symbol, + o.Side == Core.Market.Side.Buy ? "acquisto" : "vendita", + o.Type, + DescribeStatus(o.Status), + o.Quantity, + o.FilledQuantity, + o.FilledAveragePrice, + o.LimitPrice, + o.SubmittedAtUtc, + o.FilledAtUtc) + { + IsWorking = o.IsWorking, + }); + } + + rows.Sort(static (a, b) => b.SubmittedUtc.CompareTo(a.SubmittedUtc)); + return rows; + } + + private static string DescribeStatus(OrderStatus status) => status switch + { + OrderStatus.Filled => "eseguito", + OrderStatus.PartiallyFilled => "parziale", + OrderStatus.Canceled => "annullato", + OrderStatus.Expired => "scaduto", + OrderStatus.Rejected => "rifiutato", + OrderStatus.New or OrderStatus.Accepted or OrderStatus.PendingNew => "in attesa", + _ => status.ToString().ToLowerInvariant(), + }; + + private static IReadOnlyList BuildPrices(TradingEngine engine) + { + List rows = []; + + foreach (SymbolPipeline pipe in engine.Pipelines) + { + PriceSnapshot snap = pipe.History.Snapshot(); + + int n = snap.Bars.Count; + double[] closes = new double[n]; + double[] highs = new double[n]; + double[] lows = new double[n]; + double[] opens = new double[n]; + for (int i = 0; i < n; i++) + { + Core.Market.Bar b = snap.Bars[i]; + closes[i] = b.Close; + highs[i] = b.High; + lows[i] = b.Low; + opens[i] = b.Open; + } + + double[] live = new double[snap.Ticks.Count]; + for (int i = 0; i < live.Length; i++) + { + live[i] = snap.Ticks[i].Price; + } + + rows.Add(new PriceSeriesRow( + pipe.Symbol, + pipe.LastPrice, + snap.SessionOpen, + snap.SessionHigh, + snap.SessionLow, + snap.SessionChangePct, + closes, highs, lows, opens, live)); + } + + return rows; + } + + private string DescribeMode() => + config.Engine.DryRun ? "DRY-RUN" : config.Alpaca.Paper ? "PAPER" : "LIVE"; + + private void SampleEquity(double equity) + { + if (equity <= 0) + { + return; + } + + DateTime now = DateTime.UtcNow; + if (now - _lastEquitySample < TimeSpan.FromSeconds(10)) + { + return; + } + + _lastEquitySample = now; + + lock (_gate) + { + if (_equityCurve.Count >= EquityCurveCapacity) + { + _equityCurve.Dequeue(); + } + + _equityCurve.Enqueue(new EquityPoint(now, equity)); + } + } + + private EquityPoint[] SnapshotEquityCurve() + { + lock (_gate) + { + return [.. _equityCurve]; + } + } + + private EventRow[] SnapshotEvents() + { + lock (_gate) + { + return [.. _events]; + } + } + + // Fully qualified: the Web SDK's implicit usings also bring in + // Microsoft.Extensions.Logging.LogLevel. + private void RecordEvent(Logging.LogLevel level, DateTime timestamp, string message) + { + EventRow view = new( + timestamp.ToString("HH:mm:ss", CultureInfo.InvariantCulture), + level.ToString().ToLowerInvariant(), + message); + + lock (_gate) + { + if (_events.Count >= _eventCapacity) + { + _events.Dequeue(); + } + + _events.Enqueue(view); + } + + // Pushed rather than polled. The log page keeps thousands of lines, and copying + // that array into a snapshot once a second would cost more than everything else + // the UI does put together. + try + { + EventLogged?.Invoke(view); + } + catch + { + // A subscriber that throws must not take down the logging path. + } + } + + /// + /// Raised for every log line, on the thread that logged it. Subscribers that touch + /// the UI must marshal to the dispatcher themselves. + /// + public event Action? EventLogged; + + public async ValueTask DisposeAsync() + { + DetachLogSink(); + await StopAsync().ConfigureAwait(false); + } +} diff --git a/Encelado/src/Encelado.Bot/Engine/ExecutionRouter.cs b/Encelado/src/Encelado.Bot/Engine/ExecutionRouter.cs new file mode 100644 index 0000000..f976b91 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/ExecutionRouter.cs @@ -0,0 +1,477 @@ +using System.Diagnostics; +using System.Globalization; +using System.Threading.Channels; +using Encelado.Alpaca; +using Encelado.Alpaca.Rest; +using Encelado.Bot.Configuration; +using Encelado.Bot.Diagnostics; +using Encelado.Bot.Logging; +using Encelado.Core.Market; +using Encelado.Core.Portfolio; +using Encelado.Core.Risk; +using Encelado.Core.Strategies; + +namespace Encelado.Bot.Engine; + +/// A decision handed from the market-data thread to the order path. +public readonly record struct ExecutionIntent( + int SymbolId, + string Symbol, + Signal Signal, + double ReferencePrice, + long EnqueuedTimestamp, + long DecisionId); + +/// +/// Turns approved signals into Alpaca orders. Strategies never touch the broker: they +/// publish intents, this class serialises them through a single consumer so sizing, +/// risk checks and submission can never interleave for the same symbol. +/// +public sealed class ExecutionRouter( + AlpacaTradingClient trading, + PortfolioBook book, + RiskEngine risk, + AccountState account, + SessionGuard session, + EngineOptions options, + Metrics metrics, + TradeJournal journal, + AnalyticsLog analytics, + SymbolPipeline?[] pipelines) +{ + private readonly Channel _queue = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + private readonly bool _isEquity = options.ResolvedAssetClass == AssetClass.UsEquity; + private Task? _consumer; + private int _sequence; + + public int QueueDepth { get; private set; } + + public void Start(CancellationToken ct) => _consumer ??= Task.Run(() => ConsumeAsync(ct), CancellationToken.None); + + public bool Enqueue(in ExecutionIntent intent) => _queue.Writer.TryWrite(intent); + + public async Task StopAsync() + { + _queue.Writer.TryComplete(); + if (_consumer is not null) + { + try + { + await _consumer.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutdown. + } + } + } + + private async Task ConsumeAsync(CancellationToken ct) + { + try + { + await foreach (ExecutionIntent intent in _queue.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + { + QueueDepth = _queue.Reader.Count; + try + { + if (intent.Signal.Kind == SignalKind.Exit) + { + await HandleExitAsync(intent, ct).ConfigureAwait(false); + } + else + { + await HandleEntryAsync(intent, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + metrics.CountOrderError(); + Log.Error($"{intent.Symbol}: execution failed", ex); + + SymbolPipeline? pipe = Pipeline(intent.SymbolId); + pipe?.ReleaseEntry(); + pipe?.ReleaseExit(); + } + } + } + catch (OperationCanceledException) + { + // Shutdown. + } + } + + // ----------------------------------------------------------------------- + // Entries + // ----------------------------------------------------------------------- + + private async Task HandleEntryAsync(ExecutionIntent intent, CancellationToken ct) + { + SymbolPipeline? pipe = Pipeline(intent.SymbolId); + if (pipe is null) + { + return; + } + + if (!session.CanOpenNewPositions) + { + Log.Debug($"{intent.Symbol}: entry skipped, session not accepting new positions ({session.Describe()})"); + return; + } + + if (!account.HasData) + { + Log.Warn($"{intent.Symbol}: entry skipped, no account snapshot yet"); + return; + } + + if (!account.CanTrade) + { + Log.Warn($"{intent.Symbol}: entry skipped, the broker has blocked trading on this account"); + return; + } + + if (_isEquity && account.IsDayTradeBlocked) + { + Log.Warn($"{intent.Symbol}: entry skipped, PDT flag with equity below $25,000"); + return; + } + + if (options.MaxQuoteAgeSeconds > 0 && pipe.QuoteAge > TimeSpan.FromSeconds(options.MaxQuoteAgeSeconds)) + { + Log.Debug($"{intent.Symbol}: entry skipped, top-of-book is {pipe.QuoteAge.TotalSeconds:F0}s stale"); + return; + } + + PositionView position = book.View(intent.Symbol); + if (!position.IsFlat) + { + return; + } + + if (!pipe.TryClaimEntry()) + { + Log.Debug($"{intent.Symbol}: entry skipped, another entry is already in flight"); + return; + } + + bool submitted = false; + try + { + Side side = intent.Signal.EntrySide; + double reference = pipe.EntryReferencePrice(side); + if (reference <= 0) + { + reference = intent.ReferencePrice; + } + + EntryRequest request = new( + intent.Symbol, + side, + reference, + intent.Signal.StopPrice, + intent.Signal.Strength, + account.Equity, + account.BuyingPower, + book.GrossExposure, + book.OpenPositionCount, + position.Quantity, + pipe.LastQuote.IsValid ? pipe.LastQuote.RelativeSpread : 0, + options.AllowFractionalShares, + DateTime.UtcNow); + + RiskVerdict verdict = risk.ApproveEntry(request); + + analytics.Execution( + intent.DecisionId, intent.Symbol, side, "risk", verdict.Approved, + verdict.Reason.ToString(), verdict.Detail, + verdict.Quantity, reference, verdict.StopPrice, intent.Signal.TargetPrice, + account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount, + null, null, Stopwatch.GetElapsedTime(intent.EnqueuedTimestamp).TotalMilliseconds); + + if (!verdict.Approved) + { + metrics.CountRiskReject(); + Log.Debug($"{intent.Symbol}: {side} rejected by risk [{verdict.Reason}] {verdict.Detail}"); + return; + } + + NewOrder order = BuildEntryOrder(intent, side, verdict, reference, out double basePrice); + + if (options.DryRun) + { + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"[DRY-RUN] {intent.Symbol} {side} {verdict.Quantity:0.####} @ ~{basePrice:F2} " + + $"stop={Fmt(order.StopLossStopPrice)} target={Fmt(order.TakeProfitLimitPrice)} :: {intent.Signal.Reason}")); + journal.Record("dry-run-entry", intent.Symbol, side, verdict.Quantity, basePrice, + intent.Signal.Reason, null, order.StopLossStopPrice, order.TakeProfitLimitPrice, account.Equity); + return; + } + + long submitStart = Stopwatch.GetTimestamp(); + AlpacaOrder placed = await trading.SubmitOrderAsync(order, ct).ConfigureAwait(false); + metrics.SignalToOrder.RecordSince(intent.EnqueuedTimestamp); + metrics.CountOrderSubmitted(); + submitted = true; + + risk.RecordEntry(intent.Symbol, DateTime.UtcNow); + + // Without a broker-side bracket the engine has to police the stop itself. + if (!order.HasBracket) + { + pipe.LocalStop = verdict.StopPrice; + pipe.LocalTarget = intent.Signal.TargetPrice; + } + else + { + pipe.ClearProtection(); + } + + book.SetProtection(intent.Symbol, verdict.StopPrice, intent.Signal.TargetPrice); + + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"ENTRY {intent.Symbol} {side} {verdict.Quantity:0.####} @ ~{basePrice:F2} " + + $"stop={Fmt(order.StopLossStopPrice)} target={Fmt(order.TakeProfitLimitPrice)} " + + $"[{Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds:F0}ms] :: {intent.Signal.Reason}")); + + journal.Record("entry", intent.Symbol, side, verdict.Quantity, basePrice, intent.Signal.Reason, + placed.Id, order.StopLossStopPrice, order.TakeProfitLimitPrice, account.Equity); + + analytics.Execution( + intent.DecisionId, intent.Symbol, side, "order", true, "None", intent.Signal.Reason, + verdict.Quantity, basePrice, order.StopLossStopPrice, order.TakeProfitLimitPrice, + account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount, + placed.Id, null, Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds); + } + catch (AlpacaApiException ex) + { + metrics.CountOrderError(); + Log.Error($"{intent.Symbol}: order rejected by Alpaca ({ex.StatusCode})", ex); + + analytics.Execution( + intent.DecisionId, intent.Symbol, intent.Signal.EntrySide, "order", false, + $"Http{ex.StatusCode}", ex.Message, 0, intent.ReferencePrice, double.NaN, double.NaN, + account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount, + null, ex.Message, 0); + } + finally + { + // The in-flight latch is only held while an order is actually working. + if (!submitted) + { + pipe.ReleaseEntry(); + } + } + } + + private NewOrder BuildEntryOrder( + ExecutionIntent intent, + Side side, + RiskVerdict verdict, + double reference, + out double basePrice) + { + bool useLimit = options.UseLimitEntries; + double offset = options.LimitOffsetBps / 10_000.0; + + // A marketable limit: priced through the touch so it fills like a market order + // but can never fill at an absurd print. + double limitPrice = side == Side.Buy + ? reference * (1 + offset) + : reference * (1 - offset); + + basePrice = useLimit ? limitPrice : reference; + + bool wholeShares = Math.Abs(verdict.Quantity - Math.Floor(verdict.Quantity)) < 1e-9; + bool bracketAllowed = options.UseBracketOrders && _isEquity && wholeShares; + + double stop = double.NaN; + double target = double.NaN; + + if (bracketAllowed) + { + double tick = basePrice >= 1 ? 0.01 : 0.0001; + + if (!double.IsNaN(verdict.StopPrice) && verdict.StopPrice > 0) + { + stop = side == Side.Buy + ? Math.Min(verdict.StopPrice, basePrice - tick) + : Math.Max(verdict.StopPrice, basePrice + tick); + if (stop <= 0) + { + stop = double.NaN; + } + } + + double signalTarget = intent.Signal.TargetPrice; + if (!double.IsNaN(signalTarget) && signalTarget > 0) + { + target = side == Side.Buy + ? Math.Max(signalTarget, basePrice + tick) + : Math.Min(signalTarget, basePrice - tick); + if (target <= 0) + { + target = double.NaN; + } + } + } + + return new NewOrder + { + Symbol = intent.Symbol, + Side = side, + Quantity = verdict.Quantity, + Type = useLimit ? OrderType.Limit : OrderType.Market, + LimitPrice = useLimit ? limitPrice : double.NaN, + TimeInForce = _isEquity ? TimeInForce.Day : TimeInForce.GoodTillCanceled, + ClientOrderId = NextClientOrderId(intent.Symbol), + StopLossStopPrice = stop, + TakeProfitLimitPrice = target, + }; + } + + // ----------------------------------------------------------------------- + // Exits + // ----------------------------------------------------------------------- + + private async Task HandleExitAsync(ExecutionIntent intent, CancellationToken ct) + { + PositionView position = book.View(intent.Symbol); + SymbolPipeline? pipe = Pipeline(intent.SymbolId); + + try + { + if (position.IsFlat) + { + pipe?.ClearProtection(); + return; + } + + if (options.DryRun) + { + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"[DRY-RUN] EXIT {intent.Symbol} {position.Quantity:0.####} @ ~{intent.ReferencePrice:F2} :: {intent.Signal.Reason}")); + journal.Record("dry-run-exit", intent.Symbol, position.Side.Opposite(), + Math.Abs(position.Quantity), intent.ReferencePrice, intent.Signal.Reason); + return; + } + + await FlattenSymbolAsync(intent.Symbol, intent.Signal.Reason, ct).ConfigureAwait(false); + pipe?.ClearProtection(); + } + finally + { + pipe?.ReleaseExit(); + } + } + + /// + /// Cancels the symbol's working orders and liquidates it at market. The cancel is + /// required first: a resting bracket leg reserves the shares and would make the + /// liquidation fail with "insufficient qty available". + /// + public async Task FlattenSymbolAsync(string symbol, string reason, CancellationToken ct) + { + PositionView position = book.View(symbol); + + try + { + List open = await trading.ListOrdersAsync("open", 100, symbol, ct).ConfigureAwait(false); + foreach (AlpacaOrder order in open) + { + if (order.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + { + await trading.CancelOrderAsync(order.Id, ct).ConfigureAwait(false); + } + } + } + catch (AlpacaApiException ex) + { + Log.Warn($"{symbol}: could not cancel working orders before flattening: {ex.Message}"); + } + + AlpacaOrder? closing = await trading.ClosePositionAsync(symbol, null, ct).ConfigureAwait(false); + metrics.CountExit(); + + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"EXIT {symbol} {position.Quantity:0.####} @ ~{position.LastPrice:F2} " + + $"pnl={position.UnrealizedPnl:F2} :: {reason}")); + + journal.Record("exit", symbol, position.Side.Opposite(), Math.Abs(position.Quantity), + position.LastPrice, reason, closing?.Id, equity: account.Equity, + realizedPnl: position.UnrealizedPnl); + + Pipeline(symbol)?.ReleaseEntry(); + } + + /// Cancels every working order and liquidates the whole book. + public async Task FlattenAllAsync(string reason, CancellationToken ct) + { + if (options.DryRun) + { + Log.Info($"[DRY-RUN] flatten-all requested :: {reason}"); + return; + } + + Log.Warn($"flattening the entire book :: {reason}"); + + try + { + await trading.CancelAllOrdersAsync(ct).ConfigureAwait(false); + } + catch (AlpacaApiException ex) + { + Log.Warn($"cancel-all failed: {ex.Message}"); + } + + try + { + await trading.CloseAllPositionsAsync(cancelOrders: true, ct).ConfigureAwait(false); + journal.Record("flatten-all", "*", Side.None, 0, 0, reason, equity: account.Equity); + } + catch (AlpacaApiException ex) + { + Log.Error($"close-all failed: {ex.Message}", ex); + } + + foreach (SymbolPipeline? pipe in pipelines) + { + pipe?.ClearProtection(); + pipe?.ReleaseEntry(); + pipe?.ReleaseExit(); + } + } + + private SymbolPipeline? Pipeline(int id) => + (uint)id < (uint)pipelines.Length ? pipelines[id] : null; + + private SymbolPipeline? Pipeline(string symbol) + { + foreach (SymbolPipeline? pipe in pipelines) + { + if (pipe is not null && pipe.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + { + return pipe; + } + } + + return null; + } + + private string NextClientOrderId(string symbol) + { + int seq = Interlocked.Increment(ref _sequence); + string clean = symbol.Replace("/", string.Empty, StringComparison.Ordinal); + return string.Create(CultureInfo.InvariantCulture, + $"enc-{clean}-{DateTime.UtcNow:yyMMddHHmmssfff}-{seq}"); + } + + private static string Fmt(double value) => + double.IsNaN(value) ? "-" : value.ToString("F2", CultureInfo.InvariantCulture); +} diff --git a/Encelado/src/Encelado.Bot/Engine/PriceHistory.cs b/Encelado/src/Encelado.Bot/Engine/PriceHistory.cs new file mode 100644 index 0000000..20222f2 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/PriceHistory.cs @@ -0,0 +1,139 @@ +using Encelado.Core.Market; + +namespace Encelado.Bot.Engine; + +/// One point on the live price line. +public readonly record struct PricePoint(DateTime TimeUtc, double Price); + +/// +/// Rolling price memory for one symbol, feeding the charts. +/// +/// Two series, because they answer different questions. The closed bars are what +/// the strategy actually decides on — on a daily timeframe there is one per day, and a +/// hundred of them is several months of context. The live line is sampled from +/// the quote stream roughly once a second and exists so the operator can see the price +/// moving right now, between two decisions that are a day apart. +/// +/// +/// Both are bounded ring buffers written on the market-data thread and read by the UI +/// thread, so every access is under the same lock. The buffers are small and the lock +/// is held for a copy, never for I/O. +/// +/// +public sealed class PriceHistory(int barCapacity = 180, int tickCapacity = 1800) +{ + private readonly Lock _gate = new(); + private readonly Queue _bars = new(barCapacity); + private readonly Queue _ticks = new(tickCapacity); + + private DateTime _lastSampleUtc; + private double _sessionOpen; + private double _sessionHigh; + private double _sessionLow = double.MaxValue; + + /// Sampling floor for the live line. One second is well under any chart's resolution. + private static readonly TimeSpan SampleEvery = TimeSpan.FromSeconds(1); + + public void AddBar(in Bar bar) + { + lock (_gate) + { + if (_bars.Count >= barCapacity) + { + _bars.Dequeue(); + } + + _bars.Enqueue(bar); + } + } + + /// + /// Records a live price. Rate limited: quotes can arrive hundreds of times a second + /// on a busy symbol and a chart that is repainted once a second cannot show them. + /// + public void AddPrice(double price, DateTime nowUtc) + { + if (price <= 0 || !double.IsFinite(price)) + { + return; + } + + lock (_gate) + { + if (_sessionOpen <= 0) + { + _sessionOpen = price; + } + + if (price > _sessionHigh) { _sessionHigh = price; } + if (price < _sessionLow) { _sessionLow = price; } + + if (nowUtc - _lastSampleUtc < SampleEvery) + { + return; + } + + _lastSampleUtc = nowUtc; + + if (_ticks.Count >= tickCapacity) + { + _ticks.Dequeue(); + } + + _ticks.Enqueue(new PricePoint(nowUtc, price)); + } + } + + public PriceSnapshot Snapshot() + { + lock (_gate) + { + return new PriceSnapshot( + [.. _bars], + [.. _ticks], + _sessionOpen, + _sessionHigh, + _sessionLow == double.MaxValue ? 0 : _sessionLow); + } + } + + /// Called when a new trading session starts, so the day's range restarts too. + public void ResetSession() + { + lock (_gate) + { + _sessionOpen = 0; + _sessionHigh = 0; + _sessionLow = double.MaxValue; + } + } +} + +/// An immutable copy of one symbol's price memory, safe to hand to the UI. +public sealed record PriceSnapshot( + IReadOnlyList Bars, + IReadOnlyList Ticks, + double SessionOpen, + double SessionHigh, + double SessionLow) +{ + public static readonly PriceSnapshot Empty = new([], [], 0, 0, 0); + + public bool HasBars => Bars.Count > 1; + + public bool HasTicks => Ticks.Count > 1; + + /// Change since the first live sample, which is what the operator reads as "today". + public double SessionChangePct + { + get + { + if (SessionOpen <= 0 || Ticks.Count == 0) + { + return 0; + } + + return (Ticks[^1].Price - SessionOpen) / SessionOpen; + } + } +} diff --git a/Encelado/src/Encelado.Bot/Engine/SessionGuard.cs b/Encelado/src/Encelado.Bot/Engine/SessionGuard.cs new file mode 100644 index 0000000..0971c6a --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/SessionGuard.cs @@ -0,0 +1,92 @@ +using Encelado.Alpaca.Rest; +using Encelado.Bot.Configuration; +using Encelado.Bot.Logging; +using Encelado.Core.Market; + +namespace Encelado.Bot.Engine; + +/// +/// Owns "may we trade right now?". Wraps Alpaca's clock (the authority on holidays +/// and early closes), detects session rollovers and enforces the end-of-day flatten +/// window. +/// +public sealed class SessionGuard(AlpacaTradingClient client, EngineOptions options) +{ + private readonly bool _alwaysOpen = options.ResolvedAssetClass == AssetClass.Crypto; + private AlpacaClock? _clock; + + /// Raised the first time a new trading session is observed. + public Action? OnNewSession { get; set; } + + public DateOnly SessionDate { get; private set; } + + public bool IsOpen => _alwaysOpen || (_clock?.IsOpen ?? false); + + public DateTime NextCloseUtc => _clock?.NextCloseUtc ?? DateTime.MaxValue; + + public DateTime NextOpenUtc => _clock?.NextOpenUtc ?? DateTime.MaxValue; + + public TimeSpan TimeToClose => + _alwaysOpen || _clock is null ? TimeSpan.MaxValue : _clock.NextCloseUtc - DateTime.UtcNow; + + /// True inside the last N minutes of the session, where we only reduce risk. + public bool InFlattenWindow => + !_alwaysOpen && + options.FlattenBeforeCloseMinutes > 0 && + IsOpen && + TimeToClose <= TimeSpan.FromMinutes(options.FlattenBeforeCloseMinutes); + + /// Entries are allowed only in a live session and outside the flatten window. + public bool CanOpenNewPositions => IsOpen && !InFlattenWindow; + + public async Task RefreshAsync(CancellationToken ct) + { + if (_alwaysOpen) + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + if (today != SessionDate) + { + SessionDate = today; + OnNewSession?.Invoke(today); + } + + return; + } + + try + { + AlpacaClock clock = await client.GetClockAsync(ct).ConfigureAwait(false); + _clock = clock; + + // 16:00 ET always lands on the same UTC calendar day, so the close is a + // stable session key without needing a timezone database. + DateOnly sessionDate = DateOnly.FromDateTime(clock.NextCloseUtc); + if (sessionDate != SessionDate) + { + SessionDate = sessionDate; + OnNewSession?.Invoke(sessionDate); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Warn($"clock refresh failed: {ex.Message}"); + } + } + + public string Describe() + { + if (_alwaysOpen) + { + return "24/7 session"; + } + + if (_clock is null) + { + return "clock unknown"; + } + + return IsOpen + ? $"open, closes in {TimeToClose:hh\\:mm\\:ss}{(InFlattenWindow ? " (FLATTEN WINDOW)" : string.Empty)}" + : $"closed, opens {_clock.NextOpenUtc:yyyy-MM-dd HH:mm}Z"; + } +} diff --git a/Encelado/src/Encelado.Bot/Engine/SymbolPipeline.cs b/Encelado/src/Encelado.Bot/Engine/SymbolPipeline.cs new file mode 100644 index 0000000..aaf39f1 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/SymbolPipeline.cs @@ -0,0 +1,216 @@ +using System.Diagnostics; +using Encelado.Core.Market; +using Encelado.Core.Strategies; + +namespace Encelado.Bot.Engine; + +/// +/// Per-symbol state on the market-data path: the strategy instance, the latest +/// top-of-book, the bar aggregator and the flags that stop the engine from firing +/// twice on the same idea. +/// +public sealed class SymbolPipeline(int id, string symbol, IStrategy strategy, int minutesPerBar) +{ + private long _lastQuoteTimestamp; + private int _entryInFlight; + private int _exitInFlight; + private double _pendingTakerBuyVolume; + private double _pendingTakerVolume; + + public int Id { get; } = id; + + public string Symbol { get; } = symbol; + + public IStrategy Strategy { get; } = strategy; + + public BarAggregator Aggregator { get; } = new(minutesPerBar); + + public Quote LastQuote { get; private set; } + + public double LastPrice { get; private set; } + + public Bar LastBar { get; private set; } + + public int BarsSeen { get; private set; } + + /// Stop/target held locally when the broker could not hold a bracket for us. + public double LocalStop { get; set; } = double.NaN; + + public double LocalTarget { get; set; } = double.NaN; + + public bool IsWarm => Strategy.IsReady; + + /// True between submitting an entry and seeing it resolve. Blocks duplicates. + public bool EntryInFlight => Volatile.Read(ref _entryInFlight) != 0; + + public DateTime InFlightSinceUtc { get; private set; } + + /// Atomically claims the in-flight slot. Returns false when another entry is already working. + public bool TryClaimEntry() + { + if (Interlocked.CompareExchange(ref _entryInFlight, 1, 0) != 0) + { + return false; + } + + InFlightSinceUtc = DateTime.UtcNow; + return true; + } + + public void ReleaseEntry() => Interlocked.Exchange(ref _entryInFlight, 0); + + /// True while an exit is queued or executing. Stops one stop-loss breach + /// from queueing an exit on every subsequent quote. + public bool ExitInFlight => Volatile.Read(ref _exitInFlight) != 0; + + public bool TryClaimExit() => Interlocked.CompareExchange(ref _exitInFlight, 1, 0) == 0; + + public void ReleaseExit() => Interlocked.Exchange(ref _exitInFlight, 0); + + public void OnQuote(in Quote quote) + { + if (!quote.IsValid) + { + return; + } + + LastQuote = quote; + LastPrice = quote.Mid; + Volatile.Write(ref _lastQuoteTimestamp, Stopwatch.GetTimestamp()); + History.AddPrice(quote.Mid, DateTime.UtcNow); + } + + /// Rolling price memory for the charts. Never read on the decision path. + public PriceHistory History { get; } = new(); + + /// + /// Records a print and, when the feed says which side crossed the spread, + /// accumulates the aggressor breakdown for the bar currently being formed. + /// + /// Alpaca's bars carry only total volume, so the taker split has to be rebuilt from + /// the trade stream. Without it the order-flow filter has nothing to read. + /// + /// + public void OnTrade(in Tick tick, bool takerBought, bool aggressorKnown) + { + if (tick.Price > 0) + { + LastPrice = tick.Price; + History.AddPrice(tick.Price, DateTime.UtcNow); + } + + if (!aggressorKnown || tick.Size <= 0) + { + return; + } + + _pendingTakerVolume += tick.Size; + if (takerBought) + { + _pendingTakerBuyVolume += tick.Size; + } + } + + /// + /// Stamps a stream bar with the aggressor volume accumulated while it was forming, + /// then resets the accumulator for the next one. + /// + public Bar AttachOrderFlow(in Bar bar) + { + double takerBuy = _pendingTakerBuyVolume; + double observed = _pendingTakerVolume; + + _pendingTakerBuyVolume = 0; + _pendingTakerVolume = 0; + + if (observed <= 0 || bar.Volume <= 0) + { + return bar; + } + + // The tick stream and the bar's own volume rarely agree exactly (late prints, + // feed gaps), so carry the observed *ratio* onto the bar's volume rather than + // the raw figure. A ratio is what the delta actually depends on. + double ratio = Math.Clamp(takerBuy / observed, 0, 1); + + return bar with { TakerBuyVolume = bar.Volume * ratio }; + } + + /// Taker-buy volume seen since the last bar closed. Diagnostics only. + public double PendingTakerBuyVolume => _pendingTakerBuyVolume; + + public void OnBarClosed(in Bar bar) + { + LastBar = bar; + BarsSeen++; + if (bar.Close > 0) + { + LastPrice = bar.Close; + } + + History.AddBar(bar); + } + + /// Age of the last top-of-book update. when none was seen. + public TimeSpan QuoteAge + { + get + { + long ts = Volatile.Read(ref _lastQuoteTimestamp); + return ts == 0 ? TimeSpan.MaxValue : Stopwatch.GetElapsedTime(ts); + } + } + + /// + /// Reference price for an entry: the far touch when the book is usable (that is + /// what we will actually pay), otherwise the last print. + /// + public double EntryReferencePrice(Side side) + { + Quote q = LastQuote; + if (q.IsValid) + { + return side == Side.Buy ? q.AskPrice : q.BidPrice; + } + + return LastPrice; + } + + /// Checks a locally held stop/target against the latest price. + public bool ShouldExitLocally(double price, double positionQuantity, out string reason) + { + reason = string.Empty; + if (positionQuantity == 0 || price <= 0) + { + return false; + } + + bool isLong = positionQuantity > 0; + + if (!double.IsNaN(LocalStop) && LocalStop > 0) + { + if ((isLong && price <= LocalStop) || (!isLong && price >= LocalStop)) + { + reason = $"local stop {LocalStop:F4} hit at {price:F4}"; + return true; + } + } + + if (!double.IsNaN(LocalTarget) && LocalTarget > 0) + { + if ((isLong && price >= LocalTarget) || (!isLong && price <= LocalTarget)) + { + reason = $"local target {LocalTarget:F4} hit at {price:F4}"; + return true; + } + } + + return false; + } + + public void ClearProtection() + { + LocalStop = double.NaN; + LocalTarget = double.NaN; + } +} diff --git a/Encelado/src/Encelado.Bot/Engine/TradeJournal.cs b/Encelado/src/Encelado.Bot/Engine/TradeJournal.cs new file mode 100644 index 0000000..cfd52d5 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/TradeJournal.cs @@ -0,0 +1,108 @@ +using System.Buffers; +using System.IO; +using System.Text.Json; +using Encelado.Core.Market; + +namespace Encelado.Bot.Engine; + +/// +/// Append-only JSONL record of everything the engine decided. Separate from the log +/// on purpose: this file is meant to be parsed (pandas, jq, a spreadsheet) when +/// reviewing why the bot did what it did. +/// +public sealed class TradeJournal : IDisposable +{ + private readonly FileStream? _stream; + private readonly Lock _gate = new(); + + public TradeJournal(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + string full = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + _stream = new FileStream(full, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 4096); + } + + public bool IsEnabled => _stream is not null; + + public void Record( + string @event, + string symbol, + Side side, + double quantity, + double price, + string reason, + string? orderId = null, + double? stopPrice = null, + double? targetPrice = null, + double? equity = null, + double? realizedPnl = null) + { + if (_stream is null) + { + return; + } + + ArrayBufferWriter buffer = new(320); + using (Utf8JsonWriter w = new(buffer)) + { + w.WriteStartObject(); + w.WriteString("ts", DateTime.UtcNow.ToString("O")); + w.WriteString("event", @event); + w.WriteString("symbol", symbol); + w.WriteString("side", side switch { Side.Buy => "buy", Side.Sell => "sell", _ => "none" }); + w.WriteNumber("qty", Round(quantity)); + w.WriteNumber("price", Round(price)); + + if (stopPrice is { } stop && !double.IsNaN(stop)) + { + w.WriteNumber("stop", Round(stop)); + } + + if (targetPrice is { } target && !double.IsNaN(target)) + { + w.WriteNumber("target", Round(target)); + } + + if (equity is { } eq) + { + w.WriteNumber("equity", Round(eq)); + } + + if (realizedPnl is { } pnl) + { + w.WriteNumber("realizedPnl", Round(pnl)); + } + + if (!string.IsNullOrEmpty(orderId)) + { + w.WriteString("orderId", orderId); + } + + w.WriteString("reason", reason); + w.WriteEndObject(); + } + + lock (_gate) + { + _stream.Write(buffer.WrittenSpan); + _stream.WriteByte((byte)'\n'); + _stream.Flush(); + } + } + + private static double Round(double value) => + double.IsNaN(value) || double.IsInfinity(value) ? 0 : Math.Round(value, 6); + + public void Dispose() + { + lock (_gate) + { + _stream?.Dispose(); + } + } +} diff --git a/Encelado/src/Encelado.Bot/Engine/TradingEngine.cs b/Encelado/src/Encelado.Bot/Engine/TradingEngine.cs new file mode 100644 index 0000000..6a51860 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Engine/TradingEngine.cs @@ -0,0 +1,923 @@ +using System.Diagnostics; +using System.Globalization; +using Encelado.Alpaca.Rest; +using Encelado.Alpaca.Streaming; +using Encelado.Bot.Configuration; +using Encelado.Bot.Diagnostics; +using Encelado.Bot.Logging; +using Encelado.Core.Market; +using Encelado.Core.Portfolio; +using Encelado.Core.Risk; +using Encelado.Core.Strategies; + +namespace Encelado.Bot.Engine; + +/// +/// The composition root of the live bot. It wires the market-data stream to the +/// strategies, the strategies to the risk engine, and the risk engine to the order +/// router — then supervises the whole thing: session rollovers, reconciliation with +/// the broker, the end-of-day flatten and the kill switch. +/// +public sealed class TradingEngine : IAsyncDisposable +{ + private readonly BotConfig _config; + private readonly AlpacaTradingClient _trading; + private readonly AlpacaDataClient _data; + private readonly PortfolioBook _book = new(); + private readonly RiskEngine _risk; + private readonly AccountState _account = new(); + private readonly Metrics _metrics = new(); + private readonly TradeJournal _journal; + private readonly AnalyticsLog _analytics; + private readonly bool _logMarketData; + private readonly SessionGuard _session; + private readonly MarketDataStream _marketData; + private readonly TradeUpdateStream _tradeUpdates; + private readonly ExecutionRouter _router; + private readonly SymbolPipeline?[] _pipelines; + private readonly string[] _symbols; + private readonly AssetClass _assetClass; + private readonly int _minutesPerBar; + + private DateOnly _flattenedForSession; + private DateTime _lastReconcileUtc; + private DateTime _lastStatusUtc; + private DateTime _lastExplainUtc; + private readonly Dictionary _lastIntent = new(StringComparer.OrdinalIgnoreCase); + private DateTime _lastHistoryUtc; + + public TradingEngine(BotConfig config) + { + _config = config.Validate(); + _assetClass = config.Engine.ResolvedAssetClass; + _minutesPerBar = Math.Max(1, config.Engine.ResolvedTimeFrame.Seconds() / 60); + _symbols = [.. config.EnabledSymbols.Select(s => s.Symbol.Trim())]; + + _trading = new AlpacaTradingClient(config.Alpaca); + _data = new AlpacaDataClient(config.Alpaca); + _risk = new RiskEngine(config.Risk); + _journal = new TradeJournal(config.Logging.ResolvePath(config.Logging.TradeJournal)); + _analytics = new AnalyticsLog(config.Logging); + _logMarketData = config.Logging.LogMarketData && Log.IsEnabled(Logging.LogLevel.Trace); + _session = new SessionGuard(_trading, config.Engine); + + _marketData = new MarketDataStream(config.Alpaca, _symbols, _assetClass); + _tradeUpdates = new TradeUpdateStream(config.Alpaca); + + _pipelines = new SymbolPipeline?[_marketData.Symbols.Count]; + foreach (SymbolConfig sc in config.EnabledSymbols) + { + int id = _marketData.Symbols.Resolve(sc.Symbol.Trim()); + if (id < 0) + { + continue; + } + + _pipelines[id] = new SymbolPipeline( + id, + sc.Symbol.Trim(), + StrategyFactory.Create(sc.Strategy, sc.ToStrategyParameters()), + _minutesPerBar); + } + + _router = new ExecutionRouter( + _trading, _book, _risk, _account, _session, config.Engine, _metrics, _journal, _analytics, _pipelines); + } + + public Metrics Metrics => _metrics; + + public PortfolioBook Book => _book; + + public AccountState Account => _account; + + public RiskEngine Risk => _risk; + + public SessionGuard Session => _session; + + public BotConfig Config => _config; + + public MarketDataStream MarketData => _marketData; + + public TradeUpdateStream TradeUpdates => _tradeUpdates; + + public AlpacaTradingClient Trading => _trading; + + /// How many past orders the orders page keeps. Two hundred is several + /// months for a strategy that trades five to ten times a year per symbol. + private const int RecentOrderLimit = 200; + + private AlpacaOrder[] _recentOrders = []; + + /// Most recent orders as the broker reports them, newest first. + public IReadOnlyList RecentOrders => Volatile.Read(ref _recentOrders); + + public ExecutionRouter Router => _router; + + /// Equity when this engine instance started — the base for session P&L. + public double StartEquity { get; private set; } + + public DateTime StartedAtUtc { get; private set; } + + /// + /// Lifetime equity curve, refreshed slowly in the background. Purely informational, + /// so a failure to fetch it never interferes with trading. + /// + public AlpacaPortfolioHistory PortfolioHistory { get; private set; } = AlpacaPortfolioHistory.Empty; + + /// Previous session's closing equity, used for "P&L today". + public double PreviousCloseEquity { get; private set; } + + public IReadOnlyList Pipelines + { + get + { + List active = new(_pipelines.Length); + foreach (SymbolPipeline? pipe in _pipelines) + { + if (pipe is not null) + { + active.Add(pipe); + } + } + + return active; + } + } + + public async Task RunAsync(CancellationToken ct) + { + PrintBanner(); + + await Task.WhenAll(_trading.WarmupAsync(ct), _data.WarmupAsync(ct)).ConfigureAwait(false); + + AlpacaAccount account = await _trading.GetAccountAsync(ct).ConfigureAwait(false); + _account.Update(account); + StartEquity = (double)account.Equity; + StartedAtUtc = DateTime.UtcNow; + PreviousCloseEquity = (double)account.LastEquity; + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"account {account.AccountNumber} status={account.Status} equity={account.Equity:F2} " + + $"buyingPower={account.BuyingPower:F2} pdt={account.PatternDayTrader} shorting={account.ShortingEnabled}")); + + if (!account.CanTrade) + { + throw new InvalidOperationException( + $"Alpaca reports the account cannot trade (status={account.Status}, " + + $"blocked={account.TradingBlocked || account.AccountBlocked})."); + } + + if (_config.Risk.AllowShorting && !account.ShortingEnabled) + { + Log.Warn("risk.allowShorting is true but the account cannot short; short entries will be refused by Alpaca."); + } + + _session.OnNewSession = OnNewSession; + await _session.RefreshAsync(ct).ConfigureAwait(false); + _risk.StartSession((double)account.Equity, _session.SessionDate); + Log.Info($"session: {_session.Describe()}"); + + await ReconcilePositionsAsync(ct).ConfigureAwait(false); + await WarmupStrategiesAsync(ct).ConfigureAwait(false); + + WireStreams(); + _router.Start(ct); + await _tradeUpdates.StartAsync(ct).ConfigureAwait(false); + await _marketData.StartAsync(ct).ConfigureAwait(false); + + Log.Info("engine running — press Ctrl+C to stop"); + + await SuperviseAsync(ct).ConfigureAwait(false); + } + + // ----------------------------------------------------------------------- + // Startup + // ----------------------------------------------------------------------- + + private void PrintBanner() + { + string mode = _config.Engine.DryRun + ? "DRY-RUN (no orders will be sent)" + : _config.Alpaca.Paper ? "PAPER" : "*** LIVE MONEY ***"; + + Log.Info("──────────────────────────────────────────────────────────────"); + Log.Info($" Encelado trading engine mode={mode}"); + Log.Info($" endpoint={_trading.BaseUrl} feed={_config.Alpaca.DataFeed} assetClass={_assetClass}"); + Log.Info($" timeframe={_config.Engine.TimeFrame} symbols={_symbols.Length} " + + $"brackets={_config.Engine.UseBracketOrders} entries={_config.Engine.EntryOrderType}"); + Log.Info($" risk: {_config.Risk.MaxRiskPerTradePct:P2}/trade, max {_config.Risk.MaxOpenPositions} positions, " + + $"daily stop {_config.Risk.MaxDailyLossPct:P1}"); + + foreach (SymbolConfig s in _config.EnabledSymbols) + { + Log.Info($" {s.Symbol,-12} {s.Strategy}"); + } + + Log.Info("──────────────────────────────────────────────────────────────"); + } + + /// Replays recent history through every strategy so signals are valid from the first live bar. + private async Task WarmupStrategiesAsync(CancellationToken ct) + { + int warmupBars = _config.Engine.WarmupBars; + if (warmupBars <= 0) + { + Log.Warn("warmup is disabled; strategies will need live bars before they can signal"); + return; + } + + // Cover the requested bar count with slack for weekends, holidays and gaps. + double barsPerDay = _assetClass == AssetClass.Crypto ? 1440.0 / _minutesPerBar : 390.0 / _minutesPerBar; + int days = (int)Math.Ceiling(warmupBars / Math.Max(1, barsPerDay)) + (_assetClass == AssetClass.Crypto ? 2 : 5); + DateTime start = DateTime.UtcNow.AddDays(-Math.Max(2, days)); + + long t0 = Stopwatch.GetTimestamp(); + Dictionary> history; + try + { + history = await _data.GetBarsAsync( + _symbols, + _config.Engine.ResolvedTimeFrame, + start, + endUtc: null, + _assetClass, + warmupBars, + ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Warn($"warmup download failed ({ex.Message}); strategies will warm up on live bars"); + return; + } + + int fed = 0; + foreach (SymbolPipeline? pipe in _pipelines) + { + if (pipe is null || !history.TryGetValue(pipe.Symbol, out List? bars)) + { + continue; + } + + PositionView flat = PositionView.Flat(pipe.Symbol); + foreach (Bar bar in bars) + { + // Signals produced during warm-up are historical and deliberately discarded. + _ = pipe.Strategy.OnBar(bar, flat); + pipe.OnBarClosed(bar); + fed++; + } + + Log.Info($"warmup {pipe.Symbol,-12} {bars.Count,4} bars ready={pipe.Strategy.IsReady} " + + $"(needs {pipe.Strategy.WarmupBars})"); + } + + Log.Info($"warmup complete: {fed} bars in {Stopwatch.GetElapsedTime(t0).TotalMilliseconds:F0}ms"); + } + + private void WireStreams() + { + _marketData.OnLog = (message, ex) => + { + if (ex is null) + { + Log.Info(message); + } + else + { + Log.Warn(message); + } + }; + + _tradeUpdates.OnLog = _marketData.OnLog; + + _marketData.OnBar = HandleBar; + _marketData.OnQuote = HandleQuote; + _marketData.OnTrade = HandleTrade; + _tradeUpdates.OnTradeUpdate = HandleTradeUpdate; + + _marketData.OnLiveChanged = live => + Log.Info(live ? "market data stream is live" : "market data stream went down"); + _tradeUpdates.OnLiveChanged = live => + Log.Info(live ? "trade updates stream is live" : "trade updates stream went down"); + } + + // ----------------------------------------------------------------------- + // Market data path — runs on the websocket receive thread + // ----------------------------------------------------------------------- + + private void HandleBar(int symbolId, string symbol, in Bar bar) + { + long t0 = Stopwatch.GetTimestamp(); + _metrics.CountBar(); + + SymbolPipeline? pipe = PipelineFor(symbolId); + if (pipe is null) + { + return; + } + + // Alpaca bars carry total volume only; the aggressor split comes from the trade + // stream and has to be stamped on before the strategy sees the bar. + Bar enriched = pipe.AttachOrderFlow(bar); + + // Every incoming bar, not just the ones that close a strategy bucket. On a daily + // timeframe 1439 of every 1440 minute bars are folded in silently, and without + // this the log shows nothing happening for a whole day. + if (_config.Logging.LogEveryBar) + { + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"[{symbol}] barra {enriched.TimeUtc:HH:mm} " + + $"O {enriched.Open:N2} H {enriched.High:N2} L {enriched.Low:N2} C {enriched.Close:N2} " + + $"vol {enriched.Volume:N4} ({enriched.Close - enriched.Open:+0.00;-0.00;0.00} " + + $"{(enriched.Open > 0 ? (enriched.Close - enriched.Open) / enriched.Open : 0):+0.00%;-0.00%;0.00%})")); + } + + if (!pipe.Aggregator.TryAdd(enriched, out Bar closed)) + { + return; + } + + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"[{symbol}] barra {_config.Engine.TimeFrame} CHIUSA {closed.TimeUtc:yyyy-MM-dd HH:mm} " + + $"C {closed.Close:N2} — valuto la strategia")); + + Decide(pipe, symbol, closed, t0); + } + + /// + /// Runs one closed bar through the strategy and the order path. Shared by the live + /// stream and by , so a decision is identical + /// whichever route the bar arrived on. + /// + private void Decide(SymbolPipeline pipe, string symbol, in Bar closed, long startedAt) + { + pipe.OnBarClosed(closed); + _book.Mark(symbol, closed.Close); + _book.OnBarClosed(symbol); + + PositionView position = _book.View(symbol); + Signal signal = pipe.Strategy.OnBar(closed, position); + _metrics.BarToSignal.RecordSince(startedAt); + long t0 = startedAt; + + long decisionId = _analytics.NextDecisionId(); + + _analytics.Decision( + decisionId, symbol, closed, pipe.Strategy, position, signal, + pipe.LastQuote, + pipe.QuoteAge == TimeSpan.MaxValue ? -1 : pipe.QuoteAge.TotalSeconds, + _account.Equity, _session.IsOpen, _risk.IsHalted); + + // The decision itself, always at info: this is the answer to "why did it (not) + // trade", and burying it at debug is how that question became unanswerable. + string verdict = signal.Kind switch + { + SignalKind.EnterLong => $"COMPRO — {signal.Reason}", + SignalKind.EnterShort => $"VENDO ALLO SCOPERTO — {signal.Reason}", + SignalKind.Exit => $"CHIUDO — {signal.Reason}", + _ => $"NON FACCIO NULLA — {pipe.Strategy.Explain(closed.Close, position)}", + }; + + Log.Info($"[{symbol}] {verdict}"); + + if (Log.IsEnabled(Logging.LogLevel.Debug)) + { + // Built separately: concatenating onto an interpolated string breaks the + // handler chain that string.Create needs. + string internals = string.Join(" ", pipe.Strategy.Diagnostics + .Select(static m => string.Create(CultureInfo.InvariantCulture, $"{m.Name}={m.Value:F4}"))); + + Log.Debug($"[{symbol}] stato interno: {internals}"); + } + + Publish(pipe, signal, closed.Close, position, decisionId); + } + + private void HandleQuote(int symbolId, string symbol, in Quote quote) + { + _metrics.CountQuote(); + + SymbolPipeline? pipe = PipelineFor(symbolId); + if (pipe is null || !quote.IsValid) + { + return; + } + + pipe.OnQuote(quote); + _book.Mark(symbol, quote.Mid); + + if (_logMarketData) + { + Log.Trace(string.Create(CultureInfo.InvariantCulture, + $"{symbol} quote {quote.BidPrice:F2} x {quote.AskPrice:F2} spread {quote.RelativeSpread:P3}")); + } + + PositionView position = _book.View(symbol); + if (position.IsFlat) + { + return; + } + + // Locally held protection (crypto and fractional sizes cannot use broker brackets). + if (pipe.ShouldExitLocally(quote.Mid, position.Quantity, out string reason)) + { + Publish(pipe, Signal.Exit(reason), quote.Mid, position, _analytics.NextDecisionId()); + return; + } + + Signal signal = pipe.Strategy.OnQuote(quote, position); + Publish(pipe, signal, quote.Mid, position, _analytics.NextDecisionId()); + } + + private void HandleTrade(int symbolId, string symbol, in Tick tick, Aggressor aggressor) + { + _metrics.CountTrade(); + + SymbolPipeline? pipe = PipelineFor(symbolId); + if (pipe is null || tick.Price <= 0) + { + return; + } + + pipe.OnTrade(tick, aggressor == Aggressor.Buy, aggressor != Aggressor.Unknown); + _book.Mark(symbol, tick.Price); + + if (_logMarketData) + { + Log.Trace(string.Create(CultureInfo.InvariantCulture, + $"{symbol} print {tick.Price:F2} x {tick.Size:F6} taker={aggressor}")); + } + } + + /// Applies the cross-cutting gates and hands the signal to the order path. + private void Publish( + SymbolPipeline pipe, in Signal signal, double referencePrice, in PositionView position, long decisionId) + { + if (signal.Kind == SignalKind.None) + { + return; + } + + if (signal.Kind == SignalKind.Exit) + { + if (position.IsFlat || !pipe.TryClaimExit()) + { + return; + } + + _metrics.CountSignal(); + EnqueueOrRelease(pipe, signal, referencePrice, decisionId, isExit: true); + return; + } + + // Entries. Every refusal is recorded: a signal that never became an order is + // exactly the kind of thing worth reviewing later. + if (!position.IsFlat || pipe.EntryInFlight || _risk.IsHalted || !_session.CanOpenNewPositions || + !pipe.Strategy.IsReady) + { + string why = + !position.IsFlat ? "already in position" + : pipe.EntryInFlight ? "an entry is already in flight" + : _risk.IsHalted ? $"trading halted: {_risk.HaltReason}" + : !_session.CanOpenNewPositions ? "session not accepting new positions" + : "strategy still warming up"; + + Log.Debug($"{pipe.Symbol}: {signal.Kind} suppressed — {why}"); + _analytics.Execution( + decisionId, pipe.Symbol, signal.EntrySide, "suppressed", false, "Suppressed", why, + 0, referencePrice, signal.StopPrice, signal.TargetPrice, + _account.Equity, _account.BuyingPower, _book.GrossExposure, _book.OpenPositionCount, + null, null, 0); + return; + } + + _metrics.CountSignal(); + EnqueueOrRelease(pipe, signal, referencePrice, decisionId, isExit: false); + } + + private void EnqueueOrRelease( + SymbolPipeline pipe, in Signal signal, double referencePrice, long decisionId, bool isExit) + { + ExecutionIntent intent = new( + pipe.Id, pipe.Symbol, signal, referencePrice, Stopwatch.GetTimestamp(), decisionId); + + if (_router.Enqueue(intent)) + { + return; + } + + Log.Warn($"{pipe.Symbol}: execution queue refused an intent; dropping the signal"); + if (isExit) + { + pipe.ReleaseExit(); + } + } + + // ----------------------------------------------------------------------- + // Order events — runs on the trade-updates receive thread + // ----------------------------------------------------------------------- + + private void HandleTradeUpdate(TradeUpdate update) + { + SymbolPipeline? pipe = PipelineFor(update.Symbol); + + if (update.IsExecution && update.Quantity > 0 && update.Price > 0) + { + FillResult fill = _book.ApplyFill( + update.Symbol, update.Side, update.Quantity, update.Price, update.TimestampUtc); + + _metrics.CountOrderFilled(); + + if (fill.RealizedPnlDelta != 0) + { + _risk.RecordRealizedPnl(fill.RealizedPnlDelta); + } + + // Alpaca's position_qty is authoritative; trust it over our own arithmetic. + if (Math.Abs(fill.QuantityAfter - update.PositionQuantity) > 1e-6) + { + _book.Reconcile(update.Symbol, update.PositionQuantity, _book.View(update.Symbol).AverageEntryPrice, + update.Price); + } + + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"FILL {update.Symbol} {update.Side} {update.Quantity:0.####} @ {update.Price:F2} " + + $"position={update.PositionQuantity:0.####} realized={fill.RealizedPnlDelta:F2}")); + + _journal.Record("fill", update.Symbol, update.Side, update.Quantity, update.Price, + update.Event, update.Order.Id, equity: _account.Equity, realizedPnl: fill.RealizedPnlDelta); + + if (fill.Closed) + { + pipe?.ClearProtection(); + } + } + else if (update.Event is "rejected" or "canceled" or "expired") + { + Log.Warn($"{update.Symbol}: order {update.Event} (status={update.Order.Status}, id={update.Order.Id})"); + _journal.Record(update.Event, update.Symbol, update.Side, update.Order.Quantity, + update.Order.LimitPrice, $"order {update.Event}", update.Order.Id); + } + + if (update.IsTerminal) + { + pipe?.ReleaseEntry(); + } + } + + // ----------------------------------------------------------------------- + // Supervision + // ----------------------------------------------------------------------- + + private async Task SuperviseAsync(CancellationToken ct) + { + TimeSpan reconcileEvery = TimeSpan.FromSeconds(_config.Engine.ReconcileSeconds); + TimeSpan statusEvery = TimeSpan.FromSeconds(Math.Max(10, _config.Engine.StatusSeconds)); + TimeSpan explainEvery = TimeSpan.FromSeconds(Math.Max(1, _config.Engine.ExplainSeconds)); + using PeriodicTimer timer = new(TimeSpan.FromSeconds(1)); + + try + { + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + DateTime now = DateTime.UtcNow; + + if (now - _lastReconcileUtc >= reconcileEvery) + { + _lastReconcileUtc = now; + await _session.RefreshAsync(ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + await EnforceEndOfDayAsync(ct).ConfigureAwait(false); + } + + if (now - _lastExplainUtc >= explainEvery) + { + _lastExplainUtc = now; + LogIntent(); + } + + if (now - _lastStatusUtc >= statusEvery) + { + _lastStatusUtc = now; + LogStatus(); + } + } + } + catch (OperationCanceledException) + { + Log.Info("shutdown requested"); + } + } + + /// + /// Asks the broker for the most recent completed bars at the strategy + /// timeframe and runs the strategy on any it has not seen. + /// + /// This exists because building the strategy's bars out of the live minute stream is + /// not enough on a daily timeframe. The aggregator only closes a bucket when a minute + /// bar arrives belonging to the next one — at 1440 minutes per bucket that is once a + /// day, at UTC midnight, and only if the process happens to be running at that + /// instant. Started at ten and stopped at six, the bot would receive thousands of + /// quotes, log hundreds of bars, and never evaluate the strategy once: the symptom + /// was bars=40 signals=0 with an empty decision log. + /// + /// + /// Polling closed bars from REST also keeps the live bot faithful to the backtest, + /// which decides on true daily closes rather than on a partial day assembled from + /// whichever minutes the process was connected for. + /// + /// + private async Task PollClosedBarsAsync(CancellationToken ct) + { + Dictionary> latest; + try + { + // A handful of bars is enough to close any gap left by a short outage, and + // cheap enough to ask for on every reconcile. + latest = await _data.GetBarsAsync( + _symbols, + _config.Engine.ResolvedTimeFrame, + DateTime.UtcNow.AddMinutes(-_minutesPerBar * 5.0), + endUtc: null, + _assetClass, + maxBarsPerSymbol: 5, + ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Debug($"bar poll failed: {ex.Message}"); + return; + } + + foreach (SymbolPipeline? pipe in _pipelines) + { + if (pipe is null || !latest.TryGetValue(pipe.Symbol, out List? bars)) + { + continue; + } + + foreach (Bar bar in bars) + { + // Strictly newer only. The last bar the broker returns is usually the one + // still forming; acting on it would mean deciding on a partial close and + // then deciding again when it finishes. + if (bar.TimeUtc <= pipe.LastBar.TimeUtc || bar.TimeUtc >= CurrentBucketStart()) + { + continue; + } + + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"{pipe.Symbol} barra chiusa {bar.TimeUtc:yyyy-MM-dd HH:mm} C{bar.Close:F2} — valuto la strategia")); + + Decide(pipe, pipe.Symbol, bar, Stopwatch.GetTimestamp()); + } + } + } + + /// Start of the bar currently forming. Anything at or after it is incomplete. + private DateTime CurrentBucketStart() + { + long ticks = TimeSpan.TicksPerMinute * _minutesPerBar; + return new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % ticks), DateTimeKind.Utc); + } + + private async Task ReconcileAsync(CancellationToken ct) + { + try + { + AlpacaAccount account = await _trading.GetAccountAsync(ct).ConfigureAwait(false); + _account.Update(account); + PreviousCloseEquity = (double)account.LastEquity; + + // The order history the orders page shows. Polled on the reconcile cadence + // rather than kept in sync from the trade stream, because the stream only + // reports what happened while we were connected: after a reconnect the + // broker's list is the only complete one. + _recentOrders = [.. await _trading + .ListOrdersAsync("all", RecentOrderLimit, null, ct) + .ConfigureAwait(false)]; + + // Lifetime history moves slowly and is dashboard-only; poll it sparingly. + if (DateTime.UtcNow - _lastHistoryUtc > TimeSpan.FromMinutes(5)) + { + _lastHistoryUtc = DateTime.UtcNow; + PortfolioHistory = await _trading + .GetPortfolioHistoryAsync("all", "1D", ct) + .ConfigureAwait(false); + } + + if (_risk.UpdateEquity((double)account.Equity)) + { + Log.Warn($"KILL SWITCH: {_risk.HaltReason} — flattening and standing down for the session"); + await _router.FlattenAllAsync(_risk.HaltReason, ct).ConfigureAwait(false); + } + + await ReconcilePositionsAsync(ct).ConfigureAwait(false); + await ReleaseStaleLatchesAsync(ct).ConfigureAwait(false); + await PollClosedBarsAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Warn($"reconcile failed: {ex.Message}"); + } + } + + private async Task ReconcilePositionsAsync(CancellationToken ct) + { + List positions = await _trading.ListPositionsAsync(ct).ConfigureAwait(false); + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + + foreach (AlpacaPosition p in positions) + { + seen.Add(p.Symbol); + PositionView before = _book.View(p.Symbol); + _book.Reconcile(p.Symbol, p.Quantity, p.AverageEntryPrice, p.CurrentPrice); + + if (Math.Abs(before.Quantity - p.Quantity) > 1e-6) + { + Log.Debug($"reconciled {p.Symbol}: {before.Quantity:0.####} -> {p.Quantity:0.####} " + + $"@ {p.AverageEntryPrice:F2}"); + } + } + + _book.ReconcileMissing(seen); + } + + /// + /// Frees the per-symbol in-flight latch when the broker has no working order left, + /// so a dropped websocket message cannot wedge a symbol permanently. + /// + private async Task ReleaseStaleLatchesAsync(CancellationToken ct) + { + bool anyStale = false; + foreach (SymbolPipeline? pipe in _pipelines) + { + if (pipe is not null && pipe.EntryInFlight && + DateTime.UtcNow - pipe.InFlightSinceUtc > TimeSpan.FromSeconds(30)) + { + anyStale = true; + break; + } + } + + if (!anyStale) + { + return; + } + + List open = await _trading.ListOpenOrdersAsync(ct).ConfigureAwait(false); + HashSet working = new(StringComparer.OrdinalIgnoreCase); + foreach (AlpacaOrder order in open) + { + if (order.IsWorking) + { + working.Add(order.Symbol); + } + } + + foreach (SymbolPipeline? pipe in _pipelines) + { + if (pipe is null || !pipe.EntryInFlight) + { + continue; + } + + if (DateTime.UtcNow - pipe.InFlightSinceUtc > TimeSpan.FromSeconds(30) && !working.Contains(pipe.Symbol)) + { + pipe.ReleaseEntry(); + Log.Debug($"{pipe.Symbol}: released a stale in-flight latch"); + } + } + } + + private async Task EnforceEndOfDayAsync(CancellationToken ct) + { + if (!_session.InFlattenWindow || _flattenedForSession == _session.SessionDate) + { + return; + } + + if (_book.OpenPositionCount == 0) + { + _flattenedForSession = _session.SessionDate; + return; + } + + _flattenedForSession = _session.SessionDate; + await _router.FlattenAllAsync( + $"end-of-day flatten, {_config.Engine.FlattenBeforeCloseMinutes} min before the close", ct) + .ConfigureAwait(false); + } + + private void OnNewSession(DateOnly date) + { + Log.Info($"── new trading session {date:yyyy-MM-dd} ──"); + _risk.StartSession(_account.Equity, date); + _book.ResetDailyCounters(); + + foreach (SymbolPipeline? pipe in _pipelines) + { + pipe?.Aggregator.Reset(); + } + } + + /// + /// Says what each strategy is doing right now and what it is waiting for. + /// + /// On a daily timeframe the bot is legitimately silent for weeks, and from the + /// outside that is indistinguishable from a hang. This turns the silence into a + /// sentence. The line is only written when it changes, so a bot that has been + /// waiting for the same threshold all week does not fill the log with the same + /// sentence a thousand times — but the moment anything moves, it says so. + /// + /// + private void LogIntent() + { + foreach (SymbolPipeline? pipe in _pipelines) + { + if (pipe is null || pipe.LastPrice <= 0) + { + continue; + } + + string intent = pipe.Strategy.Explain(pipe.LastPrice, _book.View(pipe.Symbol)); + if (intent.Length == 0) + { + continue; + } + + if (_lastIntent.TryGetValue(pipe.Symbol, out string? previous) && previous == intent) + { + continue; + } + + _lastIntent[pipe.Symbol] = intent; + Log.Info($"[{pipe.Symbol}] {intent}"); + } + } + + private void LogStatus() + { + double unrealized = _book.TotalUnrealizedPnl; + double realized = _risk.DailyRealizedPnl; + double equity = _account.Equity; + double startEquity = _risk.SessionStartEquity; + double dayChange = startEquity > 0 ? (equity - startEquity) / startEquity : 0; + + string halt = _risk.IsHalted ? $" HALTED: {_risk.HaltReason}" : string.Empty; + Log.Info(string.Create(CultureInfo.InvariantCulture, + $"[status] equity={equity:F2} ({dayChange:P2} today) realized={realized:F2} unrealized={unrealized:F2} " + + $"open={_book.OpenPositionCount} trades={_risk.TradesToday}{halt}")); + + Log.Info($"[status] {_metrics.Summary()} queue={_router.QueueDepth}"); + Log.Info($"[status] {_metrics.BarToSignal.Summary()} | {_metrics.SignalToOrder.Summary()}"); + Log.Info($"[status] session: {_session.Describe()} | data={_marketData.State} " + + $"(reconnects={Math.Max(0, _marketData.ConnectCount - 1)}) | orders={_tradeUpdates.State}"); + + foreach (Position p in _book.Positions) + { + if (p.IsOpen) + { + Log.Info(string.Create(CultureInfo.InvariantCulture, + $" {p.Symbol,-12} {p.Quantity,10:0.####} @ {p.AverageEntryPrice,10:F2} " + + $"last={p.LastPrice,10:F2} pnl={p.UnrealizedPnl,9:F2} ({p.UnrealizedPnlPct:P2})")); + } + } + } + + private SymbolPipeline? PipelineFor(int symbolId) => + (uint)symbolId < (uint)_pipelines.Length ? _pipelines[symbolId] : null; + + private SymbolPipeline? PipelineFor(string symbol) + { + int id = _marketData.Symbols.Resolve(symbol); + return id >= 0 ? PipelineFor(id) : null; + } + + public async Task ShutdownAsync() + { + Log.Info("stopping streams…"); + await _marketData.StopAsync().ConfigureAwait(false); + await _tradeUpdates.StopAsync().ConfigureAwait(false); + + if (_config.Engine.CloseOnShutdown && !_config.Engine.DryRun) + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + await _router.FlattenAllAsync("engine shutdown", cts.Token).ConfigureAwait(false); + } + + await _router.StopAsync().ConfigureAwait(false); + + LogStatus(); + Log.Info("engine stopped"); + } + + public async ValueTask DisposeAsync() + { + await _marketData.DisposeAsync().ConfigureAwait(false); + await _tradeUpdates.DisposeAsync().ConfigureAwait(false); + _trading.Dispose(); + _data.Dispose(); + _journal.Dispose(); + _analytics.Dispose(); + } +} diff --git a/Encelado/src/Encelado.Bot/GlobalUsings.cs b/Encelado/src/Encelado.Bot/GlobalUsings.cs new file mode 100644 index 0000000..3bebb6f --- /dev/null +++ b/Encelado/src/Encelado.Bot/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Declared as a real source file rather than . The temporary project +// MSBuild generates to compile XAML markup does not inherit that property, so the +// markup pass would otherwise fail on types the rest of the project takes for granted. +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/Encelado/src/Encelado.Bot/Logging/Log.cs b/Encelado/src/Encelado.Bot/Logging/Log.cs new file mode 100644 index 0000000..f2eb4a2 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Logging/Log.cs @@ -0,0 +1,368 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading.Channels; +using Encelado.Bot.Configuration; + +namespace Encelado.Bot.Logging; + +public enum LogLevel : byte +{ + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, + None = 5, +} + +/// +/// Non-blocking logger. Call sites only enqueue; a single background writer does the +/// formatting and the I/O, so a burst of ticks never stalls the decode loop on a +/// console write. +/// +public static class Log +{ + private const string AnsiReset = "\u001b[0m"; + + private static readonly Channel Queue = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + private static Task? _writerTask; + private static StreamWriter? _file; + private static LogLevel _minimum = LogLevel.Info; + private static bool _console = true; + private static bool _colors; + private static long _dropped; + private static long _enqueued; + private static long _processed; + private static string? _path; + private static long _maxBytes; + private static int _maxFiles = 10; + private static long _written; + + public static LogLevel Minimum => _minimum; + + public static bool IsEnabled(LogLevel level) => level >= _minimum; + + /// + /// Optional secondary sink, used by the dashboard to mirror the log into its live + /// activity feed. Invoked synchronously on the calling thread, so implementations + /// must be cheap and must never throw. + /// + public static Action? Sink { get; set; } + + public static void Initialize(LoggingOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _minimum = ParseLevel(options.Level); + _console = options.Console; + _colors = _console && !Console.IsOutputRedirected; + _maxBytes = options.MaxFileSizeMb > 0 ? options.MaxFileSizeMb * 1024L * 1024L : 0; + _maxFiles = options.MaxFiles; + _path = options.ResolvePath(options.File); + + OpenFile(); + _writerTask ??= Task.Run(WriteLoopAsync); + } + + /// Absolute path of the active log file, for the UI to show and open. + public static string? FilePath => _path; + + private static void OpenFile() + { + if (string.IsNullOrWhiteSpace(_path)) + { + return; + } + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path))!); + FileStream stream = new(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 8192); + _written = stream.Length; + _file = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = false }; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[log] cannot open {_path}: {ex.Message}"); + _file = null; + } + } + + /// + /// Rolls encelado.log to encelado.1.log, shifting the older ones up and + /// dropping the oldest. Keeps a long-running bot from filling the disk while still + /// preserving recent history for analysis. + /// + private static void RotateIfNeeded() + { + if (_file is null || _maxBytes <= 0 || _written < _maxBytes || string.IsNullOrWhiteSpace(_path)) + { + return; + } + + try + { + _file.Flush(); + _file.Dispose(); + _file = null; + + string directory = Path.GetDirectoryName(Path.GetFullPath(_path))!; + string name = Path.GetFileNameWithoutExtension(_path); + string extension = Path.GetExtension(_path); + + string Slot(int i) => Path.Combine(directory, $"{name}.{i}{extension}"); + + string oldest = Slot(_maxFiles); + if (File.Exists(oldest)) + { + File.Delete(oldest); + } + + for (int i = _maxFiles - 1; i >= 1; i--) + { + if (File.Exists(Slot(i))) + { + File.Move(Slot(i), Slot(i + 1), overwrite: true); + } + } + + File.Move(_path, Slot(1), overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[log] rotation failed: {ex.Message}"); + } + finally + { + OpenFile(); + } + } + + public static void Trace(string message) => Write(LogLevel.Trace, message, null); + + public static void Debug(string message) => Write(LogLevel.Debug, message, null); + + public static void Info(string message) => Write(LogLevel.Info, message, null); + + public static void Warn(string message) => Write(LogLevel.Warn, message, null); + + public static void Error(string message, Exception? exception = null) => + Write(LogLevel.Error, message, exception); + + private static void Write(LogLevel level, string message, Exception? exception) + { + if (level < _minimum) + { + return; + } + + DateTime now = DateTime.Now; + + if (Queue.Writer.TryWrite(new Entry(now, level, message, exception))) + { + Interlocked.Increment(ref _enqueued); + } + else + { + Interlocked.Increment(ref _dropped); + } + + Action? sink = Sink; + if (sink is not null) + { + try + { + sink(level, now, exception is null ? message : $"{message} | {exception.Message}"); + } + catch + { + // A misbehaving sink must never break the caller's control flow. + } + } + } + + /// + /// Waits until the writer has caught up with everything enqueued so far. Needed + /// before writing to the console directly — an interactive prompt must not be + /// interleaved with asynchronous log lines. + /// + public static async Task FlushAsync(TimeSpan timeout) + { + long deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency); + + while (Interlocked.Read(ref _processed) < Interlocked.Read(ref _enqueued)) + { + if (Stopwatch.GetTimestamp() >= deadline) + { + return; + } + + await Task.Delay(5).ConfigureAwait(false); + } + + if (_file is not null) + { + try + { + await _file.FlushAsync().ConfigureAwait(false); + } + catch (IOException) + { + // Best effort. + } + } + } + + private static async Task WriteLoopAsync() + { + StringBuilder sb = new(256); + long lastFlush = Stopwatch.GetTimestamp(); + + await foreach (Entry entry in Queue.Reader.ReadAllAsync().ConfigureAwait(false)) + { + // The writer must never take the process down: a broken console handle or a + // full disk should cost log lines, not the trading session. + try + { + sb.Clear(); + sb.Append(entry.Timestamp.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)) + .Append(' ') + .Append(Tag(entry.Level)) + .Append(' ') + .Append(entry.Message); + + if (entry.Exception is not null) + { + sb.Append(" | ").Append(entry.Exception.GetType().Name) + .Append(": ").Append(entry.Exception.Message); + } + + string line = sb.ToString(); + + if (_console) + { + if (_colors) + { + Console.Out.Write(Color(entry.Level)); + Console.Out.Write(line); + Console.Out.WriteLine(AnsiReset); + } + else + { + Console.Out.WriteLine(line); + } + } + + if (_file is not null) + { + await _file.WriteLineAsync(line).ConfigureAwait(false); + _written += line.Length + Environment.NewLine.Length; + + // Warnings and errors flush immediately; routine lines are batched so + // a busy session is not one fsync per entry. + if (entry.Level >= LogLevel.Warn || + Stopwatch.GetElapsedTime(lastFlush) >= TimeSpan.FromMilliseconds(500)) + { + await _file.FlushAsync().ConfigureAwait(false); + lastFlush = Stopwatch.GetTimestamp(); + RotateIfNeeded(); + } + } + } + catch (Exception ex) + { + Interlocked.Increment(ref _dropped); + try + { + Console.Error.WriteLine($"[log] writer failure: {ex.Message}"); + } + catch (IOException) + { + // Nothing left to write to. + } + } + finally + { + Interlocked.Increment(ref _processed); + } + } + } + + /// Drains the queue and flushes the file. Call before the process exits. + public static async Task ShutdownAsync() + { + Queue.Writer.TryComplete(); + + if (_writerTask is not null) + { + try + { + await _writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (Exception ex) when (ex is TimeoutException or OperationCanceledException) + { + // Give up rather than hang the shutdown path. + } + + _writerTask = null; + } + + if (_file is not null) + { + try + { + await _file.FlushAsync().ConfigureAwait(false); + await _file.DisposeAsync().ConfigureAwait(false); + } + catch (IOException) + { + // Best effort. + } + + _file = null; + } + + long dropped = Interlocked.Read(ref _dropped); + if (dropped > 0) + { + Console.Error.WriteLine($"[log] {dropped} entries were dropped."); + } + } + + public static LogLevel ParseLevel(string? text) => text?.Trim().ToLowerInvariant() switch + { + "trace" or "verbose" => LogLevel.Trace, + "debug" => LogLevel.Debug, + "info" or "information" => LogLevel.Info, + "warn" or "warning" => LogLevel.Warn, + "error" => LogLevel.Error, + "none" or "off" => LogLevel.None, + _ => LogLevel.Info, + }; + + private static string Tag(LogLevel level) => level switch + { + LogLevel.Trace => "TRC", + LogLevel.Debug => "DBG", + LogLevel.Info => "INF", + LogLevel.Warn => "WRN", + LogLevel.Error => "ERR", + _ => " ", + }; + + private static string Color(LogLevel level) => level switch + { + LogLevel.Trace => "\u001b[90m", + LogLevel.Debug => "\u001b[36m", + LogLevel.Info => AnsiReset, + LogLevel.Warn => "\u001b[33m", + LogLevel.Error => "\u001b[31m", + _ => AnsiReset, + }; + + private readonly record struct Entry(DateTime Timestamp, LogLevel Level, string Message, Exception? Exception); +} diff --git a/Encelado/src/Encelado.Bot/MainWindow.xaml b/Encelado/src/Encelado.Bot/MainWindow.xaml new file mode 100644 index 0000000..9b683c4 --- /dev/null +++ b/Encelado/src/Encelado.Bot/MainWindow.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +