Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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
|
||||
Vendored
+12
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+83
@@ -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-<versione>.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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
<Deterministic>true</Deterministic>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<Product>Encelado</Product>
|
||||
<Company>Encelado</Company>
|
||||
<Version>3.2.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Hot-path tuning. The bot is a latency-sensitive process: we want the server GC
|
||||
(background, multiple heaps), full PGO and no culture-dependent parsing on the
|
||||
market-data decode path.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<TieredCompilationQuickJitForLoops>true</TieredCompilationQuickJitForLoops>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<UseSystemResourceKeys>true</UseSystemResourceKeys>
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Everything on the runtime path must stay reflection-free so PublishAot works. -->
|
||||
<PropertyGroup Condition="'$(MSBuildProjectName)' != 'Encelado.Tests'">
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Encelado.Alpaca/Encelado.Alpaca.csproj" />
|
||||
<Project Path="src/Encelado.Bot/Encelado.Bot.csproj" />
|
||||
<Project Path="src/Encelado.Core/Encelado.Core.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Encelado.Tests/Encelado.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
<Project Path="tools/Encelado.Backtest/Encelado.Backtest.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,233 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Costruisce Encelado-Setup-<versione>.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 <Version> 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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,114 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Alpaca;
|
||||
|
||||
/// <summary>Connection settings for every Alpaca endpoint the bot talks to.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Paper trading is the default. Flipping this to <see langword="false"/> risks real money.</summary>
|
||||
public bool Paper { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Equity data feed: <c>iex</c> (free), <c>sip</c> (full tape, paid),
|
||||
/// <c>delayed_sip</c>, or <c>test</c> (Alpaca's synthetic FAKEPACA stream).
|
||||
/// </summary>
|
||||
public string DataFeed { get; set; } = "iex";
|
||||
|
||||
/// <summary>Overrides the trading REST base URL. Leave empty to derive it from <see cref="Paper"/>.</summary>
|
||||
public string TradingBaseUrlOverride { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Overrides the market-data REST base URL.</summary>
|
||||
public string DataBaseUrlOverride { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Client-side throttle. Alpaca allows 200 requests/minute per account on the basic plan.</summary>
|
||||
public int RequestsPerMinute { get; set; } = 180;
|
||||
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Number of retries for transient failures (429 / 5xx / socket errors).</summary>
|
||||
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('/');
|
||||
|
||||
/// <summary>Order/position event stream. Lives on the trading host, not the data host.</summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when Alpaca answers with a non-success status or an unusable payload.</summary>
|
||||
public sealed class AlpacaApiException(string message, int statusCode = 0, string? body = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
|
||||
public string? Body { get; } = body;
|
||||
|
||||
/// <summary>Transient conditions worth retrying.</summary>
|
||||
public bool IsTransient => StatusCode is 429 or >= 500;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Encelado.Alpaca</RootNamespace>
|
||||
<AssemblyName>Encelado.Alpaca</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Alpaca.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reading helpers for Alpaca's REST payloads. Alpaca encodes most numeric fields as
|
||||
/// JSON <i>strings</i> (<c>"qty": "10"</c>), and omits or nulls fields liberally, so
|
||||
/// every accessor tolerates both shapes and a missing property.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Alpaca.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-rolled RFC3339 parser for the shape Alpaca actually emits
|
||||
/// (<c>2024-05-17T13:04:56.334262119Z</c>). 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 <see cref="DateTime.TryParse(ReadOnlySpan{char}, IFormatProvider, DateTimeStyles, out DateTime)"/>
|
||||
/// for anything unusual (offsets, missing fractions, non-UTC).
|
||||
/// </summary>
|
||||
public static class Rfc3339
|
||||
{
|
||||
/// <summary>Parses a UTC timestamp from UTF-8 bytes. Returns <see cref="DateTime.MinValue"/> on failure.</summary>
|
||||
public static DateTime ParseUtc(ReadOnlySpan<byte> 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<byte> utf8)
|
||||
{
|
||||
Span<char> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Historical and latest-snapshot market data. Used to warm indicators up before the
|
||||
/// live stream takes over, and by the replay/backtest mode.
|
||||
/// </summary>
|
||||
public sealed class AlpacaDataClient(AlpacaOptions options) : IDisposable
|
||||
{
|
||||
private readonly AlpacaHttp _http = new(options.Validate(), options.DataBaseUrl);
|
||||
private readonly string _feed = options.DataFeed;
|
||||
|
||||
/// <summary>Alpaca caps a single bars page at 10 000 rows.</summary>
|
||||
private const int PageLimit = 10_000;
|
||||
|
||||
public Task WarmupAsync(CancellationToken ct) =>
|
||||
_http.WarmupAsync("v2/stocks/bars?symbols=SPY&timeframe=1Day&limit=1", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches historical bars for one or more symbols in chronological order,
|
||||
/// following pagination across the whole requested range.
|
||||
/// <para>
|
||||
/// <paramref name="maxBarsPerSymbol"/> keeps the <b>most recent</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, List<Bar>>> GetBarsAsync(
|
||||
IReadOnlyList<string> symbols,
|
||||
TimeFrame timeFrame,
|
||||
DateTime startUtc,
|
||||
DateTime? endUtc,
|
||||
AssetClass assetClass,
|
||||
int maxBarsPerSymbol,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
Dictionary<string, List<Bar>> 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<Bar>? list))
|
||||
{
|
||||
list = new List<Bar>(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<Bar> bars = result[key];
|
||||
if (bars.Count > maxBarsPerSymbol)
|
||||
{
|
||||
result[key] = bars.GetRange(bars.Count - maxBarsPerSymbol, maxBarsPerSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, Quote>> GetLatestQuotesAsync(
|
||||
IReadOnlyList<string> symbols,
|
||||
AssetClass assetClass,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, Quote> 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<Dictionary<string, Tick>> GetLatestTradesAsync(
|
||||
IReadOnlyList<string> symbols,
|
||||
AssetClass assetClass,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, Tick> 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();
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Alpaca.Rest;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the TLS connection ahead of the first real request so the first order
|
||||
/// does not pay for the handshake.
|
||||
/// </summary>
|
||||
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<JsonDocument> GetAsync(string path, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Get, path, null, ct);
|
||||
|
||||
public Task<JsonDocument> PostAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Post, path, json, ct);
|
||||
|
||||
public Task<JsonDocument> PatchAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Patch, path, json, ct);
|
||||
|
||||
public Task<JsonDocument> DeleteAsync(string path, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Delete, path, null, ct);
|
||||
|
||||
/// <summary>Like <see cref="GetAsync"/> but maps HTTP 404 to <see langword="null"/>.</summary>
|
||||
public async Task<JsonDocument?> 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<JsonDocument> SendAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
ReadOnlyMemory<byte>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>True once the order can no longer change state.</summary>
|
||||
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)
|
||||
{
|
||||
/// <summary>True when the broker will refuse new orders for any reason.</summary>
|
||||
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<AlpacaOrder> 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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account equity over time, straight from Alpaca. <see cref="BaseValue"/> is the
|
||||
/// equity at the start of the requested window, so lifetime P&L is
|
||||
/// <c>Equity[^1] - BaseValue</c> — with the usual caveat that deposits and withdrawals
|
||||
/// move equity without being profit.
|
||||
/// </summary>
|
||||
public sealed record AlpacaPortfolioHistory(
|
||||
IReadOnlyList<long> TimestampsUnix,
|
||||
IReadOnlyList<double> Equity,
|
||||
IReadOnlyList<double> 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;
|
||||
|
||||
/// <summary>Change over the whole window in absolute terms.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An order about to be submitted. Built by the execution router, never by strategies.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Idempotency key. Alpaca rejects duplicates, which is exactly what we want on a retry.</summary>
|
||||
public string? ClientOrderId { get; init; }
|
||||
|
||||
public bool ExtendedHours { get; init; }
|
||||
|
||||
/// <summary>Attached protective stop. Turns the order into a bracket/OTO order.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Alpaca's <c>order_class</c> implied by the attached legs.</summary>
|
||||
public string OrderClass =>
|
||||
!double.IsNaN(TakeProfitLimitPrice) && !double.IsNaN(StopLossStopPrice) ? "bracket"
|
||||
: !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice) ? "oto"
|
||||
: "simple";
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Alpaca.Rest;
|
||||
|
||||
/// <summary>
|
||||
/// Typed wrapper over Alpaca's trading REST API (<c>/v2/account</c>, <c>/v2/orders</c>,
|
||||
/// <c>/v2/positions</c>, …). Request bodies are written straight to UTF-8 with
|
||||
/// <see cref="Utf8JsonWriter"/> — no serializer, no reflection, no per-order allocation
|
||||
/// beyond a pooled buffer.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Opens the TLS/HTTP2 connection before the session starts.</summary>
|
||||
public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync("v2/clock", ct);
|
||||
|
||||
public async Task<AlpacaAccount> GetAccountAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetAsync("v2/account", ct).ConfigureAwait(false);
|
||||
return AlpacaAccount.FromJson(doc.RootElement);
|
||||
}
|
||||
|
||||
public async Task<AlpacaClock> GetClockAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetAsync("v2/clock", ct).ConfigureAwait(false);
|
||||
return AlpacaClock.FromJson(doc.RootElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equity curve for the account. <paramref name="period"/> uses Alpaca's notation
|
||||
/// (<c>1D</c>, <c>1M</c>, <c>1A</c>, <c>all</c>) and <paramref name="timeframe"/> the
|
||||
/// bucket size (<c>1Min</c>, <c>15Min</c>, <c>1H</c>, <c>1D</c>).
|
||||
/// </summary>
|
||||
public async Task<AlpacaPortfolioHistory> 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<AlpacaAsset?> 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<List<AlpacaPosition>> ListPositionsAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetAsync("v2/positions", ct).ConfigureAwait(false);
|
||||
List<AlpacaPosition> positions = [];
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
positions.Add(AlpacaPosition.FromJson(e));
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
public async Task<AlpacaPosition?> 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);
|
||||
}
|
||||
|
||||
/// <summary>Liquidates a position at market. Alpaca cancels the open legs for us.</summary>
|
||||
public async Task<AlpacaOrder?> 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<List<AlpacaOrder>> 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<AlpacaOrder> orders = [];
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
orders.Add(AlpacaOrder.FromJson(e));
|
||||
}
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
public Task<List<AlpacaOrder>> ListOpenOrdersAsync(CancellationToken ct) =>
|
||||
ListOrdersAsync("open", 500, null, ct);
|
||||
|
||||
public async Task<AlpacaOrder> 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);
|
||||
}
|
||||
|
||||
/// <summary>Moves an open order's stop/limit — used to trail protective stops.</summary>
|
||||
public async Task<AlpacaOrder> ReplaceOrderAsync(
|
||||
string orderId,
|
||||
double? quantity,
|
||||
double? limitPrice,
|
||||
double? stopPrice,
|
||||
string? clientOrderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>Serialises an order to Alpaca's wire format. Public so it can be asserted on in tests.</summary>
|
||||
public static byte[] WriteOrderJson(NewOrder order)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alpaca rejects prices that are not a valid sub-penny increment: two decimals at
|
||||
/// or above $1, four decimals below it.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Whole shares stay integral; fractional sizes get at most 9 decimals.</summary>
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Which side crossed the spread on a print. Unknown when the feed omits it.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Alpaca's real-time market data socket. Frames are decoded straight out of the
|
||||
/// receive buffer with <see cref="Utf8JsonReader"/> and symbols are resolved through
|
||||
/// a <see cref="SymbolTable"/>, so a live tape produces no garbage per tick.
|
||||
/// </summary>
|
||||
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<string> 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;
|
||||
|
||||
/// <summary>Fired for every print on the tape.</summary>
|
||||
public TickHandler? OnTrade { get; set; }
|
||||
|
||||
/// <summary>Fired on every top-of-book change.</summary>
|
||||
public QuoteHandler? OnQuote { get; set; }
|
||||
|
||||
/// <summary>Fired when a minute bar closes — the engine's main decision trigger.</summary>
|
||||
public BarHandler? OnBar { get; set; }
|
||||
|
||||
/// <summary>Fired for Alpaca's rolling daily bar.</summary>
|
||||
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<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns an Alpaca stream error into either a refusal or a note.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<byte> 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<byte> 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<string> symbols, bool trades, bool quotes, bool bars)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<string> values)
|
||||
{
|
||||
w.WriteStartArray(name);
|
||||
foreach (string v in values)
|
||||
{
|
||||
w.WriteStringValue(v);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Encelado.Alpaca.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SymbolTable
|
||||
{
|
||||
private readonly Dictionary<string, int> _ids;
|
||||
private readonly Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> _lookup;
|
||||
private readonly List<string> _names = [];
|
||||
|
||||
public SymbolTable(IEnumerable<string> symbols)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
_ids = new Dictionary<string, int>(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<ReadOnlySpan<char>>();
|
||||
}
|
||||
|
||||
public int Count => _names.Count;
|
||||
|
||||
public IReadOnlyList<string> Names => _names;
|
||||
|
||||
/// <summary>Resolves a symbol from raw UTF-8. Returns -1 when it is not subscribed.</summary>
|
||||
public int Resolve(ReadOnlySpan<byte> 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<char> 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>One order lifecycle event pushed by Alpaca.</summary>
|
||||
public sealed record TradeUpdate(
|
||||
string Event,
|
||||
DateTime TimestampUtc,
|
||||
string Symbol,
|
||||
Side Side,
|
||||
double Price,
|
||||
double Quantity,
|
||||
double PositionQuantity,
|
||||
AlpacaOrder Order)
|
||||
{
|
||||
/// <summary>True when shares actually changed hands.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Raised for every order lifecycle event. Runs on the receive thread.</summary>
|
||||
public Action<TradeUpdate>? 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<byte> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>{"action":"authenticate","data":{"key_id":…,"secret_key":…}}</c></summary>
|
||||
private static byte[] BuildEnvelopeAuth(string key, string secret)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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();
|
||||
}
|
||||
|
||||
/// <summary><c>{"action":"auth","key":…,"secret":…}</c></summary>
|
||||
private static byte[] BuildFlatAuth(string key, string secret)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<byte> 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Number of times the channel has (re)established a live session.</summary>
|
||||
public int ConnectCount { get; private set; }
|
||||
|
||||
public DateTime LastMessageUtc { get; private set; }
|
||||
|
||||
/// <summary>Diagnostics sink. Set by the host so channel events land in the bot log.</summary>
|
||||
public Action<string, Exception?>? OnLog { get; set; }
|
||||
|
||||
/// <summary>Raised whenever the channel transitions to or away from <see cref="ChannelState.Live"/>.</summary>
|
||||
public Action<bool>? OnLiveChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? RejectionReason => _rejection;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the refusal once a session actually comes up.</summary>
|
||||
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<byte>.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<byte>.Shared.Rent(buffer.Length * 2);
|
||||
Buffer.BlockCopy(buffer, 0, bigger, 0, offset);
|
||||
ArrayPool<byte>.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<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
protected async ValueTask SendAsync(ReadOnlyMemory<byte> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sends the auth (and subscribe) handshake right after the socket opens.</summary>
|
||||
protected abstract ValueTask OnOpenAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Decodes one complete frame. Runs on the receive thread — keep it allocation free.</summary>
|
||||
protected abstract void OnMessage(ReadOnlySpan<byte> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ServerAuthTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// 1s, 2s, 4s … capped at 60s, with jitter. Alpaca allows a single market-data
|
||||
/// connection per account, so hammering reconnects just earns a 406.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Application x:Class="Encelado.Bot.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Ui/Theme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Loaded once at startup and shared by every window.</summary>
|
||||
public static BotConfig Config { get; private set; } = new();
|
||||
|
||||
public static IReadOnlyList<string> 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<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,328 @@
|
||||
using Encelado.Alpaca;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>Where the Alpaca credentials in use actually came from.</summary>
|
||||
public enum CredentialSource
|
||||
{
|
||||
None = 0,
|
||||
ConfigFile,
|
||||
Environment,
|
||||
SavedStore,
|
||||
Interactive,
|
||||
}
|
||||
|
||||
public sealed class BotConfig
|
||||
{
|
||||
public AlpacaOptions Alpaca { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of <see cref="AlpacaOptions.KeyId"/>. Set by the loader and by the
|
||||
/// login flow so startup can report it without ever echoing the secret.
|
||||
/// </summary>
|
||||
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<SymbolConfig> Symbols { get; set; } = [];
|
||||
|
||||
public IEnumerable<SymbolConfig> EnabledSymbols => Symbols.Where(s => s.Enabled);
|
||||
|
||||
public BotConfig Validate()
|
||||
{
|
||||
Alpaca.Validate();
|
||||
Risk.Validate();
|
||||
Engine.Validate();
|
||||
Ui.Validate();
|
||||
Logging.Validate();
|
||||
|
||||
List<SymbolConfig> enabled = [.. EnabledSymbols];
|
||||
if (enabled.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No enabled symbols in the configuration.");
|
||||
}
|
||||
|
||||
HashSet<string> 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
|
||||
{
|
||||
/// <summary><c>us_equity</c> or <c>crypto</c>. Crypto trades 24/7 and requires fractional sizes.</summary>
|
||||
public string AssetClass { get; set; } = "us_equity";
|
||||
|
||||
/// <summary>Decision timeframe. Bars are consumed straight from the stream at 1Min.</summary>
|
||||
public string TimeFrame { get; set; } = "1Min";
|
||||
|
||||
/// <summary>Historical bars pulled at startup to warm the indicators.</summary>
|
||||
public int WarmupBars { get; set; } = 300;
|
||||
|
||||
/// <summary>Refuse new entries outside 09:30–16:00 ET.</summary>
|
||||
public bool TradeOnlyRegularHours { get; set; } = true;
|
||||
|
||||
/// <summary>Flatten everything this many minutes before the close. 0 disables.</summary>
|
||||
public int FlattenBeforeCloseMinutes { get; set; } = 10;
|
||||
|
||||
public bool AllowFractionalShares { get; set; }
|
||||
|
||||
/// <summary>Attach take-profit/stop-loss legs server-side so exits survive a bot crash.</summary>
|
||||
public bool UseBracketOrders { get; set; } = true;
|
||||
|
||||
/// <summary><c>market</c> or <c>limit</c>. A marketable limit caps slippage.</summary>
|
||||
public string EntryOrderType { get; set; } = "limit";
|
||||
|
||||
/// <summary>How far through the touch a marketable limit is priced, in basis points.</summary>
|
||||
public double LimitOffsetBps { get; set; } = 5;
|
||||
|
||||
/// <summary>Log decisions but never send an order. The safest way to observe a new config.</summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
public int ReconcileSeconds { get; set; } = 30;
|
||||
|
||||
public int StatusSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int ExplainSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Reject entries when top-of-book is older than this. 0 disables the check.</summary>
|
||||
public int MaxQuoteAgeSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>Liquidate everything when the bot shuts down.</summary>
|
||||
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'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Settings for the web dashboard served by the <c>ui</c> command.</summary>
|
||||
public sealed class UiOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the dashboard listens. Use <c>http://0.0.0.0:5088</c> to reach it from
|
||||
/// another machine — there is no authentication, so only do that on a trusted LAN.
|
||||
/// </summary>
|
||||
public string Url { get; set; } = "http://localhost:5088";
|
||||
|
||||
/// <summary>Begin trading as soon as the dashboard starts, without pressing START.</summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Verbosity: <c>trace</c>, <c>debug</c>, <c>info</c>, <c>warn</c>, <c>error</c> or
|
||||
/// <c>none</c>. <c>debug</c> adds every rejected signal and risk refusal;
|
||||
/// <c>trace</c> adds per-quote detail and is very noisy.
|
||||
/// </summary>
|
||||
public string Level { get; set; } = "info";
|
||||
|
||||
public bool Console { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string Directory { get; set; } = "logs";
|
||||
|
||||
/// <summary>Application log file name. Empty disables file logging.</summary>
|
||||
public string File { get; set; } = "encelado.log";
|
||||
|
||||
/// <summary>One JSON line per order event. Empty disables it.</summary>
|
||||
public string TradeJournal { get; set; } = "trades.jsonl";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string DecisionLog { get; set; } = "decisions.csv";
|
||||
|
||||
/// <summary>
|
||||
/// One CSV row per signal that reached the order path, with the risk verdict and
|
||||
/// the order outcome. Joins to <see cref="DecisionLog"/> on <c>decisionId</c>.
|
||||
/// </summary>
|
||||
public string ExecutionLog { get; set; } = "executions.csv";
|
||||
|
||||
/// <summary>Rotate the application log once it passes this size. 0 disables rotation.</summary>
|
||||
public int MaxFileSizeMb { get; set; } = 32;
|
||||
|
||||
/// <summary>How many rotated application logs to keep.</summary>
|
||||
public int MaxFiles { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Log every quote and trade tick. Produces enormous files and is only useful when
|
||||
/// diagnosing the market-data path itself.
|
||||
/// </summary>
|
||||
public bool LogMarketData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool LogEveryBar { get; set; } = true;
|
||||
|
||||
public int StatusLines { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>debug</c> would otherwise grow without limit. The file
|
||||
/// on disk stays complete regardless, and the page can open it.
|
||||
/// </summary>
|
||||
public int BufferedLines { get; set; } = 5_000;
|
||||
|
||||
/// <summary>Absolute path of the log directory, created on demand.</summary>
|
||||
public string ResolveDirectory()
|
||||
{
|
||||
string directory = string.IsNullOrWhiteSpace(Directory) ? "logs" : Directory;
|
||||
return Path.IsPathRooted(directory)
|
||||
? directory
|
||||
: Path.Combine(AppContext.BaseDirectory, directory);
|
||||
}
|
||||
|
||||
/// <summary>Absolute path of a file inside the log directory, or null when disabled.</summary>
|
||||
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<string, double> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public StrategyParameters ToStrategyParameters() => new(Parameters);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>encelado.json</c> by hand with <see cref="JsonDocument"/>. No reflection
|
||||
/// binder means no trimming surprises and no silent type coercion — an unknown key is
|
||||
/// reported instead of ignored.
|
||||
/// <para>
|
||||
/// Precedence: file < environment variables. Credentials should live in the
|
||||
/// environment (or a gitignored local file), never in the committed config.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConfigLoader
|
||||
{
|
||||
private static readonly JsonDocumentOptions ParseOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static BotConfig Load(string path, out List<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<JsonProperty> Properties(JsonElement e, string section, List<string> 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."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Targeted edits to <c>encelado.json</c> made from the settings screen.
|
||||
/// <para>
|
||||
/// The file is parsed into a <see cref="JsonNode"/> tree, one value is replaced, and
|
||||
/// the tree is written back. Serialising a <see cref="BotConfig"/> instead would be
|
||||
/// simpler and wrong: it would silently delete every key the loader does not model —
|
||||
/// including the <c>_</c>-prefixed lines that document what each number is for and why
|
||||
/// it has that value — and reorder everything else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConfigWriter
|
||||
{
|
||||
private static readonly JsonWriterOptions WriteOptions = new() { Indented = true };
|
||||
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Sets <c>logging.directory</c> and saves.</summary>
|
||||
public static void SetLogDirectory(string configPath, string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
|
||||
Apply(configPath, new Dictionary<string, JsonNode?> { ["logging.directory"] = directory });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of values addressed by dotted path, in one atomic save.
|
||||
/// <para>
|
||||
/// Paths look like <c>risk.stakePct</c>, <c>engine.timeFrame</c> or
|
||||
/// <c>symbols[0].parameters.period</c>. Missing intermediate objects are created;
|
||||
/// missing array elements are an error, because inventing a symbol out of a typo
|
||||
/// would be worse than refusing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void Apply(string configPath, IReadOnlyDictionary<string, JsonNode?> 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.");
|
||||
}
|
||||
|
||||
/// <summary>Splits <c>symbols[0]</c> into its name and index.</summary>
|
||||
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<JsonObject> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Encelado.Alpaca;
|
||||
using Encelado.Alpaca.Rest;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>Outcome of trying to find usable Alpaca credentials.</summary>
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class CredentialResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves in order of explicitness: environment variables (automation), then keys
|
||||
/// saved by the user, then the config file. Mutates <paramref name="config"/> with
|
||||
/// whatever it settles on.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores a verified key pair and points the config at it.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>Credentials plus a human-readable note about where they came from.</summary>
|
||||
public readonly record struct StoredCredentials(string KeyId, string SecretKey);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="IsEncrypted"/> reports <see langword="false"/> so callers can warn.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CredentialStore
|
||||
{
|
||||
private const string PaperKey = "paper";
|
||||
private const string LiveKey = "live";
|
||||
|
||||
/// <summary>True when the file at rest is encrypted rather than merely permission-restricted.</summary>
|
||||
public static bool IsEncrypted => OperatingSystem.IsWindows();
|
||||
|
||||
/// <summary>
|
||||
/// Where the store lives. <c>ENCELADO_HOME</c> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Reads the credentials saved for the given environment, or null when there are none.</summary>
|
||||
public static StoredCredentials? Load(bool paper)
|
||||
{
|
||||
Dictionary<string, StoredCredentials> 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<string, StoredCredentials> all = LoadAll();
|
||||
all[paper ? PaperKey : LiveKey] = new StoredCredentials(keyId.Trim(), secretKey.Trim());
|
||||
Write(all);
|
||||
}
|
||||
|
||||
/// <summary>Removes the credentials for one environment. Returns whether anything was removed.</summary>
|
||||
public static bool Clear(bool paper)
|
||||
{
|
||||
Dictionary<string, StoredCredentials> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string? Clean(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<char> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks a key for display. Only the first four characters survive — enough to tell
|
||||
/// a paper key (<c>PK…</c>) from a live one (<c>AK…</c>) and to recognise which key
|
||||
/// is loaded, without putting anything reusable into a log file that may be shared.
|
||||
/// </summary>
|
||||
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<string, StoredCredentials> LoadAll()
|
||||
{
|
||||
Dictionary<string, StoredCredentials> 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<string, StoredCredentials> all)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>Owner-only access. On Windows DPAPI already scopes the data to the user.</summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Structured, machine-readable record of everything the engine decided and why.
|
||||
/// <para>
|
||||
/// Two CSV files, joined on <c>decisionId</c>:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>decisions</b> — 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".</item>
|
||||
/// <item><b>executions</b> — one row per signal that reached the order path: the risk
|
||||
/// verdict, the size that survived it, and the broker's answer.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Allocates the id that ties a decision row to its execution row.</summary>
|
||||
public long NextDecisionId() => Interlocked.Increment(ref _nextId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<StrategyMetric> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records what the order path did with a signal.</summary>
|
||||
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<StrategyMetric> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Encelado.Bot.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-bucket latency histogram. Recording is a single interlocked increment, so it
|
||||
/// can sit directly on the decision path without perturbing what it measures.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records the elapsed time since a <see cref="Stopwatch.GetTimestamp"/> reading.</summary>
|
||||
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))}");
|
||||
}
|
||||
|
||||
/// <summary>Upper bound of the bucket containing the requested percentile.</summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>Process-wide counters and latency traces for the trading loop.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- WPF needs the Windows-flavoured TFM; the engine libraries stay portable. -->
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<!-- Repeated here on purpose: the temporary project MSBuild generates to compile
|
||||
XAML does not import Directory.Build.props, so without these the markup pass
|
||||
fails on types the rest of the project takes for granted. -->
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
<!-- Both are inherited from Directory.Build.props, where they make sense for a
|
||||
trimmed console binary. They are fatal here: WPF's font cache needs real
|
||||
culture data and dies at startup under invariant globalization, and stripped
|
||||
resource keys turn every framework exception into an unreadable token.
|
||||
The engine itself never depends on the ambient culture — all of its parsing
|
||||
and wire formatting pins CultureInfo.InvariantCulture explicitly. -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<UseSystemResourceKeys>false</UseSystemResourceKeys>
|
||||
<RootNamespace>Encelado.Bot</RootNamespace>
|
||||
<AssemblyName>Encelado</AssemblyName>
|
||||
<ApplicationIcon>Assets\encelado.ico</ApplicationIcon>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<SelfContained>false</SelfContained>
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<IsTrimmable>false</IsTrimmable>
|
||||
<!-- A desktop app is the single entry point; no console window behind it. -->
|
||||
<DisableWinExeOutputInference>true</DisableWinExeOutputInference>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
|
||||
<ProjectReference Include="..\Encelado.Alpaca\Encelado.Alpaca.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- DPAPI (System.Security.Cryptography.ProtectedData) ships inside the Windows
|
||||
Desktop framework, so no package reference is needed: the app has zero NuGet
|
||||
dependencies at runtime. -->
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\config\encelado.json" Link="encelado.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\config\*.json" Exclude="..\..\config\*.local.json" Link="config\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Resource Include="Assets\encelado.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using Encelado.Alpaca.Rest;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The last full account payload from the broker, or <see langword="null"/> before
|
||||
/// the first reconcile. Dashboard only — the trading path reads the fields above.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsDayTradeBlocked => PatternDayTrader && Equity < 25_000;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a one-minute bar. Returns <see langword="true"/> when a higher-timeframe
|
||||
/// bar closed, with the completed bar in <paramref name="closed"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
public enum BotState
|
||||
{
|
||||
Stopped = 0,
|
||||
Starting,
|
||||
Running,
|
||||
Stopping,
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// <summary>One open position, as the positions grid shows it.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Per-symbol strategy state, including whatever the strategy chooses to expose.</summary>
|
||||
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<MetricRow> Metrics)
|
||||
{
|
||||
public double WarmupProgress => WarmupBars > 0 ? Math.Min(1, BarsSeen / (double)WarmupBars) : 1;
|
||||
|
||||
/// <summary>The blended conviction, when the strategy publishes one.</summary>
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A point on the session equity curve.</summary>
|
||||
public sealed record EquityPoint(DateTime TimeUtc, double Equity);
|
||||
|
||||
public sealed record EventRow(string Time, string Level, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The broker's own view of the account, shown verbatim on the account page. Kept as a
|
||||
/// separate record rather than folded into <see cref="BotSnapshot"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Everything that would make the broker refuse an order, in one line.</summary>
|
||||
public string Restrictions
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One row of the orders page.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Price memory for one charted symbol.</summary>
|
||||
public sealed record PriceSeriesRow(
|
||||
string Symbol,
|
||||
double LastPrice,
|
||||
double SessionOpen,
|
||||
double SessionHigh,
|
||||
double SessionLow,
|
||||
double SessionChangePct,
|
||||
IReadOnlyList<double> BarCloses,
|
||||
IReadOnlyList<double> BarHighs,
|
||||
IReadOnlyList<double> BarLows,
|
||||
IReadOnlyList<double> BarOpens,
|
||||
IReadOnlyList<double> LivePrices)
|
||||
{
|
||||
public bool HasBars => BarCloses.Count > 1;
|
||||
|
||||
public bool HasLive => LivePrices.Count > 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PositionRow> Positions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<SymbolRow> Symbols { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<EquityPoint> EquityCurve { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<EventRow> Events { get; init; } = [];
|
||||
|
||||
/// <summary>Null until the first successful reconcile against the broker.</summary>
|
||||
public AccountRow? Account { get; init; }
|
||||
|
||||
/// <summary>Named to stay clear of <see cref="Orders"/>, which counts submissions.</summary>
|
||||
public IReadOnlyList<OrderRow> OrderHistory { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<PriceSeriesRow> Prices { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Result of a start/stop/close request from the UI.</summary>
|
||||
public sealed record CommandResult(bool Ok, string Message);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the engine's lifecycle so the window can start and stop trading without
|
||||
/// restarting the process, and assembles the snapshot the UI renders.
|
||||
/// <para>
|
||||
/// Each start creates a <b>fresh</b> <see cref="TradingEngine"/>. 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<EquityPoint> _equityCurve = new(EquityCurveCapacity);
|
||||
private readonly Queue<EventRow> _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; } }
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the log into the activity feed shown in the window.</summary>
|
||||
public void AttachLogSink() => Log.Sink = RecordEvent;
|
||||
|
||||
public void DetachLogSink() => Log.Sink = null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task<CommandResult> 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<CommandResult> 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");
|
||||
}
|
||||
|
||||
/// <summary>Closes one position on demand from the positions grid.</summary>
|
||||
public async Task<CommandResult> 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(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
private IReadOnlyList<PriceSeriesRow> IdlePrices()
|
||||
{
|
||||
List<PriceSeriesRow> rows = [];
|
||||
foreach (SymbolConfig sc in config.EnabledSymbols)
|
||||
{
|
||||
rows.Add(new PriceSeriesRow(sc.Symbol, 0, 0, 0, 0, 0, [], [], [], [], []));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private IReadOnlyList<SymbolRow> IdleSymbols()
|
||||
{
|
||||
List<SymbolRow> 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<PositionRow> 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<SymbolRow> symbols = [];
|
||||
foreach (SymbolPipeline pipe in engine.Pipelines)
|
||||
{
|
||||
List<MetricRow> 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<OrderRow> BuildOrders(TradingEngine engine)
|
||||
{
|
||||
IReadOnlyList<AlpacaOrder> source = engine.RecentOrders;
|
||||
List<OrderRow> 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<PriceSeriesRow> BuildPrices(TradingEngine engine)
|
||||
{
|
||||
List<PriceSeriesRow> 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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised for every log line, on the thread that logged it. Subscribers that touch
|
||||
/// the UI must marshal to the dispatcher themselves.
|
||||
/// </summary>
|
||||
public event Action<EventRow>? EventLogged;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
DetachLogSink();
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A decision handed from the market-data thread to the order path.</summary>
|
||||
public readonly record struct ExecutionIntent(
|
||||
int SymbolId,
|
||||
string Symbol,
|
||||
Signal Signal,
|
||||
double ReferencePrice,
|
||||
long EnqueuedTimestamp,
|
||||
long DecisionId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ExecutionIntent> _queue = Channel.CreateUnbounded<ExecutionIntent>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public async Task FlattenSymbolAsync(string symbol, string reason, CancellationToken ct)
|
||||
{
|
||||
PositionView position = book.View(symbol);
|
||||
|
||||
try
|
||||
{
|
||||
List<AlpacaOrder> 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();
|
||||
}
|
||||
|
||||
/// <summary>Cancels every working order and liquidates the whole book.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>One point on the live price line.</summary>
|
||||
public readonly record struct PricePoint(DateTime TimeUtc, double Price);
|
||||
|
||||
/// <summary>
|
||||
/// Rolling price memory for one symbol, feeding the charts.
|
||||
/// <para>
|
||||
/// Two series, because they answer different questions. The <b>closed bars</b> 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 <b>live line</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PriceHistory(int barCapacity = 180, int tickCapacity = 1800)
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<Bar> _bars = new(barCapacity);
|
||||
private readonly Queue<PricePoint> _ticks = new(tickCapacity);
|
||||
|
||||
private DateTime _lastSampleUtc;
|
||||
private double _sessionOpen;
|
||||
private double _sessionHigh;
|
||||
private double _sessionLow = double.MaxValue;
|
||||
|
||||
/// <summary>Sampling floor for the live line. One second is well under any chart's resolution.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called when a new trading session starts, so the day's range restarts too.</summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_sessionOpen = 0;
|
||||
_sessionHigh = 0;
|
||||
_sessionLow = double.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An immutable copy of one symbol's price memory, safe to hand to the UI.</summary>
|
||||
public sealed record PriceSnapshot(
|
||||
IReadOnlyList<Bar> Bars,
|
||||
IReadOnlyList<PricePoint> 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;
|
||||
|
||||
/// <summary>Change since the first live sample, which is what the operator reads as "today".</summary>
|
||||
public double SessionChangePct
|
||||
{
|
||||
get
|
||||
{
|
||||
if (SessionOpen <= 0 || Ticks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (Ticks[^1].Price - SessionOpen) / SessionOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SessionGuard(AlpacaTradingClient client, EngineOptions options)
|
||||
{
|
||||
private readonly bool _alwaysOpen = options.ResolvedAssetClass == AssetClass.Crypto;
|
||||
private AlpacaClock? _clock;
|
||||
|
||||
/// <summary>Raised the first time a new trading session is observed.</summary>
|
||||
public Action<DateOnly>? 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;
|
||||
|
||||
/// <summary>True inside the last N minutes of the session, where we only reduce risk.</summary>
|
||||
public bool InFlattenWindow =>
|
||||
!_alwaysOpen &&
|
||||
options.FlattenBeforeCloseMinutes > 0 &&
|
||||
IsOpen &&
|
||||
TimeToClose <= TimeSpan.FromMinutes(options.FlattenBeforeCloseMinutes);
|
||||
|
||||
/// <summary>Entries are allowed only in a live session and outside the flatten window.</summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Diagnostics;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Stop/target held locally when the broker could not hold a bracket for us.</summary>
|
||||
public double LocalStop { get; set; } = double.NaN;
|
||||
|
||||
public double LocalTarget { get; set; } = double.NaN;
|
||||
|
||||
public bool IsWarm => Strategy.IsReady;
|
||||
|
||||
/// <summary>True between submitting an entry and seeing it resolve. Blocks duplicates.</summary>
|
||||
public bool EntryInFlight => Volatile.Read(ref _entryInFlight) != 0;
|
||||
|
||||
public DateTime InFlightSinceUtc { get; private set; }
|
||||
|
||||
/// <summary>Atomically claims the in-flight slot. Returns false when another entry is already working.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>True while an exit is queued or executing. Stops one stop-loss breach
|
||||
/// from queueing an exit on every subsequent quote.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Rolling price memory for the charts. Never read on the decision path.</summary>
|
||||
public PriceHistory History { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Records a print and, when the feed says which side crossed the spread,
|
||||
/// accumulates the aggressor breakdown for the bar currently being formed.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps a stream bar with the aggressor volume accumulated while it was forming,
|
||||
/// then resets the accumulator for the next one.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>Taker-buy volume seen since the last bar closed. Diagnostics only.</summary>
|
||||
public double PendingTakerBuyVolume => _pendingTakerBuyVolume;
|
||||
|
||||
public void OnBarClosed(in Bar bar)
|
||||
{
|
||||
LastBar = bar;
|
||||
BarsSeen++;
|
||||
if (bar.Close > 0)
|
||||
{
|
||||
LastPrice = bar.Close;
|
||||
}
|
||||
|
||||
History.AddBar(bar);
|
||||
}
|
||||
|
||||
/// <summary>Age of the last top-of-book update. <see cref="TimeSpan.MaxValue"/> when none was seen.</summary>
|
||||
public TimeSpan QuoteAge
|
||||
{
|
||||
get
|
||||
{
|
||||
long ts = Volatile.Read(ref _lastQuoteTimestamp);
|
||||
return ts == 0 ? TimeSpan.MaxValue : Stopwatch.GetElapsedTime(ts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference price for an entry: the far touch when the book is usable (that is
|
||||
/// what we will actually pay), otherwise the last print.
|
||||
/// </summary>
|
||||
public double EntryReferencePrice(Side side)
|
||||
{
|
||||
Quote q = LastQuote;
|
||||
if (q.IsValid)
|
||||
{
|
||||
return side == Side.Buy ? q.AskPrice : q.BidPrice;
|
||||
}
|
||||
|
||||
return LastPrice;
|
||||
}
|
||||
|
||||
/// <summary>Checks a locally held stop/target against the latest price.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<byte> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, string> _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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private const int RecentOrderLimit = 200;
|
||||
|
||||
private AlpacaOrder[] _recentOrders = [];
|
||||
|
||||
/// <summary>Most recent orders as the broker reports them, newest first.</summary>
|
||||
public IReadOnlyList<AlpacaOrder> RecentOrders => Volatile.Read(ref _recentOrders);
|
||||
|
||||
public ExecutionRouter Router => _router;
|
||||
|
||||
/// <summary>Equity when this engine instance started — the base for session P&L.</summary>
|
||||
public double StartEquity { get; private set; }
|
||||
|
||||
public DateTime StartedAtUtc { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime equity curve, refreshed slowly in the background. Purely informational,
|
||||
/// so a failure to fetch it never interferes with trading.
|
||||
/// </summary>
|
||||
public AlpacaPortfolioHistory PortfolioHistory { get; private set; } = AlpacaPortfolioHistory.Empty;
|
||||
|
||||
/// <summary>Previous session's closing equity, used for "P&L today".</summary>
|
||||
public double PreviousCloseEquity { get; private set; }
|
||||
|
||||
public IReadOnlyList<SymbolPipeline> Pipelines
|
||||
{
|
||||
get
|
||||
{
|
||||
List<SymbolPipeline> 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("──────────────────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
/// <summary>Replays recent history through every strategy so signals are valid from the first live bar.</summary>
|
||||
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<string, List<Bar>> 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<Bar>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one closed bar through the strategy and the order path. Shared by the live
|
||||
/// stream and by <see cref="PollClosedBarsAsync"/>, so a decision is identical
|
||||
/// whichever route the bar arrived on.
|
||||
/// </summary>
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies the cross-cutting gates and hands the signal to the order path.</summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the broker for the most recent <b>completed</b> bars at the strategy
|
||||
/// timeframe and runs the strategy on any it has not seen.
|
||||
/// <para>
|
||||
/// 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 <i>only if the process happens to be running at that
|
||||
/// instant</i>. 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 <c>bars=40 signals=0</c> with an empty decision log.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task PollClosedBarsAsync(CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, List<Bar>> 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<Bar>? 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Start of the bar currently forming. Anything at or after it is incomplete.</summary>
|
||||
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<AlpacaPosition> positions = await _trading.ListPositionsAsync(ct).ConfigureAwait(false);
|
||||
HashSet<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AlpacaOrder> open = await _trading.ListOpenOrdersAsync(ct).ConfigureAwait(false);
|
||||
HashSet<string> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says what each strategy is doing right now and what it is waiting for.
|
||||
/// <para>
|
||||
/// 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 <i>changes</i>, 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Declared as a real source file rather than <ImplicitUsings>. 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;
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class Log
|
||||
{
|
||||
private const string AnsiReset = "\u001b[0m";
|
||||
|
||||
private static readonly Channel<Entry> Queue = Channel.CreateUnbounded<Entry>(
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static Action<LogLevel, DateTime, string>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Absolute path of the active log file, for the UI to show and open.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolls <c>encelado.log</c> to <c>encelado.1.log</c>, 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.
|
||||
/// </summary>
|
||||
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<LogLevel, DateTime, string>? 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains the queue and flushes the file. Call before the process exits.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<Window x:Class="Encelado.Bot.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Encelado" Height="920" Width="1520"
|
||||
MinHeight="640" MinWidth="1120"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource Bg}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<!--
|
||||
Nessuna personalizzazione della cornice: niente WindowStyle="None", niente
|
||||
WindowChrome, niente AllowsTransparency. Barra del titolo, bordi, angoli
|
||||
arrotondati, snap, Aero Shake e i pulsanti riduci/ingrandisci/chiudi sono quelli
|
||||
di Windows. L'unica cosa che il codice tocca è l'attributo DWM che dice al gestore
|
||||
finestre di disegnare *la sua* barra in scuro invece che in chiaro — vedi
|
||||
ApplyNativeDarkTitleBar in MainWindow.xaml.cs.
|
||||
-->
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ==================== side navigation ==================== -->
|
||||
<Border Grid.Column="0" Width="236" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Line}" BorderThickness="0,0,1,0">
|
||||
<DockPanel Margin="14,16,14,14">
|
||||
|
||||
<!-- brand -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="4,0,0,18">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image x:Name="LogoImage" Width="26" Height="26" Margin="0,0,10,0"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<TextBlock Text="Encelado" FontSize="18" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="VersionText" Style="{StaticResource Label}" Margin="0,7,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== bottom block ==================== -->
|
||||
<!--
|
||||
Declared before the nav list so DockPanel gives it its height first: the list
|
||||
then takes what is left and scrolls, instead of pushing the power button off
|
||||
the bottom of a short window.
|
||||
-->
|
||||
<StackPanel DockPanel.Dock="Bottom">
|
||||
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="12,10" Margin="0,14,0,10">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Style="{StaticResource Dot}" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StateText}" Foreground="{StaticResource Dim}" FontSize="12.5"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,9,0,0">
|
||||
<Border Style="{StaticResource ModeBadge}" Margin="0,0,5,0" Padding="6,2">
|
||||
<TextBlock Text="{Binding Mode}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="10" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Chip}" Padding="6,2">
|
||||
<TextBlock x:Name="FrameChip" FontFamily="{StaticResource Mono}"
|
||||
FontSize="10" Foreground="{StaticResource Dim}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="PowerBtn" Style="{StaticResource PowerButton}"
|
||||
Content="{Binding PowerText}" IsEnabled="{Binding CanToggle}"
|
||||
Click="OnTogglePower" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox x:Name="Nav" Style="{StaticResource NavList}"
|
||||
SelectionChanged="OnNavigated"/>
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== page ==================== -->
|
||||
<ContentControl x:Name="PageHost" Grid.Column="1" Margin="18,16,14,16"/>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,576 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Bot.Ui;
|
||||
using Encelado.Bot.Ui.Pages;
|
||||
|
||||
namespace Encelado.Bot;
|
||||
|
||||
/// <summary>
|
||||
/// The shell: side navigation on the left, one page at a time on the right, and the
|
||||
/// start/stop button always reachable at the bottom of the nav.
|
||||
/// <para>
|
||||
/// Pages are plain <see cref="UserControl"/>s that know nothing about the supervisor.
|
||||
/// Anything they need done is asked for through <see cref="IUiActions"/>, which this
|
||||
/// window implements — so the credential store, the file system and the engine are
|
||||
/// touched from exactly one place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window, IUiActions
|
||||
{
|
||||
private readonly BotConfig _config = App.Config;
|
||||
private readonly MainViewModel _vm;
|
||||
private readonly BotSupervisor _supervisor;
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly List<ChartWindow> _chartWindows = [];
|
||||
|
||||
private readonly StatusPage _status = new();
|
||||
private readonly PositionsPage _positions = new();
|
||||
private readonly ChartsPage _charts = new();
|
||||
private readonly LogPage _log = new();
|
||||
private readonly OrdersPage _orders = new();
|
||||
private readonly SettingsPage _settings = new();
|
||||
private readonly AccountPage _account = new();
|
||||
|
||||
private bool _busy;
|
||||
private bool _closing;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_vm = new MainViewModel
|
||||
{
|
||||
StatusLines = _config.Logging.StatusLines,
|
||||
Log = new LogViewModel(_config.Logging.BufferedLines),
|
||||
};
|
||||
|
||||
_supervisor = new BotSupervisor(_config);
|
||||
_supervisor.AttachLogSink();
|
||||
_supervisor.EventLogged += _vm.Log.Enqueue;
|
||||
|
||||
DataContext = _vm;
|
||||
|
||||
foreach (UserControl page in new UserControl[]
|
||||
{ _status, _positions, _charts, _log, _settings, _account, _orders })
|
||||
{
|
||||
page.DataContext = _vm;
|
||||
}
|
||||
|
||||
_status.Actions = this;
|
||||
_positions.Actions = this;
|
||||
_charts.Actions = this;
|
||||
_log.Actions = this;
|
||||
_settings.Actions = this;
|
||||
|
||||
BuildNavigation();
|
||||
|
||||
FrameChip.Text = _config.Engine.TimeFrame;
|
||||
VersionText.Text = $"v{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}";
|
||||
LoadLogo();
|
||||
RefreshSettings();
|
||||
|
||||
// One snapshot per second: fast enough to feel live, cheap enough that the UI
|
||||
// never competes with the trading loop for CPU.
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Background)
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
|
||||
_timer.Tick += (_, _) => Refresh();
|
||||
_timer.Start();
|
||||
|
||||
Loaded += OnLoaded;
|
||||
Closing += OnClosing;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void BuildNavigation()
|
||||
{
|
||||
// Glyphs are Segoe MDL2 Assets code points, which ships with Windows.
|
||||
NavItem[] items =
|
||||
[
|
||||
new("Stato", "\uE80F", () => _status),
|
||||
new("Conto", "\uE8C7", () => _account),
|
||||
new("Posizioni", "\uE8A1", () => _positions),
|
||||
new("Ordini", "\uE8A5", () => _orders),
|
||||
new("Grafici", "\uE9D2", () => _charts),
|
||||
new("Log", "\uE81C", () => _log),
|
||||
new("Impostazioni", "\uE713", () => _settings),
|
||||
];
|
||||
|
||||
Nav.ItemsSource = items;
|
||||
Nav.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void OnNavigated(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (Nav.SelectedItem is NavItem item)
|
||||
{
|
||||
PageHost.Content = item.Page;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Startup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ApplyNativeDarkTitleBar();
|
||||
Refresh();
|
||||
|
||||
foreach (string warning in App.ConfigWarnings)
|
||||
{
|
||||
Log.Warn($"config: {warning}");
|
||||
}
|
||||
|
||||
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
|
||||
Log.Info($"log in {_config.Logging.ResolveDirectory()}");
|
||||
|
||||
CredentialLookup lookup = CredentialResolver.Resolve(_config);
|
||||
if (!lookup.Found)
|
||||
{
|
||||
Log.Info("nessuna credenziale trovata: apro la finestra di login");
|
||||
PromptForCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Info($"credenziali: {lookup.Describe()}");
|
||||
}
|
||||
|
||||
RefreshSettings();
|
||||
}
|
||||
|
||||
private void LoadLogo()
|
||||
{
|
||||
try
|
||||
{
|
||||
LogoImage.Source = new BitmapImage(
|
||||
new Uri("pack://application:,,,/Assets/encelado.ico", UriKind.Absolute));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug($"logo non caricato: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the desktop window manager to draw <b>its own</b> title bar dark, so the
|
||||
/// standard Windows frame does not sit in light grey on top of a near-black window.
|
||||
/// <para>
|
||||
/// This is the opposite of custom chrome: the frame stays entirely Windows', with
|
||||
/// its real buttons, snap layouts, rounded corners and accessibility behaviour. The
|
||||
/// only thing being set is which of the two colour schemes Windows uses to paint it.
|
||||
/// Ignored on builds that predate the attribute, which simply leaves it light.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ApplyNativeDarkTitleBar()
|
||||
{
|
||||
const int DwmwaUseImmersiveDarkMode = 20;
|
||||
|
||||
try
|
||||
{
|
||||
nint handle = new WindowInteropHelper(this).Handle;
|
||||
int enabled = 1;
|
||||
_ = DwmSetWindowAttribute(handle, DwmwaUseImmersiveDarkMode, ref enabled, sizeof(int));
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
// Not Windows, or a stripped image. Nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("dwmapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Live refresh
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
_vm.Apply(_supervisor.Snapshot());
|
||||
_vm.Log.Flush();
|
||||
|
||||
if (Nav.ItemsSource is IEnumerable<NavItem> items)
|
||||
{
|
||||
foreach (NavItem item in items)
|
||||
{
|
||||
item.Badge = item.Title switch
|
||||
{
|
||||
"Posizioni" when _vm.OpenPositions > 0 =>
|
||||
_vm.OpenPositions.ToString(System.Globalization.CultureInfo.CurrentCulture),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bot control
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async void OnTogglePower(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_vm.IsRunning && !EnsureCredentials())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_vm.IsRunning && !_config.Alpaca.Paper && !ConfirmLiveTrading())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Never touch PowerBtn.IsEnabled here. It is bound to CanToggle, and assigning a
|
||||
// dependency property imperatively replaces the binding with a local value — the
|
||||
// button then stays disabled for ever and the bot cannot be stopped from the
|
||||
// window. The view model owns the whole thing.
|
||||
_busy = true;
|
||||
_vm.IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
CommandResult result = _vm.IsRunning
|
||||
? await _supervisor.StopAsync().ConfigureAwait(true)
|
||||
: await _supervisor.StartAsync().ConfigureAwait(true);
|
||||
|
||||
if (!result.Ok)
|
||||
{
|
||||
MessageBox.Show(this, result.Message, "Encelado",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
_vm.IsBusy = false;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private bool ConfirmLiveTrading() =>
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Questa configurazione opera sul conto LIVE con denaro reale.\n\nAvviare comunque?",
|
||||
"Attenzione — denaro reale",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
MessageBoxResult.No) == MessageBoxResult.Yes;
|
||||
|
||||
private bool EnsureCredentials() =>
|
||||
CredentialResolver.Resolve(_config).Found || PromptForCredentials();
|
||||
|
||||
private bool PromptForCredentials()
|
||||
{
|
||||
LoginWindow dialog = new(_config) { Owner = this };
|
||||
bool ok = dialog.ShowDialog() == true;
|
||||
RefreshSettings();
|
||||
return ok;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IUiActions — everything the pages can ask the shell to do
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task ClosePositionAsync(string symbol)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
$"Chiudere la posizione su {symbol} al prezzo di mercato?",
|
||||
"Chiusura posizione",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CommandResult result = await _supervisor
|
||||
.ClosePositionAsync(symbol, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!result.Ok)
|
||||
{
|
||||
MessageBox.Show(this, result.Message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowLogin() => PromptForCredentials();
|
||||
|
||||
public void ForgetCredentials()
|
||||
{
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
$"Rimuovere le chiavi salvate per l'ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}?",
|
||||
"Rimozione credenziali",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool removed = CredentialStore.Clear(_config.Alpaca.Paper);
|
||||
Log.Info(removed ? "credenziali salvate rimosse" : "non c'erano credenziali salvate da rimuovere");
|
||||
RefreshSettings();
|
||||
}
|
||||
|
||||
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
|
||||
|
||||
public void OpenLogFolder()
|
||||
{
|
||||
string directory = _config.Logging.ResolveDirectory();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"impossibile creare {directory}: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
OpenInShell(directory);
|
||||
}
|
||||
|
||||
public void OpenLogFile()
|
||||
{
|
||||
string? path = Log.FilePath ?? _config.Logging.ResolvePath(_config.Logging.File);
|
||||
|
||||
if (path is null || !File.Exists(path))
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"Il file di log non esiste ancora.\n\nViene creato alla prima riga scritta su disco.",
|
||||
"Encelado", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
OpenInShell(path);
|
||||
}
|
||||
|
||||
public void ChangeLogDirectory()
|
||||
{
|
||||
Microsoft.Win32.OpenFolderDialog dialog = new()
|
||||
{
|
||||
Title = "Dove salvare i log di Encelado",
|
||||
InitialDirectory = SafeInitialDirectory(),
|
||||
Multiselect = false,
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog(this) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string chosen = dialog.FolderName;
|
||||
|
||||
// Refuse before writing rather than after: a directory we cannot write to would
|
||||
// leave the bot logging nowhere, and the logger fails quietly by design.
|
||||
if (!IsWritable(chosen, out string problem))
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Non posso scrivere in questa cartella:\n\n{chosen}\n\n{problem}",
|
||||
"Cartella non utilizzabile", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ConfigWriter.SetLogDirectory(App.ConfigPath, chosen);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or InvalidOperationException or FileNotFoundException)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Non sono riuscito a salvare la configurazione:\n\n{ex.Message}",
|
||||
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_config.Logging.Directory = chosen;
|
||||
Log.Info($"cartella dei log impostata su {chosen} — attiva al prossimo avvio");
|
||||
RefreshSettings();
|
||||
|
||||
MessageBox.Show(this,
|
||||
$"I log verranno salvati in:\n\n{chosen}\n\n" +
|
||||
"I file attualmente aperti restano dove sono fino al prossimo avvio dell'applicazione.",
|
||||
"Impostazione salvata", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
private string SafeInitialDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
string current = _config.Logging.ResolveDirectory();
|
||||
return Directory.Exists(current) ? current : AppContext.BaseDirectory;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return AppContext.BaseDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWritable(string directory, out string problem)
|
||||
{
|
||||
problem = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
string probe = Path.Combine(directory, $".encelado-{Guid.NewGuid():N}");
|
||||
File.WriteAllText(probe, string.Empty);
|
||||
File.Delete(probe);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
problem = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void OpenChartWindow(string symbol)
|
||||
{
|
||||
SymbolChartViewModel? chart = null;
|
||||
foreach (SymbolChartViewModel candidate in _vm.Charts)
|
||||
{
|
||||
if (string.Equals(candidate.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chart = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (chart is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Raise the existing one rather than stacking duplicates on top of each other.
|
||||
foreach (ChartWindow open in _chartWindows)
|
||||
{
|
||||
if (string.Equals(open.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (open.WindowState == WindowState.Minimized)
|
||||
{
|
||||
open.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
open.Activate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ChartWindow window = new(chart) { Owner = this };
|
||||
_chartWindows.Add(window);
|
||||
window.Closed += (_, _) => _chartWindows.Remove(window);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private static void OpenInShell(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"impossibile aprire {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Settings
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void RefreshSettings()
|
||||
{
|
||||
CredentialLookup lookup = CredentialResolver.Resolve(_config);
|
||||
|
||||
string status = lookup.Found
|
||||
? $"Origine: {lookup.Describe()} — ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}."
|
||||
: "Nessuna credenziale configurata. Il bot non può partire finché non ne inserisci una coppia.";
|
||||
|
||||
string store = CredentialStore.Exists
|
||||
? $"Archivio: {CredentialStore.FilePath}" +
|
||||
(CredentialStore.IsEncrypted ? " (cifrato con DPAPI)" : " (in chiaro, permessi ristretti)")
|
||||
: $"Nessun archivio salvato. Verrebbe creato in {CredentialStore.FilePath}.";
|
||||
|
||||
string about =
|
||||
"Encelado — motore di trading automatico su Alpaca.\n" +
|
||||
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
|
||||
$"Configurazione: {App.ConfigPath}\n" +
|
||||
$"Endpoint: {_config.Alpaca.TradingBaseUrl} feed dati: {_config.Alpaca.DataFeed}";
|
||||
|
||||
_settings.Refresh(_config, status, store, about);
|
||||
_account.Describe(_config.Risk);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vm.IsRunning &&
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
|
||||
"Le posizioni aperte restano aperte sul conto Alpaca.\n\nContinuare?",
|
||||
"Chiusura",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stopping the engine is asynchronous, so cancel this close and re-issue it
|
||||
// once the shutdown has actually finished.
|
||||
e.Cancel = true;
|
||||
_closing = true;
|
||||
_timer.Stop();
|
||||
_supervisor.EventLogged -= _vm.Log.Enqueue;
|
||||
|
||||
foreach (ChartWindow window in _chartWindows.ToArray())
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
|
||||
await _supervisor.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<Window x:Class="Encelado.Bot.Ui.ChartWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui"
|
||||
Height="620" Width="1020" MinHeight="320" MinWidth="520"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Bg}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<DockPanel Margin="14">
|
||||
|
||||
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Padding="16,13" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Symbol}" FontSize="17" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="25" FontWeight="SemiBold"
|
||||
Margin="16,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="13.5" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
|
||||
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,14,0"
|
||||
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,20,0"
|
||||
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
|
||||
<RadioButton x:Name="CandleMode" Content="Candele" IsChecked="True" GroupName="mode"
|
||||
Checked="OnModeChanged" Margin="0,0,12,0"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
<RadioButton x:Name="LiveMode" Content="Diretta" GroupName="mode" Checked="OnModeChanged"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}">
|
||||
<ui:PriceChart x:Name="Chart"
|
||||
Opens="{Binding Opens}" Highs="{Binding Highs}"
|
||||
Lows="{Binding Lows}" Closes="{Binding Closes}"
|
||||
LivePrices="{Binding Live}"
|
||||
EmptyText="in attesa di dati — avvia il bot"/>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One symbol's chart in its own window, so it can be put on a second monitor and left
|
||||
/// there. It binds to the same <see cref="SymbolChartViewModel"/> the main window uses,
|
||||
/// which means it updates on the same tick without any extra plumbing.
|
||||
/// </summary>
|
||||
public partial class ChartWindow : Window
|
||||
{
|
||||
public ChartWindow(SymbolChartViewModel chart)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chart);
|
||||
|
||||
InitializeComponent();
|
||||
DataContext = chart;
|
||||
Title = $"{chart.Symbol} — Encelado";
|
||||
}
|
||||
|
||||
/// <summary>The symbol this window is showing, so the shell can raise an existing one.</summary>
|
||||
public string Symbol => ((SymbolChartViewModel)DataContext).Symbol;
|
||||
|
||||
private void OnModeChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Chart is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Chart.Mode = LiveMode.IsChecked == true ? PriceChartMode.Live : PriceChartMode.Candles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>Shared brushes, resolved once so converters do not allocate per binding tick.</summary>
|
||||
internal static class Palette
|
||||
{
|
||||
public static readonly SolidColorBrush Up = Freeze(Color.FromRgb(0x2E, 0xE6, 0xA8));
|
||||
public static readonly SolidColorBrush Down = Freeze(Color.FromRgb(0xFF, 0x5A, 0x7A));
|
||||
// Devono restare allineati a Theme.xaml: qui vestono gli assi dei grafici, che sono
|
||||
// disegnati a mano e non passano dal dizionario risorse.
|
||||
public static readonly SolidColorBrush Dim = Freeze(Color.FromRgb(0xA3, 0xAF, 0xC6));
|
||||
public static readonly SolidColorBrush Faint = Freeze(Color.FromRgb(0x7E, 0x8A, 0xA4));
|
||||
public static readonly SolidColorBrush Warn = Freeze(Color.FromRgb(0xFF, 0xB3, 0x47));
|
||||
public static readonly SolidColorBrush Accent = Freeze(Color.FromRgb(0x5B, 0x8C, 0xFF));
|
||||
public static readonly SolidColorBrush Text = Freeze(Color.FromRgb(0xE6, 0xEB, 0xF5));
|
||||
|
||||
private static SolidColorBrush Freeze(Color c)
|
||||
{
|
||||
SolidColorBrush brush = new(c);
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Green above zero, red below, grey at zero.</summary>
|
||||
public sealed class PnlBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double v = ToDouble(value);
|
||||
return Math.Abs(v) < 1e-9 || !double.IsFinite(v) ? Palette.Dim : v > 0 ? Palette.Up : Palette.Down;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
internal static double ToDouble(object? value) => value switch
|
||||
{
|
||||
double d => d,
|
||||
float f => f,
|
||||
decimal m => (double)m,
|
||||
int i => i,
|
||||
long l => l,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class BoolToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
bool flag = value is true;
|
||||
if (parameter is string s && s.Equals("invert", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
flag = !flag;
|
||||
}
|
||||
|
||||
return flag ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class InverseBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type t, object? p, CultureInfo c) => value is not true;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) => value is not true;
|
||||
}
|
||||
|
||||
/// <summary>Colours a log line by severity.</summary>
|
||||
public sealed class LevelBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
(value as string)?.ToLowerInvariant() switch
|
||||
{
|
||||
"error" => Palette.Down,
|
||||
"warn" => Palette.Warn,
|
||||
"info" => Palette.Dim,
|
||||
_ => Palette.Faint,
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a conviction score in [-1, 1] to the left edge of the meter fill, as a
|
||||
/// fraction of a 200-pixel track centred on zero.
|
||||
/// </summary>
|
||||
public sealed class ScoreOffsetConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double score = Math.Clamp(PnlBrushConverter.ToDouble(value), -1, 1);
|
||||
const double track = 200;
|
||||
double half = Math.Abs(score) * (track / 2);
|
||||
return new Thickness(score >= 0 ? track / 2 : (track / 2) - half, 0, 0, 0);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class ScoreWidthConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
Math.Clamp(Math.Abs(PnlBrushConverter.ToDouble(value)), 0, 1) * 100;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class ScoreBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
PnlBrushConverter.ToDouble(value) >= 0 ? Palette.Up : Palette.Down;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Prices need more decimals the smaller they get; a dash when there is none.</summary>
|
||||
public sealed class PriceConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double v = PnlBrushConverter.ToDouble(value);
|
||||
if (!double.IsFinite(v) || v == 0)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
return v >= 1000 ? v.ToString("N2", culture)
|
||||
: v >= 1 ? v.ToString("N4", culture)
|
||||
: v.ToString("N6", culture);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Booleans as words, because "True" in an Italian UI reads as a bug.</summary>
|
||||
public sealed class YesNoConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true ? "sì" : "no";
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamps rendered in the operator's own time zone. Everything inside the
|
||||
/// engine is UTC on purpose; the only place that should differ is the screen.
|
||||
/// </summary>
|
||||
public sealed class LocalTimeConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not DateTime utc || utc == default)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
DateTime local = (utc.Kind == DateTimeKind.Utc ? utc : DateTime.SpecifyKind(utc, DateTimeKind.Utc))
|
||||
.ToLocalTime();
|
||||
|
||||
return local.Date == DateTime.Now.Date
|
||||
? local.ToString("HH:mm:ss", culture)
|
||||
: local.ToString("dd/MM HH:mm", culture);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Buy green, sell red — the same convention as everywhere else in the window.</summary>
|
||||
public sealed class SideBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
(value as string) switch
|
||||
{
|
||||
"acquisto" => Palette.Up,
|
||||
"vendita" => Palette.Down,
|
||||
_ => Palette.Dim,
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Order status: filled reads as done, rejected as a problem, the rest as pending.</summary>
|
||||
public sealed class OrderStatusBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
(value as string) switch
|
||||
{
|
||||
"eseguito" => Palette.Up,
|
||||
"parziale" => Palette.Warn,
|
||||
"rifiutato" => Palette.Down,
|
||||
"annullato" or "scaduto" => Palette.Faint,
|
||||
_ => Palette.Accent,
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Green only when the account has nothing standing in its way.</summary>
|
||||
public sealed class RestrictionBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value as string == "nessuna" ? Palette.Up : Palette.Warn;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Fractional crypto sizes need many decimals; whole shares need none.</summary>
|
||||
public sealed class QuantityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double v = PnlBrushConverter.ToDouble(value);
|
||||
if (!double.IsFinite(v))
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
return Math.Abs(v - Math.Truncate(v)) < 1e-9
|
||||
? v.ToString("N0", culture)
|
||||
: v.ToString("0.########", culture);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Data;
|
||||
using Encelado.Bot.Engine;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Backs the log page: a bounded, filterable, colour-coded view of everything the bot
|
||||
/// has logged since it started.
|
||||
/// <para>
|
||||
/// Lines arrive on whatever thread logged them and are parked in a lock-free queue;
|
||||
/// the UI drains that queue once a second on the same tick that refreshes the rest of
|
||||
/// the window. Dispatching each line individually would put a dispatcher hop on the
|
||||
/// logging path, which at <c>trace</c> verbosity means thousands per second.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LogViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly ConcurrentQueue<EventRow> _pending = new();
|
||||
private readonly int _capacity;
|
||||
|
||||
private string _levelFilter = "tutti";
|
||||
private string _search = string.Empty;
|
||||
private bool _autoScroll = true;
|
||||
private bool _paused;
|
||||
private long _dropped;
|
||||
|
||||
public LogViewModel(int capacity)
|
||||
{
|
||||
_capacity = Math.Max(100, capacity);
|
||||
View = (CollectionView)CollectionViewSource.GetDefaultView(Lines);
|
||||
View.Filter = Passes;
|
||||
}
|
||||
|
||||
/// <summary>Every buffered line, oldest first. Bound through <see cref="View"/>.</summary>
|
||||
public ObservableCollection<EventRow> Lines { get; } = [];
|
||||
|
||||
public CollectionView View { get; }
|
||||
|
||||
public static IReadOnlyList<string> LevelFilters { get; } =
|
||||
["tutti", "debug", "info", "warn", "error"];
|
||||
|
||||
/// <summary>Minimum severity to show. "tutti" shows everything including trace.</summary>
|
||||
public string LevelFilter
|
||||
{
|
||||
get => _levelFilter;
|
||||
set
|
||||
{
|
||||
if (Set(ref _levelFilter, value))
|
||||
{
|
||||
View.Refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Free-text filter on the message body.</summary>
|
||||
public string Search
|
||||
{
|
||||
get => _search;
|
||||
set
|
||||
{
|
||||
if (Set(ref _search, value))
|
||||
{
|
||||
View.Refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool AutoScroll
|
||||
{
|
||||
get => _autoScroll;
|
||||
set => Set(ref _autoScroll, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops draining while the operator is reading. Incoming lines still queue up, so
|
||||
/// nothing is lost — they appear when the pause ends.
|
||||
/// </summary>
|
||||
public bool Paused
|
||||
{
|
||||
get => _paused;
|
||||
set => Set(ref _paused, value);
|
||||
}
|
||||
|
||||
public string Status => _dropped > 0
|
||||
? $"{Lines.Count:N0} righe in memoria (limite {_capacity:N0}) — {_dropped:N0} più vecchie scartate, il file su disco è completo"
|
||||
: $"{Lines.Count:N0} righe in memoria (limite {_capacity:N0})";
|
||||
|
||||
/// <summary>Called from the logging thread. Must stay cheap and allocation light.</summary>
|
||||
public void Enqueue(EventRow row) => _pending.Enqueue(row);
|
||||
|
||||
/// <summary>Drains pending lines into the bound collection. UI thread only.</summary>
|
||||
public void Flush()
|
||||
{
|
||||
if (Paused || _pending.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
while (_pending.TryDequeue(out EventRow? row))
|
||||
{
|
||||
Lines.Add(row);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Lines.Count > _capacity)
|
||||
{
|
||||
// Trimmed in one batch rather than one line at a time: every RemoveAt(0)
|
||||
// shifts the whole backing array, so dropping 10% once beats dropping one
|
||||
// element on each of the next few hundred lines.
|
||||
int excess = Lines.Count - _capacity + (_capacity / 10);
|
||||
for (int i = 0; i < excess && Lines.Count > 0; i++)
|
||||
{
|
||||
Lines.RemoveAt(0);
|
||||
}
|
||||
|
||||
_dropped += excess;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
Raise(nameof(Status));
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
while (_pending.TryDequeue(out _))
|
||||
{
|
||||
// Drop anything already queued too, otherwise it reappears a second later
|
||||
// and "clear" looks broken.
|
||||
}
|
||||
|
||||
Lines.Clear();
|
||||
_dropped = 0;
|
||||
Raise(nameof(Status));
|
||||
}
|
||||
|
||||
private bool Passes(object item)
|
||||
{
|
||||
if (item is not EventRow row)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Rank(row.Level, out int rank) || rank < MinimumRank)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _search.Length == 0 ||
|
||||
row.Message.Contains(_search, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private int MinimumRank => _levelFilter switch
|
||||
{
|
||||
"debug" => 1,
|
||||
"info" => 2,
|
||||
"warn" => 3,
|
||||
"error" => 4,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static bool Rank(string level, out int rank)
|
||||
{
|
||||
rank = level switch
|
||||
{
|
||||
"trace" => 0,
|
||||
"debug" => 1,
|
||||
"info" => 2,
|
||||
"warn" => 3,
|
||||
"error" => 4,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
Raise(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Raise(string? name) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<Window x:Class="Encelado.Bot.Ui.LoginWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Credenziali Alpaca"
|
||||
Width="520" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Background="{StaticResource Bg}"
|
||||
UseLayoutRounding="True">
|
||||
|
||||
<Border Padding="24">
|
||||
<StackPanel>
|
||||
|
||||
<TextBlock Text="Credenziali Alpaca" FontSize="19" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="EnvLine" Style="{StaticResource Sub}" Margin="0,4,0,0"/>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,18,0,0" Padding="13">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"
|
||||
Text="Genera una coppia di chiavi dalla dashboard Alpaca. Il secret viene mostrato una sola volta, al momento della creazione."/>
|
||||
<TextBlock x:Name="PortalLink" Style="{StaticResource Sub}"
|
||||
Foreground="{StaticResource Accent}" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="API KEY ID" Style="{StaticResource Label}" Margin="0,18,0,6"/>
|
||||
<TextBox x:Name="KeyBox"/>
|
||||
|
||||
<TextBlock Text="API SECRET KEY" Style="{StaticResource Label}" Margin="0,14,0,6"/>
|
||||
<PasswordBox x:Name="SecretBox"/>
|
||||
|
||||
<CheckBox x:Name="SaveBox" Content="Ricorda queste chiavi su questo computer"
|
||||
IsChecked="True" Margin="0,16,0,0"/>
|
||||
<TextBlock x:Name="StorageNote" Style="{StaticResource Sub}" Margin="24,4,0,0" TextWrapping="Wrap"/>
|
||||
|
||||
<Border x:Name="StatusBox" Style="{StaticResource Card}" Margin="0,16,0,0"
|
||||
Padding="11,9" Visibility="Collapsed">
|
||||
<TextBlock x:Name="StatusText" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<ProgressBar x:Name="Busy" IsIndeterminate="True" Margin="0,14,0,0" Visibility="Collapsed"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
|
||||
<Button x:Name="CancelButton" Content="Annulla" Click="OnCancel" Width="104"/>
|
||||
<Button x:Name="OkButton" Content="Verifica e salva" Click="OnConfirm"
|
||||
Style="{StaticResource Primary}" Width="156" Margin="10,0,0,0" IsDefault="True"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Windows;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Collects an Alpaca key pair and proves it works before accepting it. Verifying here
|
||||
/// rather than at the first order means a typo surfaces immediately, with a message
|
||||
/// that says what is wrong, instead of as a 401 twenty minutes into a session.
|
||||
/// </summary>
|
||||
public partial class LoginWindow : Window
|
||||
{
|
||||
private readonly BotConfig _config;
|
||||
|
||||
public LoginWindow(BotConfig config)
|
||||
{
|
||||
InitializeComponent();
|
||||
_config = config;
|
||||
|
||||
bool paper = config.Alpaca.Paper;
|
||||
EnvLine.Text = paper
|
||||
? "Ambiente PAPER — conto simulato, denaro finto."
|
||||
: "Ambiente LIVE — denaro reale.";
|
||||
|
||||
PortalLink.Text = paper
|
||||
? "https://app.alpaca.markets/paper/dashboard/overview"
|
||||
: "https://app.alpaca.markets/live/dashboard/overview";
|
||||
|
||||
StorageNote.Text = CredentialStore.IsEncrypted
|
||||
? "Salvate cifrate con DPAPI: leggibili solo dal tuo account Windows."
|
||||
: "Su questo sistema DPAPI non è disponibile: il file sarà in chiaro, con permessi di solo proprietario.";
|
||||
|
||||
if (CredentialStore.Load(paper) is { } existing)
|
||||
{
|
||||
KeyBox.Text = existing.KeyId;
|
||||
ShowStatus($"Sono già salvate delle chiavi ({CredentialStore.Mask(existing.KeyId)}). " +
|
||||
"Inseriscine di nuove per sostituirle.", warning: false);
|
||||
}
|
||||
|
||||
Loaded += (_, _) => KeyBox.Focus();
|
||||
}
|
||||
|
||||
private async void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string keyId = CredentialStore.Clean(KeyBox.Text) ?? string.Empty;
|
||||
string secret = CredentialStore.Clean(SecretBox.Password) ?? string.Empty;
|
||||
|
||||
if (keyId.Length == 0 || secret.Length == 0)
|
||||
{
|
||||
ShowStatus("Inserisci sia la key id sia il secret.", warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
(bool ok, string message, AlpacaAccount? account) = await CredentialResolver
|
||||
.VerifyAsync(keyId, secret, _config.Alpaca.Paper, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
ShowStatus(message, warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
CredentialResolver.Apply(_config, keyId, secret, SaveBox.IsChecked == true);
|
||||
|
||||
string where = SaveBox.IsChecked == true
|
||||
? $" Salvate in {CredentialStore.FilePath}."
|
||||
: " Non salvate: valgono solo per questa sessione.";
|
||||
|
||||
ShowStatus($"{message} — equity {account!.Equity:N2} {account.Currency}.{where}", warning: false);
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
Busy.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||
OkButton.IsEnabled = !busy;
|
||||
CancelButton.IsEnabled = !busy;
|
||||
KeyBox.IsEnabled = !busy;
|
||||
SecretBox.IsEnabled = !busy;
|
||||
OkButton.Content = busy ? "Verifica in corso…" : "Verifica e salva";
|
||||
}
|
||||
|
||||
private void ShowStatus(string message, bool warning)
|
||||
{
|
||||
StatusBox.Visibility = Visibility.Visible;
|
||||
StatusText.Text = message;
|
||||
StatusText.Foreground = warning ? Palette.Down : Palette.Up;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Encelado.Bot.Engine;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation state for the operating tab. Refreshed once a second from an immutable
|
||||
/// <see cref="BotSnapshot"/>; collections are updated in place so WPF does not rebuild
|
||||
/// (and scroll-reset) the grids on every tick.
|
||||
/// </summary>
|
||||
public sealed class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private string _mode = "PAPER";
|
||||
private string _modeKind = "paper";
|
||||
private string _stateText = "fermo";
|
||||
private string _stateKind = "stopped";
|
||||
private bool _isRunning;
|
||||
private bool _isBusy;
|
||||
private string _powerText = "AVVIA";
|
||||
private string _banner = string.Empty;
|
||||
private bool _hasBanner;
|
||||
private bool _bannerIsWarning;
|
||||
|
||||
private double _equity;
|
||||
private double _cash;
|
||||
private double _pnlToday;
|
||||
private double _pnlTodayPct;
|
||||
private double _pnlSession;
|
||||
private double _pnlSessionPct;
|
||||
private double _pnlAllTime;
|
||||
private double _pnlAllTimePct;
|
||||
private bool _hasAllTime;
|
||||
private double _unrealized;
|
||||
private double _exposurePct;
|
||||
private int _openPositions;
|
||||
private int _maxOpenPositions;
|
||||
private int _tradesToday;
|
||||
private int _maxTradesPerDay;
|
||||
private double _maxDailyLossPct;
|
||||
private double _riskPerTradePct;
|
||||
|
||||
private string _sessionStatus = "—";
|
||||
private string _marketData = "—";
|
||||
private string _tradeStream = "—";
|
||||
private string _uptime = "—";
|
||||
private string _latency = string.Empty;
|
||||
private string _counters = string.Empty;
|
||||
private IReadOnlyList<double> _equityCurve = [];
|
||||
|
||||
public ObservableCollection<PositionRow> Positions { get; } = [];
|
||||
|
||||
public ObservableCollection<SymbolRow> Symbols { get; } = [];
|
||||
|
||||
public ObservableCollection<EventRow> Events { get; } = [];
|
||||
|
||||
public ObservableCollection<OrderRow> Orders { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// One entry per charted symbol, created once and updated in place. See
|
||||
/// <see cref="SymbolChartViewModel"/> for why these are not swapped each tick.
|
||||
/// </summary>
|
||||
public ObservableCollection<SymbolChartViewModel> Charts { get; } = [];
|
||||
|
||||
private AccountRow? _account;
|
||||
|
||||
/// <summary>The broker's own account view, or null before the first reconcile.</summary>
|
||||
public AccountRow? Account { get => _account; private set => Set(ref _account, value); }
|
||||
|
||||
public bool HasAccount => _account is not null;
|
||||
|
||||
/// <summary>How many lines the status strip keeps. Set once from the configuration.</summary>
|
||||
public int StatusLines { get; init; } = 200;
|
||||
|
||||
/// <summary>Backs the log page. Fed by push from the supervisor, not by the snapshot.</summary>
|
||||
public required LogViewModel Log { get; init; }
|
||||
|
||||
public string Mode { get => _mode; private set => Set(ref _mode, value); }
|
||||
|
||||
/// <summary>"paper", "live" or "dry" — drives the badge colour.</summary>
|
||||
public string ModeKind { get => _modeKind; private set => Set(ref _modeKind, value); }
|
||||
|
||||
public string StateText { get => _stateText; private set => Set(ref _stateText, value); }
|
||||
|
||||
public string StateKind { get => _stateKind; private set => Set(ref _stateKind, value); }
|
||||
|
||||
public bool IsRunning { get => _isRunning; private set => Set(ref _isRunning, value); }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the start/stop button accepts a click.
|
||||
/// <para>
|
||||
/// Owned entirely by the view model, and deliberately so. The window used to disable
|
||||
/// the button by assigning <c>PowerBtn.IsEnabled = false</c> while a start or stop
|
||||
/// was in flight — which in WPF replaces the binding with a local value and
|
||||
/// <i>permanently detaches it</i>. The button went dead on the first click and the
|
||||
/// bot could no longer be stopped from the window at all. Nothing outside this class
|
||||
/// touches that property now.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool CanToggle => !_isBusy && _stateKind is not ("starting" or "stopping");
|
||||
|
||||
/// <summary>
|
||||
/// Set by the shell around an in-flight start or stop. Guards against a second click
|
||||
/// landing before the engine has reported its new state.
|
||||
/// </summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
set
|
||||
{
|
||||
if (_isBusy == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isBusy = value;
|
||||
Raise(nameof(IsBusy));
|
||||
Raise(nameof(CanToggle));
|
||||
}
|
||||
}
|
||||
|
||||
public string PowerText { get => _powerText; private set => Set(ref _powerText, value); }
|
||||
|
||||
public string Banner { get => _banner; private set => Set(ref _banner, value); }
|
||||
|
||||
public bool HasBanner { get => _hasBanner; private set => Set(ref _hasBanner, value); }
|
||||
|
||||
public bool BannerIsWarning { get => _bannerIsWarning; private set => Set(ref _bannerIsWarning, value); }
|
||||
|
||||
public double Equity { get => _equity; private set => Set(ref _equity, value); }
|
||||
|
||||
public double Cash { get => _cash; private set => Set(ref _cash, value); }
|
||||
|
||||
public double PnlToday { get => _pnlToday; private set => Set(ref _pnlToday, value); }
|
||||
|
||||
public double PnlTodayPct { get => _pnlTodayPct; private set => Set(ref _pnlTodayPct, value); }
|
||||
|
||||
public double PnlSession { get => _pnlSession; private set => Set(ref _pnlSession, value); }
|
||||
|
||||
public double PnlSessionPct { get => _pnlSessionPct; private set => Set(ref _pnlSessionPct, value); }
|
||||
|
||||
public double PnlAllTime { get => _pnlAllTime; private set => Set(ref _pnlAllTime, value); }
|
||||
|
||||
public double PnlAllTimePct { get => _pnlAllTimePct; private set => Set(ref _pnlAllTimePct, value); }
|
||||
|
||||
public bool HasAllTime { get => _hasAllTime; private set => Set(ref _hasAllTime, value); }
|
||||
|
||||
public double Unrealized { get => _unrealized; private set => Set(ref _unrealized, value); }
|
||||
|
||||
public double ExposurePct { get => _exposurePct; private set => Set(ref _exposurePct, value); }
|
||||
|
||||
public int OpenPositions { get => _openPositions; private set => Set(ref _openPositions, value); }
|
||||
|
||||
public int MaxOpenPositions { get => _maxOpenPositions; private set => Set(ref _maxOpenPositions, value); }
|
||||
|
||||
public int TradesToday { get => _tradesToday; private set => Set(ref _tradesToday, value); }
|
||||
|
||||
public int MaxTradesPerDay { get => _maxTradesPerDay; private set => Set(ref _maxTradesPerDay, value); }
|
||||
|
||||
/// <summary>
|
||||
/// Open positions, with the cap appended only when there is one. A limit of 0 means
|
||||
/// "no limit", so rendering it as "0 / 0" states the opposite of what it means.
|
||||
/// </summary>
|
||||
public string PositionsDisplay => Counter(_openPositions, _maxOpenPositions);
|
||||
|
||||
public string TradesDisplay => Counter(_tradesToday, _maxTradesPerDay);
|
||||
|
||||
private static string Counter(int value, int limit) =>
|
||||
limit > 0
|
||||
? string.Create(CultureInfo.CurrentCulture, $"{value} / {limit}")
|
||||
: value.ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>The loss that halts the session, shown on the account page.</summary>
|
||||
public double MaxDailyLossPct { get => _maxDailyLossPct; private set => Set(ref _maxDailyLossPct, value); }
|
||||
|
||||
public double RiskPerTradePct { get => _riskPerTradePct; private set => Set(ref _riskPerTradePct, value); }
|
||||
|
||||
public string SessionStatus { get => _sessionStatus; private set => Set(ref _sessionStatus, value); }
|
||||
|
||||
public string MarketDataState { get => _marketData; private set => Set(ref _marketData, value); }
|
||||
|
||||
public string TradeStreamState { get => _tradeStream; private set => Set(ref _tradeStream, value); }
|
||||
|
||||
public string Uptime { get => _uptime; private set => Set(ref _uptime, value); }
|
||||
|
||||
public string Latency { get => _latency; private set => Set(ref _latency, value); }
|
||||
|
||||
public string Counters { get => _counters; private set => Set(ref _counters, value); }
|
||||
|
||||
public IReadOnlyList<double> EquityCurve { get => _equityCurve; private set => Set(ref _equityCurve, value); }
|
||||
|
||||
public void Apply(BotSnapshot s)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(s);
|
||||
|
||||
Mode = s.Mode;
|
||||
ModeKind = s.DryRun ? "dry" : s.Paper ? "paper" : "live";
|
||||
|
||||
IsRunning = s.State is BotState.Running or BotState.Starting;
|
||||
PowerText = IsRunning ? "FERMA" : "AVVIA";
|
||||
|
||||
// CanToggle derives from StateKind, so it has to be raised after it changes.
|
||||
StateKind = s.State.ToString().ToLowerInvariant();
|
||||
Raise(nameof(CanToggle));
|
||||
|
||||
StateText = s.State switch
|
||||
{
|
||||
BotState.Running => "in esecuzione",
|
||||
BotState.Starting => "avvio…",
|
||||
BotState.Stopping => "arresto…",
|
||||
BotState.Faulted => "errore",
|
||||
_ => "fermo",
|
||||
};
|
||||
|
||||
if (s.Halted)
|
||||
{
|
||||
Banner = $"KILL SWITCH — {s.HaltReason}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(s.StreamRejection))
|
||||
{
|
||||
// Above the engine error on purpose: when the broker is refusing the data
|
||||
// stream, that is the cause and anything else is a consequence.
|
||||
Banner = $"DATI DI MERCATO — {s.StreamRejection}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(s.Error))
|
||||
{
|
||||
Banner = s.Error;
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
}
|
||||
else if (s.DryRun && IsRunning)
|
||||
{
|
||||
Banner = "DRY-RUN attivo: le decisioni vengono calcolate e mostrate, ma nessun ordine viene inviato.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
HasBanner = false;
|
||||
}
|
||||
|
||||
Equity = s.Equity;
|
||||
Cash = s.Cash;
|
||||
PnlToday = s.PnlToday;
|
||||
PnlTodayPct = s.PnlTodayPct;
|
||||
PnlSession = s.PnlSession;
|
||||
PnlSessionPct = s.PnlSessionPct;
|
||||
PnlAllTime = s.PnlAllTime;
|
||||
PnlAllTimePct = s.PnlAllTimePct;
|
||||
HasAllTime = s.HasAllTime;
|
||||
Unrealized = s.UnrealizedPnl;
|
||||
ExposurePct = s.ExposurePct;
|
||||
OpenPositions = s.OpenPositions;
|
||||
MaxOpenPositions = s.MaxOpenPositions;
|
||||
TradesToday = s.TradesToday;
|
||||
MaxTradesPerDay = s.MaxTradesPerDay;
|
||||
MaxDailyLossPct = s.MaxDailyLossPct;
|
||||
RiskPerTradePct = s.RiskPerTradePct;
|
||||
Raise(nameof(PositionsDisplay));
|
||||
Raise(nameof(TradesDisplay));
|
||||
|
||||
SessionStatus = s.SessionStatus;
|
||||
MarketDataState = s.MarketDataState;
|
||||
TradeStreamState = s.TradeStreamState;
|
||||
Uptime = s.Uptime > TimeSpan.Zero ? FormatUptime(s.Uptime) : "—";
|
||||
Latency = $"{s.BarToSignal}\n{s.SignalToOrder}";
|
||||
Counters = $"barre {s.Bars} segnali {s.Signals} ordini {s.Orders} fill {s.Fills} " +
|
||||
$"uscite {s.Exits} blocchi risk {s.RiskRejects} errori {s.Errors}";
|
||||
|
||||
double[] curve = new double[s.EquityCurve.Count];
|
||||
for (int i = 0; i < curve.Length; i++)
|
||||
{
|
||||
curve[i] = s.EquityCurve[i].Equity;
|
||||
}
|
||||
|
||||
EquityCurve = curve;
|
||||
|
||||
Account = s.Account;
|
||||
Raise(nameof(HasAccount));
|
||||
|
||||
Sync(Positions, s.Positions, static (a, b) => a.Symbol == b.Symbol);
|
||||
Sync(Symbols, s.Symbols, static (a, b) => a.Symbol == b.Symbol);
|
||||
Sync(Orders, s.OrderHistory, static (a, b) => a.OrderId == b.OrderId);
|
||||
SyncCharts(s.Prices);
|
||||
SyncEvents(s.Events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches chart view models to symbols by name and updates them in place. The
|
||||
/// collection only changes when the configured symbol set does, which is never
|
||||
/// while the bot is running.
|
||||
/// </summary>
|
||||
private void SyncCharts(IReadOnlyList<PriceSeriesRow> source)
|
||||
{
|
||||
foreach (PriceSeriesRow row in source)
|
||||
{
|
||||
SymbolChartViewModel? target = null;
|
||||
foreach (SymbolChartViewModel candidate in Charts)
|
||||
{
|
||||
if (string.Equals(candidate.Symbol, row.Symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
target = new SymbolChartViewModel(row.Symbol);
|
||||
Charts.Add(target);
|
||||
}
|
||||
|
||||
target.Apply(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the contents only where they actually differ. Clearing and refilling
|
||||
/// would drop the user's selection and scroll position every second.
|
||||
/// </summary>
|
||||
private static void Sync<T>(ObservableCollection<T> target, IReadOnlyList<T> source, Func<T, T, bool> sameKey)
|
||||
{
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
if (i < target.Count)
|
||||
{
|
||||
if (!sameKey(target[i], source[i]) || !Equals(target[i], source[i]))
|
||||
{
|
||||
target[i] = source[i];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
target.Add(source[i]);
|
||||
}
|
||||
}
|
||||
|
||||
while (target.Count > source.Count)
|
||||
{
|
||||
target.RemoveAt(target.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The feed only ever grows at the tail, so append the new lines.</summary>
|
||||
private void SyncEvents(IReadOnlyList<EventRow> source)
|
||||
{
|
||||
if (source.Count == Events.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.Count < Events.Count)
|
||||
{
|
||||
Events.Clear();
|
||||
}
|
||||
|
||||
for (int i = Events.Count; i < source.Count; i++)
|
||||
{
|
||||
Events.Add(source[i]);
|
||||
}
|
||||
|
||||
while (Events.Count > StatusLines)
|
||||
{
|
||||
Events.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatUptime(TimeSpan t) =>
|
||||
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m"
|
||||
: t.TotalMinutes >= 1 ? $"{t.Minutes}m {t.Seconds}s"
|
||||
: $"{t.Seconds}s";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
field = value;
|
||||
Raise(name);
|
||||
}
|
||||
|
||||
private void Raise(string? name) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the side navigation. The page itself is created lazily: building all
|
||||
/// seven at startup would make the window slower to appear for the sake of pages the
|
||||
/// operator may never open, and the log page in particular is not cheap.
|
||||
/// </summary>
|
||||
public sealed class NavItem(string title, string glyph, Func<UserControl> factory) : INotifyPropertyChanged
|
||||
{
|
||||
private UserControl? _page;
|
||||
private string _badge = string.Empty;
|
||||
|
||||
public string Title { get; } = title;
|
||||
|
||||
/// <summary>A Segoe MDL2 Assets code point.</summary>
|
||||
public string Glyph { get; } = glyph;
|
||||
|
||||
/// <summary>Small count shown on the right of the entry, e.g. the number of open positions.</summary>
|
||||
public string Badge
|
||||
{
|
||||
get => _badge;
|
||||
set
|
||||
{
|
||||
if (_badge == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_badge = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Badge)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasBadge)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasBadge => _badge.Length > 0;
|
||||
|
||||
public UserControl Page => _page ??= factory();
|
||||
|
||||
/// <summary>The fallback any accessibility client falls back to. Never the type name.</summary>
|
||||
public override string ToString() => Title;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a page can ask the shell to do. Pages are given this rather than a reference to
|
||||
/// the window so they stay unaware of how the shell is put together — and so the only
|
||||
/// place that touches the supervisor, the credential store or the file system stays the
|
||||
/// window itself.
|
||||
/// </summary>
|
||||
public interface IUiActions
|
||||
{
|
||||
Task ClosePositionAsync(string symbol);
|
||||
|
||||
void ShowLogin();
|
||||
|
||||
void ForgetCredentials();
|
||||
|
||||
void OpenConfigFile();
|
||||
|
||||
void OpenLogFolder();
|
||||
|
||||
void OpenLogFile();
|
||||
|
||||
/// <summary>Asks for a new log directory and persists it to the configuration file.</summary>
|
||||
void ChangeLogDirectory();
|
||||
|
||||
/// <summary>Opens the price chart for a symbol in its own resizable window.</summary>
|
||||
void OpenChartWindow(string symbol);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.AccountPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- One label/value line, the shape the whole page is built from. -->
|
||||
<Style x:Key="Row" TargetType="Grid">
|
||||
<Setter Property="Margin" Value="0,0,0,9"/>
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
|
||||
<StackPanel Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Conto" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="I valori arrivano da Alpaca e sono aggiornati a ogni riconciliazione, circa ogni 30 secondi. Sono quello che vede il broker, non un calcolo del bot: se non coincidono con la pagina Stato, quelli giusti sono questi."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Nothing truthful to show before the first reconcile. -->
|
||||
<Border Style="{StaticResource Card}" Padding="26"
|
||||
Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}, ConverterParameter=invert}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="26"
|
||||
Foreground="{StaticResource Faint}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0" TextAlignment="Center"
|
||||
Text="Nessun dato dal conto.
Avvia il bot: i valori compaiono alla prima riconciliazione."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}}">
|
||||
|
||||
<!-- ==================== headline ==================== -->
|
||||
<UniformGrid Rows="1" Columns="4" Margin="0,0,-10,10">
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VALORE DEL PORTAFOGLIO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.PortfolioValue, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Account.Currency}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VARIAZIONE DI OGGI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.ChangeToday, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding Account.ChangeToday, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding Account.ChangeTodayPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="LIQUIDITÀ" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.Cash, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="non investita"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POTERE D'ACQUISTO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.BuyingPower, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding Account.Multiplier, StringFormat='leva {0:0.#}x'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="10"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ==================== identity ==================== -->
|
||||
<Border Grid.Column="0" Style="{StaticResource Card}" VerticalAlignment="Top">
|
||||
<StackPanel>
|
||||
<TextBlock Text="ANAGRAFICA" Style="{StaticResource Head}"/>
|
||||
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Numero di conto" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.AccountNumber}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Stato" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.Status}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Ambiente" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<Border Style="{StaticResource ModeBadge}" HorizontalAlignment="Right" Padding="7,2">
|
||||
<TextBlock Text="{Binding Mode}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="10.5" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Vendita allo scoperto" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.ShortingEnabled, Converter={StaticResource YesNo}}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Ultimo aggiornamento" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Foreground="{StaticResource Dim}"
|
||||
Text="{Binding Account.UpdatedUtc, Converter={StaticResource LocalTime}}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== balances & limits ==================== -->
|
||||
<Border Grid.Column="2" Style="{StaticResource Card}" VerticalAlignment="Top">
|
||||
<StackPanel>
|
||||
<TextBlock Text="SALDI E LIMITI" Style="{StaticResource Head}"/>
|
||||
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Equity" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.Equity, StringFormat=N2}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Equity alla chiusura precedente" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.LastEquity, StringFormat=N2}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Potere d'acquisto intraday" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.DaytradingBuyingPower, StringFormat=N2}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Operazioni intraday (5 giorni)" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.DaytradeCount}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Pattern day trader" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.PatternDayTrader, Converter={StaticResource YesNo}}"/>
|
||||
</Grid>
|
||||
|
||||
<Border Background="{StaticResource Bg}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,4,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="RESTRIZIONI" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11.5"
|
||||
TextWrapping="Wrap" Text="{Binding Account.Restrictions}"
|
||||
Foreground="{Binding Account.Restrictions, Converter={StaticResource RestrictionBrush}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ==================== what the bot is allowed to do ==================== -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,20">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="LIMITI IMPOSTI DAL BOT" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Questi non vengono dal broker: sono i vincoli della configurazione, e si applicano prima che un ordine venga inviato. Il broker dice cosa è possibile, questi dicono cosa è permesso."/>
|
||||
</StackPanel>
|
||||
|
||||
<UniformGrid Rows="1" Columns="4" Margin="0,0,-8,0">
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="DIMENSIONE" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock x:Name="SizingText" Margin="0,4,0,0" FontFamily="{StaticResource Mono}"
|
||||
FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="PERDITA MASSIMA GIORNALIERA" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Text="{Binding MaxDailyLossPct, StringFormat=P2}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POSIZIONI CONTEMPORANEE" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Text="{Binding MaxOpenPositions}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="OPERAZIONI AL GIORNO" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Text="{Binding MaxTradesPerDay}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Windows.Controls;
|
||||
using Encelado.Core.Risk;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The broker's view of the account, plus the limits the bot imposes on top of it.
|
||||
/// Both are shown because they answer different questions: the broker says what is
|
||||
/// possible, the configuration says what is permitted.
|
||||
/// </summary>
|
||||
public partial class AccountPage : UserControl
|
||||
{
|
||||
public AccountPage() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Set once by the shell. The sizing rule is described from the configuration
|
||||
/// rather than bound, because it is one sentence assembled from three fields.
|
||||
/// </summary>
|
||||
public void Describe(RiskLimits limits)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(limits);
|
||||
SizingText.Text = limits.DescribeSizing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.ChartsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Grafici" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Le candele sono le barre su cui la strategia decide davvero, una al giorno. La linea è il prezzo in diretta, campionato una volta al secondo, e serve a vedere cosa succede fra una decisione e l'altra — non a suggerirne un'altra."/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<RadioButton x:Name="CandleMode" Content="Candele" IsChecked="True"
|
||||
GroupName="mode" Checked="OnModeChanged" Margin="0,0,14,0"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5"/>
|
||||
<RadioButton x:Name="LiveMode" Content="Diretta" GroupName="mode" Checked="OnModeChanged"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<ItemsControl ItemsSource="{Binding Charts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<DockPanel>
|
||||
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Symbol}" FontSize="15" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="19" FontWeight="SemiBold"
|
||||
Margin="14,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="12.5" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
|
||||
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,12,0"
|
||||
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,14,0"
|
||||
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
|
||||
<Button Content="Finestra separata" Padding="10,4" FontSize="11"
|
||||
Click="OnPopOut" Tag="{Binding Symbol}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Bound to the page's own property, not to the view model: which way
|
||||
the charts are drawn is a view preference, not bot state. -->
|
||||
<ui:PriceChart Height="300"
|
||||
Mode="{Binding ChartMode,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Opens="{Binding Opens}" Highs="{Binding Highs}"
|
||||
Lows="{Binding Lows}" Closes="{Binding Closes}"
|
||||
LivePrices="{Binding Live}"
|
||||
EmptyText="in attesa di dati — avvia il bot"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
public partial class ChartsPage : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// A dependency property rather than a plain field so the charts inside the
|
||||
/// ItemsControl can bind to it and repaint themselves when it changes.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty ChartModeProperty = DependencyProperty.Register(
|
||||
nameof(ChartMode), typeof(PriceChartMode), typeof(ChartsPage),
|
||||
new FrameworkPropertyMetadata(PriceChartMode.Candles));
|
||||
|
||||
public ChartsPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
public PriceChartMode ChartMode
|
||||
{
|
||||
get => (PriceChartMode)GetValue(ChartModeProperty);
|
||||
set => SetValue(ChartModeProperty, value);
|
||||
}
|
||||
|
||||
private void OnModeChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Fires during InitializeComponent, before the field is assigned.
|
||||
if (LiveMode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChartMode = LiveMode.IsChecked == true ? PriceChartMode.Live : PriceChartMode.Candles;
|
||||
}
|
||||
|
||||
private void OnPopOut(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: string symbol } && symbol.Length > 0)
|
||||
{
|
||||
Actions?.OpenChartWindow(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.LogPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Log" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Tutto quello che il bot ha registrato da quando è partito, colorato per livello. Il buffer in memoria è limitato per non crescere senza fine; il file su disco è completo e si apre da qui. Pausa sospende l'aggiornamento senza perdere righe: ricompaiono alla ripresa."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== toolbar ==================== -->
|
||||
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Margin="0,0,0,10" Padding="14,11">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="LIVELLO" Style="{StaticResource Label}" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<ComboBox Width="110" ItemsSource="{Binding Log.LevelFilters}"
|
||||
SelectedItem="{Binding Log.LevelFilter}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="20,0,0,0">
|
||||
<TextBlock Text="CERCA" Style="{StaticResource Label}" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBox Width="230" Padding="8,5"
|
||||
Text="{Binding Log.Search, UpdateSourceTrigger=PropertyChanged, Delay=250}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<CheckBox Content="Segui" IsChecked="{Binding Log.AutoScroll}" VerticalAlignment="Center"
|
||||
ToolTip="Resta agganciato all'ultima riga"/>
|
||||
<CheckBox Content="Pausa" IsChecked="{Binding Log.Paused}" VerticalAlignment="Center"
|
||||
Margin="14,0,0,0"
|
||||
ToolTip="Sospende l'aggiornamento. Le righe continuano ad accumularsi e compaiono alla ripresa."/>
|
||||
<Button Content="Svuota" Click="OnClear" Margin="14,0,0,0" Padding="11,5" FontSize="11.5"/>
|
||||
<Button Content="Apri il file" Click="OnOpenFile" Margin="8,0,0,0" Padding="11,5" FontSize="11.5"/>
|
||||
<Button Content="Cartella" Click="OnOpenFolder" Margin="8,0,0,0" Padding="11,5" FontSize="11.5"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" Style="{StaticResource Sub}" Margin="2,8,0,0"
|
||||
Text="{Binding Log.Status}"/>
|
||||
|
||||
<!-- ==================== the lines ==================== -->
|
||||
<Border Style="{StaticResource Card}" Padding="0,10,0,10">
|
||||
<ListBox x:Name="Lines" ItemsSource="{Binding Log.View}"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||
SelectionMode="Extended">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="0"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="b" Background="Transparent" Padding="14,1">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="b" Property="Background" Value="#10FFFFFF"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="b" Property="Background" Value="#205B8CFF"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="66"/>
|
||||
<ColumnDefinition Width="52"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Time}" Foreground="{StaticResource Faint}"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11"/>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Level}"
|
||||
FontFamily="{StaticResource Mono}" FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="{Binding Message}" TextWrapping="Wrap"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11.5"
|
||||
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The full in-memory log. The status page shows the tail; this shows everything the
|
||||
/// buffer holds, filterable and searchable.
|
||||
/// </summary>
|
||||
public partial class LogPage : UserControl
|
||||
{
|
||||
private LogViewModel? _log;
|
||||
|
||||
public LogPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (_log is not null)
|
||||
{
|
||||
((INotifyCollectionChanged)_log.Lines).CollectionChanged -= OnLinesChanged;
|
||||
}
|
||||
|
||||
_log = (DataContext as MainViewModel)?.Log;
|
||||
|
||||
if (_log is not null)
|
||||
{
|
||||
((INotifyCollectionChanged)_log.Lines).CollectionChanged += OnLinesChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follows the tail when asked to. Scrolling the newest item into view rather than
|
||||
/// scrolling to the end keeps it correct under virtualization, where the extent is
|
||||
/// an estimate until the containers are realised.
|
||||
/// </summary>
|
||||
private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (_log is not { AutoScroll: true } || e.Action != NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int count = Lines.Items.Count;
|
||||
if (count > 0)
|
||||
{
|
||||
Lines.ScrollIntoView(Lines.Items[count - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClear(object sender, RoutedEventArgs e) => _log?.Clear();
|
||||
|
||||
private void OnOpenFile(object sender, RoutedEventArgs e) => Actions?.OpenLogFile();
|
||||
|
||||
private void OnOpenFolder(object sender, RoutedEventArgs e) => Actions?.OpenLogFolder();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.OrdersPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Ordini" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Lo storico come lo riporta Alpaca, non come lo ricorda il bot: dopo una riconnessione l'elenco del broker è l'unico completo. Si aggiorna a ogni riconciliazione, circa ogni 30 secondi. Le righe evidenziate sono ordini ancora aperti."/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="0,14,0,6">
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" Margin="16,0,16,8">
|
||||
<TextBlock Text="STORICO" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" Style="{StaticResource Label}">
|
||||
<Run Text="{Binding Orders.Count, Mode=OneWay}"/><Run Text=" ordini"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Margin="0,26,0,26"
|
||||
TextAlignment="Center"
|
||||
Text="Nessun ordine.
Compaiono qui appena il bot ne invia uno, o se il conto ne ha di precedenti.">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Orders.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Orders}">
|
||||
<DataGrid.RowStyle>
|
||||
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
|
||||
<Style.Triggers>
|
||||
<!-- A working order is the one thing on this page that can still change. -->
|
||||
<DataTrigger Binding="{Binding IsWorking}" Value="True">
|
||||
<Setter Property="Background" Value="#145B8CFF"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</DataGrid.RowStyle>
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ORA" Width="1.3*" Binding="{Binding SubmittedLocal}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="ASSET" Width="1.2*" Binding="{Binding Symbol}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="LATO" Width="1*" Binding="{Binding Side}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Side, Converter={StaticResource SideBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="TIPO" Width="0.9*" Binding="{Binding Type}"/>
|
||||
<DataGridTextColumn Header="QUANTITÀ" Width="1.2*"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="ESEGUITA" Width="1.2*"
|
||||
Binding="{Binding FilledQuantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="PREZZO MEDIO" Width="1.3*"
|
||||
Binding="{Binding FilledAveragePrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="CONTROVALORE" Width="1.3*"
|
||||
Binding="{Binding Notional, StringFormat=N2}"/>
|
||||
<DataGridTextColumn Header="STATO" Width="1.2*" Binding="{Binding Status}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Status, Converter={StaticResource OrderStatusBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
public partial class OrdersPage : UserControl
|
||||
{
|
||||
public OrdersPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.PositionsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Posizioni" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Tutto ciò che è aperto adesso. Lo stop in tabella è sorvegliato dal motore a ogni quotazione, non è un ordine depositato sul broker: Alpaca non accetta bracket order sulle crypto. Se il processo si chiude con una posizione aperta, quella posizione resta senza stop."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Totals strip: reading a dozen rows to work out the exposure is what this avoids. -->
|
||||
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Margin="0,0,0,10" Padding="16,13">
|
||||
<UniformGrid Rows="1" Columns="4">
|
||||
<StackPanel>
|
||||
<TextBlock Text="APERTE" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding PositionsDisplay}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="ESPOSIZIONE" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding ExposurePct, StringFormat='{}{0:0.0%}'}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="NON REALIZZATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding Unrealized, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding Unrealized, Converter={StaticResource PnlBrush}}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="OPERAZIONI OGGI" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding TradesDisplay}"/>
|
||||
</StackPanel>
|
||||
</UniformGrid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="0,14,0,6">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="DETTAGLIO" Style="{StaticResource Head}" Margin="16,0,0,8"/>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="Nessuna posizione aperta"
|
||||
HorizontalAlignment="Center" Margin="0,26,0,26">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Positions.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Positions}">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ASSET" Binding="{Binding Symbol}" Width="1.3*">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="LATO" Binding="{Binding Side}" Width="0.7*"/>
|
||||
<DataGridTextColumn Header="QUANTITÀ" Width="1.2*"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="PREZZO MEDIO" Width="1.2*"
|
||||
Binding="{Binding EntryPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="ULTIMO" Width="1.2*"
|
||||
Binding="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="VALORE" Width="1.2*"
|
||||
Binding="{Binding MarketValue, StringFormat=N2}"/>
|
||||
<DataGridTextColumn Header="P&L" Width="1.2*"
|
||||
Binding="{Binding UnrealizedPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="%" Width="0.9*"
|
||||
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="STOP" Width="1.1*"
|
||||
Binding="{Binding StopPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="BARRE" Width="0.7*" Binding="{Binding BarsHeld}"/>
|
||||
<DataGridTextColumn Header="APERTA" Width="1.2*"
|
||||
Binding="{Binding OpenedAtUtc, Converter={StaticResource LocalTime}}"/>
|
||||
<DataGridTemplateColumn Header="" Width="92">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Content="Chiudi" Style="{StaticResource Danger}"
|
||||
Padding="10,3" FontSize="11"
|
||||
Click="OnClosePosition" Tag="{Binding Symbol}"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
public partial class PositionsPage : UserControl
|
||||
{
|
||||
public PositionsPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
private async void OnClosePosition(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string symbol } button || symbol.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabled for the round trip so an impatient second click cannot submit a
|
||||
// second closing order against a position that is already on its way out.
|
||||
button.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.ClosePositionAsync(symbol);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
button.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.SettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<UserControl.Resources>
|
||||
|
||||
<!--
|
||||
Una riga del form. I campi non modificabili restano campi: stessa etichetta, stessa
|
||||
cornice, stessa posizione. Cambia solo che non accettano il fuoco e lo dicono.
|
||||
Nasconderli in un paragrafo è ciò che faceva sembrare questa pagina un documento.
|
||||
-->
|
||||
<DataTemplate x:Key="FieldTemplate">
|
||||
<Grid Margin="0,0,0,11">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="230"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="150"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Label}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5" TextWrapping="Wrap"
|
||||
Margin="0,0,10,0" ToolTip="{Binding FullTooltip}"/>
|
||||
|
||||
<TextBlock Grid.Column="1" Style="{StaticResource Hint}" Margin="0,0,10,0"
|
||||
ToolTip="{Binding FullTooltip}"/>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
<TextBox Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsReadOnly="{Binding IsReadOnly}"
|
||||
ToolTip="{Binding FullTooltip}">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
|
||||
<Style.Triggers>
|
||||
<!-- Read-only: dimmed and not focusable, but still a field. -->
|
||||
<DataTrigger Binding="{Binding IsReadOnly}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource Bg}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
<Setter Property="Cursor" Value="Arrow"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding HasError}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsDirty}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBox.Style>
|
||||
</TextBox>
|
||||
|
||||
<!-- Lucchetto sui campi fissati dalla strategia. -->
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
|
||||
Foreground="{StaticResource Faint}" IsHitTestVisible="False"
|
||||
Visibility="{Binding IsReadOnly, Converter={StaticResource BoolVis}}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0">
|
||||
<TextBlock Text="{Binding Suffix}" Foreground="{StaticResource Faint}" FontSize="11.5"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Error}" Foreground="{StaticResource Down}" FontSize="11.5"
|
||||
VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
Visibility="{Binding HasError, Converter={StaticResource BoolVis}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
</UserControl.Resources>
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Impostazioni" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Ogni campo ha una spiegazione: passa il puntatore sopra l'etichetta o sul pallino. I campi con il lucchetto sono fissati dalla strategia e non modificabili. Le credenziali si applicano subito; tutto il resto ha effetto al prossimo avvio."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== barra di salvataggio ==================== -->
|
||||
<Border DockPanel.Dock="Bottom" Style="{StaticResource Card}" Padding="14,11" Margin="0,10,0,0">
|
||||
<Grid>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="SaveStatus" Style="{StaticResource Sub}" Margin="0"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Annulla modifiche" Click="OnRevert" Margin="0,0,10,0"/>
|
||||
<Button x:Name="SaveButton" Content="Salva" Click="OnSave" Style="{StaticResource Primary}"
|
||||
MinWidth="120"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel MaxWidth="1000" HorizontalAlignment="Left">
|
||||
|
||||
<!-- ==================== credenziali ==================== -->
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="CREDENZIALI ALPACA" Style="{StaticResource Head}"/>
|
||||
<TextBlock x:Name="CredStatus" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
|
||||
<TextBlock x:Name="CredPath" Style="{StaticResource Sub}" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
|
||||
<Button Content="Inserisci / sostituisci chiavi" Click="OnLogin" Style="{StaticResource Primary}"/>
|
||||
<Button Content="Rimuovi chiavi salvate" Click="OnLogout" Margin="10,0,0,0" Style="{StaticResource Danger}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== cartella dei log ==================== -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="CARTELLA DEI LOG" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock x:Name="LogHint" Style="{StaticResource Hint}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="LogPathBox" Grid.Column="0" IsReadOnly="True"/>
|
||||
<Button Grid.Column="1" Content="Cambia…" Click="OnChangeLogDirectory" Margin="8,0,0,0"/>
|
||||
<Button Grid.Column="2" Content="Apri" Click="OnOpenLogFolder" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== i gruppi di campi ==================== -->
|
||||
<ItemsControl x:Name="Groups" Margin="0,10,0,0">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" Style="{StaticResource Head}" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="{Binding Description}" Style="{StaticResource Sub}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,14"/>
|
||||
<ItemsControl ItemsSource="{Binding Fields}"
|
||||
ItemTemplate="{StaticResource FieldTemplate}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="FILE DI CONFIGURAZIONE" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Il salvataggio riscrive solo i valori cambiati e conserva tutto il resto del file, commenti compresi. Per modifiche che questa pagina non copre, apri il file a mano."/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="ConfigSummary" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
|
||||
<Button Content="Apri il file di configurazione" Click="OnOpenConfig"
|
||||
HorizontalAlignment="Left" Margin="0,14,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="INFORMAZIONI" Style="{StaticResource Head}"/>
|
||||
<TextBlock x:Name="AboutText" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Encelado.Bot.Configuration;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration, as a form. Every value the bot runs on is a field here — including
|
||||
/// the ones the strategy fixes, which are shown read-only rather than hidden in prose.
|
||||
/// </summary>
|
||||
public partial class SettingsPage : UserControl
|
||||
{
|
||||
private IReadOnlyList<SettingGroup> _groups = [];
|
||||
private BotConfig? _config;
|
||||
|
||||
public SettingsPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
/// <summary>Rebuilds the form from the configuration on disk.</summary>
|
||||
public void Refresh(BotConfig config, string credentialStatus, string credentialPath, string about)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
_config = config;
|
||||
|
||||
CredStatus.Text = credentialStatus;
|
||||
CredPath.Text = credentialPath;
|
||||
AboutText.Text = about;
|
||||
|
||||
LogPathBox.Text = config.Logging.ResolveDirectory();
|
||||
LogHint.ToolTip = DescribeLogFiles(config.Logging);
|
||||
ConfigSummary.Text = $"File: {App.ConfigPath}";
|
||||
|
||||
foreach (SettingGroup group in _groups)
|
||||
{
|
||||
foreach (SettingField field in group.Fields)
|
||||
{
|
||||
field.PropertyChanged -= OnFieldChanged;
|
||||
}
|
||||
}
|
||||
|
||||
string strategy = config.EnabledSymbols.FirstOrDefault()?.Strategy ?? "trend-filter";
|
||||
_groups = SettingsCatalogue.Build(config, strategy);
|
||||
|
||||
foreach (SettingGroup group in _groups)
|
||||
{
|
||||
foreach (SettingField field in group.Fields)
|
||||
{
|
||||
field.PropertyChanged += OnFieldChanged;
|
||||
}
|
||||
}
|
||||
|
||||
Groups.ItemsSource = _groups;
|
||||
UpdateSaveState();
|
||||
}
|
||||
|
||||
private void OnFieldChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(SettingField.Value) or nameof(SettingField.Error))
|
||||
{
|
||||
UpdateSaveState();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<SettingField> AllFields =>
|
||||
_groups.SelectMany(static g => g.Fields);
|
||||
|
||||
private void UpdateSaveState()
|
||||
{
|
||||
int dirty = AllFields.Count(static f => f.IsDirty);
|
||||
int broken = AllFields.Count(static f => f.HasError);
|
||||
|
||||
SaveButton.IsEnabled = dirty > 0 && broken == 0;
|
||||
|
||||
SaveStatus.Text = broken > 0
|
||||
? $"{broken} campo/i da correggere"
|
||||
: dirty == 0
|
||||
? "Nessuna modifica da salvare"
|
||||
: $"{dirty} modifica/e non salvate — hanno effetto al prossimo avvio";
|
||||
}
|
||||
|
||||
private void OnRevert(object sender, RoutedEventArgs e)
|
||||
{
|
||||
foreach (SettingField field in AllFields)
|
||||
{
|
||||
field.Revert();
|
||||
}
|
||||
|
||||
UpdateSaveState();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_config is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<SettingField> changed = [.. AllFields.Where(static f => f.IsDirty)];
|
||||
if (changed.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, JsonNode?> changes = [];
|
||||
try
|
||||
{
|
||||
foreach (SettingField field in changed)
|
||||
{
|
||||
changes[field.Path] = field.ToJson();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException or ArgumentException)
|
||||
{
|
||||
Warn($"Un valore non è interpretabile:\n\n{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validated as a whole before anything is written. Individual fields can each be
|
||||
// reasonable while the combination is not — a stake above the position cap, for
|
||||
// instance — and finding that out at the next start, from a file the operator
|
||||
// already closed, is the worst moment to find it out.
|
||||
if (!Validates(changes, out string problem))
|
||||
{
|
||||
Warn($"La combinazione di valori non è valida:\n\n{problem}\n\nNulla è stato salvato.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ConfigWriter.Apply(App.ConfigPath, changes);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or InvalidOperationException or FileNotFoundException
|
||||
or ArgumentException)
|
||||
{
|
||||
Warn($"Non sono riuscito a salvare:\n\n{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (SettingField field in changed)
|
||||
{
|
||||
field.Load(field.Value);
|
||||
}
|
||||
|
||||
UpdateSaveState();
|
||||
|
||||
MessageBox.Show(
|
||||
Window.GetWindow(this),
|
||||
$"{changed.Count} valore/i salvati in:\n{App.ConfigPath}\n\n" +
|
||||
"Le modifiche hanno effetto al prossimo avvio dell'applicazione.",
|
||||
"Impostazioni salvate", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the pending changes to a throwaway copy of the configuration and runs the
|
||||
/// real validators over it.
|
||||
/// </summary>
|
||||
private bool Validates(Dictionary<string, JsonNode?> changes, out string problem)
|
||||
{
|
||||
problem = string.Empty;
|
||||
|
||||
string temporary = Path.Combine(
|
||||
Path.GetTempPath(), $"encelado-check-{Guid.NewGuid():N}.json");
|
||||
|
||||
try
|
||||
{
|
||||
File.Copy(App.ConfigPath, temporary, overwrite: true);
|
||||
ConfigWriter.Apply(temporary, changes);
|
||||
|
||||
BotConfig candidate = ConfigLoader.Load(temporary, out _);
|
||||
candidate.Risk.Validate();
|
||||
candidate.Logging.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or IOException
|
||||
or ArgumentException or UnauthorizedAccessException)
|
||||
{
|
||||
problem = ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temporary);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A leftover in the temp folder is not worth failing the save over.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Warn(string message) => MessageBox.Show(
|
||||
Window.GetWindow(this), message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
|
||||
private static string DescribeLogFiles(LoggingOptions logging)
|
||||
{
|
||||
List<string?> parts =
|
||||
[
|
||||
"Cosa viene scritto in questa cartella:",
|
||||
string.Empty,
|
||||
Named(logging.File, "log dell'applicazione"),
|
||||
Named(logging.TradeJournal, "diario delle operazioni"),
|
||||
Named(logging.DecisionLog, "una riga per barra valutata"),
|
||||
Named(logging.ExecutionLog, "una riga per segnale arrivato agli ordini"),
|
||||
];
|
||||
|
||||
parts.RemoveAll(static p => p is null);
|
||||
return string.Join("\n", parts);
|
||||
|
||||
static string? Named(string? file, string what) =>
|
||||
string.IsNullOrWhiteSpace(file) ? null : $"{file} — {what}";
|
||||
}
|
||||
|
||||
private void OnLogin(object sender, RoutedEventArgs e) => Actions?.ShowLogin();
|
||||
|
||||
private void OnLogout(object sender, RoutedEventArgs e) => Actions?.ForgetCredentials();
|
||||
|
||||
private void OnOpenConfig(object sender, RoutedEventArgs e) => Actions?.OpenConfigFile();
|
||||
|
||||
private void OnOpenLogFolder(object sender, RoutedEventArgs e) => Actions?.OpenLogFolder();
|
||||
|
||||
private void OnChangeLogDirectory(object sender, RoutedEventArgs e) => Actions?.ChangeLogDirectory();
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.StatusPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
|
||||
<!-- Kill switch, engine error or dry-run notice. Never more than one at a time. -->
|
||||
<Border Margin="0,0,0,12" CornerRadius="9" Padding="14,10"
|
||||
Visibility="{Binding HasBanner, Converter={StaticResource BoolVis}}">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#1AFF5A7A"/>
|
||||
<Setter Property="BorderBrush" Value="#66FF5A7A"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
|
||||
<Setter Property="Background" Value="#1AFFB347"/>
|
||||
<Setter Property="BorderBrush" Value="#66FFB347"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Banner}" TextWrapping="Wrap" FontSize="12.5">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Down}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== KPI row ==================== -->
|
||||
<UniformGrid Rows="1" Columns="6" Margin="0,0,-10,12">
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="EQUITY" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Equity, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Cash, StringFormat='liquidità {0:N2}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L OGGI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PnlToday, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding PnlToday, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding PnlTodayPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L SESSIONE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PnlSession, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding PnlSession, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding PnlSessionPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L TOTALE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PnlAllTime, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding PnlAllTime, Converter={StaticResource PnlBrush}}"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="—" Foreground="{StaticResource Faint}"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}, ConverterParameter=invert}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding PnlAllTimePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="storico non disponibile"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}, ConverterParameter=invert}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="NON REALIZZATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Unrealized, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding Unrealized, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding ExposurePct, StringFormat='esposizione {0:0.0%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POSIZIONI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PositionsDisplay}"/>
|
||||
<TextBlock Style="{StaticResource Sub}">
|
||||
<Run Text="{Binding TradesDisplay, Mode=OneWay}"/><Run Text=" trade oggi"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<!-- ==================== prices + strategies ==================== -->
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2.1*"/>
|
||||
<ColumnDefinition Width="10"/>
|
||||
<ColumnDefinition Width="1*" MinWidth="330"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0">
|
||||
|
||||
<!--
|
||||
Live price strip. The full charts live on their own page; here the operator
|
||||
just needs the number and which way it is going, which is what fits on a
|
||||
status screen without pushing everything else below the fold.
|
||||
-->
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="PREZZI" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Prezzo in diretta, campionato dallo stream una volta al secondo. Clic su un riquadro per aprire il grafico in una finestra separata."/>
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Charts}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Rows="1"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="14,11" Margin="0,0,8,0"
|
||||
Cursor="Hand" MouseLeftButtonUp="OnChartRequested" Tag="{Binding Symbol}"
|
||||
ToolTip="Apri il grafico in una finestra separata">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Symbol}" FontWeight="SemiBold" FontSize="12.5"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="23" FontWeight="SemiBold"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="11.5"
|
||||
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
|
||||
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="10,0,0,0"
|
||||
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="8,0,0,0"
|
||||
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
|
||||
</StackPanel>
|
||||
<ui:PriceChart Height="52" Margin="0,8,0,0" Mode="Live"
|
||||
LivePrices="{Binding Live}"
|
||||
EmptyText="in attesa di quotazioni"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Nessun asset configurato" HorizontalAlignment="Center" Margin="0,14">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Charts.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- open positions, compact -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0" Padding="0,14,0,6">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POSIZIONI APERTE" Style="{StaticResource Head}" Margin="16,0,0,8"/>
|
||||
<DataGrid ItemsSource="{Binding Positions}" MaxHeight="200">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ASSET" Binding="{Binding Symbol}" Width="1.2*">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="QUANTITÀ" Width="1.1*"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="INGRESSO" Width="1.1*"
|
||||
Binding="{Binding EntryPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="ULTIMO" Width="1.1*"
|
||||
Binding="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="P&L" Width="1.1*"
|
||||
Binding="{Binding UnrealizedPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="%" Width="0.8*"
|
||||
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="STOP" Width="1*"
|
||||
Binding="{Binding StopPrice, Converter={StaticResource Price}}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<TextBlock Text="Nessuna posizione aperta" HorizontalAlignment="Center" Margin="0,16">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Positions.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- equity curve -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="EQUITY DELLA SESSIONE" Style="{StaticResource Head}"/>
|
||||
<ui:SparkChart Height="110" Values="{Binding EquityCurve}"
|
||||
EmptyText="in attesa di dati — avvia il bot"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- activity strip: bounded on purpose, the full log has its own page -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0" Padding="0,14,0,10">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="16,0,16,8">
|
||||
<TextBlock Text="ATTIVITÀ" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12">
|
||||
<TextBlock.ToolTip>
|
||||
<TextBlock>
|
||||
<Run Text="Le ultime "/><Run Text="{Binding StatusLines, Mode=OneWay}"/><Run
|
||||
Text=" righe, per non tenerne migliaia in memoria su una pagina che si guarda di sfuggita. Il log completo, con filtri e ricerca, è nella scheda Log."/>
|
||||
</TextBlock>
|
||||
</TextBlock.ToolTip>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<ScrollViewer x:Name="FeedScroll" VerticalScrollBarVisibility="Auto" MaxHeight="230">
|
||||
<ItemsControl ItemsSource="{Binding Events}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><VirtualizingStackPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="16,1">
|
||||
<TextBlock Text="{Binding Time}" Foreground="{StaticResource Faint}"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11" Margin="0,0,10,0"/>
|
||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== right column ==================== -->
|
||||
<StackPanel Grid.Column="2">
|
||||
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="STRATEGIE" Style="{StaticResource Head}"/>
|
||||
<ItemsControl ItemsSource="{Binding Symbols}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="12,10" Margin="0,0,0,8">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Symbol}" FontWeight="SemiBold" FontSize="13.5"/>
|
||||
<Border Style="{StaticResource Chip}" Margin="8,0,0,0" Padding="6,2">
|
||||
<TextBlock Text="{Binding Strategy}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="9.5" Foreground="{StaticResource Faint}"/>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Chip}" Margin="5,0,0,0" Padding="6,2"
|
||||
Visibility="{Binding InPosition, Converter={StaticResource BoolVis}}">
|
||||
<TextBlock Text="IN POSIZIONE" FontFamily="{StaticResource Mono}"
|
||||
FontSize="9.5" Foreground="{StaticResource Accent}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}"
|
||||
FontSize="13.5" FontWeight="SemiBold"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Margin="0,9,0,0"
|
||||
Visibility="{Binding Ready, Converter={StaticResource BoolVis}, ConverterParameter=invert}">
|
||||
<ProgressBar Value="{Binding WarmupProgress, Mode=OneWay}" Maximum="1"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,4,0,0">
|
||||
<Run Text="warm-up "/><Run Text="{Binding BarsSeen, Mode=OneWay}"/><Run Text=" / "/><Run Text="{Binding WarmupBars, Mode=OneWay}"/><Run Text=" barre"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,10,0,0">
|
||||
<StackPanel.Style>
|
||||
<Style TargetType="StackPanel">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Score}" Value="{x:Null}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</StackPanel.Style>
|
||||
<Grid Width="200" HorizontalAlignment="Center" Height="6">
|
||||
<Border Background="{StaticResource Bg}" CornerRadius="3"/>
|
||||
<Border Width="1" HorizontalAlignment="Center" Background="{StaticResource Line}"/>
|
||||
<Border HorizontalAlignment="Left" CornerRadius="3"
|
||||
Margin="{Binding Score, Converter={StaticResource ScoreOffset}}"
|
||||
Width="{Binding Score, Converter={StaticResource ScoreWidth}}"
|
||||
Background="{Binding Score, Converter={StaticResource ScoreBrush}}"/>
|
||||
</Grid>
|
||||
<TextBlock HorizontalAlignment="Center" Margin="0,4,0,0"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Foreground="{Binding Score, Converter={StaticResource ScoreBrush}}"
|
||||
Text="{Binding Score, StringFormat='convinzione {0:0.00}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Metrics}" Margin="0,9,0,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Bg}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="7,4" Margin="0,0,5,5">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Text="{Binding Display}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="11.5" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="MOTORE" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="{Binding Counters}" Style="{StaticResource Sub}" TextWrapping="Wrap" Margin="0"/>
|
||||
<TextBlock Text="{Binding Latency}" Style="{StaticResource Sub}" TextWrapping="Wrap" Margin="0,8,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="SESSIONE" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="{Binding SessionStatus}" Style="{StaticResource Sub}" TextWrapping="Wrap" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Sub}">
|
||||
<Run Text="dati "/><Run Text="{Binding MarketDataState, Mode=OneWay}"/>
|
||||
<Run Text=" ordini "/><Run Text="{Binding TradeStreamState, Mode=OneWay}"/>
|
||||
<Run Text=" uptime "/><Run Text="{Binding Uptime, Mode=OneWay}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The page the window opens on: is the bot alive, what does it hold, what is it
|
||||
/// thinking, and what has it just done.
|
||||
/// </summary>
|
||||
public partial class StatusPage : UserControl
|
||||
{
|
||||
private bool _pinnedToBottom = true;
|
||||
|
||||
public StatusPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
FeedScroll.ScrollChanged += OnFeedScrolled;
|
||||
}
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the activity strip pinned to the newest line, but only while the operator
|
||||
/// has not scrolled up. Following the tail unconditionally would yank the view away
|
||||
/// from whatever they were trying to read.
|
||||
/// </summary>
|
||||
private void OnFeedScrolled(object sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
if (e.ExtentHeightChange == 0)
|
||||
{
|
||||
_pinnedToBottom = FeedScroll.VerticalOffset >= FeedScroll.ScrollableHeight - 2;
|
||||
}
|
||||
else if (_pinnedToBottom)
|
||||
{
|
||||
FeedScroll.ScrollToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChartRequested(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: string symbol } && symbol.Length > 0)
|
||||
{
|
||||
Actions?.OpenChartWindow(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>What the chart draws.</summary>
|
||||
public enum PriceChartMode
|
||||
{
|
||||
/// <summary>Closed bars as candles — what the strategy actually decides on.</summary>
|
||||
Candles = 0,
|
||||
|
||||
/// <summary>The live quote line, sampled roughly once a second.</summary>
|
||||
Live = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Price chart drawn straight into a <see cref="DrawingContext"/>: candles or a live
|
||||
/// line, a right-hand price axis, and a marker on the last price.
|
||||
/// <para>
|
||||
/// Hand-drawn for the same reason as <see cref="SparkChart"/> — a couple of hundred
|
||||
/// candles repainted once a second is far cheaper as immediate-mode drawing than as a
|
||||
/// retained visual tree, and it keeps the application free of charting dependencies.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PriceChart : FrameworkElement
|
||||
{
|
||||
private static readonly Typeface Face = new("Cascadia Mono, Consolas");
|
||||
|
||||
private static readonly SolidColorBrush Grid = Frozen(Color.FromArgb(0x30, 0x21, 0x2A, 0x3D));
|
||||
private static readonly SolidColorBrush UpFill = Frozen(Color.FromArgb(0xE0, 0x2E, 0xE6, 0xA8));
|
||||
private static readonly SolidColorBrush DownFill = Frozen(Color.FromArgb(0xE0, 0xFF, 0x5A, 0x7A));
|
||||
private static readonly Pen UpPen = FrozenPen(Color.FromRgb(0x2E, 0xE6, 0xA8), 1);
|
||||
private static readonly Pen DownPen = FrozenPen(Color.FromRgb(0xFF, 0x5A, 0x7A), 1);
|
||||
private static readonly Pen GridPen = FrozenPen(Color.FromArgb(0x28, 0x84, 0x92, 0xAD), 1);
|
||||
|
||||
/// <summary>Width reserved on the right for price labels.</summary>
|
||||
private const double AxisWidth = 62;
|
||||
|
||||
private const double PadY = 10;
|
||||
|
||||
public static readonly DependencyProperty OpensProperty = Series(nameof(Opens));
|
||||
public static readonly DependencyProperty HighsProperty = Series(nameof(Highs));
|
||||
public static readonly DependencyProperty LowsProperty = Series(nameof(Lows));
|
||||
public static readonly DependencyProperty ClosesProperty = Series(nameof(Closes));
|
||||
public static readonly DependencyProperty LivePricesProperty = Series(nameof(LivePrices));
|
||||
|
||||
public static readonly DependencyProperty ModeProperty = DependencyProperty.Register(
|
||||
nameof(Mode), typeof(PriceChartMode), typeof(PriceChart),
|
||||
new FrameworkPropertyMetadata(PriceChartMode.Candles, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty EmptyTextProperty = DependencyProperty.Register(
|
||||
nameof(EmptyText), typeof(string), typeof(PriceChart),
|
||||
new FrameworkPropertyMetadata("in attesa di dati", FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
private static DependencyProperty Series(string name) => DependencyProperty.Register(
|
||||
name, typeof(IReadOnlyList<double>), typeof(PriceChart),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public IReadOnlyList<double>? Opens
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(OpensProperty);
|
||||
set => SetValue(OpensProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Highs
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(HighsProperty);
|
||||
set => SetValue(HighsProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Lows
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(LowsProperty);
|
||||
set => SetValue(LowsProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Closes
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(ClosesProperty);
|
||||
set => SetValue(ClosesProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? LivePrices
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(LivePricesProperty);
|
||||
set => SetValue(LivePricesProperty, value);
|
||||
}
|
||||
|
||||
public PriceChartMode Mode
|
||||
{
|
||||
get => (PriceChartMode)GetValue(ModeProperty);
|
||||
set => SetValue(ModeProperty, value);
|
||||
}
|
||||
|
||||
public string EmptyText
|
||||
{
|
||||
get => (string)GetValue(EmptyTextProperty);
|
||||
set => SetValue(EmptyTextProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
double w = ActualWidth;
|
||||
double h = ActualHeight;
|
||||
if (w <= AxisWidth + 20 || h <= 30)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Mode == PriceChartMode.Live)
|
||||
{
|
||||
RenderLine(dc, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderCandles(dc, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderCandles(DrawingContext dc, double w, double h)
|
||||
{
|
||||
IReadOnlyList<double>? o = Opens, hi = Highs, lo = Lows, c = Closes;
|
||||
|
||||
int n = c?.Count ?? 0;
|
||||
if (o is null || hi is null || lo is null || c is null || n < 2 ||
|
||||
o.Count < n || hi.Count < n || lo.Count < n)
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Bounds(hi, lo, n, out double min, out double max))
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
double plotW = w - AxisWidth;
|
||||
double plotH = h - (PadY * 2);
|
||||
double span = max - min;
|
||||
|
||||
double Y(double v) => PadY + ((1 - ((v - min) / span)) * plotH);
|
||||
|
||||
DrawGrid(dc, plotW, h, min, max, Y);
|
||||
|
||||
// A candle body thinner than a pixel is invisible; below that, fall back to a
|
||||
// close-only line so a long history still shows its shape.
|
||||
double slot = plotW / n;
|
||||
if (slot < 2.5)
|
||||
{
|
||||
DrawPolyline(dc, c, n, i => i / (double)(n - 1) * plotW, Y,
|
||||
c[n - 1] >= c[0] ? UpPen : DownPen);
|
||||
}
|
||||
else
|
||||
{
|
||||
double body = Math.Max(1, slot * 0.62);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double x = (i * slot) + (slot / 2);
|
||||
bool up = c[i] >= o[i];
|
||||
Pen pen = up ? UpPen : DownPen;
|
||||
Brush fill = up ? UpFill : DownFill;
|
||||
|
||||
// Wick.
|
||||
dc.DrawLine(pen, new Point(Snap(x), Y(hi[i])), new Point(Snap(x), Y(lo[i])));
|
||||
|
||||
double top = Y(Math.Max(o[i], c[i]));
|
||||
double bottom = Y(Math.Min(o[i], c[i]));
|
||||
double height = Math.Max(1, bottom - top);
|
||||
|
||||
dc.DrawRectangle(fill, pen, new Rect(Snap(x - (body / 2)), top, body, height));
|
||||
}
|
||||
}
|
||||
|
||||
DrawAxis(dc, plotW, h, min, max, Y);
|
||||
DrawLastPrice(dc, plotW, c[n - 1], Y, c[n - 1] >= c[0]);
|
||||
}
|
||||
|
||||
private void RenderLine(DrawingContext dc, double w, double h)
|
||||
{
|
||||
IReadOnlyList<double>? values = LivePrices;
|
||||
int n = values?.Count ?? 0;
|
||||
if (values is null || n < 2)
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Bounds(values, values, n, out double min, out double max))
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
double plotW = w - AxisWidth;
|
||||
double plotH = h - (PadY * 2);
|
||||
double span = max - min;
|
||||
double Y(double v) => PadY + ((1 - ((v - min) / span)) * plotH);
|
||||
double X(int i) => i / (double)(n - 1) * plotW;
|
||||
|
||||
DrawGrid(dc, plotW, h, min, max, Y);
|
||||
|
||||
bool up = values[n - 1] >= values[0];
|
||||
Color colour = up ? Color.FromRgb(0x2E, 0xE6, 0xA8) : Color.FromRgb(0xFF, 0x5A, 0x7A);
|
||||
|
||||
StreamGeometry area = new();
|
||||
using (StreamGeometryContext ctx = area.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(0, h), isFilled: true, isClosed: true);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(X(i), Y(values[i])), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
ctx.LineTo(new Point(plotW, h), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
area.Freeze();
|
||||
|
||||
LinearGradientBrush fill = new(
|
||||
Color.FromArgb(0x3C, colour.R, colour.G, colour.B),
|
||||
Color.FromArgb(0x00, colour.R, colour.G, colour.B),
|
||||
new Point(0, 0), new Point(0, 1));
|
||||
fill.Freeze();
|
||||
|
||||
dc.DrawGeometry(fill, null, area);
|
||||
DrawPolyline(dc, values, n, X, Y, FrozenPen(colour, 1.7));
|
||||
|
||||
DrawAxis(dc, plotW, h, min, max, Y);
|
||||
DrawLastPrice(dc, plotW, values[n - 1], Y, up);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static bool Bounds(
|
||||
IReadOnlyList<double> highs, IReadOnlyList<double> lows, int n, out double min, out double max)
|
||||
{
|
||||
min = double.MaxValue;
|
||||
max = double.MinValue;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (double.IsFinite(highs[i]) && highs[i] > 0) { max = Math.Max(max, highs[i]); }
|
||||
if (double.IsFinite(lows[i]) && lows[i] > 0) { min = Math.Min(min, lows[i]); }
|
||||
}
|
||||
|
||||
if (min > max)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Leave a little air, and give a dead-flat series a nominal band so the scale
|
||||
// does not collapse to a division by zero.
|
||||
double span = max - min;
|
||||
if (span <= 0)
|
||||
{
|
||||
span = Math.Max(Math.Abs(max) * 0.002, 0.01);
|
||||
min -= span / 2;
|
||||
max += span / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
min -= span * 0.04;
|
||||
max += span * 0.04;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext dc, double plotW, double h, double min, double max, Func<double, double> y)
|
||||
{
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
double value = min + ((max - min) * i / 4.0);
|
||||
double py = Snap(y(value));
|
||||
dc.DrawLine(GridPen, new Point(0, py), new Point(plotW, py));
|
||||
}
|
||||
|
||||
dc.DrawLine(GridPen, new Point(Snap(plotW), 0), new Point(Snap(plotW), h));
|
||||
}
|
||||
|
||||
private void DrawAxis(DrawingContext dc, double plotW, double h, double min, double max, Func<double, double> y)
|
||||
{
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
double value = min + ((max - min) * i / 4.0);
|
||||
FormattedText ft = Text(Format(value), 10, Palette.Faint);
|
||||
double py = y(value) - (ft.Height / 2);
|
||||
dc.DrawText(ft, new Point(plotW + 6, Math.Clamp(py, 0, h - ft.Height)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLastPrice(DrawingContext dc, double plotW, double price, Func<double, double> y, bool up)
|
||||
{
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Brush brush = up ? Palette.Up : Palette.Down;
|
||||
Pen pen = up ? UpPen : DownPen;
|
||||
|
||||
double py = Snap(y(price));
|
||||
|
||||
// Dashed marker so it reads as "now" rather than as another grid line.
|
||||
Pen dashed = new(brush, 1) { DashStyle = new DashStyle([4, 3], 0) };
|
||||
dashed.Freeze();
|
||||
dc.DrawLine(dashed, new Point(0, py), new Point(plotW, py));
|
||||
|
||||
FormattedText ft = Text(Format(price), 10.5, Brushes.Black);
|
||||
Rect tag = new(plotW + 2, py - (ft.Height / 2) - 2, ft.Width + 8, ft.Height + 4);
|
||||
dc.DrawRectangle(brush, pen, tag);
|
||||
dc.DrawText(ft, new Point(tag.X + 4, tag.Y + 2));
|
||||
}
|
||||
|
||||
private void DrawPolyline(
|
||||
DrawingContext dc, IReadOnlyList<double> values, int n,
|
||||
Func<int, double> x, Func<double, double> y, Pen pen)
|
||||
{
|
||||
StreamGeometry line = new();
|
||||
using (StreamGeometryContext ctx = line.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(x(0), y(values[0])), isFilled: false, isClosed: false);
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(x(i), y(values[i])), isStroked: true, isSmoothJoin: true);
|
||||
}
|
||||
}
|
||||
|
||||
line.Freeze();
|
||||
dc.DrawGeometry(null, pen, line);
|
||||
}
|
||||
|
||||
private void DrawCentred(DrawingContext dc, string text, double w, double h)
|
||||
{
|
||||
FormattedText ft = Text(text, 11, Palette.Faint);
|
||||
dc.DrawText(ft, new Point((w - ft.Width) / 2, (h - ft.Height) / 2));
|
||||
}
|
||||
|
||||
private FormattedText Text(string text, double size, Brush brush) => new(
|
||||
text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Face, size, brush,
|
||||
VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||
|
||||
private static string Format(double v) =>
|
||||
v >= 1000 ? v.ToString("N0", CultureInfo.CurrentCulture)
|
||||
: v >= 1 ? v.ToString("N2", CultureInfo.CurrentCulture)
|
||||
: v.ToString("N4", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Aligns a coordinate to the pixel grid so hairlines stay crisp.</summary>
|
||||
private static double Snap(double v) => Math.Round(v) + 0.5;
|
||||
|
||||
private static SolidColorBrush Frozen(Color c)
|
||||
{
|
||||
SolidColorBrush b = new(c);
|
||||
b.Freeze();
|
||||
return b;
|
||||
}
|
||||
|
||||
private static Pen FrozenPen(Color c, double thickness)
|
||||
{
|
||||
Pen p = new(Frozen(c), thickness);
|
||||
p.Freeze();
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>What a field accepts, which decides how it is parsed and validated.</summary>
|
||||
public enum SettingKind
|
||||
{
|
||||
Text = 0,
|
||||
Integer,
|
||||
Number,
|
||||
|
||||
/// <summary>Shown as a percentage, stored as a fraction. 20 on screen is 0.2 in the file.</summary>
|
||||
Percent,
|
||||
Boolean,
|
||||
Choice,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One editable line on the settings page.
|
||||
/// <para>
|
||||
/// Read-only fields are rendered as fields, not as prose. A value that the strategy
|
||||
/// fixes is still a value the operator should be able to see, find and understand in the
|
||||
/// same place as everything else — hiding it in a paragraph makes the page read like
|
||||
/// documentation, and documentation is what people stop reading.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SettingField : INotifyPropertyChanged
|
||||
{
|
||||
private string _value = string.Empty;
|
||||
private string? _error;
|
||||
|
||||
public required string Path { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value as loaded from the configuration. Setting it establishes both the
|
||||
/// current text and the baseline that <see cref="IsDirty"/> compares against, so a
|
||||
/// freshly built field is never reported as edited.
|
||||
/// </summary>
|
||||
public required string Initial
|
||||
{
|
||||
init
|
||||
{
|
||||
_value = value;
|
||||
Original = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The detailed explanation, including what changing it does.</summary>
|
||||
public required string Tooltip { get; init; }
|
||||
|
||||
public SettingKind Kind { get; init; } = SettingKind.Text;
|
||||
|
||||
/// <summary>Fixed by the strategy. Visible and copyable, but not editable.</summary>
|
||||
public bool IsReadOnly { get; init; }
|
||||
|
||||
/// <summary>Why it cannot be edited. Appended to the tooltip.</summary>
|
||||
public string? ReadOnlyReason { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Choices { get; init; } = [];
|
||||
|
||||
/// <summary>Unit suffix shown after the box, e.g. "%" or "secondi".</summary>
|
||||
public string Suffix { get; init; } = string.Empty;
|
||||
|
||||
public double Minimum { get; init; } = double.NegativeInfinity;
|
||||
|
||||
public double Maximum { get; init; } = double.PositiveInfinity;
|
||||
|
||||
/// <summary>The value as first loaded, so edits can be detected and reverted.</summary>
|
||||
public string Original { get; private set; } = string.Empty;
|
||||
|
||||
public string Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (_value == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_value = value;
|
||||
Raise();
|
||||
Raise(nameof(IsDirty));
|
||||
Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
private set
|
||||
{
|
||||
if (_error == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_error = value;
|
||||
Raise();
|
||||
Raise(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => _error is not null;
|
||||
|
||||
public bool IsDirty => !IsReadOnly && _value != Original;
|
||||
|
||||
public bool IsEditable => !IsReadOnly;
|
||||
|
||||
public string FullTooltip => ReadOnlyReason is null
|
||||
? Tooltip
|
||||
: $"{Tooltip}\n\nNON MODIFICABILE — {ReadOnlyReason}";
|
||||
|
||||
public void Load(string value)
|
||||
{
|
||||
_value = value;
|
||||
Original = value;
|
||||
Error = null;
|
||||
Raise(nameof(Value));
|
||||
Raise(nameof(IsDirty));
|
||||
}
|
||||
|
||||
public void Revert() => Load(Original);
|
||||
|
||||
/// <summary>Checks the text in isolation. Cross-field rules are the config's own job.</summary>
|
||||
public void Validate()
|
||||
{
|
||||
Error = null;
|
||||
|
||||
if (IsReadOnly)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (Kind)
|
||||
{
|
||||
case SettingKind.Integer:
|
||||
if (!int.TryParse(_value, NumberStyles.Integer, CultureInfo.CurrentCulture, out int i))
|
||||
{
|
||||
Error = "serve un numero intero";
|
||||
}
|
||||
else
|
||||
{
|
||||
Range(i);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SettingKind.Number:
|
||||
case SettingKind.Percent:
|
||||
if (!double.TryParse(_value, NumberStyles.Float, CultureInfo.CurrentCulture, out double d))
|
||||
{
|
||||
Error = "serve un numero";
|
||||
}
|
||||
else
|
||||
{
|
||||
Range(d);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SettingKind.Boolean:
|
||||
if (!bool.TryParse(_value, out _) && _value is not ("sì" or "no" or "si"))
|
||||
{
|
||||
Error = "serve sì o no";
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SettingKind.Choice:
|
||||
if (Choices.Count > 0 && !Choices.Contains(_value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Error = $"valori ammessi: {string.Join(", ", Choices)}";
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SettingKind.Text:
|
||||
default:
|
||||
if (_value.Length == 0)
|
||||
{
|
||||
Error = "non può essere vuoto";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
void Range(double v)
|
||||
{
|
||||
if (v < Minimum)
|
||||
{
|
||||
Error = $"non può essere sotto {Minimum.ToString("0.####", CultureInfo.CurrentCulture)}";
|
||||
}
|
||||
else if (v > Maximum)
|
||||
{
|
||||
Error = $"non può essere sopra {Maximum.ToString("0.####", CultureInfo.CurrentCulture)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The value as it must be written to the JSON file.</summary>
|
||||
public JsonNode? ToJson() => Kind switch
|
||||
{
|
||||
SettingKind.Integer => JsonValue.Create(int.Parse(_value, CultureInfo.CurrentCulture)),
|
||||
SettingKind.Number => JsonValue.Create(double.Parse(_value, NumberStyles.Float, CultureInfo.CurrentCulture)),
|
||||
|
||||
// Shown as 20, stored as 0.2. Rounded because 20/100 in binary floating point is
|
||||
// 0.200000000000000011, and writing that into a hand-edited file is unkind.
|
||||
SettingKind.Percent => JsonValue.Create(
|
||||
Math.Round(double.Parse(_value, NumberStyles.Float, CultureInfo.CurrentCulture) / 100.0, 10)),
|
||||
|
||||
// "no" is a valid answer and bool.Parse throws on it, so the Italian words are
|
||||
// resolved first and anything unrecognised falls through to false.
|
||||
SettingKind.Boolean => JsonValue.Create(
|
||||
_value is "sì" or "si" || (bool.TryParse(_value, out bool flag) && flag)),
|
||||
_ => JsonValue.Create(_value),
|
||||
};
|
||||
|
||||
/// <summary>Formats a stored value for display, inverting <see cref="ToJson"/>.</summary>
|
||||
public static string Format(double value, SettingKind kind) => kind switch
|
||||
{
|
||||
SettingKind.Percent => Math.Round(value * 100, 6).ToString("0.####", CultureInfo.CurrentCulture),
|
||||
SettingKind.Integer => ((int)Math.Round(value)).ToString(CultureInfo.CurrentCulture),
|
||||
_ => value.ToString("0.######", CultureInfo.CurrentCulture),
|
||||
};
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Raise([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
/// <summary>A titled block of fields on the settings page.</summary>
|
||||
public sealed class SettingGroup(string title, string description)
|
||||
{
|
||||
public string Title { get; } = title;
|
||||
|
||||
public string Description { get; } = description;
|
||||
|
||||
public ObservableCollection<SettingField> Fields { get; } = [];
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Bot.Configuration;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Every configurable value, with the explanation of what it does <b>to this strategy</b>
|
||||
/// and what happens if it is changed.
|
||||
/// <para>
|
||||
/// The tooltips are the point of this file. A number like <c>band = 0.02</c> means
|
||||
/// nothing on its own; "raise it to 5% and the bot trades a third as often, misses the
|
||||
/// start of moves, but stops being whipsawed by a price sitting on the average" is
|
||||
/// something an operator can act on. Where a figure came out of a backtest, the tooltip
|
||||
/// says so, because the difference between a tuned number and an arbitrary one is the
|
||||
/// only thing that tells you how freely you may change it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SettingsCatalogue
|
||||
{
|
||||
public static IReadOnlyList<SettingGroup> Build(BotConfig config, string strategyName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
return
|
||||
[
|
||||
Strategy(config, strategyName),
|
||||
Sizing(config),
|
||||
Protection(config),
|
||||
Frequency(config),
|
||||
Engine(config),
|
||||
Logging(config),
|
||||
];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static SettingGroup Strategy(BotConfig config, string strategyName)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Strategia",
|
||||
"I numeri che decidono quando comprare e quando vendere. Sono stati scelti " +
|
||||
"misurando su due dataset indipendenti: cambiarli cambia il comportamento del bot.");
|
||||
|
||||
SymbolConfig? symbol = config.EnabledSymbols.FirstOrDefault();
|
||||
string prefix = "symbols[0].parameters";
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "symbols[0].symbol",
|
||||
Label = "Asset",
|
||||
Initial = symbol?.Symbol ?? "BTC/USD",
|
||||
Kind = SettingKind.Text,
|
||||
IsReadOnly = true,
|
||||
ReadOnlyReason =
|
||||
"la strategia è tarata e verificata su BTC/USD. Su un altro asset i 100 giorni " +
|
||||
"di media e la banda del 2% non hanno alcuna validazione alle spalle.",
|
||||
Tooltip =
|
||||
"Lo strumento su cui opera il bot.\n\n" +
|
||||
"BTC/USD è stato scelto perché ha tredici anni di storia liquida su più exchange " +
|
||||
"indipendenti — che è ciò che rende verificabile la strategia — perché il mercato è " +
|
||||
"aperto 24/7 (niente gap di apertura, niente regola PDT) e perché è frazionabile, " +
|
||||
"quindi anche un conto piccolo può dimensionare con precisione.\n\n" +
|
||||
"ETH è stato rimosso: i suoi parametri erano copiati da quelli di BTC e non erano " +
|
||||
"mai stati validati su dati ETH.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "symbols[0].strategy",
|
||||
Label = "Strategia",
|
||||
Initial = strategyName,
|
||||
Kind = SettingKind.Text,
|
||||
IsReadOnly = true,
|
||||
ReadOnlyReason = "ne esiste una sola. Le altre sono state cancellate, non disattivate.",
|
||||
Tooltip =
|
||||
"Il modello che genera i segnali.\n\n" +
|
||||
"'trend-filter' compra quando il prezzo sta sopra la media a 100 giorni di una " +
|
||||
"certa percentuale, e vende quando scende sotto della stessa percentuale. Nient'altro: " +
|
||||
"nessun trailing stop, nessun target, nessun filtro di volatilità.\n\n" +
|
||||
"Sette modelli più elaborati sono stati scritti e misurati prima di questo. Tutti " +
|
||||
"hanno perso, o contro il mercato o contro il semplice comprare e tenere.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.period",
|
||||
Label = "Media mobile",
|
||||
Initial = Number(symbol, "period", 100, SettingKind.Integer),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "giorni",
|
||||
Minimum = 5,
|
||||
Maximum = 400,
|
||||
Tooltip =
|
||||
"Su quanti giorni si calcola la media che separa 'dentro' da 'fuori'.\n\n" +
|
||||
"100 non è il valore migliore su un singolo file: è l'unico che batte il comprare " +
|
||||
"e tenere su ENTRAMBI i dataset di verifica. La media a 120 giorni rende molto di " +
|
||||
"più su Bitstamp (+18,6 punti) ma perde su Binance (−2,2): è taratura sul campione.\n\n" +
|
||||
"Se lo abbassi a 50: il bot reagisce prima ai cambi di direzione, ma entra e esce " +
|
||||
"molto più spesso e paga più commissioni sui falsi segnali.\n" +
|
||||
"Se lo alzi a 200: molte meno operazioni, ma esce dai ribassi troppo tardi. " +
|
||||
"Misurato: −7,2 punti di rendimento annuo su Bitstamp e −6,9 su Binance.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.band",
|
||||
Label = "Banda di isteresi",
|
||||
Initial = Number(symbol, "band", 0.02, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "%",
|
||||
Minimum = 0,
|
||||
Maximum = 50,
|
||||
Tooltip =
|
||||
"Quanto il prezzo deve superare la media prima che il bot agisca.\n\n" +
|
||||
"Si compra a +2% sopra la media e si vende a −2% sotto. Non è un filtro di qualità: " +
|
||||
"è isteresi, serve a impedire che un prezzo appoggiato esattamente sulla media generi " +
|
||||
"un'operazione ogni due giorni. Sui tredici anni dimezza il numero di scambi lasciando " +
|
||||
"il rendimento dov'era.\n\n" +
|
||||
"Se la porti a 0: il bot reagisce immediatamente al passaggio della media, ma il " +
|
||||
"numero di operazioni raddoppia e con esso le commissioni.\n" +
|
||||
"Se la porti a 6%: circa un terzo delle operazioni, ingressi e uscite più tardi. " +
|
||||
"Il rendimento cala poco, il numero di falsi segnali molto.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.stopPct",
|
||||
Label = "Stop di emergenza",
|
||||
Initial = Number(symbol, "stopPct", 0.35, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% sotto l'ingresso",
|
||||
Minimum = 1,
|
||||
Maximum = 90,
|
||||
Tooltip =
|
||||
"Rete di sicurezza per un crollo improvviso, NON il controllo del rischio.\n\n" +
|
||||
"Il vero controllo del rischio è l'uscita sotto la media: questo stop esiste solo " +
|
||||
"per il caso in cui il prezzo salti così in basso, così in fretta, che aspettare la " +
|
||||
"chiusura della barra sarebbe peggio.\n\n" +
|
||||
"È volutamente lontano. Se lo stringi al 10%, uno stormo di oscillazioni normali su " +
|
||||
"BTC lo colpirà: il bot venderà, e poi dovrà aspettare un nuovo attraversamento della " +
|
||||
"media per rientrare, ricomprando spesso più in alto di dove ha venduto. È esattamente " +
|
||||
"il meccanismo con cui il modello precedente trasformava le oscillazioni in perdite " +
|
||||
"realizzate e rendeva un sesto del mercato.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.cvdThreshold",
|
||||
Label = "Filtro di order flow (CVD)",
|
||||
Initial = Number(symbol, "cvdThreshold", 0, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Minimum = 0,
|
||||
Maximum = 3,
|
||||
Tooltip =
|
||||
"Richiede che anche il flusso degli ordini sia rialzista prima di comprare. " +
|
||||
"0 = disattivato.\n\n" +
|
||||
"Il Cumulative Volume Delta misura quanto del volume è stato mosso da compratori " +
|
||||
"aggressivi rispetto ai venditori aggressivi. L'idea è non comprare un rialzo che " +
|
||||
"in realtà stanno vendendo.\n\n" +
|
||||
"È disattivato perché è stato misurato e peggiora QUESTA strategia, in modo " +
|
||||
"monotono: su nove anni di dati con il volume taker il Calmar scende da 0,71 " +
|
||||
"(disattivato) a 0,66 (soglia 0,3) a 0,64 (soglia 0,6). 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.\n\n" +
|
||||
"Il valore resta calcolato, visibile nel pannello Strategie e registrato nei log.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Sizing(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Quanto puntare",
|
||||
"Quanta parte del conto impegnare a ogni ingresso.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.stakePct",
|
||||
Label = "Percentuale del saldo per operazione",
|
||||
Initial = SettingField.Format(config.Risk.StakePct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% (0 = decide il bot)",
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"Quanto del saldo impegnare a ogni ingresso.\n\n" +
|
||||
"È a 100% perché questa strategia compete con il comprare e tenere: senza leva " +
|
||||
"(Alpaca sulle crypto è solo spot) chi è investito a metà non può, per aritmetica, " +
|
||||
"tenere il passo di chi è investito per intero. Ogni frazione inferiore perde la " +
|
||||
"gara in partenza.\n\n" +
|
||||
"Se la porti al 50%: rendimento circa dimezzato, drawdown circa dimezzato. È una " +
|
||||
"scelta legittima se il 74% di drawdown massimo storico ti sembra troppo.\n" +
|
||||
"Se la porti a 0: torna a decidere il bot, dimensionando sul rischio fino allo stop " +
|
||||
"(vedi 'Rischio per operazione').\n\n" +
|
||||
"Attenzione: con un valore impostato, la convinzione della strategia non riduce più " +
|
||||
"la dimensione, e lo stop protegge ma non dimensiona.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.stakeAmount",
|
||||
Label = "Importo massimo per operazione",
|
||||
Initial = SettingField.Format(config.Risk.StakeAmount, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "(0 = nessun tetto)",
|
||||
Minimum = 0,
|
||||
Maximum = 100_000_000,
|
||||
Tooltip =
|
||||
"Tetto in valuta sull'importo impegnato a ogni ingresso. 0 = nessun tetto.\n\n" +
|
||||
"Combinato con la percentuale qui sopra: si usa la percentuale, ma senza mai " +
|
||||
"superare questo importo. Con 20% e 5.000, su un conto da 100.000 impegna 5.000 " +
|
||||
"(non 20.000); su un conto da 10.000 impegna 2.000.\n\n" +
|
||||
"Da solo (percentuale a 0): diventa un importo fisso, uguale a ogni operazione " +
|
||||
"indipendentemente da quanto è cresciuto o calato il conto. Utile per limitare " +
|
||||
"l'esposizione in valore assoluto mentre si prova il bot dal vivo.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxRiskPerTradePct",
|
||||
Label = "Rischio per operazione",
|
||||
Initial = SettingField.Format(config.Risk.MaxRiskPerTradePct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% dell'equity",
|
||||
Minimum = 0.01,
|
||||
Maximum = 25,
|
||||
Tooltip =
|
||||
"Criterio di riserva: si usa solo se la percentuale del saldo è a 0.\n\n" +
|
||||
"In quel caso il bot calcola la quantità in modo che, se il prezzo arriva allo stop, " +
|
||||
"la perdita sia questa frazione del conto. Con il 5% e uno stop distante il 35%, la " +
|
||||
"posizione vale circa il 14% del conto.\n\n" +
|
||||
"La differenza rispetto alla percentuale fissa: con questo criterio ogni operazione " +
|
||||
"perde più o meno lo stesso importo quando va male, ma la dimensione cambia da " +
|
||||
"un'operazione all'altra. Con la percentuale fissa la dimensione è sempre uguale, e " +
|
||||
"un'operazione con stop lontano può perdere molto più di una con stop vicino.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxPositionNotionalPct",
|
||||
Label = "Posizione massima",
|
||||
Initial = SettingField.Format(config.Risk.MaxPositionNotionalPct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% dell'equity",
|
||||
Minimum = 1,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"Tetto assoluto su quanto può valere una singola posizione.\n\n" +
|
||||
"Agisce DOPO la percentuale del saldo: è l'ultima parola. Se chiedi di puntare il " +
|
||||
"100% ma questo è al 50%, l'applicazione non parte — preferisce un errore al " +
|
||||
"silenzio di una dimensione tagliata che non hai chiesto.\n\n" +
|
||||
"Con un solo asset e stake pieno la posizione È il portafoglio, quindi abbassarlo " +
|
||||
"significa restare parzialmente liquidi: meno rendimento, senza guadagnare " +
|
||||
"protezione. La protezione la dà l'uscita sotto la media, non il restare a metà.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Protection(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Protezioni",
|
||||
"I limiti che fermano il bot quando qualcosa va storto.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxDailyLossPct",
|
||||
Label = "Perdita giornaliera che ferma tutto",
|
||||
Initial = SettingField.Format(config.Risk.MaxDailyLossPct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "%",
|
||||
Minimum = 0.1,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"Il kill switch. Se l'equity scende di questa percentuale rispetto all'apertura " +
|
||||
"della sessione, il bot chiude tutto e non riapre fino al giorno dopo.\n\n" +
|
||||
"È al 25% e non al 2% come vorrebbe un manuale di trading intraday: su BTC un −20% " +
|
||||
"in un giorno è successo più volte, e non è una ragione per liquidare. Un valore " +
|
||||
"troppo stretto qui non protegge, garantisce solo di vendere sul minimo e restare " +
|
||||
"fuori durante il rimbalzo.\n\n" +
|
||||
"Se lo porti al 5%: il bot verrà fermato più volte l'anno da giornate che poi si " +
|
||||
"sono riprese, e ogni volta salterà la ripresa.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxStopDistancePct",
|
||||
Label = "Distanza massima dello stop",
|
||||
Initial = SettingField.Format(config.Risk.MaxStopDistancePct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "%",
|
||||
Minimum = 1,
|
||||
Maximum = 99,
|
||||
Tooltip =
|
||||
"Rifiuta un ingresso se lo stop richiesto è più lontano di così.\n\n" +
|
||||
"Protegge da un errore di configurazione: uno stop assurdo produrrebbe una posizione " +
|
||||
"assurda. Deve restare sopra lo 'Stop di emergenza' della strategia, altrimenti ogni " +
|
||||
"ingresso viene rifiutato con 'stop troppo lontano' e il bot non opera mai — senza " +
|
||||
"che sia ovvio il perché.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxRelativeSpread",
|
||||
Label = "Spread massimo accettato",
|
||||
Initial = SettingField.Format(config.Risk.MaxRelativeSpread, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% (0 = nessun controllo)",
|
||||
Minimum = 0,
|
||||
Maximum = 10,
|
||||
Tooltip =
|
||||
"Non invia ordini se la forbice fra denaro e lettera è più larga di così.\n\n" +
|
||||
"Uno spread anomalo di solito significa mercato sottile o dati vecchi: entrare in " +
|
||||
"quel momento significa pagare la differenza. Su BTC lo spread normale è di pochi " +
|
||||
"punti base, quindi 0,15% lascia passare tutto tranne le anomalie.\n\n" +
|
||||
"Se lo stringi troppo, il bot salterà ingressi validi nei momenti di volatilità — " +
|
||||
"che sono esattamente i momenti in cui la strategia vuole entrare.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.minOrderNotional",
|
||||
Label = "Ordine minimo",
|
||||
Initial = SettingField.Format(config.Risk.MinOrderNotional, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Minimum = 1,
|
||||
Maximum = 1_000_000,
|
||||
Tooltip =
|
||||
"Sotto questo controvalore l'ordine non viene inviato.\n\n" +
|
||||
"Un ordine da pochi euro paga in commissioni e slippage una frazione sproporzionata " +
|
||||
"del proprio valore. Serve anche a evitare che un errore di calcolo mandi al broker " +
|
||||
"un ordine da zero virgola.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Frequency(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Frequenza",
|
||||
"Quante operazioni il bot può fare. A 0 non c'è limite: a fermarlo è la strategia, " +
|
||||
"non un contatore.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxOpenPositions",
|
||||
Label = "Posizioni contemporanee",
|
||||
Initial = config.Risk.MaxOpenPositions.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "(0 = nessun limite)",
|
||||
Minimum = 0,
|
||||
Maximum = 1000,
|
||||
Tooltip =
|
||||
"Quante posizioni possono essere aperte nello stesso momento. 0 = nessun limite.\n\n" +
|
||||
"Con un solo asset configurato il numero resta comunque 1: il risk engine rifiuta un " +
|
||||
"secondo ingresso sullo stesso strumento ('already in position'), e con il saldo " +
|
||||
"impegnato al 100% una seconda posizione non avrebbe con cosa aprirsi. Questo limite " +
|
||||
"torna a contare solo aggiungendo altri asset.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxTradesPerDay",
|
||||
Label = "Operazioni al giorno",
|
||||
Initial = config.Risk.MaxTradesPerDay.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "(0 = nessun limite)",
|
||||
Minimum = 0,
|
||||
Maximum = 100_000,
|
||||
Tooltip =
|
||||
"Quanti ingressi il bot può fare in una sessione. 0 = nessun limite.\n\n" +
|
||||
"Sulla configurazione attuale il modello cambia stato circa cinque volte l'anno, " +
|
||||
"quindi un limite giornaliero non morde mai. Serviva come rete contro un bug: un " +
|
||||
"ciclo che riapre la stessa posizione mille volte costa mille commissioni. Con 0 " +
|
||||
"quella rete non c'è più.\n\n" +
|
||||
"Il kill switch sulla perdita giornaliera resta attivo e non è toccato da questo.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.minSecondsBetweenEntries",
|
||||
Label = "Attesa fra due ingressi",
|
||||
Initial = config.Risk.MinSecondsBetweenEntries.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "secondi (0 = nessuna attesa)",
|
||||
Minimum = 0,
|
||||
Maximum = 86_400,
|
||||
Tooltip =
|
||||
"Tempo minimo fra due ingressi sullo stesso strumento. 0 = nessuna attesa.\n\n" +
|
||||
"È un antirimbalzo: impedisce che un prezzo che oscilla attorno a una soglia generi " +
|
||||
"una raffica di ordini. Con la banda di isteresi già attiva sulla strategia, questo " +
|
||||
"secondo freno è quasi sempre superfluo.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Engine(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Motore",
|
||||
"Come il bot parla con il broker e con che ritmo lavora.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.timeFrame",
|
||||
Label = "Barre",
|
||||
Initial = config.Engine.TimeFrame,
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["1Min", "5Min", "15Min", "1Hour", "1Day"],
|
||||
IsReadOnly = true,
|
||||
ReadOnlyReason =
|
||||
"sotto i 15 minuti la strategia perde, e non per colpa delle commissioni: " +
|
||||
"misurata a costo zero, a 5 minuti il profit factor è 0,78 e a 1 minuto è 0,30.",
|
||||
Tooltip =
|
||||
"L'ampiezza delle barre su cui la strategia decide.\n\n" +
|
||||
"Giornaliero. Misurato sugli stessi dati con le stesse regole, il profit factor " +
|
||||
"scende così: 1 giorno 5,54 · 4 ore 1,84 · 1 ora 1,27 · 15 minuti 1,03 · " +
|
||||
"5 minuti 0,25 · 1 minuto 0,28. Sotto 1 si perde.\n\n" +
|
||||
"Il crollo non dipende dai costi: ripetendo la misura a commissioni ZERO — un limite " +
|
||||
"che nessun exchange può battere — a 5 minuti si perde comunque. Sotto i quindici " +
|
||||
"minuti il prezzo non contiene informazione direzionale che questo modello sappia " +
|
||||
"usare: è rumore, e pagarlo meno non lo rende segnale.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.warmupBars",
|
||||
Label = "Barre di riscaldamento",
|
||||
Initial = config.Engine.WarmupBars.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "barre",
|
||||
Minimum = 0,
|
||||
Maximum = 5000,
|
||||
Tooltip =
|
||||
"Quante barre storiche scaricare all'avvio per far partire la strategia già pronta.\n\n" +
|
||||
"Servono almeno tante barre quanto la media mobile, altrimenti il bot resta cieco " +
|
||||
"per cento giorni prima di poter decidere. 220 per una media a 100 danno margine " +
|
||||
"abbondante.\n\n" +
|
||||
"Se lo porti sotto il periodo della media, all'avvio vedrai 'warm-up' nel pannello " +
|
||||
"Strategie e il bot non opererà finché non avrà accumulato abbastanza barre dal vivo.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.entryOrderType",
|
||||
Label = "Tipo di ordine",
|
||||
Initial = config.Engine.EntryOrderType,
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["market", "limit"],
|
||||
Tooltip =
|
||||
"Come vengono inviati gli ordini di ingresso.\n\n" +
|
||||
"'limit' invia un ordine con un prezzo massimo, leggermente sopra il mercato " +
|
||||
"(vedi lo scarto qui sotto): protegge da un'esecuzione a un prezzo molto peggiore " +
|
||||
"del previsto, al rischio di non essere eseguito se il prezzo scappa.\n\n" +
|
||||
"'market' viene sempre eseguito, ma paga qualunque prezzo trovi. Su un asset " +
|
||||
"liquido come BTC la differenza è piccola; su un mercato sottile può essere grande.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.limitOffsetBps",
|
||||
Label = "Scarto dell'ordine limite",
|
||||
Initial = SettingField.Format(config.Engine.LimitOffsetBps, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "punti base (100 = 1%)",
|
||||
Minimum = 0,
|
||||
Maximum = 500,
|
||||
Tooltip =
|
||||
"Di quanto il prezzo limite viene messo oltre il mercato, per farsi eseguire.\n\n" +
|
||||
"8 punti base sono lo 0,08%: abbastanza per attraversare la forbice e prendere " +
|
||||
"liquidità senza inseguire.\n\n" +
|
||||
"Se lo porti a 0, l'ordine resterà spesso ineseguito e il bot perderà l'ingresso. " +
|
||||
"Se lo alzi molto, l'ordine si comporta come un ordine a mercato.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.reconcileSeconds",
|
||||
Label = "Riconciliazione col broker",
|
||||
Initial = config.Engine.ReconcileSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "secondi",
|
||||
Minimum = 5,
|
||||
Maximum = 3600,
|
||||
Tooltip =
|
||||
"Ogni quanto il bot ricontrolla conto, posizioni e ordini contro il broker, e " +
|
||||
"chiede le barre chiuse più recenti.\n\n" +
|
||||
"Lo stream WebSocket è la via veloce ma non è la verità: dopo una disconnessione " +
|
||||
"l'elenco del broker è l'unico completo. È anche il momento in cui il bot verifica " +
|
||||
"se è comparsa una barra nuova da valutare, quindi abbassarlo rende il bot più " +
|
||||
"reattivo all'apertura di una barra.\n\n" +
|
||||
"Sotto i 10 secondi si rischia di consumare inutilmente il limite di richieste.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.dryRun",
|
||||
Label = "Simulazione (nessun ordine reale)",
|
||||
Initial = config.Engine.DryRun ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Con 'sì' il bot calcola e mostra tutte le decisioni ma non invia nessun ordine.\n\n" +
|
||||
"È il modo per osservare cosa farebbe senza che faccia niente. Utile dopo aver " +
|
||||
"cambiato un parametro: si lascia girare qualche giorno e si guardano i log.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Logging(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Registro",
|
||||
"Quanto il bot racconta di quello che fa, e dove lo scrive.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "logging.level",
|
||||
Label = "Dettaglio",
|
||||
Initial = config.Logging.Level,
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["trace", "debug", "info", "warn", "error", "none"],
|
||||
Tooltip =
|
||||
"Quanto scrivere nel registro.\n\n" +
|
||||
"'info' racconta cosa fa il bot: ogni barra, ogni decisione e il motivo.\n" +
|
||||
"'debug' aggiunge lo stato interno del modello a ogni valutazione e ogni rifiuto " +
|
||||
"del risk engine — cioè il perché il bot NON ha fatto qualcosa.\n" +
|
||||
"'trace' aggiunge il dettaglio per quotazione: file molto grandi, serve solo per " +
|
||||
"diagnosticare il flusso dati.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "logging.logEveryBar",
|
||||
Label = "Registra ogni barra in arrivo",
|
||||
Initial = config.Logging.LogEveryBar ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Scrive una riga per ogni barra da un minuto che arriva dallo stream, non solo per " +
|
||||
"quelle che chiudono una barra della strategia.\n\n" +
|
||||
"Su barre giornaliere 1439 minuti su 1440 vengono assorbiti in silenzio: senza " +
|
||||
"questo, il registro non mostra nulla per ventiquattr'ore e il bot sembra fermo.\n\n" +
|
||||
"Con 'no' il registro resta molto più corto, ma si vede solo la decisione quotidiana.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.explainSeconds",
|
||||
Label = "Battito di stato",
|
||||
Initial = config.Engine.ExplainSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "secondi",
|
||||
Minimum = 1,
|
||||
Maximum = 3600,
|
||||
Tooltip =
|
||||
"Ogni quanto il bot ricontrolla cosa farebbe al prezzo attuale e lo scrive nel " +
|
||||
"registro, se è cambiato rispetto a prima.\n\n" +
|
||||
"Su barre giornaliere il bot è legittimamente silenzioso per settimane, e da fuori " +
|
||||
"il silenzio è indistinguibile da un blocco. Questa riga trasforma il silenzio in " +
|
||||
"una frase: 'prezzo 96.400 è −1,2% dalla media 97.600, per comprare serve che superi " +
|
||||
"99.550'.\n\n" +
|
||||
"La riga viene scritta solo quando cambia, quindi un valore basso non riempie il " +
|
||||
"registro di ripetizioni.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "logging.bufferedLines",
|
||||
Label = "Righe tenute in memoria",
|
||||
Initial = config.Logging.BufferedLines.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "righe",
|
||||
Minimum = 100,
|
||||
Maximum = 200_000,
|
||||
Tooltip =
|
||||
"Quante righe conserva la scheda Registro.\n\n" +
|
||||
"È il tetto di memoria del registro dentro l'applicazione: a 5.000 righe sono " +
|
||||
"pochi megabyte. Non è illimitato di proposito — un bot lasciato acceso una " +
|
||||
"settimana a 'debug' crescerebbe senza fine.\n\n" +
|
||||
"Il file su disco resta completo comunque, e la scheda Registro lo può aprire.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "logging.maxFileSizeMb",
|
||||
Label = "Dimensione massima del file",
|
||||
Initial = config.Logging.MaxFileSizeMb.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "MB",
|
||||
Minimum = 0,
|
||||
Maximum = 4096,
|
||||
Tooltip =
|
||||
"Superata questa dimensione, il file viene ruotato: encelado.log diventa " +
|
||||
"encelado.1.log e ne comincia uno nuovo.\n\n" +
|
||||
"0 disattiva la rotazione e lascia crescere il file senza limite.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "logging.maxFiles",
|
||||
Label = "File conservati",
|
||||
Initial = config.Logging.MaxFiles.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "file",
|
||||
Minimum = 1,
|
||||
Maximum = 500,
|
||||
Tooltip =
|
||||
"Quanti file ruotati tenere prima di cancellare il più vecchio.\n\n" +
|
||||
"Con 32 MB e 10 file, il registro occupa al massimo circa 320 MB.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static string Number(SymbolConfig? symbol, string key, double fallback, SettingKind kind)
|
||||
{
|
||||
double value = symbol is not null && symbol.Parameters.TryGetValue(key, out double v) ? v : fallback;
|
||||
return SettingField.Format(value, kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight equity curve: filled area plus a stroked line, coloured by whether
|
||||
/// the series finished above or below where it started.
|
||||
/// <para>
|
||||
/// Drawn directly in <see cref="OnRender"/> rather than with a charting library. The
|
||||
/// series is a few hundred points refreshed once a second — a retained-mode chart
|
||||
/// would cost far more than the drawing itself, and this keeps the app dependency
|
||||
/// free.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SparkChart : FrameworkElement
|
||||
{
|
||||
private static readonly Typeface LabelFace = new("Cascadia Mono, Consolas");
|
||||
|
||||
public static readonly DependencyProperty ValuesProperty = DependencyProperty.Register(
|
||||
nameof(Values), typeof(IReadOnlyList<double>), typeof(SparkChart),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty ShowScaleProperty = DependencyProperty.Register(
|
||||
nameof(ShowScale), typeof(bool), typeof(SparkChart),
|
||||
new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty EmptyTextProperty = DependencyProperty.Register(
|
||||
nameof(EmptyText), typeof(string), typeof(SparkChart),
|
||||
new FrameworkPropertyMetadata("nessun dato", FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public IReadOnlyList<double>? Values
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(ValuesProperty);
|
||||
set => SetValue(ValuesProperty, value);
|
||||
}
|
||||
|
||||
public bool ShowScale
|
||||
{
|
||||
get => (bool)GetValue(ShowScaleProperty);
|
||||
set => SetValue(ShowScaleProperty, value);
|
||||
}
|
||||
|
||||
public string EmptyText
|
||||
{
|
||||
get => (string)GetValue(EmptyTextProperty);
|
||||
set => SetValue(EmptyTextProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
double w = ActualWidth;
|
||||
double h = ActualHeight;
|
||||
if (w <= 4 || h <= 4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<double>? values = Values;
|
||||
if (values is null || values.Count < 2)
|
||||
{
|
||||
DrawCentredText(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
double lo = double.MaxValue, hi = double.MinValue;
|
||||
foreach (double v in values)
|
||||
{
|
||||
if (!double.IsFinite(v))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lo = Math.Min(lo, v);
|
||||
hi = Math.Max(hi, v);
|
||||
}
|
||||
|
||||
if (lo > hi)
|
||||
{
|
||||
DrawCentredText(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
// A perfectly flat series would divide by zero; give it a nominal band.
|
||||
double span = hi - lo;
|
||||
if (span <= 0)
|
||||
{
|
||||
span = Math.Max(Math.Abs(hi) * 0.001, 1);
|
||||
lo -= span / 2;
|
||||
hi += span / 2;
|
||||
span = hi - lo;
|
||||
}
|
||||
|
||||
const double pad = 8;
|
||||
double plotH = Math.Max(1, h - (pad * 2));
|
||||
double X(int i) => i / (double)(values.Count - 1) * w;
|
||||
double Y(double v) => pad + ((1 - ((v - lo) / span)) * plotH);
|
||||
|
||||
bool gained = values[^1] >= values[0];
|
||||
Color colour = gained ? Color.FromRgb(0x2E, 0xE6, 0xA8) : Color.FromRgb(0xFF, 0x5A, 0x7A);
|
||||
|
||||
StreamGeometry line = new();
|
||||
using (StreamGeometryContext ctx = line.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(X(0), Y(values[0])), isFilled: false, isClosed: false);
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(X(i), Y(values[i])), isStroked: true, isSmoothJoin: true);
|
||||
}
|
||||
}
|
||||
|
||||
line.Freeze();
|
||||
|
||||
StreamGeometry area = new();
|
||||
using (StreamGeometryContext ctx = area.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(X(0), h), isFilled: true, isClosed: true);
|
||||
for (int i = 0; i < values.Count; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(X(i), Y(values[i])), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
ctx.LineTo(new Point(w, h), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
area.Freeze();
|
||||
|
||||
LinearGradientBrush fill = new(
|
||||
Color.FromArgb(0x46, colour.R, colour.G, colour.B),
|
||||
Color.FromArgb(0x00, colour.R, colour.G, colour.B),
|
||||
new Point(0, 0),
|
||||
new Point(0, 1));
|
||||
fill.Freeze();
|
||||
|
||||
SolidColorBrush stroke = new(colour);
|
||||
stroke.Freeze();
|
||||
Pen pen = new(stroke, 1.8);
|
||||
pen.Freeze();
|
||||
|
||||
dc.DrawGeometry(fill, null, area);
|
||||
dc.DrawGeometry(null, pen, line);
|
||||
|
||||
if (ShowScale)
|
||||
{
|
||||
DrawLabel(dc, hi.ToString("N2", CultureInfo.CurrentCulture), 4, 2);
|
||||
DrawLabel(dc, lo.ToString("N2", CultureInfo.CurrentCulture), 4, h - 16);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLabel(DrawingContext dc, string text, double x, double y)
|
||||
{
|
||||
FormattedText ft = new(
|
||||
text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, LabelFace, 10,
|
||||
Palette.Faint, VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||
|
||||
dc.DrawText(ft, new Point(x, y));
|
||||
}
|
||||
|
||||
private void DrawCentredText(DrawingContext dc, string text, double w, double h)
|
||||
{
|
||||
FormattedText ft = new(
|
||||
text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, LabelFace, 11,
|
||||
Palette.Faint, VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||
|
||||
dc.DrawText(ft, new Point((w - ft.Width) / 2, (h - ft.Height) / 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Encelado.Bot.Engine;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One charted symbol, updated in place.
|
||||
/// <para>
|
||||
/// Deliberately not a record swapped into the collection each tick. Replacing the item
|
||||
/// would make the <c>ItemsControl</c> tear down and rebuild its container — and with it
|
||||
/// the <see cref="PriceChart"/> — once a second, which flickers and churns. Raising
|
||||
/// property changes on a stable instance repaints the chart and nothing else.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SymbolChartViewModel(string symbol) : INotifyPropertyChanged
|
||||
{
|
||||
private double _lastPrice;
|
||||
private double _sessionOpen;
|
||||
private double _sessionHigh;
|
||||
private double _sessionLow;
|
||||
private double _changePct;
|
||||
private bool _hasBars;
|
||||
private bool _hasLive;
|
||||
private IReadOnlyList<double> _opens = [];
|
||||
private IReadOnlyList<double> _highs = [];
|
||||
private IReadOnlyList<double> _lows = [];
|
||||
private IReadOnlyList<double> _closes = [];
|
||||
private IReadOnlyList<double> _live = [];
|
||||
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
public double LastPrice { get => _lastPrice; private set => Set(ref _lastPrice, value); }
|
||||
|
||||
public double SessionOpen { get => _sessionOpen; private set => Set(ref _sessionOpen, value); }
|
||||
|
||||
public double SessionHigh { get => _sessionHigh; private set => Set(ref _sessionHigh, value); }
|
||||
|
||||
public double SessionLow { get => _sessionLow; private set => Set(ref _sessionLow, value); }
|
||||
|
||||
public double ChangePct { get => _changePct; private set => Set(ref _changePct, value); }
|
||||
|
||||
public bool HasBars { get => _hasBars; private set => Set(ref _hasBars, value); }
|
||||
|
||||
public bool HasLive { get => _hasLive; private set => Set(ref _hasLive, value); }
|
||||
|
||||
public IReadOnlyList<double> Opens { get => _opens; private set => Set(ref _opens, value); }
|
||||
|
||||
public IReadOnlyList<double> Highs { get => _highs; private set => Set(ref _highs, value); }
|
||||
|
||||
public IReadOnlyList<double> Lows { get => _lows; private set => Set(ref _lows, value); }
|
||||
|
||||
public IReadOnlyList<double> Closes { get => _closes; private set => Set(ref _closes, value); }
|
||||
|
||||
public IReadOnlyList<double> Live { get => _live; private set => Set(ref _live, value); }
|
||||
|
||||
public void Apply(PriceSeriesRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
LastPrice = row.LastPrice;
|
||||
SessionOpen = row.SessionOpen;
|
||||
SessionHigh = row.SessionHigh;
|
||||
SessionLow = row.SessionLow;
|
||||
ChangePct = row.SessionChangePct;
|
||||
HasBars = row.HasBars;
|
||||
HasLive = row.HasLive;
|
||||
|
||||
// The arrays are rebuilt by the snapshot each tick, so a reference comparison is
|
||||
// enough to know something changed — and a length comparison is enough to know
|
||||
// nothing has, which is the common case between bar closes.
|
||||
if (!ReferenceEquals(_closes, row.BarCloses))
|
||||
{
|
||||
Opens = row.BarOpens;
|
||||
Highs = row.BarHighs;
|
||||
Lows = row.BarLows;
|
||||
Closes = row.BarCloses;
|
||||
}
|
||||
|
||||
Live = row.LivePrices;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
<!--
|
||||
Il tema dell'applicazione: tavolozza, tipografia, superfici e i template dei
|
||||
controlli che WPF disegnerebbe con la sua veste chiara.
|
||||
|
||||
Sta qui e non dentro App.xaml perche' App.xaml dichiara x:Class: caricarlo come
|
||||
dizionario costruisce l'oggetto Application, e in un AppDomain ne puo' esistere uno
|
||||
solo. I test che istanziano le pagine per verificarne i binding hanno bisogno degli
|
||||
stili senza far partire l'applicazione.
|
||||
-->
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<!-- ================= palette ================= -->
|
||||
<Color x:Key="BgColor">#FF0A0D14</Color>
|
||||
<SolidColorBrush x:Key="Bg" Color="#FF0A0D14"/>
|
||||
<SolidColorBrush x:Key="Panel" Color="#FF111725"/>
|
||||
<SolidColorBrush x:Key="Panel2" Color="#FF161D2E"/>
|
||||
<SolidColorBrush x:Key="Line" Color="#FF212A3D"/>
|
||||
<!--
|
||||
Dim e Faint sono stati schiariti perche' i valori precedenti (#8492AD e #5A6780)
|
||||
davano rispettivamente 5,4:1 e 2,95:1 di contrasto sulle superfici scure. Faint
|
||||
veste le etichette dei KPI, le intestazioni di sezione e quelle delle tabelle, a
|
||||
corpo 9-10,5: sotto i 4,5:1 richiesti dalle WCAG per il testo normale erano da
|
||||
indovinare piu' che da leggere. Ora il minimo su qualunque superficie e' 4,85:1
|
||||
per Faint e 7,61:1 per Dim, e fra i due resta un salto di luminanza di 1,68x che
|
||||
conserva la gerarchia.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Txt" Color="#FFE6EBF5"/>
|
||||
<SolidColorBrush x:Key="Dim" Color="#FFA3AFC6"/>
|
||||
<SolidColorBrush x:Key="Faint" Color="#FF7E8AA4"/>
|
||||
<SolidColorBrush x:Key="Up" Color="#FF2EE6A8"/>
|
||||
<SolidColorBrush x:Key="Down" Color="#FFFF5A7A"/>
|
||||
<SolidColorBrush x:Key="Accent" Color="#FF5B8CFF"/>
|
||||
<SolidColorBrush x:Key="Warn" Color="#FFFFB347"/>
|
||||
|
||||
<FontFamily x:Key="Mono">Cascadia Mono, Consolas, Courier New</FontFamily>
|
||||
|
||||
<ui:PnlBrushConverter x:Key="PnlBrush"/>
|
||||
<ui:BoolToVisibilityConverter x:Key="BoolVis"/>
|
||||
<ui:InverseBoolConverter x:Key="NotBool"/>
|
||||
<ui:LevelBrushConverter x:Key="LevelBrush"/>
|
||||
<ui:ScoreOffsetConverter x:Key="ScoreOffset"/>
|
||||
<ui:ScoreWidthConverter x:Key="ScoreWidth"/>
|
||||
<ui:ScoreBrushConverter x:Key="ScoreBrush"/>
|
||||
<ui:PriceConverter x:Key="Price"/>
|
||||
<ui:QuantityConverter x:Key="Qty"/>
|
||||
<ui:YesNoConverter x:Key="YesNo"/>
|
||||
<ui:LocalTimeConverter x:Key="LocalTime"/>
|
||||
<ui:SideBrushConverter x:Key="SideBrush"/>
|
||||
<ui:OrderStatusBrushConverter x:Key="OrderStatusBrush"/>
|
||||
<ui:RestrictionBrushConverter x:Key="RestrictionBrush"/>
|
||||
|
||||
<!-- ================= text ================= -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Label" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Value" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="21"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="0,7,0,0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Sub" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Margin" Value="0,5,0,0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Head" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="10.5"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
</Style>
|
||||
|
||||
<!-- ================= surfaces ================= -->
|
||||
<Style x:Key="Card" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Padding" Value="14"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Chip" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="5"/>
|
||||
<Setter Property="Padding" Value="7,3"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- ================= buttons ================= -->
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="13,7"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="b" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="7" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="b" Property="BorderBrush" Value="{StaticResource Accent}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
<Setter Property="Cursor" Value="Arrow"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Danger" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Foreground" Value="{StaticResource Down}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ================= inputs ================= -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="9,7"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="PasswordBox">
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="9,7"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Fully templated. Setting Background on the stock ComboBox does almost nothing:
|
||||
its default template wraps a system-themed ToggleButton that paints its own
|
||||
chrome, so the control renders as a light box on a near-black page.
|
||||
-->
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
<Setter Property="Padding" Value="9,6"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="b" Background="Transparent" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="b" Property="Background" Value="#205B8CFF"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="b" Property="Background" Value="#2E5B8CFF"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="9,6"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="Toggle" Focusable="False" ClickMode="Press"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
|
||||
RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="bg" Background="{StaticResource Panel2}"
|
||||
BorderBrush="{StaticResource Line}" BorderThickness="1"
|
||||
CornerRadius="7">
|
||||
<Path x:Name="arrow" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Margin="0,0,10,0" Data="M 0 0 L 4 4 L 8 0"
|
||||
Stroke="{StaticResource Dim}" StrokeThickness="1.4"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bg" Property="BorderBrush" Value="{StaticResource Accent}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
|
||||
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
IsHitTestVisible="False"/>
|
||||
|
||||
<Popup IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom"
|
||||
AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Margin="0,3,0,0"
|
||||
MinWidth="{TemplateBinding ActualWidth}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<ScrollViewer>
|
||||
<ItemsPresenter/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Height" Value="4"/>
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<!-- ================= side navigation ================= -->
|
||||
<!--
|
||||
The nav is a ListBox: one selected item at a time, arrow keys and Home/End for
|
||||
free, and a SelectedIndex the window can switch pages on. A stack of ToggleButtons
|
||||
would have needed all of that written by hand.
|
||||
-->
|
||||
<Style x:Key="NavList" TargetType="ListBox">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
|
||||
<Setter Property="ItemContainerStyle">
|
||||
<Setter.Value>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Margin" Value="0,1"/>
|
||||
<!-- Without this the item announces itself as the type name of its data
|
||||
object. The label is inside the template, so nothing else exposes it. -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{Binding Title}"/>
|
||||
<Setter Property="ToolTip" Value="{Binding Title}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="b" CornerRadius="8" Padding="12,10" Background="Transparent">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="3"/>
|
||||
<ColumnDefinition Width="26"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Selection rail: reads at a glance from the far left. -->
|
||||
<Border x:Name="rail" Grid.Column="0" Width="3" CornerRadius="2"
|
||||
Background="Transparent" Margin="-6,1,0,1"/>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Glyph}"
|
||||
FontFamily="Segoe MDL2 Assets" FontSize="14"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{TemplateBinding Foreground}"/>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="13"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{TemplateBinding Foreground}"/>
|
||||
|
||||
<Border Grid.Column="3" Background="{StaticResource Panel2}"
|
||||
CornerRadius="9" Padding="6,1" VerticalAlignment="Center"
|
||||
Visibility="{Binding HasBadge, Converter={StaticResource BoolVis}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="10"
|
||||
FontFamily="{StaticResource Mono}"
|
||||
Foreground="{StaticResource Accent}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="b" Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter TargetName="rail" Property="Background" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsSelected" Value="False"/>
|
||||
<Condition Property="IsMouseOver" Value="True"/>
|
||||
</MultiTrigger.Conditions>
|
||||
<Setter TargetName="b" Property="Background" Value="#14FFFFFF"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
</MultiTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= tooltip ================= -->
|
||||
<!--
|
||||
Le spiegazioni delle funzionalita' vivono qui invece che nella pagina. Quel testo
|
||||
serve una volta, la prima; lasciarlo a schermo per sempre costa spazio a ogni
|
||||
avvio successivo. Il tooltip di sistema e' chiaro e stretto, quindi va rivestito
|
||||
e allargato, e tenuto aperto abbastanza da poter leggere un paragrafo.
|
||||
-->
|
||||
<Style TargetType="ToolTip">
|
||||
<Setter Property="Background" Value="{StaticResource Panel2}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="12,10"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="MaxWidth" Value="420"/>
|
||||
<Setter Property="HasDropShadow" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToolTip">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter>
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="LineHeight" Value="17"/>
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Il pallino informativo. Si mette accanto a un titolo e porta la spiegazione che
|
||||
prima occupava tre righe sotto di esso.
|
||||
-->
|
||||
<Style x:Key="Hint" TargetType="TextBlock">
|
||||
<Setter Property="Text" Value=""/>
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Margin" Value="7,0,0,0"/>
|
||||
<Setter Property="Cursor" Value="Help"/>
|
||||
<!-- Il default sparisce dopo 5 secondi: troppo poco per un paragrafo. -->
|
||||
<Setter Property="ToolTipService.ShowDuration" Value="60000"/>
|
||||
<Setter Property="ToolTipService.InitialShowDelay" Value="250"/>
|
||||
<Setter Property="ToolTipService.Placement" Value="Bottom"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ================= page furniture ================= -->
|
||||
<Style x:Key="PageTitle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="19"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PageSubtitle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
<Setter Property="Margin" Value="0,3,0,0"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
</Style>
|
||||
|
||||
<!-- Titolo di pagina con il pallino accanto, al posto del sottotitolo. -->
|
||||
<Style x:Key="PageHeader" TargetType="StackPanel">
|
||||
<Setter Property="Orientation" Value="Horizontal"/>
|
||||
<Setter Property="Margin" Value="0,0,0,14"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Kpi" TargetType="Border" BasedOn="{StaticResource Card}">
|
||||
<Setter Property="Padding" Value="14,12"/>
|
||||
<Setter Property="Margin" Value="0,0,10,0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Dot" TargetType="Ellipse">
|
||||
<Setter Property="Width" Value="9"/>
|
||||
<Setter Property="Height" Value="9"/>
|
||||
<Setter Property="Fill" Value="{StaticResource Faint}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding StateKind}" Value="running">
|
||||
<Setter Property="Fill" Value="{StaticResource Up}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding StateKind}" Value="faulted">
|
||||
<Setter Property="Fill" Value="{StaticResource Down}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding StateKind}" Value="starting">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding StateKind}" Value="stopping">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ModeBadge" TargetType="Border" BasedOn="{StaticResource Chip}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ModeKind}" Value="live">
|
||||
<Setter Property="Background" Value="#22FF5A7A"/>
|
||||
<Setter Property="BorderBrush" Value="#66FF5A7A"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding ModeKind}" Value="paper">
|
||||
<Setter Property="Background" Value="#225B8CFF"/>
|
||||
<Setter Property="BorderBrush" Value="#665B8CFF"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding ModeKind}" Value="dry">
|
||||
<Setter Property="Background" Value="#22FFB347"/>
|
||||
<Setter Property="BorderBrush" Value="#66FFB347"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PowerButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Padding" Value="20,11"/>
|
||||
<Setter Property="FontSize" Value="13.5"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="#FF04121A"/>
|
||||
<Setter Property="Background" Value="{StaticResource Up}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Up}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsRunning}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource Down}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ================= data grid ================= -->
|
||||
<Style TargetType="DataGrid">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="RowBackground" Value="Transparent"/>
|
||||
<Setter Property="AlternatingRowBackground" Value="Transparent"/>
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="#22212A3D"/>
|
||||
<Setter Property="HeadersVisibility" Value="Column"/>
|
||||
<Setter Property="AutoGenerateColumns" Value="False"/>
|
||||
<Setter Property="IsReadOnly" Value="True"/>
|
||||
<Setter Property="CanUserResizeRows" Value="False"/>
|
||||
<Setter Property="SelectionMode" Value="Single"/>
|
||||
<Setter Property="RowHeight" Value="34"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Padding" Value="9,7"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Right"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DataGridCell">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Padding" Value="9,0"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="DataGridCell">
|
||||
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DataGridRow">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#145B8CFF"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Background" Value="#205B8CFF"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ================= scrollbar ================= -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="8"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||
<Track.Thumb>
|
||||
<Thumb>
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border Background="{StaticResource Line}" CornerRadius="4" Margin="2,0"/>
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="False"/>
|
||||
</Track.IncreaseRepeatButton>
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="False"/>
|
||||
</Track.DecreaseRepeatButton>
|
||||
</Track>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>One symbol and the strategy instance that will trade it during a replay.</summary>
|
||||
public sealed record BacktestSymbol(string Symbol, IStrategy Strategy);
|
||||
|
||||
/// <summary>
|
||||
/// Everything that makes a replay realistic. The defaults model Alpaca crypto: a
|
||||
/// marketable-limit slippage plus a per-fill fee, both charged in both directions.
|
||||
/// </summary>
|
||||
public sealed record BacktestSettings
|
||||
{
|
||||
public RiskLimits Risk { get; init; } = new();
|
||||
|
||||
public double StartingEquity { get; init; } = 10_000;
|
||||
|
||||
/// <summary>Price concession paid on every fill, in basis points.</summary>
|
||||
public double SlippageBps { get; init; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Broker fee per fill, in basis points of notional. Alpaca crypto taker fees start
|
||||
/// around 25 bps, so a round trip costs roughly 50 bps. Leaving this at zero is the
|
||||
/// single easiest way to produce a backtest that cannot be reproduced live.
|
||||
/// </summary>
|
||||
public double FeeBps { get; init; } = 25;
|
||||
|
||||
public bool AllowFractional { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record ClosedTrade(
|
||||
string Symbol,
|
||||
Side Side,
|
||||
DateTime EntryUtc,
|
||||
DateTime ExitUtc,
|
||||
double Quantity,
|
||||
double EntryPrice,
|
||||
double ExitPrice,
|
||||
double GrossPnl,
|
||||
double Fees,
|
||||
string ExitReason)
|
||||
{
|
||||
/// <summary>Profit after slippage and broker fees. This is the only number that matters.</summary>
|
||||
public double Pnl => GrossPnl - Fees;
|
||||
|
||||
public bool IsWin => Pnl > 0;
|
||||
|
||||
public TimeSpan Holding => ExitUtc - EntryUtc;
|
||||
|
||||
public double ReturnPct => EntryPrice > 0 && Quantity > 0 ? Pnl / (EntryPrice * Quantity) : 0;
|
||||
}
|
||||
|
||||
public sealed record BacktestReport(
|
||||
double StartEquity,
|
||||
double EndEquity,
|
||||
double MaxDrawdownPct,
|
||||
IReadOnlyList<ClosedTrade> Trades,
|
||||
int BarsProcessed,
|
||||
DateTime FromUtc,
|
||||
DateTime ToUtc,
|
||||
double TotalFees)
|
||||
{
|
||||
public static readonly BacktestReport Empty =
|
||||
new(0, 0, 0, [], 0, DateTime.MinValue, DateTime.MinValue, 0);
|
||||
|
||||
public double NetPnl => EndEquity - StartEquity;
|
||||
|
||||
public double ReturnPct => StartEquity > 0 ? NetPnl / StartEquity : 0;
|
||||
|
||||
public int Wins => Trades.Count(t => t.IsWin);
|
||||
|
||||
public int Losses => Trades.Count - Wins;
|
||||
|
||||
public double WinRate => Trades.Count > 0 ? Wins / (double)Trades.Count : 0;
|
||||
|
||||
public double GrossProfit => Trades.Where(t => t.Pnl > 0).Sum(t => t.Pnl);
|
||||
|
||||
public double GrossLoss => -Trades.Where(t => t.Pnl < 0).Sum(t => t.Pnl);
|
||||
|
||||
public double ProfitFactor =>
|
||||
GrossLoss > 0 ? GrossProfit / GrossLoss : GrossProfit > 0 ? double.PositiveInfinity : 0;
|
||||
|
||||
public double AverageWin => Wins > 0 ? GrossProfit / Wins : 0;
|
||||
|
||||
public double AverageLoss => Losses > 0 ? GrossLoss / Losses : 0;
|
||||
|
||||
public double Expectancy => Trades.Count > 0 ? NetPnl / Trades.Count : 0;
|
||||
|
||||
public double Years => (ToUtc - FromUtc).TotalDays / 365.25;
|
||||
|
||||
/// <summary>Compound annual growth rate. Meaningless for very short windows.</summary>
|
||||
public double Cagr
|
||||
{
|
||||
get
|
||||
{
|
||||
if (StartEquity <= 0 || EndEquity <= 0 || Years < 0.08)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Pow(EndEquity / StartEquity, 1.0 / Years) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Annualised return divided by max drawdown — the ratio that decides if a book is fundable.</summary>
|
||||
public double CalmarRatio => MaxDrawdownPct > 0 ? Cagr / MaxDrawdownPct : 0;
|
||||
|
||||
public TimeSpan AverageHolding => Trades.Count == 0
|
||||
? TimeSpan.Zero
|
||||
: TimeSpan.FromMinutes(Trades.Average(t => t.Holding.TotalMinutes));
|
||||
|
||||
public string Render()
|
||||
{
|
||||
StringBuilder sb = new(1400);
|
||||
CultureInfo ci = CultureInfo.InvariantCulture;
|
||||
|
||||
sb.AppendLine(ci, $"period {FromUtc:yyyy-MM-dd} .. {ToUtc:yyyy-MM-dd} ({Years:F2} years, {BarsProcessed:N0} bars)");
|
||||
sb.AppendLine(ci, $"equity {StartEquity:N2} -> {EndEquity:N2} ({ReturnPct:P2})");
|
||||
sb.AppendLine(ci, $"CAGR {Cagr:P2}");
|
||||
sb.AppendLine(ci, $"max drawdown {MaxDrawdownPct:P2} Calmar {CalmarRatio:F2}");
|
||||
sb.AppendLine(ci, $"net P&L {NetPnl:N2} fees paid {TotalFees:N2}");
|
||||
sb.AppendLine(ci, $"trades {Trades.Count} (wins {Wins} / losses {Losses}, win rate {WinRate:P1})");
|
||||
sb.AppendLine(ci, $"profit factor {(double.IsInfinity(ProfitFactor) ? "inf" : ProfitFactor.ToString("F2", ci))}");
|
||||
sb.AppendLine(ci, $"avg win / loss {AverageWin:N2} / {AverageLoss:N2} expectancy {Expectancy:N2}/trade");
|
||||
sb.AppendLine(ci, $"avg holding {AverageHolding.TotalHours:F1} h");
|
||||
|
||||
if (Trades.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("per symbol:");
|
||||
foreach (IGrouping<string, ClosedTrade> g in Trades.GroupBy(t => t.Symbol).OrderBy(g => g.Key))
|
||||
{
|
||||
sb.AppendLine(ci,
|
||||
$" {g.Key,-12} trades={g.Count(),4} wins={g.Count(t => t.IsWin),4} pnl={g.Sum(t => t.Pnl),14:N2}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("exit reasons:");
|
||||
foreach (IGrouping<string, ClosedTrade> g in Trades.GroupBy(t => Bucket(t.ExitReason)).OrderByDescending(g => g.Count()))
|
||||
{
|
||||
sb.AppendLine(ci, $" {g.Key,-24} {g.Count(),4} pnl={g.Sum(t => t.Pnl),14:N2}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
// A trailing stop is how a trend trade normally *takes profit*, so lumping it
|
||||
// in with the protective stop would make a winning exit look like a loss.
|
||||
static string Bucket(string reason) =>
|
||||
reason.Contains("trailing stop", StringComparison.OrdinalIgnoreCase) ? "trailing stop"
|
||||
: reason.Contains("take profit", StringComparison.OrdinalIgnoreCase) ? "take profit"
|
||||
: reason.Contains("stop", StringComparison.OrdinalIgnoreCase) ? "stop loss"
|
||||
: reason.Contains("end of backtest", StringComparison.OrdinalIgnoreCase) ? "end of data"
|
||||
: "signal exit";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// Reads OHLCV bars from a CSV file so a strategy can be tested against a long history
|
||||
/// without paying for a data subscription.
|
||||
/// <para>
|
||||
/// The column layout is detected from the header, which makes it tolerant of the two
|
||||
/// shapes that turn up in practice: exchange dumps (Binance klines, where the timestamp
|
||||
/// is Unix milliseconds) and generic exports (an ISO date column). Parsing is done on
|
||||
/// spans, one line at a time, so a 300 000-row file costs a single buffered pass.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CsvBarSource
|
||||
{
|
||||
private static readonly string[] TimeNames =
|
||||
["timestamp", "time", "open_time", "opentime", "date", "datetime"];
|
||||
|
||||
/// <summary>Millisecond epochs below this are almost certainly seconds instead.</summary>
|
||||
private const long MillisecondThreshold = 100_000_000_000L;
|
||||
|
||||
public static IReadOnlyList<Bar> Load(string path, CancellationToken ct = default)
|
||||
{
|
||||
// Roughly one bar per 60 bytes; a good enough hint to avoid repeated regrowth.
|
||||
List<Bar> bars = new((int)Math.Min(int.MaxValue, Measure(path) / 60) + 16);
|
||||
|
||||
foreach (Bar bar in Read(path, ct))
|
||||
{
|
||||
bars.Add(bar);
|
||||
}
|
||||
|
||||
if (bars.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("The CSV contained no parsable rows.");
|
||||
}
|
||||
|
||||
// Exchange dumps are normally already ordered, but never assume it.
|
||||
for (int i = 1; i < bars.Count; i++)
|
||||
{
|
||||
if (bars[i].TimeUtc < bars[i - 1].TimeUtc)
|
||||
{
|
||||
bars.Sort(static (a, b) => a.TimeUtc.CompareTo(b.TimeUtc));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a fine-grained file and folds it into coarser bars in a single streaming
|
||||
/// pass, so memory tracks the number of bars produced rather than the number read.
|
||||
/// <para>
|
||||
/// This is what makes a 342 MB minute file usable: loaded as-is it is 6.8 million
|
||||
/// <see cref="Bar"/> values and roughly half a gigabyte of live objects, but folded
|
||||
/// into days it is under five thousand bars. Buckets are keyed rather than assumed
|
||||
/// to arrive in order, so a file that jumps backwards produces correct bars instead
|
||||
/// of quietly corrupting the one currently open.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="bucket">Bar width, aligned to the Unix epoch. One day gives UTC midnight opens.</param>
|
||||
public static IReadOnlyList<Bar> LoadAggregated(
|
||||
string path, TimeSpan bucket, CancellationToken ct = default)
|
||||
{
|
||||
if (bucket <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "The bar width must be positive.");
|
||||
}
|
||||
|
||||
long ticks = bucket.Ticks;
|
||||
Dictionary<DateTime, Aggregate> buckets = [];
|
||||
|
||||
foreach (Bar bar in Read(path, ct))
|
||||
{
|
||||
DateTime key = new(bar.TimeUtc.Ticks - (bar.TimeUtc.Ticks % ticks), DateTimeKind.Utc);
|
||||
|
||||
ref Aggregate slot = ref System.Runtime.InteropServices.CollectionsMarshal
|
||||
.GetValueRefOrAddDefault(buckets, key, out bool existed);
|
||||
|
||||
if (existed)
|
||||
{
|
||||
slot.Add(bar);
|
||||
}
|
||||
else
|
||||
{
|
||||
slot = Aggregate.Start(bar);
|
||||
}
|
||||
}
|
||||
|
||||
if (buckets.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("The CSV contained no parsable rows.");
|
||||
}
|
||||
|
||||
List<Bar> bars = new(buckets.Count);
|
||||
foreach (KeyValuePair<DateTime, Aggregate> entry in buckets)
|
||||
{
|
||||
bars.Add(entry.Value.ToBar(entry.Key));
|
||||
}
|
||||
|
||||
bars.Sort(static (a, b) => a.TimeUtc.CompareTo(b.TimeUtc));
|
||||
return bars;
|
||||
}
|
||||
|
||||
/// <summary>One bucket under construction. A struct so the dictionary holds no references.</summary>
|
||||
private struct Aggregate
|
||||
{
|
||||
private double _open;
|
||||
private double _high;
|
||||
private double _low;
|
||||
private double _close;
|
||||
private double _volume;
|
||||
private double _takerBuy;
|
||||
private double _notional;
|
||||
private int _trades;
|
||||
private long _firstTicks;
|
||||
private long _lastTicks;
|
||||
|
||||
public static Aggregate Start(in Bar bar)
|
||||
{
|
||||
Aggregate a = new()
|
||||
{
|
||||
_open = bar.Open,
|
||||
_high = bar.High,
|
||||
_low = bar.Low,
|
||||
_close = bar.Close,
|
||||
_volume = bar.Volume,
|
||||
_takerBuy = bar.TakerBuyVolume,
|
||||
_notional = Typical(bar) * bar.Volume,
|
||||
_trades = bar.TradeCount,
|
||||
_firstTicks = bar.TimeUtc.Ticks,
|
||||
_lastTicks = bar.TimeUtc.Ticks,
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
public void Add(in Bar bar)
|
||||
{
|
||||
if (bar.High > _high) { _high = bar.High; }
|
||||
if (bar.Low < _low) { _low = bar.Low; }
|
||||
|
||||
// Open and close follow the clock, not the arrival order, so that an
|
||||
// out-of-order file still yields the true first and last price. Both edges
|
||||
// are tracked: comparing only against the latest would let the second of two
|
||||
// out-of-order rows overwrite the open set by the first.
|
||||
long t = bar.TimeUtc.Ticks;
|
||||
|
||||
if (t > _lastTicks)
|
||||
{
|
||||
_close = bar.Close;
|
||||
_lastTicks = t;
|
||||
}
|
||||
|
||||
if (t < _firstTicks)
|
||||
{
|
||||
_open = bar.Open;
|
||||
_firstTicks = t;
|
||||
}
|
||||
|
||||
_volume += bar.Volume;
|
||||
_takerBuy += bar.TakerBuyVolume;
|
||||
_notional += Typical(bar) * bar.Volume;
|
||||
_trades += bar.TradeCount;
|
||||
}
|
||||
|
||||
public readonly Bar ToBar(DateTime time) =>
|
||||
new(time, _open, _high, _low, _close, _volume,
|
||||
_volume > 0 ? _notional / _volume : 0, _trades, _takerBuy);
|
||||
|
||||
private static double Typical(in Bar bar) => (bar.High + bar.Low + bar.Close) / 3.0;
|
||||
}
|
||||
|
||||
private static long Measure(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"CSV not found: {path}", path);
|
||||
}
|
||||
|
||||
return new FileInfo(path).Length;
|
||||
}
|
||||
|
||||
/// <summary>Streams parsed bars, skipping rows that do not make sense.</summary>
|
||||
private static IEnumerable<Bar> Read(string path, CancellationToken ct)
|
||||
{
|
||||
Measure(path);
|
||||
|
||||
using StreamReader reader = new(path);
|
||||
|
||||
string? header = reader.ReadLine()
|
||||
?? throw new InvalidDataException("The CSV is empty.");
|
||||
|
||||
Layout layout = Layout.Detect(header);
|
||||
|
||||
int lineNumber = 1;
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
lineNumber++;
|
||||
|
||||
if ((lineNumber & 0xFFFF) == 0)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layout.TryParse(line, out Bar bar))
|
||||
{
|
||||
yield return bar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Column positions resolved once from the header row.</summary>
|
||||
private sealed class Layout
|
||||
{
|
||||
private int _time = -1;
|
||||
private int _open = -1;
|
||||
private int _high = -1;
|
||||
private int _low = -1;
|
||||
private int _close = -1;
|
||||
private int _volume = -1;
|
||||
private int _trades = -1;
|
||||
private int _takerBuy = -1;
|
||||
private int _columns;
|
||||
|
||||
public static Layout Detect(string header)
|
||||
{
|
||||
Layout layout = new();
|
||||
int index = 0;
|
||||
|
||||
foreach (Range range in Split(header))
|
||||
{
|
||||
string name = header[range].Trim().Trim('"').ToLowerInvariant();
|
||||
|
||||
if (layout._time < 0 && Array.IndexOf(TimeNames, name) >= 0)
|
||||
{
|
||||
layout._time = index;
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "open" or "o": layout._open = index; break;
|
||||
case "high" or "h": layout._high = index; break;
|
||||
case "low" or "l": layout._low = index; break;
|
||||
case "close" or "c": layout._close = index; break;
|
||||
case "volume" or "v" or "base_asset_volume": layout._volume = index; break;
|
||||
case "number_of_trades" or "trades" or "count" or "n": layout._trades = index; break;
|
||||
|
||||
// The aggressor breakdown. Without it there is no volume delta
|
||||
// and the order-flow strategy has nothing to work with.
|
||||
case "taker_buy_base_asset_volume" or "taker_buy_volume" or "taker_buy_base":
|
||||
layout._takerBuy = index;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
layout._columns = index;
|
||||
|
||||
if (layout._time < 0 || layout._open < 0 || layout._high < 0 ||
|
||||
layout._low < 0 || layout._close < 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The CSV header must contain a time column plus open, high, low and close. " +
|
||||
$"Found: {header}");
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public bool TryParse(string line, out Bar bar)
|
||||
{
|
||||
bar = default;
|
||||
|
||||
Span<Range> fields = stackalloc Range[Math.Max(_columns, 16)];
|
||||
int count = 0;
|
||||
foreach (Range range in Split(line))
|
||||
{
|
||||
if (count == fields.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
fields[count++] = range;
|
||||
}
|
||||
|
||||
if (count <= _close)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryTime(line.AsSpan(fields[_time]), out DateTime time) ||
|
||||
!TryDouble(line.AsSpan(fields[_open]), out double open) ||
|
||||
!TryDouble(line.AsSpan(fields[_high]), out double high) ||
|
||||
!TryDouble(line.AsSpan(fields[_low]), out double low) ||
|
||||
!TryDouble(line.AsSpan(fields[_close]), out double close))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (open <= 0 || high <= 0 || low <= 0 || close <= 0 || high < low)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double volume = 0;
|
||||
if (_volume >= 0 && _volume < count)
|
||||
{
|
||||
TryDouble(line.AsSpan(fields[_volume]), out volume);
|
||||
}
|
||||
|
||||
int trades = 0;
|
||||
if (_trades >= 0 && _trades < count &&
|
||||
TryDouble(line.AsSpan(fields[_trades]), out double t))
|
||||
{
|
||||
trades = (int)t;
|
||||
}
|
||||
|
||||
double takerBuy = 0;
|
||||
if (_takerBuy >= 0 && _takerBuy < count)
|
||||
{
|
||||
TryDouble(line.AsSpan(fields[_takerBuy]), out takerBuy);
|
||||
}
|
||||
|
||||
// No VWAP column in these dumps: 0 makes the indicators fall back to the
|
||||
// typical price, which is the correct approximation.
|
||||
bar = new Bar(time, open, high, low, close, volume, 0, trades, takerBuy);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryTime(ReadOnlySpan<char> text, out DateTime time)
|
||||
{
|
||||
text = text.Trim().Trim('"');
|
||||
|
||||
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long epoch))
|
||||
{
|
||||
// Binance dumps use milliseconds; plenty of other exporters use seconds.
|
||||
time = epoch >= MillisecondThreshold
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(epoch).UtcDateTime
|
||||
: DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(text, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out time))
|
||||
{
|
||||
time = DateTime.SpecifyKind(time, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
|
||||
time = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDouble(ReadOnlySpan<char> text, out double value) =>
|
||||
double.TryParse(text.Trim().Trim('"'), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
/// <summary>Splits on commas or semicolons without allocating substrings.</summary>
|
||||
private static IEnumerable<Range> Split(string line)
|
||||
{
|
||||
int start = 0;
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
if (line[i] is ',' or ';')
|
||||
{
|
||||
yield return new Range(start, i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
yield return new Range(start, line.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic bar-by-bar replay of strategies over historical data.
|
||||
/// <para>
|
||||
/// It drives the same <see cref="IStrategy"/> and <see cref="RiskEngine"/> instances the
|
||||
/// live engine uses, so what it measures is the code that will actually trade. The
|
||||
/// execution assumptions are deliberately pessimistic:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>entries fill at the <b>next</b> bar's open, never at the signal bar's close —
|
||||
/// there is no way to act on a close you have only just observed;</item>
|
||||
/// <item>the stop is checked before the target, so a bar that straddles both is scored
|
||||
/// as a loss;</item>
|
||||
/// <item>every fill pays slippage <i>and</i> a broker fee, in both directions;</item>
|
||||
/// <item>all symbols are merged into one chronological stream, so shared equity and the
|
||||
/// shared risk limits behave exactly as they would live.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class Replayer(BacktestSettings settings)
|
||||
{
|
||||
private sealed class SimState(string symbol, IStrategy strategy, IReadOnlyList<Bar> bars)
|
||||
{
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
public IStrategy Strategy { get; } = strategy;
|
||||
|
||||
public IReadOnlyList<Bar> Bars { get; } = bars;
|
||||
|
||||
public double Quantity { get; set; }
|
||||
|
||||
public double EntryPrice { get; set; }
|
||||
|
||||
public double LastPrice { get; set; }
|
||||
|
||||
public double EntryFee { get; set; }
|
||||
|
||||
public double Stop { get; set; } = double.NaN;
|
||||
|
||||
public double Target { get; set; } = double.NaN;
|
||||
|
||||
public DateTime EntryUtc { get; set; }
|
||||
|
||||
public int BarsHeld { get; set; }
|
||||
|
||||
public Signal PendingEntry { get; set; } = Signal.Flat;
|
||||
}
|
||||
|
||||
public BacktestSettings Settings { get; } = settings;
|
||||
|
||||
/// <summary>Progress callback: fraction complete in [0, 1]. Optional.</summary>
|
||||
public Action<double>? OnProgress { get; set; }
|
||||
|
||||
public BacktestReport Run(
|
||||
IReadOnlyList<BacktestSymbol> symbols,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<Bar>> history,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
ArgumentNullException.ThrowIfNull(history);
|
||||
|
||||
List<SimState> states = [];
|
||||
foreach (BacktestSymbol s in symbols)
|
||||
{
|
||||
if (history.TryGetValue(s.Symbol, out IReadOnlyList<Bar>? bars) && bars.Count > 0)
|
||||
{
|
||||
s.Strategy.Reset();
|
||||
states.Add(new SimState(s.Symbol, s.Strategy, bars));
|
||||
}
|
||||
}
|
||||
|
||||
if (states.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No historical data was available for any requested symbol.");
|
||||
}
|
||||
|
||||
return Simulate(states, ct);
|
||||
}
|
||||
|
||||
private BacktestReport Simulate(List<SimState> states, CancellationToken ct)
|
||||
{
|
||||
RiskEngine risk = new(Settings.Risk);
|
||||
|
||||
DateTime from = states.Min(s => s.Bars[0].TimeUtc);
|
||||
DateTime to = states.Max(s => s.Bars[^1].TimeUtc);
|
||||
|
||||
risk.StartSession(Settings.StartingEquity, DateOnly.FromDateTime(from));
|
||||
|
||||
List<ClosedTrade> trades = [];
|
||||
double equity = Settings.StartingEquity;
|
||||
double peak = equity;
|
||||
double maxDrawdown = 0;
|
||||
double totalFees = 0;
|
||||
int processed = 0;
|
||||
DateOnly session = DateOnly.FromDateTime(from);
|
||||
|
||||
(DateTime Time, int State, int Index)[] timeline = BuildTimeline(states);
|
||||
int reportEvery = Math.Max(1, timeline.Length / 100);
|
||||
|
||||
for (int step = 0; step < timeline.Length; step++)
|
||||
{
|
||||
if ((step & 0x3FFF) == 0)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
(DateTime time, int stateIndex, int barIndex) = timeline[step];
|
||||
SimState state = states[stateIndex];
|
||||
Bar bar = state.Bars[barIndex];
|
||||
state.LastPrice = bar.Close;
|
||||
processed++;
|
||||
|
||||
DateOnly today = DateOnly.FromDateTime(time);
|
||||
if (today != session)
|
||||
{
|
||||
session = today;
|
||||
risk.StartSession(equity, today);
|
||||
}
|
||||
|
||||
// 1. A pending entry from the previous bar fills at this bar's open.
|
||||
if (state.PendingEntry.IsEntry && state.Quantity == 0)
|
||||
{
|
||||
totalFees += FillPendingEntry(state, bar, risk, equity, states, ref equity);
|
||||
}
|
||||
|
||||
state.PendingEntry = Signal.Flat;
|
||||
|
||||
// 2. Protective exits, stop before target.
|
||||
if (state.Quantity != 0)
|
||||
{
|
||||
state.BarsHeld++;
|
||||
if (TryProtectiveExit(state, bar, out double exitPrice, out string exitReason))
|
||||
{
|
||||
equity += Close(state, exitPrice, bar.TimeUtc, exitReason, trades, out double fee);
|
||||
totalFees += fee;
|
||||
risk.RecordRealizedPnl(trades[^1].Pnl);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Feed the strategy.
|
||||
PositionView view = new(
|
||||
state.Symbol, state.Quantity, state.EntryPrice, bar.Close,
|
||||
state.Stop, state.Target, state.EntryUtc, state.BarsHeld, 0);
|
||||
|
||||
Signal signal = state.Strategy.OnBar(bar, view);
|
||||
|
||||
if (signal.Kind == SignalKind.Exit && state.Quantity != 0)
|
||||
{
|
||||
double price = Slip(bar.Close, state.Quantity > 0 ? Side.Sell : Side.Buy);
|
||||
equity += Close(state, price, bar.TimeUtc, signal.Reason, trades, out double fee);
|
||||
totalFees += fee;
|
||||
risk.RecordRealizedPnl(trades[^1].Pnl);
|
||||
}
|
||||
else if (signal.IsEntry && state.Quantity == 0 && state.Strategy.IsReady)
|
||||
{
|
||||
state.PendingEntry = signal;
|
||||
}
|
||||
|
||||
// 4. Mark to market and track the drawdown.
|
||||
double marked = equity + OpenPnl(states);
|
||||
peak = Math.Max(peak, marked);
|
||||
if (peak > 0)
|
||||
{
|
||||
maxDrawdown = Math.Max(maxDrawdown, (peak - marked) / peak);
|
||||
}
|
||||
|
||||
risk.UpdateEquity(marked);
|
||||
|
||||
if (step % reportEvery == 0)
|
||||
{
|
||||
OnProgress?.Invoke((double)step / timeline.Length);
|
||||
}
|
||||
}
|
||||
|
||||
// Liquidate whatever is still open at the last observed price.
|
||||
foreach (SimState state in states)
|
||||
{
|
||||
if (state.Quantity != 0)
|
||||
{
|
||||
Bar last = state.Bars[^1];
|
||||
equity += Close(state, last.Close, last.TimeUtc, "end of backtest", trades, out double fee);
|
||||
totalFees += fee;
|
||||
}
|
||||
}
|
||||
|
||||
OnProgress?.Invoke(1.0);
|
||||
|
||||
return new BacktestReport(
|
||||
Settings.StartingEquity, equity, maxDrawdown, trades, processed, from, to, totalFees);
|
||||
}
|
||||
|
||||
private static (DateTime, int, int)[] BuildTimeline(List<SimState> states)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (SimState s in states)
|
||||
{
|
||||
total += s.Bars.Count;
|
||||
}
|
||||
|
||||
(DateTime, int, int)[] timeline = new (DateTime, int, int)[total];
|
||||
int i = 0;
|
||||
for (int s = 0; s < states.Count; s++)
|
||||
{
|
||||
IReadOnlyList<Bar> bars = states[s].Bars;
|
||||
for (int b = 0; b < bars.Count; b++)
|
||||
{
|
||||
timeline[i++] = (bars[b].TimeUtc, s, b);
|
||||
}
|
||||
}
|
||||
|
||||
// A single symbol is already ordered; sorting 300k tuples for nothing is waste.
|
||||
if (states.Count > 1)
|
||||
{
|
||||
Array.Sort(timeline, static (a, b) => a.Item1.CompareTo(b.Item1));
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private double FillPendingEntry(
|
||||
SimState state, in Bar bar, RiskEngine risk, double equity, List<SimState> all, ref double cash)
|
||||
{
|
||||
Signal signal = state.PendingEntry;
|
||||
Side side = signal.EntrySide;
|
||||
double fillPrice = Slip(bar.Open, side);
|
||||
if (fillPrice <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double grossExposure = 0;
|
||||
int openPositions = 0;
|
||||
foreach (SimState s in all)
|
||||
{
|
||||
if (s.Quantity != 0)
|
||||
{
|
||||
openPositions++;
|
||||
grossExposure += Math.Abs(s.Quantity) * s.LastPrice;
|
||||
}
|
||||
}
|
||||
|
||||
EntryRequest request = new(
|
||||
state.Symbol, side, fillPrice, signal.StopPrice, signal.Strength,
|
||||
equity, equity, grossExposure, openPositions, 0, 0,
|
||||
Settings.AllowFractional, bar.TimeUtc);
|
||||
|
||||
RiskVerdict verdict = risk.ApproveEntry(request);
|
||||
if (!verdict.Approved)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double fee = Fee(verdict.Quantity * fillPrice);
|
||||
|
||||
state.Quantity = side == Side.Buy ? verdict.Quantity : -verdict.Quantity;
|
||||
state.EntryPrice = fillPrice;
|
||||
state.EntryFee = fee;
|
||||
state.Stop = verdict.StopPrice;
|
||||
state.Target = signal.TargetPrice;
|
||||
state.EntryUtc = bar.TimeUtc;
|
||||
state.BarsHeld = 0;
|
||||
|
||||
risk.RecordEntry(state.Symbol, bar.TimeUtc);
|
||||
|
||||
// The entry fee leaves the account immediately.
|
||||
cash -= fee;
|
||||
return fee;
|
||||
}
|
||||
|
||||
private static bool TryProtectiveExit(SimState state, in Bar bar, out double price, out string reason)
|
||||
{
|
||||
bool isLong = state.Quantity > 0;
|
||||
|
||||
if (!double.IsNaN(state.Stop) && state.Stop > 0)
|
||||
{
|
||||
if (isLong && bar.Low <= state.Stop)
|
||||
{
|
||||
// A gap through the stop fills at the open, not at the stop price.
|
||||
price = Math.Min(state.Stop, bar.Open);
|
||||
reason = "stop loss";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLong && bar.High >= state.Stop)
|
||||
{
|
||||
price = Math.Max(state.Stop, bar.Open);
|
||||
reason = "stop loss";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!double.IsNaN(state.Target) && state.Target > 0)
|
||||
{
|
||||
if (isLong && bar.High >= state.Target)
|
||||
{
|
||||
price = state.Target;
|
||||
reason = "take profit";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLong && bar.Low <= state.Target)
|
||||
{
|
||||
price = state.Target;
|
||||
reason = "take profit";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
price = 0;
|
||||
reason = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private double Close(
|
||||
SimState state, double exitPrice, DateTime exitUtc, string reason,
|
||||
List<ClosedTrade> trades, out double exitFee)
|
||||
{
|
||||
double gross = state.Quantity * (exitPrice - state.EntryPrice);
|
||||
exitFee = Fee(Math.Abs(state.Quantity) * exitPrice);
|
||||
double fees = state.EntryFee + exitFee;
|
||||
|
||||
trades.Add(new ClosedTrade(
|
||||
state.Symbol,
|
||||
state.Quantity > 0 ? Side.Buy : Side.Sell,
|
||||
state.EntryUtc,
|
||||
exitUtc,
|
||||
Math.Abs(state.Quantity),
|
||||
state.EntryPrice,
|
||||
exitPrice,
|
||||
gross,
|
||||
fees,
|
||||
reason));
|
||||
|
||||
state.Quantity = 0;
|
||||
state.EntryPrice = 0;
|
||||
state.EntryFee = 0;
|
||||
state.Stop = double.NaN;
|
||||
state.Target = double.NaN;
|
||||
state.BarsHeld = 0;
|
||||
|
||||
// The entry fee was already deducted when the position opened.
|
||||
return gross - exitFee;
|
||||
}
|
||||
|
||||
private static double OpenPnl(List<SimState> states)
|
||||
{
|
||||
double total = 0;
|
||||
foreach (SimState s in states)
|
||||
{
|
||||
if (s.Quantity != 0 && s.LastPrice > 0)
|
||||
{
|
||||
total += s.Quantity * (s.LastPrice - s.EntryPrice);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private double Slip(double price, Side side)
|
||||
{
|
||||
double offset = Settings.SlippageBps / 10_000.0;
|
||||
return side == Side.Buy ? price * (1 + offset) : price * (1 - offset);
|
||||
}
|
||||
|
||||
private double Fee(double notional) => Math.Abs(notional) * (Settings.FeeBps / 10_000.0);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Encelado.Core</RootNamespace>
|
||||
<AssemblyName>Encelado.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,225 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Indicators;
|
||||
|
||||
/// <summary>
|
||||
/// Kaufman's Efficiency Ratio: net directional movement divided by the total distance
|
||||
/// travelled to achieve it.
|
||||
/// <para>
|
||||
/// 1.0 means a straight line (a clean trend); 0.0 means the price ended where it
|
||||
/// started after a lot of churn. It is the engine's regime detector — trend models
|
||||
/// deserve weight when this is high, mean-reversion models when it is low.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EfficiencyRatio : IIndicator
|
||||
{
|
||||
private readonly RollingWindow<double> _closes;
|
||||
private readonly RollingWindow<double> _absChanges;
|
||||
private double _pathLength;
|
||||
private double _previousClose;
|
||||
private bool _hasPrevious;
|
||||
|
||||
public EfficiencyRatio(int period = 20)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 2);
|
||||
Period = period;
|
||||
_closes = new RollingWindow<double>(period + 1);
|
||||
_absChanges = new RollingWindow<double>(period);
|
||||
Value = double.NaN;
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _absChanges.IsFull && _closes.IsFull;
|
||||
|
||||
/// <summary>Efficiency in [0, 1]. <see cref="double.NaN"/> until warm.</summary>
|
||||
public double Value { get; private set; }
|
||||
|
||||
public double Update(double close)
|
||||
{
|
||||
_closes.Add(close);
|
||||
|
||||
if (_hasPrevious)
|
||||
{
|
||||
double change = Math.Abs(close - _previousClose);
|
||||
if (_absChanges.TryAdd(change, out double evicted))
|
||||
{
|
||||
_pathLength -= evicted;
|
||||
}
|
||||
|
||||
_pathLength += change;
|
||||
}
|
||||
|
||||
_previousClose = close;
|
||||
_hasPrevious = true;
|
||||
|
||||
if (!IsReady)
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
double netMovement = Math.Abs(_closes[0] - _closes[Period]);
|
||||
Value = _pathLength > 0 ? Math.Clamp(netMovement / _pathLength, 0, 1) : 0;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_closes.Clear();
|
||||
_absChanges.Clear();
|
||||
_pathLength = 0;
|
||||
_previousClose = 0;
|
||||
_hasPrevious = false;
|
||||
Value = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Realized volatility from log returns, annualised. Used both to size positions
|
||||
/// (volatility targeting) and to veto entries when the tape becomes untradeable.
|
||||
/// </summary>
|
||||
public sealed class RealizedVolatility : IIndicator
|
||||
{
|
||||
private readonly RollingStdDev _returns;
|
||||
private readonly double _annualizationFactor;
|
||||
private double _previousClose;
|
||||
private bool _hasPrevious;
|
||||
|
||||
/// <param name="period">Number of returns in the estimation window.</param>
|
||||
/// <param name="barsPerYear">
|
||||
/// Bars in a trading year — 525 600 for 1-minute crypto bars (24/7),
|
||||
/// 98 280 for 1-minute US equity bars (390 per day × 252).
|
||||
/// </param>
|
||||
public RealizedVolatility(int period = 60, double barsPerYear = 525_600)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 2);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(barsPerYear);
|
||||
Period = period;
|
||||
_returns = new RollingStdDev(period);
|
||||
_annualizationFactor = Math.Sqrt(barsPerYear);
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _returns.IsReady;
|
||||
|
||||
/// <summary>Annualised standard deviation of log returns (0.60 = 60% a year).</summary>
|
||||
public double Value => IsReady ? _returns.Value * _annualizationFactor : double.NaN;
|
||||
|
||||
/// <summary>The same figure without annualisation — volatility per bar.</summary>
|
||||
public double PerBar => IsReady ? _returns.Value : double.NaN;
|
||||
|
||||
public double Update(double close)
|
||||
{
|
||||
if (close <= 0)
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
if (_hasPrevious && _previousClose > 0)
|
||||
{
|
||||
_returns.Update(Math.Log(close / _previousClose));
|
||||
}
|
||||
|
||||
_previousClose = close;
|
||||
_hasPrevious = true;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_returns.Reset();
|
||||
_previousClose = 0;
|
||||
_hasPrevious = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keltner channels: an EMA envelope scaled by ATR. Paired with Bollinger bands it
|
||||
/// identifies volatility compression — the bands sitting <i>inside</i> the channel.
|
||||
/// </summary>
|
||||
public sealed class Keltner : IIndicator
|
||||
{
|
||||
private readonly Ema _middle;
|
||||
private readonly Atr _atr;
|
||||
private readonly double _multiplier;
|
||||
|
||||
public Keltner(int period = 20, double atrMultiplier = 1.5, int atrPeriod = 20)
|
||||
{
|
||||
_middle = new Ema(period);
|
||||
_atr = new Atr(atrPeriod);
|
||||
_multiplier = atrMultiplier;
|
||||
Upper = double.NaN;
|
||||
Lower = double.NaN;
|
||||
}
|
||||
|
||||
public bool IsReady => _middle.IsReady && _atr.IsReady;
|
||||
|
||||
/// <summary>The channel midline.</summary>
|
||||
public double Value => _middle.Value;
|
||||
|
||||
public double Upper { get; private set; }
|
||||
|
||||
public double Lower { get; private set; }
|
||||
|
||||
public double Width => IsReady ? Upper - Lower : double.NaN;
|
||||
|
||||
public double Update(in Bar bar)
|
||||
{
|
||||
double middle = _middle.Update(bar.Close);
|
||||
double atr = _atr.Update(bar);
|
||||
|
||||
if (IsReady)
|
||||
{
|
||||
Upper = middle + (_multiplier * atr);
|
||||
Lower = middle - (_multiplier * atr);
|
||||
}
|
||||
|
||||
return middle;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_middle.Reset();
|
||||
_atr.Reset();
|
||||
Upper = Lower = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rolling z-score: how many standard deviations the latest sample sits from its own mean.</summary>
|
||||
public sealed class RollingZScore : IIndicator
|
||||
{
|
||||
private readonly Sma _mean;
|
||||
private readonly RollingStdDev _deviation;
|
||||
|
||||
public RollingZScore(int period)
|
||||
{
|
||||
_mean = new Sma(period);
|
||||
_deviation = new RollingStdDev(period);
|
||||
Value = double.NaN;
|
||||
}
|
||||
|
||||
public bool IsReady => _mean.IsReady && _deviation.IsReady;
|
||||
|
||||
public double Value { get; private set; }
|
||||
|
||||
public double Mean => _mean.Value;
|
||||
|
||||
public double StandardDeviation => _deviation.Value;
|
||||
|
||||
public double Update(double sample)
|
||||
{
|
||||
double mean = _mean.Update(sample);
|
||||
double sd = _deviation.Update(sample);
|
||||
|
||||
Value = IsReady && sd > 0 ? (sample - mean) / sd : 0;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_mean.Reset();
|
||||
_deviation.Reset();
|
||||
Value = double.NaN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Indicators;
|
||||
|
||||
/// <summary>
|
||||
/// Common shape for every indicator in the engine. All implementations are
|
||||
/// <b>incremental</b>: one <c>Update</c> is O(1) amortised and allocation free,
|
||||
/// so adding symbols scales linearly instead of quadratically.
|
||||
/// </summary>
|
||||
public interface IIndicator
|
||||
{
|
||||
bool IsReady { get; }
|
||||
|
||||
double Value { get; }
|
||||
|
||||
void Reset();
|
||||
}
|
||||
|
||||
/// <summary>Simple moving average over a fixed window.</summary>
|
||||
public sealed class Sma : IIndicator
|
||||
{
|
||||
private const int RecomputeEvery = 4096;
|
||||
|
||||
private readonly RollingWindow<double> _window;
|
||||
private double _sum;
|
||||
private int _sinceRecompute;
|
||||
|
||||
public Sma(int period)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 1);
|
||||
Period = period;
|
||||
_window = new RollingWindow<double>(period);
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _window.IsFull;
|
||||
|
||||
public double Value => _window.IsEmpty ? double.NaN : _sum / _window.Count;
|
||||
|
||||
public double Update(double sample)
|
||||
{
|
||||
if (_window.TryAdd(sample, out double evicted))
|
||||
{
|
||||
_sum -= evicted;
|
||||
}
|
||||
|
||||
_sum += sample;
|
||||
|
||||
// Running sums drift after millions of updates; re-derive periodically.
|
||||
if (++_sinceRecompute >= RecomputeEvery)
|
||||
{
|
||||
_sinceRecompute = 0;
|
||||
double exact = 0;
|
||||
for (int i = 0; i < _window.Count; i++)
|
||||
{
|
||||
exact += _window[i];
|
||||
}
|
||||
|
||||
_sum = exact;
|
||||
}
|
||||
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_window.Clear();
|
||||
_sum = 0;
|
||||
_sinceRecompute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exponential moving average, seeded with the SMA of the first <c>period</c>
|
||||
/// samples (the conventional warm-up used by charting packages).
|
||||
/// </summary>
|
||||
public sealed class Ema : IIndicator
|
||||
{
|
||||
private readonly double _alpha;
|
||||
private double _value;
|
||||
private double _seedSum;
|
||||
private int _seedCount;
|
||||
|
||||
public Ema(int period)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 1);
|
||||
Period = period;
|
||||
_alpha = 2.0 / (period + 1);
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady { get; private set; }
|
||||
|
||||
public double Value => _seedCount == 0 ? double.NaN : _value;
|
||||
|
||||
public double Update(double sample)
|
||||
{
|
||||
if (!IsReady)
|
||||
{
|
||||
_seedSum += sample;
|
||||
_seedCount++;
|
||||
_value = _seedSum / _seedCount;
|
||||
if (_seedCount >= Period)
|
||||
{
|
||||
IsReady = true;
|
||||
}
|
||||
|
||||
return _value;
|
||||
}
|
||||
|
||||
_value += _alpha * (sample - _value);
|
||||
return _value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
IsReady = false;
|
||||
_value = 0;
|
||||
_seedSum = 0;
|
||||
_seedCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wilder's Relative Strength Index. Needs <c>period + 1</c> samples to become ready.</summary>
|
||||
public sealed class Rsi : IIndicator
|
||||
{
|
||||
private double _prev;
|
||||
private double _avgGain;
|
||||
private double _avgLoss;
|
||||
private double _seedGain;
|
||||
private double _seedLoss;
|
||||
private int _samples;
|
||||
|
||||
public Rsi(int period)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 2);
|
||||
Period = period;
|
||||
Value = double.NaN;
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady { get; private set; }
|
||||
|
||||
public double Value { get; private set; }
|
||||
|
||||
public double Update(double close)
|
||||
{
|
||||
if (_samples == 0)
|
||||
{
|
||||
_prev = close;
|
||||
_samples = 1;
|
||||
return Value;
|
||||
}
|
||||
|
||||
double delta = close - _prev;
|
||||
_prev = close;
|
||||
double gain = delta > 0 ? delta : 0;
|
||||
double loss = delta < 0 ? -delta : 0;
|
||||
_samples++;
|
||||
|
||||
int deltas = _samples - 1;
|
||||
if (deltas < Period)
|
||||
{
|
||||
_seedGain += gain;
|
||||
_seedLoss += loss;
|
||||
return Value;
|
||||
}
|
||||
|
||||
if (deltas == Period)
|
||||
{
|
||||
_seedGain += gain;
|
||||
_seedLoss += loss;
|
||||
_avgGain = _seedGain / Period;
|
||||
_avgLoss = _seedLoss / Period;
|
||||
IsReady = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
double w = Period - 1;
|
||||
_avgGain = ((_avgGain * w) + gain) / Period;
|
||||
_avgLoss = ((_avgLoss * w) + loss) / Period;
|
||||
}
|
||||
|
||||
Value = _avgLoss <= 0
|
||||
? (_avgGain <= 0 ? 50.0 : 100.0)
|
||||
: 100.0 - (100.0 / (1.0 + (_avgGain / _avgLoss)));
|
||||
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
IsReady = false;
|
||||
Value = double.NaN;
|
||||
_prev = _avgGain = _avgLoss = _seedGain = _seedLoss = 0;
|
||||
_samples = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moving Average Convergence/Divergence with its signal line and histogram.</summary>
|
||||
public sealed class Macd : IIndicator
|
||||
{
|
||||
private readonly Ema _fast;
|
||||
private readonly Ema _slow;
|
||||
private readonly Ema _signal;
|
||||
|
||||
public Macd(int fastPeriod = 12, int slowPeriod = 26, int signalPeriod = 9)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(fastPeriod, slowPeriod);
|
||||
_fast = new Ema(fastPeriod);
|
||||
_slow = new Ema(slowPeriod);
|
||||
_signal = new Ema(signalPeriod);
|
||||
}
|
||||
|
||||
/// <summary>The MACD line (fast EMA minus slow EMA).</summary>
|
||||
public double Value { get; private set; } = double.NaN;
|
||||
|
||||
public double Signal { get; private set; } = double.NaN;
|
||||
|
||||
public double Histogram { get; private set; } = double.NaN;
|
||||
|
||||
public bool IsReady => _slow.IsReady && _signal.IsReady;
|
||||
|
||||
public double Update(double close)
|
||||
{
|
||||
double fast = _fast.Update(close);
|
||||
double slow = _slow.Update(close);
|
||||
if (!_slow.IsReady)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
Value = fast - slow;
|
||||
Signal = _signal.Update(Value);
|
||||
Histogram = Value - Signal;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_fast.Reset();
|
||||
_slow.Reset();
|
||||
_signal.Reset();
|
||||
Value = Signal = Histogram = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wilder's Average True Range — the engine's volatility unit for stops and sizing.</summary>
|
||||
public sealed class Atr : IIndicator
|
||||
{
|
||||
private double _prevClose;
|
||||
private double _seedSum;
|
||||
private int _samples;
|
||||
private bool _hasPrevClose;
|
||||
|
||||
public Atr(int period = 14)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 1);
|
||||
Period = period;
|
||||
Value = double.NaN;
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady { get; private set; }
|
||||
|
||||
public double Value { get; private set; }
|
||||
|
||||
public double Update(in Bar bar)
|
||||
{
|
||||
double tr;
|
||||
if (!_hasPrevClose)
|
||||
{
|
||||
tr = bar.High - bar.Low;
|
||||
_hasPrevClose = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
double hl = bar.High - bar.Low;
|
||||
double hc = Math.Abs(bar.High - _prevClose);
|
||||
double lc = Math.Abs(bar.Low - _prevClose);
|
||||
tr = Math.Max(hl, Math.Max(hc, lc));
|
||||
}
|
||||
|
||||
_prevClose = bar.Close;
|
||||
_samples++;
|
||||
|
||||
if (_samples < Period)
|
||||
{
|
||||
_seedSum += tr;
|
||||
return Value;
|
||||
}
|
||||
|
||||
if (_samples == Period)
|
||||
{
|
||||
_seedSum += tr;
|
||||
Value = _seedSum / Period;
|
||||
IsReady = true;
|
||||
return Value;
|
||||
}
|
||||
|
||||
Value = ((Value * (Period - 1)) + tr) / Period;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
IsReady = false;
|
||||
Value = double.NaN;
|
||||
_prevClose = _seedSum = 0;
|
||||
_samples = 0;
|
||||
_hasPrevClose = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sample standard deviation over a rolling window.</summary>
|
||||
public sealed class RollingStdDev : IIndicator
|
||||
{
|
||||
private const int RecomputeEvery = 4096;
|
||||
|
||||
private readonly RollingWindow<double> _window;
|
||||
private double _sum;
|
||||
private double _sumSq;
|
||||
private int _sinceRecompute;
|
||||
|
||||
public RollingStdDev(int period)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 2);
|
||||
Period = period;
|
||||
_window = new RollingWindow<double>(period);
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _window.IsFull;
|
||||
|
||||
public double Mean => _window.IsEmpty ? double.NaN : _sum / _window.Count;
|
||||
|
||||
public double Value
|
||||
{
|
||||
get
|
||||
{
|
||||
int n = _window.Count;
|
||||
if (n < 2)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double variance = (_sumSq - (_sum * _sum / n)) / (n - 1);
|
||||
return variance <= 0 ? 0 : Math.Sqrt(variance);
|
||||
}
|
||||
}
|
||||
|
||||
public double Update(double sample)
|
||||
{
|
||||
if (_window.TryAdd(sample, out double evicted))
|
||||
{
|
||||
_sum -= evicted;
|
||||
_sumSq -= evicted * evicted;
|
||||
}
|
||||
|
||||
_sum += sample;
|
||||
_sumSq += sample * sample;
|
||||
|
||||
if (++_sinceRecompute >= RecomputeEvery)
|
||||
{
|
||||
_sinceRecompute = 0;
|
||||
double s = 0, sq = 0;
|
||||
for (int i = 0; i < _window.Count; i++)
|
||||
{
|
||||
double v = _window[i];
|
||||
s += v;
|
||||
sq += v * v;
|
||||
}
|
||||
|
||||
_sum = s;
|
||||
_sumSq = sq;
|
||||
}
|
||||
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_window.Clear();
|
||||
_sum = _sumSq = 0;
|
||||
_sinceRecompute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bollinger bands built on top of <see cref="Sma"/> and <see cref="RollingStdDev"/>.</summary>
|
||||
public sealed class BollingerBands : IIndicator
|
||||
{
|
||||
private readonly Sma _sma;
|
||||
private readonly RollingStdDev _sd;
|
||||
private readonly double _k;
|
||||
|
||||
public BollingerBands(int period = 20, double stdDevMultiplier = 2.0)
|
||||
{
|
||||
_sma = new Sma(period);
|
||||
_sd = new RollingStdDev(period);
|
||||
_k = stdDevMultiplier;
|
||||
}
|
||||
|
||||
public bool IsReady => _sma.IsReady && _sd.IsReady;
|
||||
|
||||
/// <summary>The middle band (the moving average).</summary>
|
||||
public double Value => _sma.Value;
|
||||
|
||||
public double Upper { get; private set; } = double.NaN;
|
||||
|
||||
public double Lower { get; private set; } = double.NaN;
|
||||
|
||||
/// <summary>The dispersion the bands are built from — the unit for z-scoring a deviation.</summary>
|
||||
public double StandardDeviation => _sd.Value;
|
||||
|
||||
/// <summary>Band width relative to the middle band — a normalised volatility reading.</summary>
|
||||
public double Width => IsReady && _sma.Value > 0 ? (Upper - Lower) / _sma.Value : double.NaN;
|
||||
|
||||
/// <summary>Where <paramref name="price"/> sits inside the bands: 0 = lower, 1 = upper.</summary>
|
||||
public double PercentB(double price)
|
||||
{
|
||||
double span = Upper - Lower;
|
||||
return span > 0 ? (price - Lower) / span : 0.5;
|
||||
}
|
||||
|
||||
public double Update(double sample)
|
||||
{
|
||||
double mean = _sma.Update(sample);
|
||||
double sd = _sd.Update(sample);
|
||||
if (IsReady)
|
||||
{
|
||||
Upper = mean + (_k * sd);
|
||||
Lower = mean - (_k * sd);
|
||||
}
|
||||
|
||||
return mean;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_sma.Reset();
|
||||
_sd.Reset();
|
||||
Upper = Lower = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Donchian channel (rolling highest-high / lowest-low). Extremes are cached and
|
||||
/// only rescanned when the evicted bar was itself the extreme, so the common case
|
||||
/// is O(1).
|
||||
/// </summary>
|
||||
public sealed class Donchian : IIndicator
|
||||
{
|
||||
private readonly RollingWindow<double> _highs;
|
||||
private readonly RollingWindow<double> _lows;
|
||||
|
||||
public Donchian(int period)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 1);
|
||||
Period = period;
|
||||
_highs = new RollingWindow<double>(period);
|
||||
_lows = new RollingWindow<double>(period);
|
||||
Upper = double.NegativeInfinity;
|
||||
Lower = double.PositiveInfinity;
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _highs.IsFull;
|
||||
|
||||
public double Upper { get; private set; }
|
||||
|
||||
public double Lower { get; private set; }
|
||||
|
||||
/// <summary>Channel midpoint.</summary>
|
||||
public double Value => IsReady ? (Upper + Lower) * 0.5 : double.NaN;
|
||||
|
||||
public void Update(double high, double low)
|
||||
{
|
||||
// Both windows share the same capacity and are fed in lockstep, so a single
|
||||
// eviction flag describes both.
|
||||
bool evicted = _highs.TryAdd(high, out double oldHigh);
|
||||
_lows.TryAdd(low, out double oldLow);
|
||||
|
||||
if (high >= Upper)
|
||||
{
|
||||
Upper = high;
|
||||
}
|
||||
else if (evicted && oldHigh >= Upper)
|
||||
{
|
||||
Upper = double.NegativeInfinity;
|
||||
for (int i = 0; i < _highs.Count; i++)
|
||||
{
|
||||
if (_highs[i] > Upper)
|
||||
{
|
||||
Upper = _highs[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (low <= Lower)
|
||||
{
|
||||
Lower = low;
|
||||
}
|
||||
else if (evicted && oldLow <= Lower)
|
||||
{
|
||||
Lower = double.PositiveInfinity;
|
||||
for (int i = 0; i < _lows.Count; i++)
|
||||
{
|
||||
if (_lows[i] < Lower)
|
||||
{
|
||||
Lower = _lows[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(in Bar bar) => Update(bar.High, bar.Low);
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_highs.Clear();
|
||||
_lows.Clear();
|
||||
Upper = double.NegativeInfinity;
|
||||
Lower = double.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Session-anchored VWAP. Call <see cref="Reset"/> at every session open.</summary>
|
||||
public sealed class SessionVwap : IIndicator
|
||||
{
|
||||
private double _cumulativePv;
|
||||
private double _cumulativeVolume;
|
||||
|
||||
public bool IsReady => _cumulativeVolume > 0;
|
||||
|
||||
public double Value => _cumulativeVolume > 0 ? _cumulativePv / _cumulativeVolume : double.NaN;
|
||||
|
||||
public double Update(in Bar bar)
|
||||
{
|
||||
if (bar.Volume <= 0)
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
double price = bar.Vwap > 0 ? bar.Vwap : bar.TypicalPrice;
|
||||
_cumulativePv += price * bar.Volume;
|
||||
_cumulativeVolume += bar.Volume;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_cumulativePv = 0;
|
||||
_cumulativeVolume = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Indicators;
|
||||
|
||||
/// <summary>
|
||||
/// Volume Weighted Average Price over a rolling window.
|
||||
/// <para>
|
||||
/// The classic VWAP is anchored to a session, which crypto does not have — it trades
|
||||
/// continuously. A rolling window gives the same thing conceptually (the average price
|
||||
/// institutional flow actually paid over the recent past) without an arbitrary daily
|
||||
/// reset, and it is what price is measured against to decide whether buyers or sellers
|
||||
/// currently have the upper hand.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RollingVwap : IIndicator
|
||||
{
|
||||
private readonly RollingWindow<double> _priceVolume;
|
||||
private readonly RollingWindow<double> _volume;
|
||||
private double _sumPv;
|
||||
private double _sumVolume;
|
||||
|
||||
public RollingVwap(int period)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 2);
|
||||
Period = period;
|
||||
_priceVolume = new RollingWindow<double>(period);
|
||||
_volume = new RollingWindow<double>(period);
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _volume.IsFull && _sumVolume > 0;
|
||||
|
||||
public double Value => _sumVolume > 0 ? _sumPv / _sumVolume : double.NaN;
|
||||
|
||||
public double Update(in Bar bar)
|
||||
{
|
||||
double price = bar.Vwap > 0 ? bar.Vwap : bar.TypicalPrice;
|
||||
double volume = Math.Max(0, bar.Volume);
|
||||
double pv = price * volume;
|
||||
|
||||
if (_priceVolume.TryAdd(pv, out double oldPv))
|
||||
{
|
||||
_sumPv -= oldPv;
|
||||
}
|
||||
|
||||
if (_volume.TryAdd(volume, out double oldVolume))
|
||||
{
|
||||
_sumVolume -= oldVolume;
|
||||
}
|
||||
|
||||
_sumPv += pv;
|
||||
_sumVolume += volume;
|
||||
|
||||
// Guard against drift pushing the running sums slightly negative.
|
||||
if (_sumVolume < 0)
|
||||
{
|
||||
_sumVolume = 0;
|
||||
}
|
||||
|
||||
return Value;
|
||||
}
|
||||
|
||||
/// <summary>Distance of a price from the VWAP, as a fraction of the VWAP.</summary>
|
||||
public double RelativeDistance(double price) =>
|
||||
IsReady && Value > 0 ? (price - Value) / Value : 0;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_priceVolume.Clear();
|
||||
_volume.Clear();
|
||||
_sumPv = 0;
|
||||
_sumVolume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cumulative Volume Delta over a rolling window, expressed as a z-score.
|
||||
/// <para>
|
||||
/// The raw delta is aggressive buy volume minus aggressive sell volume. Summed over a
|
||||
/// window it says whether market orders have net lifted offers or hit bids. The
|
||||
/// absolute figure is meaningless across regimes — a thousand BTC of net buying means
|
||||
/// something different in 2018 than in 2025 — so it is standardised against its own
|
||||
/// recent distribution. That z-score is what the strategy thresholds on.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CumulativeVolumeDelta : IIndicator
|
||||
{
|
||||
private readonly RollingWindow<double> _deltas;
|
||||
private readonly RollingStdDev _cvdDistribution;
|
||||
private double _sum;
|
||||
|
||||
public CumulativeVolumeDelta(int period, int normalisationPeriod)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, 2);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(normalisationPeriod, 2);
|
||||
Period = period;
|
||||
_deltas = new RollingWindow<double>(period);
|
||||
_cvdDistribution = new RollingStdDev(normalisationPeriod);
|
||||
}
|
||||
|
||||
public int Period { get; }
|
||||
|
||||
public bool IsReady => _deltas.IsFull && _cvdDistribution.IsReady;
|
||||
|
||||
/// <summary>Standardised CVD. Positive means net aggressive buying.</summary>
|
||||
public double Value { get; private set; }
|
||||
|
||||
/// <summary>The raw rolling sum of deltas, in units of base volume.</summary>
|
||||
public double Cumulative => _sum;
|
||||
|
||||
/// <summary>True when the bars being fed actually carry an aggressor breakdown.</summary>
|
||||
public bool HasFlowData { get; private set; }
|
||||
|
||||
public double Update(in Bar bar)
|
||||
{
|
||||
if (bar.HasOrderFlow)
|
||||
{
|
||||
HasFlowData = true;
|
||||
}
|
||||
|
||||
double delta = bar.Delta;
|
||||
|
||||
if (_deltas.TryAdd(delta, out double evicted))
|
||||
{
|
||||
_sum -= evicted;
|
||||
}
|
||||
|
||||
_sum += delta;
|
||||
_cvdDistribution.Update(_sum);
|
||||
|
||||
double dispersion = _cvdDistribution.Value;
|
||||
Value = IsReady && dispersion > 0 ? (_sum - _cvdDistribution.Mean) / dispersion : 0;
|
||||
return Value;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_deltas.Clear();
|
||||
_cvdDistribution.Reset();
|
||||
_sum = 0;
|
||||
Value = 0;
|
||||
HasFlowData = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Encelado.Core.Indicators;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-capacity circular buffer. Allocated once per symbol at startup and never
|
||||
/// resized, so the indicator hot path performs zero allocations.
|
||||
/// Index 0 is the most recent item.
|
||||
/// </summary>
|
||||
public sealed class RollingWindow<T>
|
||||
{
|
||||
private readonly T[] _buffer;
|
||||
private int _head;
|
||||
private int _count;
|
||||
|
||||
public RollingWindow(int capacity)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_buffer = new T[capacity];
|
||||
}
|
||||
|
||||
public int Capacity => _buffer.Length;
|
||||
|
||||
public int Count => _count;
|
||||
|
||||
public bool IsFull => _count == _buffer.Length;
|
||||
|
||||
public bool IsEmpty => _count == 0;
|
||||
|
||||
/// <summary>Appends an item, evicting the oldest one once the window is full.</summary>
|
||||
/// <returns><see langword="true"/> when an item was evicted (i.e. the window was already full).</returns>
|
||||
public bool TryAdd(T item, out T evicted)
|
||||
{
|
||||
bool wasFull = IsFull;
|
||||
evicted = wasFull ? _buffer[_head] : default!;
|
||||
_buffer[_head] = item;
|
||||
_head = _head + 1 == _buffer.Length ? 0 : _head + 1;
|
||||
if (!wasFull)
|
||||
{
|
||||
_count++;
|
||||
}
|
||||
|
||||
return wasFull;
|
||||
}
|
||||
|
||||
public void Add(T item) => TryAdd(item, out _);
|
||||
|
||||
/// <summary>Most-recent-first indexer: <c>this[0]</c> is the latest value.</summary>
|
||||
public T this[int index]
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get
|
||||
{
|
||||
if ((uint)index >= (uint)_count)
|
||||
{
|
||||
ThrowIndexOutOfRange(index);
|
||||
}
|
||||
|
||||
int i = _head - 1 - index;
|
||||
if (i < 0)
|
||||
{
|
||||
i += _buffer.Length;
|
||||
}
|
||||
|
||||
return _buffer[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The most recently added item.</summary>
|
||||
public T Newest => this[0];
|
||||
|
||||
/// <summary>The oldest item still inside the window.</summary>
|
||||
public T Oldest => this[_count - 1];
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Array.Clear(_buffer);
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
private static void ThrowIndexOutOfRange(int index) =>
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, "Index is outside the populated part of the window.");
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
namespace Encelado.Core.Market;
|
||||
|
||||
/// <summary>
|
||||
/// Bar aggregation intervals supported by the engine. The numeric value is the
|
||||
/// bar length in seconds, so <see cref="TimeFrameExtensions.Seconds"/> is free.
|
||||
/// </summary>
|
||||
public enum TimeFrame
|
||||
{
|
||||
OneMinute = 60,
|
||||
FiveMinutes = 300,
|
||||
FifteenMinutes = 900,
|
||||
OneHour = 3600,
|
||||
OneDay = 86_400,
|
||||
}
|
||||
|
||||
public static class TimeFrameExtensions
|
||||
{
|
||||
public static int Seconds(this TimeFrame tf) => (int)tf;
|
||||
|
||||
/// <summary>Alpaca wire representation of the timeframe (e.g. <c>5Min</c>).</summary>
|
||||
public static string ToAlpaca(this TimeFrame tf) => tf switch
|
||||
{
|
||||
TimeFrame.OneMinute => "1Min",
|
||||
TimeFrame.FiveMinutes => "5Min",
|
||||
TimeFrame.FifteenMinutes => "15Min",
|
||||
TimeFrame.OneHour => "1Hour",
|
||||
TimeFrame.OneDay => "1Day",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tf), tf, "Unsupported timeframe."),
|
||||
};
|
||||
|
||||
public static bool TryParse(string? text, out TimeFrame tf)
|
||||
{
|
||||
switch (text?.Trim().ToUpperInvariant())
|
||||
{
|
||||
case "1MIN" or "1M" or "MINUTE": tf = TimeFrame.OneMinute; return true;
|
||||
case "5MIN" or "5M": tf = TimeFrame.FiveMinutes; return true;
|
||||
case "15MIN" or "15M": tf = TimeFrame.FifteenMinutes; return true;
|
||||
case "1HOUR" or "1H" or "HOUR": tf = TimeFrame.OneHour; return true;
|
||||
case "1DAY" or "1D" or "DAY": tf = TimeFrame.OneDay; return true;
|
||||
default: tf = TimeFrame.OneMinute; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which asset universe a symbol belongs to. Drives endpoint + session rules.</summary>
|
||||
public enum AssetClass : byte
|
||||
{
|
||||
UsEquity = 0,
|
||||
Crypto = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OHLCV bar. Prices are <see cref="double"/> on purpose: the whole indicator
|
||||
/// path is floating point and double carries 15+ significant digits, far beyond
|
||||
/// the 4-6 decimals any venue quotes. Money that must round-trip exactly (order
|
||||
/// prices, account balances) stays <c>decimal</c> in the REST layer.
|
||||
/// </summary>
|
||||
public readonly record struct Bar(
|
||||
DateTime TimeUtc,
|
||||
double Open,
|
||||
double High,
|
||||
double Low,
|
||||
double Close,
|
||||
double Volume,
|
||||
double Vwap,
|
||||
int TradeCount,
|
||||
double TakerBuyVolume = 0)
|
||||
{
|
||||
public double TypicalPrice => (High + Low + Close) / 3.0;
|
||||
|
||||
public double Range => High - Low;
|
||||
|
||||
public bool IsBullish => Close >= Open;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bar carries the aggressor breakdown needed for order-flow analysis.
|
||||
/// Exchange dumps provide it; a bar rebuilt from OHLCV alone does not.
|
||||
/// </summary>
|
||||
public bool HasOrderFlow => TakerBuyVolume > 0 && Volume > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Volume delta: aggressive buying minus aggressive selling.
|
||||
/// <para>
|
||||
/// Taker sells are whatever is left of the volume after taker buys, so
|
||||
/// <c>delta = takerBuy − (volume − takerBuy) = 2·takerBuy − volume</c>. A positive
|
||||
/// delta means market orders were lifting offers; negative means they were hitting
|
||||
/// bids. Price direction alone cannot distinguish a breakout that aggressive flow
|
||||
/// is driving from one that passive limit orders are quietly absorbing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public double Delta => HasOrderFlow ? (2 * TakerBuyVolume) - Volume : 0;
|
||||
}
|
||||
|
||||
/// <summary>Top-of-book snapshot.</summary>
|
||||
public readonly record struct Quote(
|
||||
DateTime TimeUtc,
|
||||
double BidPrice,
|
||||
double BidSize,
|
||||
double AskPrice,
|
||||
double AskSize)
|
||||
{
|
||||
public double Mid => (BidPrice + AskPrice) * 0.5;
|
||||
|
||||
public double Spread => AskPrice - BidPrice;
|
||||
|
||||
/// <summary>Spread as a fraction of the mid price. Returns +inf on a crossed/empty book.</summary>
|
||||
public double RelativeSpread
|
||||
{
|
||||
get
|
||||
{
|
||||
double mid = Mid;
|
||||
return mid > 0 ? (AskPrice - BidPrice) / mid : double.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsValid => BidPrice > 0 && AskPrice > 0 && AskPrice >= BidPrice;
|
||||
}
|
||||
|
||||
/// <summary>A single executed print on the tape.</summary>
|
||||
public readonly record struct Tick(
|
||||
DateTime TimeUtc,
|
||||
double Price,
|
||||
double Size);
|
||||
|
||||
/// <summary>Direction of an order or position.</summary>
|
||||
public enum Side : sbyte
|
||||
{
|
||||
Sell = -1,
|
||||
None = 0,
|
||||
Buy = 1,
|
||||
}
|
||||
|
||||
public enum OrderType : byte
|
||||
{
|
||||
Market = 0,
|
||||
Limit = 1,
|
||||
Stop = 2,
|
||||
StopLimit = 3,
|
||||
TrailingStop = 4,
|
||||
}
|
||||
|
||||
public enum TimeInForce : byte
|
||||
{
|
||||
/// <summary>Day order — the default for equities.</summary>
|
||||
Day = 0,
|
||||
GoodTillCanceled = 1,
|
||||
ImmediateOrCancel = 2,
|
||||
FillOrKill = 3,
|
||||
Opening = 4,
|
||||
Closing = 5,
|
||||
}
|
||||
|
||||
public static class MarketEnumExtensions
|
||||
{
|
||||
public static string ToAlpaca(this Side side) => side switch
|
||||
{
|
||||
Side.Buy => "buy",
|
||||
Side.Sell => "sell",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(side), side, "Side.None is not orderable."),
|
||||
};
|
||||
|
||||
public static string ToAlpaca(this OrderType type) => type switch
|
||||
{
|
||||
OrderType.Market => "market",
|
||||
OrderType.Limit => "limit",
|
||||
OrderType.Stop => "stop",
|
||||
OrderType.StopLimit => "stop_limit",
|
||||
OrderType.TrailingStop => "trailing_stop",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
|
||||
};
|
||||
|
||||
public static string ToAlpaca(this TimeInForce tif) => tif switch
|
||||
{
|
||||
TimeInForce.Day => "day",
|
||||
TimeInForce.GoodTillCanceled => "gtc",
|
||||
TimeInForce.ImmediateOrCancel => "ioc",
|
||||
TimeInForce.FillOrKill => "fok",
|
||||
TimeInForce.Opening => "opg",
|
||||
TimeInForce.Closing => "cls",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tif), tif, null),
|
||||
};
|
||||
|
||||
public static Side Opposite(this Side side) => side switch
|
||||
{
|
||||
Side.Buy => Side.Sell,
|
||||
Side.Sell => Side.Buy,
|
||||
_ => Side.None,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Portfolio;
|
||||
|
||||
/// <summary>Outcome of applying a fill, useful for journalling and daily PnL accounting.</summary>
|
||||
public readonly record struct FillResult(
|
||||
string Symbol,
|
||||
double QuantityBefore,
|
||||
double QuantityAfter,
|
||||
double RealizedPnlDelta,
|
||||
bool Opened,
|
||||
bool Closed,
|
||||
bool Flipped);
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative in-process view of open positions.
|
||||
/// <para>
|
||||
/// Writes come from a single consumer (the trade-updates stream) but reads happen
|
||||
/// on the market-data path, so the map is concurrent and each position is only
|
||||
/// mutated under <see cref="_writeLock"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PortfolioBook
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Position> _positions = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _writeLock = new();
|
||||
|
||||
public int OpenPositionCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int n = 0;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
if (p.IsOpen)
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public double GrossExposure
|
||||
{
|
||||
get
|
||||
{
|
||||
double total = 0;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
total += p.GrossExposure;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public double TotalUnrealizedPnl
|
||||
{
|
||||
get
|
||||
{
|
||||
double total = 0;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
total += p.UnrealizedPnl;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public double TotalRealizedPnl
|
||||
{
|
||||
get
|
||||
{
|
||||
double total = 0;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
total += p.RealizedPnl;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<Position> Positions => _positions.Values;
|
||||
|
||||
public Position GetOrCreate(string symbol) =>
|
||||
_positions.GetOrAdd(symbol, static s => new Position(s));
|
||||
|
||||
public bool TryGet(string symbol, out Position position) =>
|
||||
_positions.TryGetValue(symbol, out position!);
|
||||
|
||||
public PositionView View(string symbol) =>
|
||||
_positions.TryGetValue(symbol, out Position? p) ? p.ToView() : PositionView.Flat(symbol);
|
||||
|
||||
/// <summary>Marks a symbol to a fresh price. Called on every trade/quote tick.</summary>
|
||||
public void Mark(string symbol, double price)
|
||||
{
|
||||
if (price > 0 && _positions.TryGetValue(symbol, out Position? p))
|
||||
{
|
||||
p.LastPrice = price;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnBarClosed(string symbol)
|
||||
{
|
||||
if (_positions.TryGetValue(symbol, out Position? p) && p.IsOpen)
|
||||
{
|
||||
p.BarsHeld++;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetProtection(string symbol, double stopPrice, double targetPrice)
|
||||
{
|
||||
Position p = GetOrCreate(symbol);
|
||||
lock (_writeLock)
|
||||
{
|
||||
p.StopPrice = stopPrice;
|
||||
p.TargetPrice = targetPrice;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an execution to the book, handling adds, partial reductions, closes
|
||||
/// and reversals in one place. Returns the realized PnL produced by the fill.
|
||||
/// </summary>
|
||||
public FillResult ApplyFill(string symbol, Side side, double quantity, double price, DateTime timeUtc)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbol);
|
||||
if (quantity <= 0 || price <= 0 || side == Side.None)
|
||||
{
|
||||
return new FillResult(symbol, 0, 0, 0, false, false, false);
|
||||
}
|
||||
|
||||
Position p = GetOrCreate(symbol);
|
||||
|
||||
lock (_writeLock)
|
||||
{
|
||||
double before = p.Quantity;
|
||||
double signed = side == Side.Buy ? quantity : -quantity;
|
||||
double after = before + signed;
|
||||
double realized = 0;
|
||||
|
||||
if (before == 0 || Math.Sign(before) == Math.Sign(signed))
|
||||
{
|
||||
// Opening or scaling into the same direction: blend the entry price.
|
||||
double absBefore = Math.Abs(before);
|
||||
double absAdded = Math.Abs(signed);
|
||||
p.AverageEntryPrice = absBefore + absAdded > 0
|
||||
? ((p.AverageEntryPrice * absBefore) + (price * absAdded)) / (absBefore + absAdded)
|
||||
: price;
|
||||
|
||||
if (before == 0)
|
||||
{
|
||||
p.OpenedAtUtc = timeUtc;
|
||||
p.BarsHeld = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reducing, closing or flipping: realize PnL on the closed portion.
|
||||
double closedQty = Math.Min(Math.Abs(before), Math.Abs(signed));
|
||||
realized = closedQty * (price - p.AverageEntryPrice) * Math.Sign(before);
|
||||
p.RealizedPnl += realized;
|
||||
|
||||
if (after == 0)
|
||||
{
|
||||
p.AverageEntryPrice = 0;
|
||||
p.StopPrice = double.NaN;
|
||||
p.TargetPrice = double.NaN;
|
||||
p.BarsHeld = 0;
|
||||
p.EntryOrderId = null;
|
||||
}
|
||||
else if (Math.Sign(after) != Math.Sign(before))
|
||||
{
|
||||
// Reversal: the residual quantity is a brand new position.
|
||||
p.AverageEntryPrice = price;
|
||||
p.OpenedAtUtc = timeUtc;
|
||||
p.BarsHeld = 0;
|
||||
p.StopPrice = double.NaN;
|
||||
p.TargetPrice = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
p.Quantity = Math.Abs(after) < 1e-9 ? 0 : after;
|
||||
p.LastPrice = price;
|
||||
p.LastFillUtc = timeUtc;
|
||||
|
||||
return new FillResult(
|
||||
symbol,
|
||||
before,
|
||||
p.Quantity,
|
||||
realized,
|
||||
Opened: before == 0 && p.Quantity != 0,
|
||||
Closed: before != 0 && p.Quantity == 0,
|
||||
Flipped: before != 0 && p.Quantity != 0 && Math.Sign(before) != Math.Sign(p.Quantity));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overwrites local state with the broker's truth. Used by the reconciler so a
|
||||
/// missed websocket message can never leave the bot trading a phantom position.
|
||||
/// </summary>
|
||||
public void Reconcile(string symbol, double quantity, double averageEntryPrice, double lastPrice)
|
||||
{
|
||||
Position p = GetOrCreate(symbol);
|
||||
lock (_writeLock)
|
||||
{
|
||||
if (p.Quantity == 0 && quantity != 0)
|
||||
{
|
||||
p.OpenedAtUtc = DateTime.UtcNow;
|
||||
p.BarsHeld = 0;
|
||||
}
|
||||
|
||||
p.Quantity = quantity;
|
||||
p.AverageEntryPrice = averageEntryPrice;
|
||||
if (lastPrice > 0)
|
||||
{
|
||||
p.LastPrice = lastPrice;
|
||||
}
|
||||
|
||||
if (quantity == 0)
|
||||
{
|
||||
p.AverageEntryPrice = 0;
|
||||
p.StopPrice = double.NaN;
|
||||
p.TargetPrice = double.NaN;
|
||||
p.EntryOrderId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Flattens every locally tracked position that the broker no longer reports.</summary>
|
||||
public void ReconcileMissing(IReadOnlySet<string> brokerSymbols)
|
||||
{
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
if (p.IsOpen && !brokerSymbols.Contains(p.Symbol))
|
||||
{
|
||||
Reconcile(p.Symbol, 0, 0, p.LastPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetDailyCounters()
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
p.RealizedPnl = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Portfolio;
|
||||
|
||||
/// <summary>
|
||||
/// Live state of a single symbol's position. Mutated only by the fill-processing
|
||||
/// path (<see cref="PortfolioBook"/>); everything else reads an immutable
|
||||
/// <see cref="PositionView"/> snapshot.
|
||||
/// </summary>
|
||||
public sealed class Position(string symbol)
|
||||
{
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
/// <summary>Signed size: positive is long, negative is short, zero is flat.</summary>
|
||||
public double Quantity { get; internal set; }
|
||||
|
||||
public double AverageEntryPrice { get; internal set; }
|
||||
|
||||
public double RealizedPnl { get; internal set; }
|
||||
|
||||
/// <summary>Latest known market price, used to mark the position.</summary>
|
||||
public double LastPrice { get; internal set; }
|
||||
|
||||
public DateTime OpenedAtUtc { get; internal set; }
|
||||
|
||||
public DateTime LastFillUtc { get; internal set; }
|
||||
|
||||
/// <summary>Protective stop attached to the position, <see cref="double.NaN"/> when absent.</summary>
|
||||
public double StopPrice { get; internal set; } = double.NaN;
|
||||
|
||||
/// <summary>Profit target attached to the position, <see cref="double.NaN"/> when absent.</summary>
|
||||
public double TargetPrice { get; internal set; } = double.NaN;
|
||||
|
||||
/// <summary>Number of closed bars observed since the position was opened.</summary>
|
||||
public int BarsHeld { get; internal set; }
|
||||
|
||||
/// <summary>Broker id of the order that opened (or last increased) the position.</summary>
|
||||
public string? EntryOrderId { get; internal set; }
|
||||
|
||||
public bool IsOpen => Quantity != 0;
|
||||
|
||||
public Side Side => Quantity > 0 ? Side.Buy : Quantity < 0 ? Side.Sell : Side.None;
|
||||
|
||||
public double MarketValue => Quantity * LastPrice;
|
||||
|
||||
public double GrossExposure => Math.Abs(Quantity) * LastPrice;
|
||||
|
||||
public double UnrealizedPnl => Quantity == 0 ? 0 : Quantity * (LastPrice - AverageEntryPrice);
|
||||
|
||||
public double UnrealizedPnlPct =>
|
||||
Quantity == 0 || AverageEntryPrice <= 0
|
||||
? 0
|
||||
: (LastPrice - AverageEntryPrice) / AverageEntryPrice * Math.Sign(Quantity);
|
||||
|
||||
public PositionView ToView() => new(
|
||||
Symbol,
|
||||
Quantity,
|
||||
AverageEntryPrice,
|
||||
LastPrice,
|
||||
StopPrice,
|
||||
TargetPrice,
|
||||
OpenedAtUtc,
|
||||
BarsHeld,
|
||||
RealizedPnl);
|
||||
}
|
||||
|
||||
/// <summary>Immutable snapshot of a position, handed to strategies and the risk engine.</summary>
|
||||
public readonly record struct PositionView(
|
||||
string Symbol,
|
||||
double Quantity,
|
||||
double AverageEntryPrice,
|
||||
double LastPrice,
|
||||
double StopPrice,
|
||||
double TargetPrice,
|
||||
DateTime OpenedAtUtc,
|
||||
int BarsHeld,
|
||||
double RealizedPnl)
|
||||
{
|
||||
public static PositionView Flat(string symbol) =>
|
||||
new(symbol, 0, 0, 0, double.NaN, double.NaN, default, 0, 0);
|
||||
|
||||
public bool IsFlat => Quantity == 0;
|
||||
|
||||
public bool IsLong => Quantity > 0;
|
||||
|
||||
public bool IsShort => Quantity < 0;
|
||||
|
||||
public Side Side => Quantity > 0 ? Side.Buy : Quantity < 0 ? Side.Sell : Side.None;
|
||||
|
||||
public double UnrealizedPnl => Quantity == 0 ? 0 : Quantity * (LastPrice - AverageEntryPrice);
|
||||
|
||||
public double UnrealizedPnlPct =>
|
||||
Quantity == 0 || AverageEntryPrice <= 0
|
||||
? 0
|
||||
: (LastPrice - AverageEntryPrice) / AverageEntryPrice * Math.Sign(Quantity);
|
||||
|
||||
public double GrossExposure => Math.Abs(Quantity) * LastPrice;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Risk;
|
||||
|
||||
public enum RiskReject : byte
|
||||
{
|
||||
None = 0,
|
||||
TradingHalted,
|
||||
DailyLossLimit,
|
||||
DailyProfitTarget,
|
||||
MaxOpenPositions,
|
||||
MaxDailyTrades,
|
||||
MaxSymbolTrades,
|
||||
Cooldown,
|
||||
ShortingDisabled,
|
||||
AlreadyInPosition,
|
||||
PriceOutOfBand,
|
||||
SpreadTooWide,
|
||||
InvalidStop,
|
||||
SizeTooSmall,
|
||||
InsufficientBuyingPower,
|
||||
ExposureLimit,
|
||||
InvalidRequest,
|
||||
}
|
||||
|
||||
/// <summary>Everything the risk engine needs to size and vet one entry.</summary>
|
||||
public readonly record struct EntryRequest(
|
||||
string Symbol,
|
||||
Side Side,
|
||||
double Price,
|
||||
double StopPrice,
|
||||
double Strength,
|
||||
double Equity,
|
||||
double BuyingPower,
|
||||
double GrossExposure,
|
||||
int OpenPositions,
|
||||
double ExistingQuantity,
|
||||
double RelativeSpread,
|
||||
bool AllowFractional,
|
||||
DateTime NowUtc);
|
||||
|
||||
/// <summary>The engine's answer: an approved size, or a machine-readable reason why not.</summary>
|
||||
public readonly record struct RiskVerdict(
|
||||
bool Approved,
|
||||
double Quantity,
|
||||
double StopPrice,
|
||||
RiskReject Reason,
|
||||
string Detail)
|
||||
{
|
||||
public static RiskVerdict Reject(RiskReject reason, string detail) =>
|
||||
new(false, 0, double.NaN, reason, detail);
|
||||
|
||||
public static RiskVerdict Approve(double quantity, double stopPrice, string detail) =>
|
||||
new(true, quantity, stopPrice, RiskReject.None, detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single gate every order must pass. It owns position sizing (fixed-fractional
|
||||
/// risk against the stop distance), the per-session counters and the kill switch.
|
||||
/// <para>
|
||||
/// Thread safe: entries are vetted on the market-data thread while fills and equity
|
||||
/// updates arrive on the trade-updates thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RiskEngine(RiskLimits limits)
|
||||
{
|
||||
private readonly RiskLimits _limits = limits.Validate();
|
||||
private readonly Dictionary<string, SymbolCounters> _counters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
private double _sessionStartEquity;
|
||||
private double _peakEquity;
|
||||
private double _dailyRealizedPnl;
|
||||
private int _tradesToday;
|
||||
private bool _halted;
|
||||
private string _haltReason = string.Empty;
|
||||
private DateOnly _sessionDate;
|
||||
|
||||
public RiskLimits Limits => _limits;
|
||||
|
||||
public bool IsHalted
|
||||
{
|
||||
get { lock (_gate) { return _halted; } }
|
||||
}
|
||||
|
||||
public string HaltReason
|
||||
{
|
||||
get { lock (_gate) { return _haltReason; } }
|
||||
}
|
||||
|
||||
public double SessionStartEquity
|
||||
{
|
||||
get { lock (_gate) { return _sessionStartEquity; } }
|
||||
}
|
||||
|
||||
public double DailyRealizedPnl
|
||||
{
|
||||
get { lock (_gate) { return _dailyRealizedPnl; } }
|
||||
}
|
||||
|
||||
public int TradesToday
|
||||
{
|
||||
get { lock (_gate) { return _tradesToday; } }
|
||||
}
|
||||
|
||||
/// <summary>Resets the daily counters and rebases the drawdown reference to <paramref name="equity"/>.</summary>
|
||||
public void StartSession(double equity, DateOnly sessionDate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_sessionStartEquity = equity > 0 ? equity : _sessionStartEquity;
|
||||
_peakEquity = _sessionStartEquity;
|
||||
_dailyRealizedPnl = 0;
|
||||
_tradesToday = 0;
|
||||
_counters.Clear();
|
||||
_halted = false;
|
||||
_haltReason = string.Empty;
|
||||
_sessionDate = sessionDate;
|
||||
}
|
||||
}
|
||||
|
||||
public DateOnly SessionDate
|
||||
{
|
||||
get { lock (_gate) { return _sessionDate; } }
|
||||
}
|
||||
|
||||
public void Halt(string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_halted)
|
||||
{
|
||||
_halted = true;
|
||||
_haltReason = reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_halted = false;
|
||||
_haltReason = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordRealizedPnl(double pnl)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_dailyRealizedPnl += pnl;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds live account equity in. Trips the kill switch when the session loss or
|
||||
/// profit boundary is crossed. Returns <see langword="true"/> when it halted here.
|
||||
/// </summary>
|
||||
public bool UpdateEquity(double equity)
|
||||
{
|
||||
if (equity <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessionStartEquity <= 0)
|
||||
{
|
||||
_sessionStartEquity = equity;
|
||||
_peakEquity = equity;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (equity > _peakEquity)
|
||||
{
|
||||
_peakEquity = equity;
|
||||
}
|
||||
|
||||
if (_halted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double change = (equity - _sessionStartEquity) / _sessionStartEquity;
|
||||
|
||||
if (change <= -_limits.MaxDailyLossPct)
|
||||
{
|
||||
_halted = true;
|
||||
_haltReason = $"daily loss limit hit ({change:P2} <= {-_limits.MaxDailyLossPct:P2})";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_limits.MaxDailyProfitPct > 0 && change >= _limits.MaxDailyProfitPct)
|
||||
{
|
||||
_halted = true;
|
||||
_haltReason = $"daily profit target reached ({change:P2} >= {_limits.MaxDailyProfitPct:P2})";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called once an entry order has actually been submitted.</summary>
|
||||
public void RecordEntry(string symbol, DateTime nowUtc)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_tradesToday++;
|
||||
ref SymbolCounters c = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
|
||||
_counters, symbol, out _);
|
||||
c.Trades++;
|
||||
c.LastEntryUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Vets an entry and returns the size to send, or the reason for refusing.</summary>
|
||||
public RiskVerdict ApproveEntry(in EntryRequest r)
|
||||
{
|
||||
if (r.Side == Side.None || r.Price <= 0 || r.Equity <= 0 || double.IsNaN(r.Price))
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InvalidRequest, "price, side or equity is not usable");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_halted)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.TradingHalted, _haltReason);
|
||||
}
|
||||
|
||||
if (r.Side == Side.Sell && !_limits.AllowShorting && r.ExistingQuantity <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.ShortingDisabled, "shorting is disabled");
|
||||
}
|
||||
|
||||
if (r.ExistingQuantity != 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.AlreadyInPosition,
|
||||
$"already holding {r.ExistingQuantity:0.####}");
|
||||
}
|
||||
|
||||
if (r.Price < _limits.MinPrice || r.Price > _limits.MaxPrice)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.PriceOutOfBand,
|
||||
$"price {r.Price:F2} outside [{_limits.MinPrice:F2}, {_limits.MaxPrice:F2}]");
|
||||
}
|
||||
|
||||
if (_limits.MaxRelativeSpread > 0 && r.RelativeSpread > _limits.MaxRelativeSpread)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.SpreadTooWide,
|
||||
$"spread {r.RelativeSpread:P3} > {_limits.MaxRelativeSpread:P3}");
|
||||
}
|
||||
|
||||
// Each of these caps is disabled by setting it to 0.
|
||||
if (_limits.MaxOpenPositions > 0 && r.OpenPositions >= _limits.MaxOpenPositions)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.MaxOpenPositions,
|
||||
$"{r.OpenPositions} positions already open");
|
||||
}
|
||||
|
||||
if (_limits.MaxTradesPerDay > 0 && _tradesToday >= _limits.MaxTradesPerDay)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.MaxDailyTrades,
|
||||
$"{_tradesToday} trades today");
|
||||
}
|
||||
|
||||
if (_counters.TryGetValue(r.Symbol, out SymbolCounters counters))
|
||||
{
|
||||
if (_limits.MaxTradesPerSymbolPerDay > 0 &&
|
||||
counters.Trades >= _limits.MaxTradesPerSymbolPerDay)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.MaxSymbolTrades,
|
||||
$"{counters.Trades} trades today on {r.Symbol}");
|
||||
}
|
||||
|
||||
double elapsed = (r.NowUtc - counters.LastEntryUtc).TotalSeconds;
|
||||
if (counters.LastEntryUtc != default && elapsed < _limits.MinSecondsBetweenEntries)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.Cooldown,
|
||||
$"{elapsed:F0}s since last entry, need {_limits.MinSecondsBetweenEntries}s");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sizing -------------------------------------------------------
|
||||
double stop = ResolveStop(r);
|
||||
double riskPerShare = Math.Abs(r.Price - stop);
|
||||
if (riskPerShare <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InvalidStop, "stop equals entry price");
|
||||
}
|
||||
|
||||
if (riskPerShare / r.Price > _limits.MaxStopDistancePct)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InvalidStop,
|
||||
$"stop is {riskPerShare / r.Price:P2} away, max {_limits.MaxStopDistancePct:P2}");
|
||||
}
|
||||
|
||||
double strength = r.Strength is > 0 and <= 1 ? r.Strength : 1.0;
|
||||
double quantity;
|
||||
string sizing;
|
||||
|
||||
if (_limits.HasExplicitStake)
|
||||
{
|
||||
// The operator has fixed the stake, so conviction does not scale it:
|
||||
// "20% of the account" that silently becomes 12% on a weaker signal is
|
||||
// not the instruction that was given.
|
||||
double stake = _limits.ResolveStake(r.Equity);
|
||||
quantity = stake / r.Price;
|
||||
sizing = $"stake {stake:F2} ({_limits.DescribeSizing()})";
|
||||
}
|
||||
else
|
||||
{
|
||||
double riskBudget = r.Equity * _limits.MaxRiskPerTradePct * strength;
|
||||
quantity = riskBudget / riskPerShare;
|
||||
sizing = $"risk {riskBudget:F2} @ {riskPerShare:F4}/share";
|
||||
}
|
||||
|
||||
quantity = CapByNotional(quantity, r.Price, r.Equity * _limits.MaxPositionNotionalPct);
|
||||
|
||||
double exposureHeadroom = (r.Equity * _limits.MaxGrossExposurePct) - r.GrossExposure;
|
||||
if (exposureHeadroom <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.ExposureLimit,
|
||||
$"gross exposure {r.GrossExposure:F0} already at the {_limits.MaxGrossExposurePct:P0} cap");
|
||||
}
|
||||
|
||||
quantity = CapByNotional(quantity, r.Price, exposureHeadroom);
|
||||
|
||||
if (_limits.MaxOrderNotional > 0)
|
||||
{
|
||||
quantity = CapByNotional(quantity, r.Price, _limits.MaxOrderNotional);
|
||||
}
|
||||
|
||||
if (r.BuyingPower > 0)
|
||||
{
|
||||
quantity = CapByNotional(quantity, r.Price, r.BuyingPower * 0.98);
|
||||
}
|
||||
|
||||
if (!r.AllowFractional)
|
||||
{
|
||||
quantity = Math.Floor(quantity);
|
||||
}
|
||||
else
|
||||
{
|
||||
quantity = Math.Round(quantity, 6, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.SizeTooSmall,
|
||||
$"computed size rounds to zero ({sizing})");
|
||||
}
|
||||
|
||||
double notional = quantity * r.Price;
|
||||
if (notional < _limits.MinOrderNotional)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.SizeTooSmall,
|
||||
$"notional {notional:F2} < minimum {_limits.MinOrderNotional:F2}");
|
||||
}
|
||||
|
||||
if (r.BuyingPower > 0 && notional > r.BuyingPower)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InsufficientBuyingPower,
|
||||
$"notional {notional:F2} > buying power {r.BuyingPower:F2}");
|
||||
}
|
||||
|
||||
return RiskVerdict.Approve(quantity, stop,
|
||||
$"{sizing} -> {quantity:0.######} ({notional:F2} notional)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Uses the strategy's stop when sane, otherwise falls back to a percentage stop.</summary>
|
||||
private double ResolveStop(in EntryRequest r)
|
||||
{
|
||||
bool longSide = r.Side == Side.Buy;
|
||||
|
||||
if (!double.IsNaN(r.StopPrice) && r.StopPrice > 0 &&
|
||||
((longSide && r.StopPrice < r.Price) || (!longSide && r.StopPrice > r.Price)))
|
||||
{
|
||||
return r.StopPrice;
|
||||
}
|
||||
|
||||
return longSide
|
||||
? r.Price * (1 - _limits.DefaultStopPct)
|
||||
: r.Price * (1 + _limits.DefaultStopPct);
|
||||
}
|
||||
|
||||
private static double CapByNotional(double quantity, double price, double maxNotional)
|
||||
{
|
||||
if (maxNotional <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double max = maxNotional / price;
|
||||
return quantity > max ? max : quantity;
|
||||
}
|
||||
|
||||
private struct SymbolCounters
|
||||
{
|
||||
public int Trades;
|
||||
public DateTime LastEntryUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
namespace Encelado.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Every hard boundary the engine is allowed to operate inside. Defaults are
|
||||
/// deliberately conservative: a misconfigured bot should be boring, not broke.
|
||||
/// </summary>
|
||||
public sealed class RiskLimits
|
||||
{
|
||||
/// <summary>Fraction of equity risked between entry and stop on a single trade.</summary>
|
||||
public double MaxRiskPerTradePct { get; set; } = 0.005;
|
||||
|
||||
/// <summary>
|
||||
/// Notional committed per entry, as a fraction of account equity. 0 means "not set":
|
||||
/// the engine then sizes by risk-to-stop, which is the original behaviour.
|
||||
/// <para>
|
||||
/// This is the *stake*, not the risk. At 0.20 the bot buys 20% of the account and
|
||||
/// the amount actually at risk is that times the stop distance — with a 30% stop,
|
||||
/// 6% of equity. Sizing by risk instead makes every trade lose the same amount when
|
||||
/// it is wrong, regardless of how far away its stop happens to be; sizing by stake
|
||||
/// makes every trade the same size, which is easier to reason about but means a
|
||||
/// wide-stop trade can hurt several times more than a narrow-stop one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public double StakePct { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on the committed notional, in account currency. Combined with
|
||||
/// <see cref="StakePct"/> it is an upper bound (the percentage is used, but never
|
||||
/// more than this); on its own it is a fixed order size. 0 means "not set".
|
||||
/// </summary>
|
||||
public double StakeAmount { get; set; }
|
||||
|
||||
/// <summary>True when the operator has taken sizing into their own hands.</summary>
|
||||
public bool HasExplicitStake => StakePct > 0 || StakeAmount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The notional to commit for one entry at the given equity. Only meaningful when
|
||||
/// <see cref="HasExplicitStake"/> is true.
|
||||
/// </summary>
|
||||
public double ResolveStake(double equity)
|
||||
{
|
||||
if (StakePct <= 0)
|
||||
{
|
||||
return StakeAmount;
|
||||
}
|
||||
|
||||
double byPercent = equity * StakePct;
|
||||
return StakeAmount > 0 ? Math.Min(byPercent, StakeAmount) : byPercent;
|
||||
}
|
||||
|
||||
/// <summary>One line describing the sizing rule in force, for the settings screen.</summary>
|
||||
public string DescribeSizing() =>
|
||||
StakePct > 0 && StakeAmount > 0
|
||||
? $"{StakePct:P2} dell'equity per operazione, mai oltre {StakeAmount:N0}"
|
||||
: StakePct > 0 ? $"{StakePct:P2} dell'equity per operazione"
|
||||
: StakeAmount > 0 ? $"importo fisso di {StakeAmount:N0} per operazione"
|
||||
: $"deciso dal bot: {MaxRiskPerTradePct:P2} di equity a rischio fino allo stop";
|
||||
|
||||
/// <summary>Hard cap on one symbol's notional, as a fraction of equity.</summary>
|
||||
public double MaxPositionNotionalPct { get; set; } = 0.10;
|
||||
|
||||
/// <summary>Cap on the sum of all position notionals, as a fraction of equity.</summary>
|
||||
public double MaxGrossExposurePct { get; set; } = 1.00;
|
||||
|
||||
/// <summary>Simultaneous open positions. 0 means no limit.</summary>
|
||||
public int MaxOpenPositions { get; set; } = 5;
|
||||
|
||||
/// <summary>Entries per session across all symbols. 0 means no limit.</summary>
|
||||
public int MaxTradesPerDay { get; set; } = 40;
|
||||
|
||||
/// <summary>Entries per session on one symbol. 0 means no limit.</summary>
|
||||
public int MaxTradesPerSymbolPerDay { get; set; } = 6;
|
||||
|
||||
/// <summary>Session is halted once equity drops this fraction below the session open.</summary>
|
||||
public double MaxDailyLossPct { get; set; } = 0.03;
|
||||
|
||||
/// <summary>Optional profit lock-in: halt for the day above this gain. 0 disables.</summary>
|
||||
public double MaxDailyProfitPct { get; set; }
|
||||
|
||||
/// <summary>Debounce between two entries on the same symbol.</summary>
|
||||
public int MinSecondsBetweenEntries { get; set; } = 60;
|
||||
|
||||
/// <summary>Widest acceptable bid/ask spread as a fraction of mid. 0 disables the check.</summary>
|
||||
public double MaxRelativeSpread { get; set; } = 0.004;
|
||||
|
||||
public double MinPrice { get; set; } = 1.0;
|
||||
|
||||
public double MaxPrice { get; set; } = 100_000;
|
||||
|
||||
/// <summary>Orders smaller than this notional are not worth the commission-free slippage.</summary>
|
||||
public double MinOrderNotional { get; set; } = 25;
|
||||
|
||||
/// <summary>Absolute ceiling on a single order's notional. 0 disables.</summary>
|
||||
public double MaxOrderNotional { get; set; }
|
||||
|
||||
public bool AllowShorting { get; set; }
|
||||
|
||||
/// <summary>Fallback stop distance (fraction of price) when a strategy supplies none.</summary>
|
||||
public double DefaultStopPct { get; set; } = 0.02;
|
||||
|
||||
/// <summary>Rejects nonsensical stops that are further than this fraction from entry.</summary>
|
||||
public double MaxStopDistancePct { get; set; } = 0.15;
|
||||
|
||||
public RiskLimits Validate()
|
||||
{
|
||||
Require(MaxRiskPerTradePct is > 0 and <= 0.25, nameof(MaxRiskPerTradePct), "must be in (0, 0.25]");
|
||||
Require(MaxPositionNotionalPct is > 0 and <= 1.0, nameof(MaxPositionNotionalPct), "must be in (0, 1]");
|
||||
Require(StakePct is >= 0 and <= 1.0, nameof(StakePct), "must be in [0, 1] — it is a fraction of equity, so 0.2 means 20%");
|
||||
Require(StakeAmount >= 0, nameof(StakeAmount), "must be >= 0");
|
||||
|
||||
// Caught here rather than silently clipped at order time: an operator who asks
|
||||
// for 60% and gets 50% without being told would keep believing the wrong number.
|
||||
Require(StakePct <= MaxPositionNotionalPct, nameof(StakePct),
|
||||
$"is {StakePct:P0} but maxPositionNotionalPct caps a position at {MaxPositionNotionalPct:P0}; " +
|
||||
"raise the cap or lower the stake");
|
||||
Require(MaxGrossExposurePct is > 0 and <= 4.0, nameof(MaxGrossExposurePct), "must be in (0, 4]");
|
||||
// 0 disables the cap, the same convention maxOrderNotional and maxDailyProfitPct
|
||||
// already use. Negative is a typo, not an intention.
|
||||
Require(MaxOpenPositions >= 0, nameof(MaxOpenPositions), "must be >= 0 (0 = no limit)");
|
||||
Require(MaxTradesPerDay >= 0, nameof(MaxTradesPerDay), "must be >= 0 (0 = no limit)");
|
||||
Require(MaxTradesPerSymbolPerDay >= 0, nameof(MaxTradesPerSymbolPerDay), "must be >= 0 (0 = no limit)");
|
||||
Require(MaxDailyLossPct is > 0 and <= 1.0, nameof(MaxDailyLossPct), "must be in (0, 1]");
|
||||
Require(MaxDailyProfitPct >= 0, nameof(MaxDailyProfitPct), "must be >= 0");
|
||||
Require(MinSecondsBetweenEntries >= 0, nameof(MinSecondsBetweenEntries), "must be >= 0");
|
||||
Require(MinPrice > 0 && MaxPrice > MinPrice, nameof(MinPrice), "must satisfy 0 < MinPrice < MaxPrice");
|
||||
Require(MinOrderNotional > 0, nameof(MinOrderNotional), "must be > 0");
|
||||
Require(DefaultStopPct is > 0 and < 1, nameof(DefaultStopPct), "must be in (0, 1)");
|
||||
Require(MaxStopDistancePct is > 0 and < 1, nameof(MaxStopDistancePct), "must be in (0, 1)");
|
||||
return this;
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string field, string requirement)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
throw new InvalidOperationException($"risk.{field} {requirement}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
public enum SignalKind : byte
|
||||
{
|
||||
None = 0,
|
||||
EnterLong = 1,
|
||||
EnterShort = 2,
|
||||
Exit = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A strategy's decision for one symbol. Prices are absolute; <see cref="double.NaN"/>
|
||||
/// means "let the risk engine pick a default".
|
||||
/// </summary>
|
||||
public readonly record struct Signal(
|
||||
SignalKind Kind,
|
||||
double Strength,
|
||||
double StopPrice,
|
||||
double TargetPrice,
|
||||
string Reason)
|
||||
{
|
||||
public static readonly Signal Flat = new(SignalKind.None, 0, double.NaN, double.NaN, string.Empty);
|
||||
|
||||
public bool IsEntry => Kind is SignalKind.EnterLong or SignalKind.EnterShort;
|
||||
|
||||
public Side EntrySide => Kind switch
|
||||
{
|
||||
SignalKind.EnterLong => Side.Buy,
|
||||
SignalKind.EnterShort => Side.Sell,
|
||||
_ => Side.None,
|
||||
};
|
||||
|
||||
public static Signal EnterLong(string reason, double stopPrice = double.NaN, double targetPrice = double.NaN, double strength = 1.0) =>
|
||||
new(SignalKind.EnterLong, Math.Clamp(strength, 0, 1), stopPrice, targetPrice, reason);
|
||||
|
||||
public static Signal EnterShort(string reason, double stopPrice = double.NaN, double targetPrice = double.NaN, double strength = 1.0) =>
|
||||
new(SignalKind.EnterShort, Math.Clamp(strength, 0, 1), stopPrice, targetPrice, reason);
|
||||
|
||||
public static Signal Exit(string reason) =>
|
||||
new(SignalKind.Exit, 1.0, double.NaN, double.NaN, reason);
|
||||
}
|
||||
|
||||
/// <summary>A named internal reading a strategy exposes for the dashboard.</summary>
|
||||
public readonly record struct StrategyMetric(string Name, double Value, string Format = "F2");
|
||||
|
||||
/// <summary>
|
||||
/// One instance per (symbol, strategy) pair: implementations own their indicator
|
||||
/// state, so they must never be shared across symbols.
|
||||
/// </summary>
|
||||
public interface IStrategy
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>Number of closed bars required before signals become meaningful.</summary>
|
||||
int WarmupBars { get; }
|
||||
|
||||
/// <summary>True once every internal indicator has enough history.</summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a newly closed bar and returns the resulting decision. This is the
|
||||
/// primary decision point and runs on the market-data thread.
|
||||
/// </summary>
|
||||
Signal OnBar(in Bar bar, in PositionView position);
|
||||
|
||||
/// <summary>
|
||||
/// Optional intra-bar reaction (trailing stops, hard exits). Default: do nothing.
|
||||
/// Called on every top-of-book update, so it must stay allocation free.
|
||||
/// </summary>
|
||||
Signal OnQuote(in Quote quote, in PositionView position) => Signal.Flat;
|
||||
|
||||
/// <summary>
|
||||
/// Latest internal readings, surfaced by the dashboard so the operator can see
|
||||
/// <i>why</i> the strategy is doing what it is doing. Read at UI refresh rate, not
|
||||
/// on the decision path. Default: nothing to show.
|
||||
/// </summary>
|
||||
IReadOnlyList<StrategyMetric> Diagnostics => [];
|
||||
|
||||
/// <summary>
|
||||
/// One sentence saying what the strategy would do <b>at this instant and this price</b>,
|
||||
/// and what would have to change for it to do something else.
|
||||
/// <para>
|
||||
/// Unlike <see cref="OnBar"/> this must not touch any indicator state: it is called
|
||||
/// between decisions, from the quote path and from the dashboard, purely to answer
|
||||
/// the operator's question "why isn't it doing anything?". On a daily timeframe a
|
||||
/// strategy is silent for weeks at a time, and silence is indistinguishable from a
|
||||
/// hang unless it can say what it is waiting for.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
string Explain(double price, in PositionView position) => string.Empty;
|
||||
|
||||
void Reset();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loosely typed parameter bag: config files carry <c>{"fast": 9, "slow": 21}</c>
|
||||
/// and the strategy reads what it needs with a fallback. Keeps the config format
|
||||
/// open without reflection-based binding.
|
||||
/// </summary>
|
||||
public sealed class StrategyParameters
|
||||
{
|
||||
private readonly Dictionary<string, double> _values;
|
||||
|
||||
public StrategyParameters(IReadOnlyDictionary<string, double>? values = null) =>
|
||||
_values = values is null
|
||||
? new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, double>(values, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static StrategyParameters Empty => new();
|
||||
|
||||
public double Get(string key, double fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? v : fallback;
|
||||
|
||||
public int GetInt(string key, int fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? (int)Math.Round(v) : fallback;
|
||||
|
||||
public bool GetBool(string key, bool fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? v != 0 : fallback;
|
||||
|
||||
public StrategyParameters Set(string key, double value)
|
||||
{
|
||||
_values[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, double> Values => _values;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Encelado.Core.Indicators;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>
|
||||
/// Shared plumbing for the built-in strategies: ATR tracking, volatility gating and
|
||||
/// ATR-derived bracket levels. Concrete strategies only implement
|
||||
/// <see cref="Evaluate"/>.
|
||||
/// </summary>
|
||||
public abstract class StrategyBase : IStrategy
|
||||
{
|
||||
protected StrategyBase(StrategyParameters p, int defaultAtrPeriod = 14)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(p);
|
||||
Atr = new Atr(p.GetInt("atrPeriod", defaultAtrPeriod));
|
||||
AllowShort = p.GetBool("allowShort", false);
|
||||
AtrStopMultiple = p.Get("atrStopMult", 2.0);
|
||||
RewardRisk = p.Get("rewardRisk", 2.0);
|
||||
MinAtrPct = p.Get("minAtrPct", 0.0);
|
||||
MaxBarsInTrade = p.GetInt("maxBarsInTrade", 0);
|
||||
}
|
||||
|
||||
protected Atr Atr { get; }
|
||||
|
||||
/// <summary>Short entries are opt-in: they need a marginable account and locate availability.</summary>
|
||||
protected bool AllowShort { get; }
|
||||
|
||||
protected double AtrStopMultiple { get; }
|
||||
|
||||
protected double RewardRisk { get; }
|
||||
|
||||
/// <summary>Minimum ATR/price ratio required to trade. Filters out dead, un-tradeable tape.</summary>
|
||||
protected double MinAtrPct { get; }
|
||||
|
||||
/// <summary>Hard time stop in bars. 0 disables it.</summary>
|
||||
protected int MaxBarsInTrade { get; }
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public abstract int WarmupBars { get; }
|
||||
|
||||
public abstract bool IsReady { get; }
|
||||
|
||||
public Signal OnBar(in Bar bar, in PositionView position)
|
||||
{
|
||||
Atr.Update(bar);
|
||||
|
||||
// Evaluate runs on every bar, including during warm-up: the concrete strategies
|
||||
// advance their indicators inside it, so skipping the call would leave them
|
||||
// permanently un-ready. Only the resulting *signal* is suppressed until ready.
|
||||
Signal signal = Evaluate(bar, position);
|
||||
|
||||
if (MaxBarsInTrade > 0 && !position.IsFlat && position.BarsHeld >= MaxBarsInTrade)
|
||||
{
|
||||
return Signal.Exit($"time stop after {position.BarsHeld} bars");
|
||||
}
|
||||
|
||||
return IsReady ? signal : Signal.Flat;
|
||||
}
|
||||
|
||||
public virtual Signal OnQuote(in Quote quote, in PositionView position) => Signal.Flat;
|
||||
|
||||
/// <summary>Internal readings for the dashboard. Overridden by strategies worth introspecting.</summary>
|
||||
public virtual IReadOnlyList<StrategyMetric> Diagnostics => [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string Explain(double price, in PositionView position) => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Called once per closed bar. Implementations must advance their own indicators
|
||||
/// here — this is invoked during warm-up too, and the returned signal is discarded
|
||||
/// until <see cref="IsReady"/> is true.
|
||||
/// </summary>
|
||||
protected abstract Signal Evaluate(in Bar bar, in PositionView position);
|
||||
|
||||
/// <summary>ATR-derived protective stop and profit target for a fresh entry.</summary>
|
||||
protected (double Stop, double Target) Brackets(double entryPrice, Side side)
|
||||
{
|
||||
if (!Atr.IsReady || Atr.Value <= 0 || AtrStopMultiple <= 0)
|
||||
{
|
||||
return (double.NaN, double.NaN);
|
||||
}
|
||||
|
||||
double risk = Atr.Value * AtrStopMultiple;
|
||||
double reward = risk * (RewardRisk > 0 ? RewardRisk : 0);
|
||||
|
||||
return side == Side.Buy
|
||||
? (entryPrice - risk, reward > 0 ? entryPrice + reward : double.NaN)
|
||||
: (entryPrice + risk, reward > 0 ? entryPrice - reward : double.NaN);
|
||||
}
|
||||
|
||||
protected bool VolatilityOk(double price) =>
|
||||
MinAtrPct <= 0 || (Atr.IsReady && price > 0 && Atr.Value / price >= MinAtrPct);
|
||||
|
||||
public virtual void Reset() => Atr.Reset();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the trading strategy named in the configuration.
|
||||
/// <para>
|
||||
/// There is exactly one. Seven others were written and backtested against thirteen
|
||||
/// years of BTC data; every one of them lost money after realistic costs, collapsed out
|
||||
/// of sample, or — in the case of the multi-factor model this one replaced — made money
|
||||
/// while returning a sixth of what simply holding the asset returned. Keeping a losing
|
||||
/// strategy available "just in case" is how it ends up in production, so they were
|
||||
/// deleted rather than disabled.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class StrategyFactory
|
||||
{
|
||||
public const string Default = "trend-filter";
|
||||
|
||||
public static readonly string[] Available = [Default];
|
||||
|
||||
/// <summary>Creates a new, independent strategy instance. One per symbol.</summary>
|
||||
public static IStrategy Create(string name, StrategyParameters? parameters = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
StrategyParameters p = parameters ?? StrategyParameters.Empty;
|
||||
|
||||
return Normalize(name) switch
|
||||
{
|
||||
"trend-filter" or "trendfilter" or "trend" or "filter" => new TrendFilterStrategy(p),
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown strategy '{name}'. The only supported strategy is '{Default}'.", nameof(name)),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsKnown(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = Create(name);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string name) =>
|
||||
name.Trim().ToLowerInvariant().Replace('_', '-');
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Indicators;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>
|
||||
/// Long while price holds above a medium-term moving average, flat below it. Nothing
|
||||
/// more.
|
||||
/// <para>
|
||||
/// The simplicity is the point, and it was arrived at by measurement rather than taste.
|
||||
/// Without leverage — Alpaca crypto is spot only — a long/flat rule can never hold more
|
||||
/// than the market holds, so every day spent out is a day of compounding surrendered.
|
||||
/// Against an asset that rose twenty-two thousandfold, such a rule beats buying and
|
||||
/// holding only if the days it sits out are disproportionately the bad ones. It cannot
|
||||
/// out-earn the market; it can only out-avoid it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That reframes what a good signal is. The model this replaced blended trend with mean
|
||||
/// reversion, gated entries on an efficiency ratio and trailed a stop at five ATR: each
|
||||
/// piece defensible, and together they cut time in market to the point of returning
|
||||
/// 20% a year where simply holding returned 115%. Cleverness that shortens the holding
|
||||
/// period is not free on an asset like this — it is the most expensive thing in the
|
||||
/// book.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The band around the average is hysteresis, not a filter: entering 2% above and
|
||||
/// leaving 2% below stops a price sitting on the line from generating a trade every
|
||||
/// other day. On thirteen years of BTC it cuts the number of switches by more than half
|
||||
/// while leaving the return where it was.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TrendFilterStrategy : StrategyBase
|
||||
{
|
||||
private readonly Sma _average;
|
||||
private readonly RealizedVolatility _volatility;
|
||||
private readonly CumulativeVolumeDelta _cvd;
|
||||
|
||||
private readonly double _band;
|
||||
private readonly double _cvdThreshold;
|
||||
private readonly double _stopPct;
|
||||
|
||||
private double _lastClose;
|
||||
private double _distance;
|
||||
|
||||
public TrendFilterStrategy(StrategyParameters p)
|
||||
: base(p, defaultAtrPeriod: 14)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(p);
|
||||
|
||||
int period = p.GetInt("period", 100);
|
||||
if (period < 5)
|
||||
{
|
||||
throw new ArgumentException("period must be at least 5 bars.", nameof(p));
|
||||
}
|
||||
|
||||
_band = p.Get("band", 0.02);
|
||||
if (_band is < 0 or > 0.5)
|
||||
{
|
||||
throw new ArgumentException("band must be a fraction in [0, 0.5].", nameof(p));
|
||||
}
|
||||
|
||||
// Off by default. Adding the order-flow gate to *this* strategy makes it worse,
|
||||
// monotonically: on nine years of Binance data with the aggressor breakdown the
|
||||
// Calmar fell from 0.71 to 0.66 to 0.64 as the threshold rose. The filter earned
|
||||
// its place in the previous model, which traded rarely and could afford to wait
|
||||
// for confirmation; here every bar spent waiting is a bar not compounding. Left
|
||||
// configurable because the reading is still worth logging, and because a future
|
||||
// dataset could say otherwise.
|
||||
_cvdThreshold = p.Get("cvdThreshold", 0);
|
||||
|
||||
// A backstop for a gap, not the risk control. The exit that matters is the
|
||||
// average giving way; a tight stop here would sell the position and then wait
|
||||
// for a fresh crossing to buy it back, which is how the previous model kept
|
||||
// realising drawdowns it would otherwise have ridden through.
|
||||
_stopPct = p.Get("stopPct", 0.35);
|
||||
|
||||
_average = new Sma(period);
|
||||
_volatility = new RealizedVolatility(p.GetInt("volPeriod", 30), p.GetInt("barsPerYear", 365));
|
||||
_cvd = new CumulativeVolumeDelta(p.GetInt("cvdPeriod", 10), p.GetInt("cvdNormPeriod", 60));
|
||||
}
|
||||
|
||||
public override string Name => "trend-filter";
|
||||
|
||||
public override int WarmupBars => _average.Period + 2;
|
||||
|
||||
public override bool IsReady => _average.IsReady;
|
||||
|
||||
/// <summary>Where price sits relative to the average, as a fraction. Positive is above.</summary>
|
||||
public double Distance => _distance;
|
||||
|
||||
public double Average => _average.IsReady ? _average.Value : double.NaN;
|
||||
|
||||
public double Volatility => _volatility.IsReady ? _volatility.Value : double.NaN;
|
||||
|
||||
public double CvdScore => _cvd.IsReady ? _cvd.Value : 0;
|
||||
|
||||
protected override Signal Evaluate(in Bar bar, in PositionView position)
|
||||
{
|
||||
_average.Update(bar.Close);
|
||||
_volatility.Update(bar.Close);
|
||||
_cvd.Update(bar);
|
||||
_lastClose = bar.Close;
|
||||
|
||||
if (!_average.IsReady || _average.Value <= 0)
|
||||
{
|
||||
return Signal.Flat;
|
||||
}
|
||||
|
||||
_distance = (bar.Close - _average.Value) / _average.Value;
|
||||
|
||||
if (!position.IsFlat)
|
||||
{
|
||||
// Only one thing closes this position: price losing the average by more than
|
||||
// the band. No trailing stop, no profit target, no time stop — each of those
|
||||
// ends the trade during the moves the whole strategy exists to capture.
|
||||
return _distance <= -_band
|
||||
? Signal.Exit($"prezzo sotto la media di {-_distance:P1}")
|
||||
: Signal.Flat;
|
||||
}
|
||||
|
||||
if (_distance < _band)
|
||||
{
|
||||
return Signal.Flat;
|
||||
}
|
||||
|
||||
if (_cvdThreshold > 0 && _cvd.HasFlowData && CvdScore < _cvdThreshold)
|
||||
{
|
||||
return Signal.Flat;
|
||||
}
|
||||
|
||||
return Signal.EnterLong(
|
||||
$"prezzo sopra la media di {_distance:P1}",
|
||||
stopPrice: bar.Close * (1 - _stopPct),
|
||||
targetPrice: double.NaN,
|
||||
strength: 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the model would do at <paramref name="price"/> right now, and how far the
|
||||
/// price is from making it do something else. Reads state, never changes it.
|
||||
/// </summary>
|
||||
public override string Explain(double price, in PositionView position)
|
||||
{
|
||||
if (!_average.IsReady)
|
||||
{
|
||||
return $"warm-up: mancano ancora barre alla media a {_average.Period} giorni";
|
||||
}
|
||||
|
||||
double average = _average.Value;
|
||||
if (average <= 0 || price <= 0)
|
||||
{
|
||||
return "in attesa di un prezzo valido";
|
||||
}
|
||||
|
||||
double distance = (price - average) / average;
|
||||
|
||||
if (!position.IsFlat)
|
||||
{
|
||||
double exitAt = average * (1 - _band);
|
||||
double room = (price - exitAt) / price;
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"IN POSIZIONE — prezzo {price:N0} è {distance:+0.00%;-0.00%} dalla media {average:N0}. " +
|
||||
$"Esco se scende sotto {exitAt:N0} (−{room:P1} da qui). Nessun target: si lascia correre.");
|
||||
}
|
||||
|
||||
if (distance >= _band)
|
||||
{
|
||||
if (_cvdThreshold > 0 && _cvd.HasFlowData && CvdScore < _cvdThreshold)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"FERMO — prezzo sopra la soglia, ma il filtro di order flow è chiuso: " +
|
||||
$"CVD {CvdScore:F2} sotto la soglia {_cvdThreshold:F2}.");
|
||||
}
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"PRONTO A COMPRARE — prezzo {price:N0} è {distance:+0.00%} sopra la media {average:N0}, " +
|
||||
$"oltre la soglia del {_band:P0}. Entro alla prossima barra chiusa.");
|
||||
}
|
||||
|
||||
double entryAt = average * (1 + _band);
|
||||
double needed = (entryAt - price) / price;
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"FERMO — prezzo {price:N0} è {distance:+0.00%;-0.00%} dalla media {average:N0}. " +
|
||||
$"Per comprare serve che superi {entryAt:N0}, cioè {needed:+0.00%} da qui.");
|
||||
}
|
||||
|
||||
public override IReadOnlyList<StrategyMetric> Diagnostics =>
|
||||
[
|
||||
new("distanza", double.IsFinite(_distance) ? _distance : 0, "P1"),
|
||||
new("media", _average.IsReady ? _average.Value : 0, "F0"),
|
||||
new("prezzo", _lastClose, "F0"),
|
||||
new("volatilità", _volatility.IsReady ? _volatility.Value : 0, "P1"),
|
||||
new("cvd", CvdScore, "F2"),
|
||||
];
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
base.Reset();
|
||||
_average.Reset();
|
||||
_volatility.Reset();
|
||||
_cvd.Reset();
|
||||
_distance = 0;
|
||||
_lastClose = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Encelado.Core.Indicators;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class EfficiencyRatioTests
|
||||
{
|
||||
[Fact]
|
||||
public void AStraightLineIsPerfectlyEfficient()
|
||||
{
|
||||
EfficiencyRatio er = new(10);
|
||||
for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
er.Update(i);
|
||||
}
|
||||
|
||||
Assert.True(er.IsReady);
|
||||
Assert.Equal(1.0, er.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PureOscillationIsCompletelyInefficient()
|
||||
{
|
||||
EfficiencyRatio er = new(10);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
er.Update(i % 2 == 0 ? 100 : 101);
|
||||
}
|
||||
|
||||
Assert.True(er.IsReady);
|
||||
Assert.Equal(0.0, er.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANoisyTrendSitsBetweenTheExtremes()
|
||||
{
|
||||
EfficiencyRatio er = new(10);
|
||||
double price = 100;
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
price += i % 3 == 0 ? -0.5 : 1.0;
|
||||
er.Update(price);
|
||||
}
|
||||
|
||||
Assert.InRange(er.Value, 0.05, 0.95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsAFullWindowBeforeReporting()
|
||||
{
|
||||
EfficiencyRatio er = new(5);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
er.Update(100 + i);
|
||||
Assert.False(er.IsReady);
|
||||
}
|
||||
|
||||
er.Update(105);
|
||||
Assert.True(er.IsReady);
|
||||
}
|
||||
}
|
||||
|
||||
public class RealizedVolatilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void AFlatSeriesHasZeroVolatility()
|
||||
{
|
||||
RealizedVolatility vol = new(20, 525_600);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
vol.Update(100);
|
||||
}
|
||||
|
||||
Assert.True(vol.IsReady);
|
||||
Assert.Equal(0, vol.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnnualisationScalesByTheSquareRootOfBarsPerYear()
|
||||
{
|
||||
RealizedVolatility perBar = new(20, 1);
|
||||
RealizedVolatility annual = new(20, 4);
|
||||
|
||||
double price = 100;
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
price *= i % 2 == 0 ? 1.01 : 0.995;
|
||||
perBar.Update(price);
|
||||
annual.Update(price);
|
||||
}
|
||||
|
||||
Assert.True(perBar.Value > 0);
|
||||
Assert.Equal(perBar.Value * 2, annual.Value, 10);
|
||||
Assert.Equal(perBar.PerBar, annual.PerBar, 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresNonPositivePrices()
|
||||
{
|
||||
RealizedVolatility vol = new(5, 1);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
vol.Update(100);
|
||||
}
|
||||
|
||||
double before = vol.Value;
|
||||
vol.Update(0);
|
||||
vol.Update(-5);
|
||||
|
||||
Assert.Equal(before, vol.Value, 12);
|
||||
}
|
||||
}
|
||||
|
||||
public class KeltnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChannelSitsAtTheAtrMultipleAroundTheEma()
|
||||
{
|
||||
Keltner keltner = new(period: 10, atrMultiplier: 2.0, atrPeriod: 10);
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
keltner.Update(new Bar(DateTime.UtcNow, 100, 100.5, 99.5, 100, 1000, 100, 10));
|
||||
}
|
||||
|
||||
Assert.True(keltner.IsReady);
|
||||
Assert.Equal(100, keltner.Value, 6);
|
||||
|
||||
// Constant 1.0 range means ATR = 1, so the channel is +/- 2.
|
||||
Assert.Equal(102, keltner.Upper, 6);
|
||||
Assert.Equal(98, keltner.Lower, 6);
|
||||
Assert.Equal(4, keltner.Width, 6);
|
||||
}
|
||||
}
|
||||
|
||||
public class RollingZScoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScoresTheLatestSampleAgainstItsOwnWindow()
|
||||
{
|
||||
RollingZScore z = new(4);
|
||||
foreach (double v in new double[] { 2, 4, 4, 6 })
|
||||
{
|
||||
z.Update(v);
|
||||
}
|
||||
|
||||
// mean 4, sample stddev sqrt(8/3); the last sample sits 2 above the mean.
|
||||
Assert.True(z.IsReady);
|
||||
Assert.Equal(2.0 / Math.Sqrt(8.0 / 3.0), z.Value, 10);
|
||||
Assert.Equal(4, z.Mean, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AConstantSeriesScoresZeroInsteadOfDividingByZero()
|
||||
{
|
||||
RollingZScore z = new(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
z.Update(42);
|
||||
}
|
||||
|
||||
Assert.Equal(0, z.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
public class BollingerDispersionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExposesTheStandardDeviationTheBandsAreBuiltFrom()
|
||||
{
|
||||
BollingerBands bands = new(4, 2.0);
|
||||
foreach (double v in new double[] { 2, 4, 4, 6 })
|
||||
{
|
||||
bands.Update(v);
|
||||
}
|
||||
|
||||
double sd = Math.Sqrt(8.0 / 3.0);
|
||||
Assert.Equal(sd, bands.StandardDeviation, 10);
|
||||
Assert.Equal(4 + (2 * sd), bands.Upper, 10);
|
||||
Assert.Equal(4 - (2 * sd), bands.Lower, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Core.Backtest;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Folding a fine-grained file into coarser bars. This is what makes a 342 MB minute
|
||||
/// file usable, so the arithmetic has to be exactly right: an open taken from the wrong
|
||||
/// row silently shifts every signal derived from it.
|
||||
/// </summary>
|
||||
public class CsvAggregationTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-agg-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>Minute rows starting at a known midnight, so bucket edges are obvious.</summary>
|
||||
private static string Minutes(params (int Minute, double O, double H, double L, double C, double V)[] rows)
|
||||
{
|
||||
// 1704067200 = 2024-01-01 00:00:00 UTC
|
||||
System.Text.StringBuilder sb = new("timestamp,open,high,low,close,volume\n");
|
||||
foreach ((int minute, double o, double h, double l, double c, double v) in rows)
|
||||
{
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"{1704067200 + (minute * 60)},{o},{h},{l},{c},{v}\n");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoldsMinutesIntoOneDailyBar()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 105, 99, 104, 10),
|
||||
(1, 104, 110, 103, 108, 20),
|
||||
(2, 108, 109, 95, 97, 30)));
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Single(bars);
|
||||
Assert.Equal(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
|
||||
Assert.Equal(100, bars[0].Open); // first row's open
|
||||
Assert.Equal(110, bars[0].High); // highest high anywhere in the bucket
|
||||
Assert.Equal(95, bars[0].Low); // lowest low
|
||||
Assert.Equal(97, bars[0].Close); // last row's close
|
||||
Assert.Equal(60, bars[0].Volume); // summed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitsAcrossBucketBoundaries()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 100, 100, 100, 1),
|
||||
(1439, 200, 200, 200, 200, 1), // last minute of day one
|
||||
(1440, 300, 300, 300, 300, 1))); // first minute of day two
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.Equal(100, bars[0].Open);
|
||||
Assert.Equal(200, bars[0].Close);
|
||||
Assert.Equal(300, bars[1].Open);
|
||||
Assert.Equal(new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc), bars[1].TimeUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HonoursHourlyAndFourHourlyWidths()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 100, 100, 100, 1),
|
||||
(59, 110, 110, 110, 110, 1),
|
||||
(60, 120, 120, 120, 120, 1),
|
||||
(239, 130, 130, 130, 130, 1)));
|
||||
|
||||
Assert.Equal(3, CsvBarSource.LoadAggregated(path, TimeSpan.FromHours(1)).Count);
|
||||
Assert.Single(CsvBarSource.LoadAggregated(path, TimeSpan.FromHours(4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfOrderRowsStillProduceTheRightOpenAndClose()
|
||||
{
|
||||
// Keyed by bucket rather than assumed sequential, so a file that jumps backwards
|
||||
// cannot corrupt the bar that happens to be open at the time.
|
||||
string path = Write(Minutes(
|
||||
(2, 108, 109, 95, 97, 30),
|
||||
(0, 100, 105, 99, 104, 10),
|
||||
(1, 104, 110, 103, 108, 20)));
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Single(bars);
|
||||
Assert.Equal(100, bars[0].Open);
|
||||
Assert.Equal(97, bars[0].Close);
|
||||
Assert.Equal(110, bars[0].High);
|
||||
Assert.Equal(95, bars[0].Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarriesTheTakerBreakdownThroughSoOrderFlowSurvives()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close,volume,taker_buy_base_asset_volume
|
||||
1704067200,100,100,100,100,100,70
|
||||
1704067260,100,100,100,100,100,10
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Single(bars);
|
||||
Assert.Equal(200, bars[0].Volume);
|
||||
Assert.Equal(80, bars[0].TakerBuyVolume);
|
||||
Assert.True(bars[0].HasOrderFlow);
|
||||
|
||||
// delta = 2*80 - 200
|
||||
Assert.Equal(-40, bars[0].Delta, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputesAVolumeWeightedPrice()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 10, 10, 10, 10, 100),
|
||||
(1, 30, 30, 30, 30, 300)));
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
// (10*100 + 30*300) / 400
|
||||
Assert.Equal(25, bars[0].Vwap, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AggregatingIsEquivalentToLoadingWhenTheBucketMatchesTheSource()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 105, 99, 104, 10),
|
||||
(1, 104, 110, 103, 108, 20)));
|
||||
|
||||
IReadOnlyList<Bar> raw = CsvBarSource.Load(path);
|
||||
IReadOnlyList<Bar> folded = CsvBarSource.LoadAggregated(path, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.Equal(raw.Count, folded.Count);
|
||||
for (int i = 0; i < raw.Count; i++)
|
||||
{
|
||||
Assert.Equal(raw[i].TimeUtc, folded[i].TimeUtc);
|
||||
Assert.Equal(raw[i].Open, folded[i].Open, 9);
|
||||
Assert.Equal(raw[i].High, folded[i].High, 9);
|
||||
Assert.Equal(raw[i].Low, folded[i].Low, 9);
|
||||
Assert.Equal(raw[i].Close, folded[i].Close, 9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsANonPositiveWidth()
|
||||
{
|
||||
string path = Write(Minutes((0, 1, 1, 1, 1, 1)));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
CsvBarSource.LoadAggregated(path, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAMissingFileClearly() =>
|
||||
Assert.Throws<FileNotFoundException>(() =>
|
||||
CsvBarSource.LoadAggregated(
|
||||
Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.csv"),
|
||||
TimeSpan.FromDays(1)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Editing the configuration from the settings screen. The point of these is that the
|
||||
/// file survives: it carries the documentation for every tuned number, and a writer
|
||||
/// that reformatted or dropped keys would destroy it.
|
||||
/// </summary>
|
||||
public class ConfigWriterTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-cfg-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetsTheLogDirectory()
|
||||
{
|
||||
string path = Write("""{"logging":{"directory":"logs","level":"info"}}""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, @"D:\encelado-logs");
|
||||
|
||||
Assert.Contains(@"D:\\encelado-logs", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeepsEverythingElseIncludingTheInlineDocumentation()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
{
|
||||
"_comment": "questa riga documenta la configurazione",
|
||||
"alpaca": { "paper": true, "dataFeed": "iex" },
|
||||
"logging": {
|
||||
"_level": "spiegazione del livello",
|
||||
"level": "debug",
|
||||
"directory": "logs",
|
||||
"maxFiles": 10
|
||||
},
|
||||
"symbols": [ { "symbol": "BTC/USD", "parameters": { "fast": 50 } } ]
|
||||
}
|
||||
""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, "altrove");
|
||||
|
||||
string after = File.ReadAllText(path);
|
||||
Assert.Contains("_comment", after, StringComparison.Ordinal);
|
||||
Assert.Contains("questa riga documenta", after, StringComparison.Ordinal);
|
||||
Assert.Contains("_level", after, StringComparison.Ordinal);
|
||||
Assert.Contains("spiegazione del livello", after, StringComparison.Ordinal);
|
||||
Assert.Contains("\"debug\"", after, StringComparison.Ordinal);
|
||||
Assert.Contains("\"iex\"", after, StringComparison.Ordinal);
|
||||
Assert.Contains("\"fast\"", after, StringComparison.Ordinal);
|
||||
Assert.Contains("altrove", after, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StillLoadsAfterBeingWritten()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
{
|
||||
"alpaca": { "paper": true },
|
||||
"logging": { "level": "debug", "directory": "logs" },
|
||||
"symbols": [ { "symbol": "BTC/USD", "strategy": "trend-filter", "enabled": true } ]
|
||||
}
|
||||
""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, "nuova-cartella");
|
||||
|
||||
BotConfig reloaded = ConfigLoader.Load(path, out _);
|
||||
Assert.Equal("nuova-cartella", reloaded.Logging.Directory);
|
||||
Assert.Equal("debug", reloaded.Logging.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatesTheLoggingSectionWhenItIsAbsent()
|
||||
{
|
||||
string path = Write("""{"alpaca":{"paper":true}}""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, "logs");
|
||||
|
||||
Assert.Contains("logging", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAMissingFileRatherThanCreatingOne()
|
||||
{
|
||||
string missing = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json");
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() => ConfigWriter.SetLogDirectory(missing, "logs"));
|
||||
Assert.False(File.Exists(missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavesTheOriginalIntactWhenTheFileIsNotAnObject()
|
||||
{
|
||||
string path = Write("[1, 2, 3]");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ConfigWriter.SetLogDirectory(path, "logs"));
|
||||
Assert.Equal("[1, 2, 3]", File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Alpaca;
|
||||
using Encelado.Alpaca.Internal;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Alpaca.Streaming;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class Rfc3339Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesNanosecondPrecisionTimestamps()
|
||||
{
|
||||
DateTime parsed = Rfc3339.ParseUtc("2024-05-17T13:04:56.334262119Z"u8);
|
||||
|
||||
Assert.Equal(DateTimeKind.Utc, parsed.Kind);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 4, 56, DateTimeKind.Utc).AddTicks(3_342_621), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesTimestampsWithoutAFraction()
|
||||
{
|
||||
DateTime parsed = Rfc3339.ParseUtc("2024-05-17T13:04:56Z"u8);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 4, 56, DateTimeKind.Utc), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesShortFractions()
|
||||
{
|
||||
DateTime parsed = Rfc3339.ParseUtc("2024-05-17T13:04:56.5Z"u8);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 4, 56, 500, DateTimeKind.Utc), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsMinValueForGarbage()
|
||||
{
|
||||
Assert.Equal(DateTime.MinValue, Rfc3339.ParseUtc("not-a-date"u8));
|
||||
Assert.Equal(DateTime.MinValue, Rfc3339.ParseUtc((string?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesTheFrameworkParserOnValidInput()
|
||||
{
|
||||
const string text = "2026-07-29T09:15:00.1234567Z";
|
||||
Assert.Equal(Rfc3339.ParseUtc(text), Rfc3339.ParseUtc(Encoding.UTF8.GetBytes(text)));
|
||||
}
|
||||
}
|
||||
|
||||
public class SymbolTableTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolvesSubscribedSymbolsFromUtf8()
|
||||
{
|
||||
SymbolTable table = new(["AAPL", "MSFT", "BTC/USD"]);
|
||||
|
||||
Assert.Equal(0, table.Resolve("AAPL"u8));
|
||||
Assert.Equal(1, table.Resolve("MSFT"u8));
|
||||
Assert.Equal(2, table.Resolve("BTC/USD"u8));
|
||||
Assert.Equal("BTC/USD", table.Name(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsMinusOneForUnknownSymbols()
|
||||
{
|
||||
SymbolTable table = new(["AAPL"]);
|
||||
|
||||
Assert.Equal(-1, table.Resolve("TSLA"u8));
|
||||
Assert.Equal(-1, table.Resolve(""u8));
|
||||
Assert.Equal(-1, table.Resolve("THIS-SYMBOL-IS-FAR-TOO-LONG-TO-BE-REAL"u8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresDuplicatesAndIsCaseInsensitive()
|
||||
{
|
||||
SymbolTable table = new(["AAPL", "aapl", " MSFT "]);
|
||||
|
||||
Assert.Equal(2, table.Count);
|
||||
Assert.Equal(0, table.Resolve("aapl"u8));
|
||||
Assert.Equal(1, table.Resolve("MSFT"u8));
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void WritesASimpleLimitOrder()
|
||||
{
|
||||
NewOrder order = new()
|
||||
{
|
||||
Symbol = "AAPL",
|
||||
Side = Side.Buy,
|
||||
Quantity = 10,
|
||||
Type = OrderType.Limit,
|
||||
LimitPrice = 123.456,
|
||||
TimeInForce = TimeInForce.Day,
|
||||
ClientOrderId = "enc-1",
|
||||
};
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(AlpacaTradingClient.WriteOrderJson(order));
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
Assert.Equal("AAPL", root.GetProperty("symbol").GetString());
|
||||
Assert.Equal("buy", root.GetProperty("side").GetString());
|
||||
Assert.Equal("limit", root.GetProperty("type").GetString());
|
||||
Assert.Equal("day", root.GetProperty("time_in_force").GetString());
|
||||
Assert.Equal("10", root.GetProperty("qty").GetString());
|
||||
Assert.Equal("123.46", root.GetProperty("limit_price").GetString());
|
||||
Assert.Equal("enc-1", root.GetProperty("client_order_id").GetString());
|
||||
Assert.False(root.TryGetProperty("order_class", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritesABracketOrderWithBothLegs()
|
||||
{
|
||||
NewOrder order = new()
|
||||
{
|
||||
Symbol = "MSFT",
|
||||
Side = Side.Buy,
|
||||
Quantity = 5,
|
||||
Type = OrderType.Market,
|
||||
StopLossStopPrice = 95.5,
|
||||
TakeProfitLimitPrice = 110.25,
|
||||
};
|
||||
|
||||
Assert.Equal("bracket", order.OrderClass);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(AlpacaTradingClient.WriteOrderJson(order));
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
Assert.Equal("bracket", root.GetProperty("order_class").GetString());
|
||||
Assert.Equal("110.25", root.GetProperty("take_profit").GetProperty("limit_price").GetString());
|
||||
Assert.Equal("95.5", root.GetProperty("stop_loss").GetProperty("stop_price").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneLegProducesAnOtoOrder()
|
||||
{
|
||||
NewOrder order = new()
|
||||
{
|
||||
Symbol = "MSFT",
|
||||
Side = Side.Buy,
|
||||
Quantity = 5,
|
||||
StopLossStopPrice = 95.5,
|
||||
};
|
||||
|
||||
Assert.Equal("oto", order.OrderClass);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(AlpacaTradingClient.WriteOrderJson(order));
|
||||
Assert.Equal("oto", doc.RootElement.GetProperty("order_class").GetString());
|
||||
Assert.False(doc.RootElement.TryGetProperty("take_profit", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(123.456, "123.46")]
|
||||
[InlineData(100.0, "100")]
|
||||
[InlineData(0.12345, "0.1235")]
|
||||
[InlineData(0.5, "0.5")]
|
||||
[InlineData(1.006, "1.01")]
|
||||
[InlineData(0.99999, "1")]
|
||||
public void PricesAreRoundedToAValidIncrement(double input, string expected) =>
|
||||
Assert.Equal(expected, AlpacaTradingClient.FormatPrice(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData(10.0, "10")]
|
||||
[InlineData(0.5, "0.5")]
|
||||
[InlineData(1.234567890123, "1.23456789")]
|
||||
public void QuantitiesKeepAtMostNineDecimals(double input, string expected) =>
|
||||
Assert.Equal(expected, AlpacaTradingClient.FormatQuantity(input));
|
||||
}
|
||||
|
||||
public class AlpacaModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesAnAccountWithStringEncodedNumbers()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "abc", "account_number": "PA123", "status": "ACTIVE", "currency": "USD",
|
||||
"cash": "12345.67", "equity": "100000.00", "last_equity": "99000.00",
|
||||
"buying_power": "400000.00", "daytrading_buying_power": "400000.00",
|
||||
"portfolio_value": "100000.00", "multiplier": "4", "daytrade_count": 2,
|
||||
"pattern_day_trader": false, "trading_blocked": false, "account_blocked": false,
|
||||
"transfers_blocked": false, "trade_suspended_by_user": false, "shorting_enabled": true
|
||||
}
|
||||
""";
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
AlpacaAccount account = AlpacaAccount.FromJson(doc.RootElement);
|
||||
|
||||
Assert.Equal(100_000m, account.Equity);
|
||||
Assert.Equal(12_345.67m, account.Cash);
|
||||
Assert.Equal(2, account.DaytradeCount);
|
||||
Assert.True(account.CanTrade);
|
||||
Assert.True(account.ShortingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABlockedAccountCannotTrade()
|
||||
{
|
||||
const string json = """{"status":"ACTIVE","trading_blocked":true}""";
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
|
||||
Assert.False(AlpacaAccount.FromJson(doc.RootElement).CanTrade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesShortPositionsAsNegativeQuantity()
|
||||
{
|
||||
const string json = """
|
||||
{"symbol":"AAPL","asset_class":"us_equity","qty":"10","side":"short",
|
||||
"avg_entry_price":"150.25","current_price":"148.00","market_value":"-1480",
|
||||
"unrealized_pl":"22.5","unrealized_plpc":"0.015"}
|
||||
""";
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
AlpacaPosition position = AlpacaPosition.FromJson(doc.RootElement);
|
||||
|
||||
Assert.Equal(-10, position.Quantity);
|
||||
Assert.Equal(150.25, position.AverageEntryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesAnOrderWithNestedBracketLegs()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "parent", "client_order_id": "enc-1", "symbol": "AAPL", "side": "buy",
|
||||
"type": "limit", "order_class": "bracket", "status": "new",
|
||||
"qty": "10", "filled_qty": "0", "filled_avg_price": null,
|
||||
"limit_price": "150.00", "stop_price": null, "submitted_at": "2026-07-29T13:30:00Z",
|
||||
"legs": [
|
||||
{"id":"tp","symbol":"AAPL","side":"sell","type":"limit","status":"held","qty":"10","limit_price":"160.00"},
|
||||
{"id":"sl","symbol":"AAPL","side":"sell","type":"stop","status":"held","qty":"10","stop_price":"145.00"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
AlpacaOrder order = AlpacaOrder.FromJson(doc.RootElement);
|
||||
|
||||
Assert.Equal("parent", order.Id);
|
||||
Assert.Equal(Side.Buy, order.Side);
|
||||
Assert.Equal(OrderStatus.New, order.Status);
|
||||
Assert.True(order.IsWorking);
|
||||
Assert.Equal(2, order.Legs.Count);
|
||||
Assert.Equal("sl", order.Legs[1].Id);
|
||||
Assert.Equal(145.0, order.Legs[1].StopPrice);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("filled", OrderStatus.Filled, true)]
|
||||
[InlineData("canceled", OrderStatus.Canceled, true)]
|
||||
[InlineData("partially_filled", OrderStatus.PartiallyFilled, false)]
|
||||
[InlineData("new", OrderStatus.New, false)]
|
||||
[InlineData("nonsense", OrderStatus.Unknown, false)]
|
||||
public void OrderStatusesMapAndClassify(string wire, OrderStatus expected, bool terminal)
|
||||
{
|
||||
OrderStatus status = OrderStatusParser.Parse(wire);
|
||||
Assert.Equal(expected, status);
|
||||
Assert.Equal(terminal, status.IsTerminal());
|
||||
}
|
||||
}
|
||||
|
||||
public class AlpacaOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DerivesEndpointsFromThePaperFlag()
|
||||
{
|
||||
AlpacaOptions paper = new() { KeyId = "k", SecretKey = "s", Paper = true };
|
||||
Assert.Equal(AlpacaOptions.PaperTradingBase, paper.TradingBaseUrl);
|
||||
Assert.Equal("wss://paper-api.alpaca.markets/stream", paper.TradeUpdatesStreamUri.ToString());
|
||||
|
||||
AlpacaOptions live = new() { KeyId = "k", SecretKey = "s", Paper = false };
|
||||
Assert.Equal(AlpacaOptions.LiveTradingBase, live.TradingBaseUrl);
|
||||
Assert.Equal("wss://api.alpaca.markets/stream", live.TradeUpdatesStreamUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PicksTheRightMarketDataStreamPerAssetClass()
|
||||
{
|
||||
AlpacaOptions options = new() { KeyId = "k", SecretKey = "s", DataFeed = "sip" };
|
||||
|
||||
Assert.Equal("wss://stream.data.alpaca.markets/v2/sip",
|
||||
options.MarketDataStreamUri(AssetClass.UsEquity).ToString());
|
||||
Assert.Equal("wss://stream.data.alpaca.markets/v1beta3/crypto/us",
|
||||
options.MarketDataStreamUri(AssetClass.Crypto).ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationRequiresCredentialsAndAKnownFeed()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => new AlpacaOptions().Validate());
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
new AlpacaOptions { KeyId = "k", SecretKey = "s", DataFeed = "nope" }.Validate());
|
||||
|
||||
AlpacaOptions valid = new() { KeyId = "k", SecretKey = "s" };
|
||||
Assert.Same(valid, valid.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PK\u00A0KEY")] // non-breaking space from a web page
|
||||
[InlineData("PK\u200BKEY")] // zero-width space
|
||||
[InlineData("PKKEY\uFEFF")] // byte-order mark
|
||||
[InlineData("PK\tKEY")] // tab
|
||||
[InlineData("PK\u201CKEY")] // smart quote
|
||||
public void CredentialsWithNonAsciiCharactersAreRejectedUpFront(string keyId)
|
||||
{
|
||||
AlpacaOptions options = new() { KeyId = keyId, SecretKey = "secret" };
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
Assert.Contains("printable ASCII", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANonAsciiSecretIsRejectedToo()
|
||||
{
|
||||
AlpacaOptions options = new() { KeyId = "PKKEY", SecretKey = "secret\u00A0value" };
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
Assert.Contains("secretKey", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class TimeFrameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TimeFrame.OneMinute, "1Min", 60)]
|
||||
[InlineData(TimeFrame.FiveMinutes, "5Min", 300)]
|
||||
[InlineData(TimeFrame.FifteenMinutes, "15Min", 900)]
|
||||
[InlineData(TimeFrame.OneHour, "1Hour", 3600)]
|
||||
[InlineData(TimeFrame.OneDay, "1Day", 86_400)]
|
||||
public void MapsToTheAlpacaWireFormat(TimeFrame tf, string wire, int seconds)
|
||||
{
|
||||
Assert.Equal(wire, tf.ToAlpaca());
|
||||
Assert.Equal(seconds, tf.Seconds());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1Min", TimeFrame.OneMinute)]
|
||||
[InlineData("5m", TimeFrame.FiveMinutes)]
|
||||
[InlineData("1HOUR", TimeFrame.OneHour)]
|
||||
public void ParsesCommonSpellings(string text, TimeFrame expected)
|
||||
{
|
||||
Assert.True(TimeFrameExtensions.TryParse(text, out TimeFrame tf));
|
||||
Assert.Equal(expected, tf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsUnsupportedTimeframes() =>
|
||||
Assert.False(TimeFrameExtensions.TryParse("3Min", out _));
|
||||
}
|
||||
|
||||
public class QuoteTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputesMidAndRelativeSpread()
|
||||
{
|
||||
Quote quote = new(DateTime.UtcNow, 99.9, 100, 100.1, 200);
|
||||
|
||||
Assert.Equal(100, quote.Mid, 10);
|
||||
Assert.Equal(0.2, quote.Spread, 10);
|
||||
Assert.Equal(0.002, quote.RelativeSpread, 10);
|
||||
Assert.True(quote.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEmptyOrCrossedBookIsInvalid()
|
||||
{
|
||||
Assert.False(new Quote(DateTime.UtcNow, 0, 0, 100, 1).IsValid);
|
||||
Assert.False(new Quote(DateTime.UtcNow, 101, 1, 100, 1).IsValid);
|
||||
Assert.Equal(double.PositiveInfinity, new Quote(DateTime.UtcNow, 0, 0, 0, 0).RelativeSpread);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
using Encelado.Core.Backtest;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class ReplayerTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Short periods so a few hundred synthetic bars produce several round trips. The
|
||||
// shipped values (100 bars, 2% band) would barely trade inside a test fixture.
|
||||
private static StrategyParameters Params() => new StrategyParameters()
|
||||
.Set("period", 12)
|
||||
.Set("band", 0.01)
|
||||
.Set("stopPct", 0.35)
|
||||
.Set("cvdThreshold", 0)
|
||||
.Set("volPeriod", 12)
|
||||
.Set("atrPeriod", 8)
|
||||
.Set("barsPerYear", 365);
|
||||
|
||||
private static BacktestSettings Settings(double feeBps = 0) => new()
|
||||
{
|
||||
// Mirrors config/encelado.json where it matters: full stake, and a stop-distance
|
||||
// ceiling wide enough to accept the strategy's deliberately far backstop. At the
|
||||
// default 15% every entry would be refused as InvalidStop and the replayer would
|
||||
// report a clean run with zero trades.
|
||||
Risk = new RiskLimits
|
||||
{
|
||||
StakePct = 1.0,
|
||||
MaxRiskPerTradePct = 0.01,
|
||||
MaxPositionNotionalPct = 1.0,
|
||||
MaxGrossExposurePct = 1.0,
|
||||
MaxOpenPositions = 1,
|
||||
MaxTradesPerDay = 1000,
|
||||
MaxTradesPerSymbolPerDay = 1000,
|
||||
MinSecondsBetweenEntries = 0,
|
||||
MaxRelativeSpread = 0,
|
||||
MinOrderNotional = 1,
|
||||
DefaultStopPct = 0.35,
|
||||
MaxStopDistancePct = 0.60,
|
||||
},
|
||||
StartingEquity = 100_000,
|
||||
SlippageBps = 5,
|
||||
FeeBps = feeBps,
|
||||
AllowFractional = true,
|
||||
};
|
||||
|
||||
/// <summary>A saw-tooth: long enough legs in both directions to trigger crossings.</summary>
|
||||
private static List<Bar> SawTooth(int cycles, int legLength, double amplitude)
|
||||
{
|
||||
List<Bar> bars = [];
|
||||
double price = 100;
|
||||
int index = 0;
|
||||
|
||||
for (int c = 0; c < cycles; c++)
|
||||
{
|
||||
for (int i = 0; i < legLength; i++)
|
||||
{
|
||||
price += amplitude;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
|
||||
for (int i = 0; i < legLength; i++)
|
||||
{
|
||||
price -= amplitude;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
}
|
||||
|
||||
return bars;
|
||||
|
||||
static Bar Make(int i, double close) =>
|
||||
new(Start.AddMinutes(i), close, close + 0.4, close - 0.4, close, 10_000, close, 25);
|
||||
}
|
||||
|
||||
private static BacktestReport Run(
|
||||
IReadOnlyList<Bar> bars, BacktestSettings settings, StrategyParameters? p = null) =>
|
||||
new Replayer(settings).Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", p ?? Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = bars });
|
||||
|
||||
[Fact]
|
||||
public void ProducesTradesAndACoherentEquityCurve()
|
||||
{
|
||||
List<Bar> bars = SawTooth(6, 45, 1.2);
|
||||
BacktestReport report = Run(bars, Settings());
|
||||
|
||||
Assert.True(report.Trades.Count > 0, "the saw-tooth should trigger at least one round trip");
|
||||
Assert.Equal(bars.Count, report.BarsProcessed);
|
||||
|
||||
// Every closed trade must be accounted for exactly once in the final equity.
|
||||
Assert.Equal(report.StartEquity + report.Trades.Sum(t => t.Pnl), report.EndEquity, 6);
|
||||
Assert.Equal(report.Trades.Count, report.Wins + report.Losses);
|
||||
Assert.InRange(report.MaxDrawdownPct, 0, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntriesFillOnTheNextBarNotTheSignalBar()
|
||||
{
|
||||
List<Bar> bars = SawTooth(6, 45, 1.2);
|
||||
BacktestReport report = Run(bars, Settings());
|
||||
|
||||
foreach (ClosedTrade trade in report.Trades)
|
||||
{
|
||||
// The fill must match some bar's open plus slippage, never a close.
|
||||
Assert.Contains(bars, b => Math.Abs((b.Open * 1.0005) - trade.EntryPrice) < 1e-6);
|
||||
Assert.True(trade.ExitUtc >= trade.EntryUtc);
|
||||
Assert.True(trade.Quantity > 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EverythingIsLiquidatedAtTheEndOfTheRun()
|
||||
{
|
||||
// Down then up, ending firmly above the average, so the run finishes holding a
|
||||
// position that the replayer has to liquidate.
|
||||
List<Bar> bars = [];
|
||||
double price = 200;
|
||||
int index = 0;
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
price *= 0.995;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
|
||||
for (int i = 0; i < 150; i++)
|
||||
{
|
||||
price *= 1.006;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
|
||||
BacktestReport report = Run(bars, Settings(), Params());
|
||||
|
||||
Assert.Contains(report.Trades, t => t.ExitReason == "end of backtest");
|
||||
Assert.Equal(report.StartEquity + report.Trades.Sum(t => t.Pnl), report.EndEquity, 6);
|
||||
|
||||
static Bar Make(int i, double close) =>
|
||||
new(Start.AddMinutes(i), close, close + 0.3, close - 0.3, close, 10_000, close, 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeesAreChargedOnBothSidesAndReduceTheResult()
|
||||
{
|
||||
List<Bar> bars = SawTooth(6, 45, 1.2);
|
||||
|
||||
BacktestReport free = Run(bars, Settings(feeBps: 0));
|
||||
BacktestReport charged = Run(bars, Settings(feeBps: 25));
|
||||
|
||||
Assert.Equal(0, free.TotalFees, 6);
|
||||
Assert.True(charged.TotalFees > 0, "a 25 bps fee must actually cost something");
|
||||
Assert.True(charged.EndEquity < free.EndEquity, "fees must reduce the final equity");
|
||||
|
||||
// Two fills per round trip, so the total is roughly 2 x fee x notional.
|
||||
foreach (ClosedTrade trade in charged.Trades)
|
||||
{
|
||||
Assert.True(trade.Fees > 0);
|
||||
Assert.Equal(trade.GrossPnl - trade.Fees, trade.Pnl, 9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowsWhenNoSymbolHasHistory()
|
||||
{
|
||||
Replayer replayer = new(Settings());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
replayer.Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsProgressAndHonoursCancellation()
|
||||
{
|
||||
List<double> progress = [];
|
||||
Replayer replayer = new(Settings()) { OnProgress = progress.Add };
|
||||
|
||||
replayer.Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = SawTooth(6, 45, 1.2) });
|
||||
|
||||
Assert.NotEmpty(progress);
|
||||
Assert.Equal(1.0, progress[^1], 6);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
cts.Cancel();
|
||||
|
||||
Assert.Throws<OperationCanceledException>(() =>
|
||||
new Replayer(Settings()).Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = SawTooth(6, 45, 1.2) },
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderProducesASummaryWithoutThrowingOnAnEmptyRun()
|
||||
{
|
||||
BacktestReport empty = new(100_000, 100_000, 0, [], 0, Start, Start.AddDays(1), 0);
|
||||
|
||||
Assert.Equal(0, empty.WinRate);
|
||||
Assert.Equal(0, empty.ProfitFactor);
|
||||
Assert.Contains("trades 0", empty.Render(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class CsvBarSourceTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-csv-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsBinanceKlinesWithMillisecondTimestamps()
|
||||
{
|
||||
// 1502942400000 = 2017-08-17 04:00:00 UTC
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close,volume,close_timestamp,quote_asset_volume,number_of_trades
|
||||
1502942400000,4261.48,4280.56,4261.48,4261.48,2,1502943299999,9333.62,9
|
||||
1502943300000,4261.48,4270.41,4261.32,4261.45,9,1502944199999,38891.1,40
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.Equal(new DateTime(2017, 8, 17, 4, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
|
||||
Assert.Equal(4261.48, bars[0].Open);
|
||||
Assert.Equal(4280.56, bars[0].High);
|
||||
Assert.Equal(4261.48, bars[0].Low);
|
||||
Assert.Equal(4261.48, bars[0].Close);
|
||||
Assert.Equal(2, bars[0].Volume);
|
||||
Assert.Equal(9, bars[0].TradeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsIsoDatesAndSecondEpochsToo()
|
||||
{
|
||||
string iso = Write(
|
||||
"""
|
||||
date,open,high,low,close,volume
|
||||
2024-05-17T13:00:00Z,100,110,95,105,1000
|
||||
2024-05-17T14:00:00Z,105,115,100,112,1200
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(iso);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
|
||||
Assert.Equal(105, bars[0].Close);
|
||||
|
||||
string seconds = Write(
|
||||
"""
|
||||
time,open,high,low,close
|
||||
1715950800,100,110,95,105
|
||||
""");
|
||||
|
||||
Assert.Equal(
|
||||
DateTimeOffset.FromUnixTimeSeconds(1715950800).UtcDateTime,
|
||||
CsvBarSource.Load(seconds)[0].TimeUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsMalformedRowsInsteadOfFailing()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close,volume
|
||||
1502942400000,4261.48,4280.56,4261.48,4261.48,2
|
||||
not-a-number,1,2,3,4,5
|
||||
1502943300000,abc,4270.41,4261.32,4261.45,9
|
||||
1502944200000,0,0,0,0,0
|
||||
1502945100000,100,110,95,105,7
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.Equal(105, bars[1].Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortsRowsThatArriveOutOfOrder()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close
|
||||
1502943300000,2,2,2,2
|
||||
1502942400000,1,1,1,1
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.True(bars[0].TimeUtc < bars[1].TimeUtc);
|
||||
Assert.Equal(1, bars[0].Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAHeaderWithoutTheRequiredColumns()
|
||||
{
|
||||
string path = Write("alpha,beta\n1,2");
|
||||
Assert.Throws<InvalidDataException>(() => CsvBarSource.Load(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAFileWithNoUsableRows()
|
||||
{
|
||||
string path = Write("timestamp,open,high,low,close\nx,x,x,x,x");
|
||||
Assert.Throws<InvalidDataException>(() => CsvBarSource.Load(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAMissingFileClearly() =>
|
||||
Assert.Throws<FileNotFoundException>(() =>
|
||||
CsvBarSource.Load(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.csv")));
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Encelado.Bot.Configuration;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Every test redirects the store to a scratch directory via ENCELADO_HOME, so the
|
||||
/// developer's real saved credentials are never read, written or deleted.
|
||||
/// </summary>
|
||||
public sealed class CredentialStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _home;
|
||||
private readonly string? _previousHome;
|
||||
|
||||
public CredentialStoreTests()
|
||||
{
|
||||
_previousHome = Environment.GetEnvironmentVariable("ENCELADO_HOME");
|
||||
_home = Path.Combine(Path.GetTempPath(), $"encelado-store-{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("ENCELADO_HOME", _home);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ENCELADO_HOME", _previousHome);
|
||||
if (Directory.Exists(_home))
|
||||
{
|
||||
Directory.Delete(_home, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HonoursTheHomeOverride() =>
|
||||
Assert.Equal(Path.Combine(_home, "credentials.dat"), CredentialStore.FilePath);
|
||||
|
||||
[Fact]
|
||||
public void SaveThenLoadRoundTrips()
|
||||
{
|
||||
Assert.False(CredentialStore.Exists);
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
|
||||
CredentialStore.Save(paper: true, "PKTESTKEY", "supersecret");
|
||||
|
||||
Assert.True(CredentialStore.Exists);
|
||||
StoredCredentials? loaded = CredentialStore.Load(paper: true);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("PKTESTKEY", loaded.Value.KeyId);
|
||||
Assert.Equal("supersecret", loaded.Value.SecretKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaperAndLiveAreStoredSeparately()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "paper-secret");
|
||||
CredentialStore.Save(paper: false, "AKLIVE", "live-secret");
|
||||
|
||||
Assert.Equal("PKPAPER", CredentialStore.Load(paper: true)!.Value.KeyId);
|
||||
Assert.Equal("AKLIVE", CredentialStore.Load(paper: false)!.Value.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingTwiceReplacesTheEntry()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKOLD", "old");
|
||||
CredentialStore.Save(paper: true, "PKNEW", "new");
|
||||
|
||||
StoredCredentials loaded = CredentialStore.Load(paper: true)!.Value;
|
||||
Assert.Equal("PKNEW", loaded.KeyId);
|
||||
Assert.Equal("new", loaded.SecretKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRemovesOnlyTheRequestedEnvironment()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "paper-secret");
|
||||
CredentialStore.Save(paper: false, "AKLIVE", "live-secret");
|
||||
|
||||
Assert.True(CredentialStore.Clear(paper: true));
|
||||
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
Assert.NotNull(CredentialStore.Load(paper: false));
|
||||
Assert.True(CredentialStore.Exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearingTheLastEntryDeletesTheFile()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "secret");
|
||||
|
||||
Assert.True(CredentialStore.Clear(paper: true));
|
||||
Assert.False(CredentialStore.Exists);
|
||||
Assert.False(CredentialStore.Clear(paper: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearAllRemovesEverything()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "a");
|
||||
CredentialStore.Save(paper: false, "AKLIVE", "b");
|
||||
|
||||
Assert.True(CredentialStore.ClearAll());
|
||||
Assert.False(CredentialStore.Exists);
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
Assert.Null(CredentialStore.Load(paper: false));
|
||||
Assert.False(CredentialStore.ClearAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecretsAreNotReadableAsPlainTextOnWindows()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKTESTKEY", "supersecretvalue");
|
||||
string onDisk = File.ReadAllText(CredentialStore.FilePath);
|
||||
|
||||
if (CredentialStore.IsEncrypted)
|
||||
{
|
||||
Assert.DoesNotContain("supersecretvalue", onDisk, StringComparison.Ordinal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Without DPAPI the file is plain JSON; the caller is warned about it.
|
||||
Assert.Contains("supersecretvalue", onDisk, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACorruptFileIsTreatedAsAbsentRatherThanThrowing()
|
||||
{
|
||||
Directory.CreateDirectory(_home);
|
||||
File.WriteAllBytes(CredentialStore.FilePath, [0x00, 0x01, 0x02, 0x03, 0x04]);
|
||||
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
|
||||
// And it must still be recoverable by saving again.
|
||||
CredentialStore.Save(paper: true, "PKNEW", "secret");
|
||||
Assert.Equal("PKNEW", CredentialStore.Load(paper: true)!.Value.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveRejectsEmptyValues()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => CredentialStore.Save(paper: true, "", "secret"));
|
||||
Assert.Throws<ArgumentException>(() => CredentialStore.Save(paper: true, "PKKEY", " "));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PKABCDEFGH1234", "PKAB**********")]
|
||||
[InlineData("PKAB", "****")]
|
||||
[InlineData("ab", "**")]
|
||||
[InlineData("", "(empty)")]
|
||||
[InlineData(null, "(empty)")]
|
||||
public void MaskKeepsOnlyThePrefix(string? input, string expected) =>
|
||||
Assert.Equal(expected, CredentialStore.Mask(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData(" PKKEY123 ", "PKKEY123")]
|
||||
[InlineData("\uFEFFPKKEY123", "PKKEY123")]
|
||||
[InlineData("PK\u200BKEY123", "PKKEY123")]
|
||||
[InlineData("P\0K\0K\0E\0Y\0", "PKKEY")]
|
||||
[InlineData("PKKEY123\r\n", "PKKEY123")]
|
||||
public void CleanStripsInvisibleCharactersFromPastedKeys(string raw, string expected) =>
|
||||
Assert.Equal(expected, CredentialStore.Clean(raw));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\uFEFF\r\n")]
|
||||
public void CleanReturnsNullWhenNothingUsableRemains(string? raw) =>
|
||||
Assert.Null(CredentialStore.Clean(raw));
|
||||
|
||||
[Fact]
|
||||
public void MaskNeverEchoesAWholeSecret()
|
||||
{
|
||||
const string secret = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
string masked = CredentialStore.Mask(secret);
|
||||
|
||||
Assert.DoesNotContain(secret, masked, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(secret[4..], masked, StringComparison.Ordinal);
|
||||
Assert.StartsWith("abcd", masked, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- The app is a Windows desktop program, so its tests target the same TFM. -->
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RootNamespace>Encelado.Tests</RootNamespace>
|
||||
|
||||
<!-- Needed by UiBindingTests, which instantiate the real pages on an STA thread and
|
||||
listen to WPF's data-binding trace source. Without this the WPF assemblies are
|
||||
not referenced and those tests cannot even compile. -->
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<!-- Both are inherited from Directory.Build.props and both are fatal once this
|
||||
assembly renders WPF, for exactly the reasons documented in Encelado.Bot.csproj:
|
||||
the font cache needs real culture data, and stripped resource keys turn every
|
||||
framework exception into an unreadable token — which is the difference between
|
||||
a test failure that explains itself and one that says "TypeInitialization_Type". -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<UseSystemResourceKeys>false</UseSystemResourceKeys>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<!-- UseWPF swaps the implicit-usings set for the WPF one, which does not include
|
||||
System.IO. Several suites read and write temporary files. -->
|
||||
<Using Include="System.IO" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Encelado.Core\Encelado.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Encelado.Alpaca\Encelado.Alpaca.csproj" />
|
||||
<ProjectReference Include="..\..\src\Encelado.Bot\Encelado.Bot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,303 @@
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class BarAggregatorTests
|
||||
{
|
||||
private static readonly DateTime Open = new(2026, 1, 5, 14, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void OneMinuteIsAPassthrough()
|
||||
{
|
||||
BarAggregator aggregator = new(1);
|
||||
Assert.True(aggregator.IsPassthrough);
|
||||
|
||||
Bar input = Minute(0, 100, 101, 99, 100.5, 1000);
|
||||
Assert.True(aggregator.TryAdd(input, out Bar closed));
|
||||
Assert.Equal(input, closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiveMinuteBucketsCloseWhenTheNextBucketStarts()
|
||||
{
|
||||
BarAggregator aggregator = new(5);
|
||||
|
||||
// 14:30..14:34 all fall in the same five-minute bucket.
|
||||
Assert.False(aggregator.TryAdd(Minute(0, 100, 102, 99, 101, 1000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(1, 101, 105, 100, 104, 2000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(2, 104, 104, 97, 98, 3000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(3, 98, 99, 98, 99, 1000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(4, 99, 100, 98, 100, 1000), out _));
|
||||
|
||||
// 14:35 belongs to the next bucket and closes the previous one.
|
||||
Assert.True(aggregator.TryAdd(Minute(5, 100, 101, 100, 101, 500), out Bar closed));
|
||||
|
||||
Assert.Equal(Open, closed.TimeUtc);
|
||||
Assert.Equal(100, closed.Open);
|
||||
Assert.Equal(105, closed.High);
|
||||
Assert.Equal(97, closed.Low);
|
||||
Assert.Equal(100, closed.Close);
|
||||
Assert.Equal(8000, closed.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetDiscardsThePartialBucket()
|
||||
{
|
||||
BarAggregator aggregator = new(5);
|
||||
aggregator.TryAdd(Minute(0, 100, 100, 100, 100, 10), out _);
|
||||
aggregator.Reset();
|
||||
|
||||
// After a reset the next bar starts a fresh bucket instead of closing the old one.
|
||||
Assert.False(aggregator.TryAdd(Minute(5, 200, 200, 200, 200, 10), out _));
|
||||
}
|
||||
|
||||
private static Bar Minute(int index, double o, double h, double l, double c, double v) =>
|
||||
new(Open.AddMinutes(index), o, h, l, c, v, c, 10);
|
||||
}
|
||||
|
||||
public class SymbolPipelineTests
|
||||
{
|
||||
private static SymbolPipeline NewPipeline() =>
|
||||
new(0, "AAPL", StrategyFactory.Create("trend-filter"), 1);
|
||||
|
||||
[Fact]
|
||||
public void TheEntryLatchIsExclusive()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
Assert.False(pipe.TryClaimEntry());
|
||||
Assert.True(pipe.EntryInFlight);
|
||||
|
||||
pipe.ReleaseEntry();
|
||||
Assert.False(pipe.EntryInFlight);
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheExitLatchIsIndependentOfTheEntryLatch()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
Assert.True(pipe.TryClaimExit());
|
||||
Assert.False(pipe.TryClaimExit());
|
||||
|
||||
pipe.ReleaseExit();
|
||||
Assert.True(pipe.EntryInFlight);
|
||||
Assert.False(pipe.ExitInFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntryReferenceUsesTheFarTouch()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.OnQuote(new Quote(DateTime.UtcNow, 99.5, 100, 100.5, 100));
|
||||
|
||||
Assert.Equal(100.5, pipe.EntryReferencePrice(Side.Buy));
|
||||
Assert.Equal(99.5, pipe.EntryReferencePrice(Side.Sell));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntryReferenceFallsBackToTheLastPriceWithoutABook()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.OnTrade(new Tick(DateTime.UtcNow, 42, 100), takerBought: true, aggressorKnown: true);
|
||||
|
||||
Assert.Equal(42, pipe.EntryReferencePrice(Side.Buy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalStopFiresOnTheCorrectSide()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.LocalStop = 95;
|
||||
pipe.LocalTarget = 110;
|
||||
|
||||
Assert.False(pipe.ShouldExitLocally(100, 10, out _));
|
||||
Assert.True(pipe.ShouldExitLocally(94.5, 10, out string stopReason));
|
||||
Assert.Contains("local stop", stopReason, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.True(pipe.ShouldExitLocally(111, 10, out string targetReason));
|
||||
Assert.Contains("local target", targetReason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalStopIsMirroredForShorts()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.LocalStop = 105;
|
||||
|
||||
Assert.False(pipe.ShouldExitLocally(100, -10, out _));
|
||||
Assert.True(pipe.ShouldExitLocally(106, -10, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoProtectionMeansNoLocalExit()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.ClearProtection();
|
||||
|
||||
Assert.False(pipe.ShouldExitLocally(1, 10, out _));
|
||||
Assert.False(pipe.ShouldExitLocally(100, 0, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteAgeIsUnboundedUntilTheFirstQuote()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
Assert.Equal(TimeSpan.MaxValue, pipe.QuoteAge);
|
||||
|
||||
pipe.OnQuote(new Quote(DateTime.UtcNow, 99, 1, 101, 1));
|
||||
Assert.True(pipe.QuoteAge < TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigValidationTests
|
||||
{
|
||||
private static BotConfig MinimalConfig() => new()
|
||||
{
|
||||
Alpaca = { KeyId = "key", SecretKey = "secret" },
|
||||
Symbols = [new SymbolConfig { Symbol = "AAPL", Strategy = "trend-filter" }],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AcceptsAMinimalValidConfiguration()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
Assert.Same(config, config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsDuplicateSymbols()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Symbols.Add(new SymbolConfig { Symbol = "aapl", Strategy = "trend-filter" });
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("more than once", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsUnknownStrategies()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Symbols[0].Strategy = "moon-phase";
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("unknown strategy", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAConfigurationWithNoEnabledSymbols()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Symbols[0].Enabled = false;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CryptoRequiresFractionalShares()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Engine.AssetClass = "crypto";
|
||||
config.Engine.AllowFractionalShares = false;
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("allowFractionalShares", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesAssetClassAndTimeframe()
|
||||
{
|
||||
EngineOptions options = new() { AssetClass = "crypto", TimeFrame = "5Min", AllowFractionalShares = true };
|
||||
|
||||
Assert.Equal(AssetClass.Crypto, options.ResolvedAssetClass);
|
||||
Assert.Equal(TimeFrame.FiveMinutes, options.ResolvedTimeFrame);
|
||||
Assert.True(options.UseLimitEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAnUnsupportedEntryOrderType()
|
||||
{
|
||||
EngineOptions options = new() { EntryOrderType = "iceberg" };
|
||||
Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsSectionsSymbolsAndParameters()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-test-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path,
|
||||
"""
|
||||
{
|
||||
"alpaca": { "keyId": "abc", "secretKey": "def", "paper": true, "dataFeed": "sip" },
|
||||
"engine": { "timeFrame": "5Min", "warmupBars": 120, "dryRun": true },
|
||||
"risk": { "maxOpenPositions": 3, "maxRiskPerTradePct": 0.002 },
|
||||
"symbols": [
|
||||
{ "symbol": "AAPL", "strategy": "ema-cross", "parameters": { "fast": 9, "slow": 21 } },
|
||||
"MSFT"
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
BotConfig config = ConfigLoader.Load(path, out List<string> warnings);
|
||||
|
||||
Assert.Empty(warnings);
|
||||
Assert.Equal("abc", config.Alpaca.KeyId);
|
||||
Assert.Equal("sip", config.Alpaca.DataFeed);
|
||||
Assert.Equal(TimeFrame.FiveMinutes, config.Engine.ResolvedTimeFrame);
|
||||
Assert.Equal(120, config.Engine.WarmupBars);
|
||||
Assert.True(config.Engine.DryRun);
|
||||
Assert.Equal(3, config.Risk.MaxOpenPositions);
|
||||
Assert.Equal(0.002, config.Risk.MaxRiskPerTradePct);
|
||||
|
||||
Assert.Equal(2, config.Symbols.Count);
|
||||
Assert.Equal(9, config.Symbols[0].Parameters["fast"]);
|
||||
Assert.Equal("MSFT", config.Symbols[1].Symbol);
|
||||
Assert.Equal("ema-cross", config.Symbols[1].Strategy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsUnknownKeysInsteadOfSilentlyIgnoringThem()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-test-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, """{ "engine": { "warmupBars": 10, "wramupBars": 10 } }""");
|
||||
|
||||
try
|
||||
{
|
||||
ConfigLoader.Load(path, out List<string> warnings);
|
||||
Assert.Contains(warnings, w => w.Contains("wramupBars", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMissingFileFallsBackToDefaultsWithAWarning()
|
||||
{
|
||||
BotConfig config = ConfigLoader.Load(
|
||||
Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json"),
|
||||
out List<string> warnings);
|
||||
|
||||
Assert.Contains(warnings, w => w.Contains("not found", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(config.Alpaca.Paper);
|
||||
Assert.Empty(config.Symbols);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Encelado.Core.Indicators;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class RollingWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void IndexerIsMostRecentFirst()
|
||||
{
|
||||
RollingWindow<int> window = new(3);
|
||||
window.Add(1);
|
||||
window.Add(2);
|
||||
window.Add(3);
|
||||
|
||||
Assert.True(window.IsFull);
|
||||
Assert.Equal(3, window[0]);
|
||||
Assert.Equal(2, window[1]);
|
||||
Assert.Equal(1, window[2]);
|
||||
Assert.Equal(3, window.Newest);
|
||||
Assert.Equal(1, window.Oldest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvictsOldestOnceFull()
|
||||
{
|
||||
RollingWindow<int> window = new(2);
|
||||
Assert.False(window.TryAdd(1, out _));
|
||||
Assert.False(window.TryAdd(2, out _));
|
||||
|
||||
Assert.True(window.TryAdd(3, out int evicted));
|
||||
Assert.Equal(1, evicted);
|
||||
Assert.Equal(3, window[0]);
|
||||
Assert.Equal(2, window[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowsWhenIndexingBeyondPopulatedRange()
|
||||
{
|
||||
RollingWindow<int> window = new(4);
|
||||
window.Add(7);
|
||||
|
||||
Assert.Equal(7, window[0]);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => window[1]);
|
||||
}
|
||||
}
|
||||
|
||||
public class MovingAverageTests
|
||||
{
|
||||
[Fact]
|
||||
public void SmaAveragesTheWindowOnly()
|
||||
{
|
||||
Sma sma = new(3);
|
||||
sma.Update(1);
|
||||
sma.Update(2);
|
||||
Assert.False(sma.IsReady);
|
||||
|
||||
Assert.Equal(2, sma.Update(3), 10);
|
||||
Assert.True(sma.IsReady);
|
||||
|
||||
// The 1 drops out: (2 + 3 + 4) / 3.
|
||||
Assert.Equal(3, sma.Update(4), 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmaSeedsWithSmaThenSmooths()
|
||||
{
|
||||
Ema ema = new(3);
|
||||
ema.Update(1);
|
||||
ema.Update(2);
|
||||
Assert.False(ema.IsReady);
|
||||
|
||||
Assert.Equal(2, ema.Update(3), 10);
|
||||
Assert.True(ema.IsReady);
|
||||
|
||||
// alpha = 2/(3+1) = 0.5
|
||||
Assert.Equal(3, ema.Update(4), 10);
|
||||
Assert.Equal(4, ema.Update(5), 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetClearsState()
|
||||
{
|
||||
Ema ema = new(2);
|
||||
ema.Update(10);
|
||||
ema.Update(20);
|
||||
Assert.True(ema.IsReady);
|
||||
|
||||
ema.Reset();
|
||||
Assert.False(ema.IsReady);
|
||||
Assert.True(double.IsNaN(ema.Value));
|
||||
}
|
||||
}
|
||||
|
||||
public class RsiTests
|
||||
{
|
||||
[Fact]
|
||||
public void MonotonicRiseSaturatesAtOneHundred()
|
||||
{
|
||||
Rsi rsi = new(14);
|
||||
for (int i = 1; i <= 40; i++)
|
||||
{
|
||||
rsi.Update(i);
|
||||
}
|
||||
|
||||
Assert.True(rsi.IsReady);
|
||||
Assert.Equal(100, rsi.Value, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonotonicFallSaturatesAtZero()
|
||||
{
|
||||
Rsi rsi = new(14);
|
||||
for (int i = 40; i >= 1; i--)
|
||||
{
|
||||
rsi.Update(i);
|
||||
}
|
||||
|
||||
Assert.True(rsi.IsReady);
|
||||
Assert.Equal(0, rsi.Value, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlatSeriesIsNeutral()
|
||||
{
|
||||
Rsi rsi = new(14);
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
rsi.Update(100);
|
||||
}
|
||||
|
||||
Assert.Equal(50, rsi.Value, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsPeriodPlusOneSamples()
|
||||
{
|
||||
Rsi rsi = new(5);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
rsi.Update(100 + i);
|
||||
Assert.False(rsi.IsReady);
|
||||
}
|
||||
|
||||
rsi.Update(105);
|
||||
Assert.True(rsi.IsReady);
|
||||
}
|
||||
}
|
||||
|
||||
public class AtrTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstantRangeConvergesToThatRange()
|
||||
{
|
||||
Atr atr = new(14);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
atr.Update(new Bar(DateTime.UtcNow, 100, 101, 99, 100, 1000, 100, 10));
|
||||
}
|
||||
|
||||
Assert.True(atr.IsReady);
|
||||
Assert.Equal(2, atr.Value, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GapsCountTowardsTrueRange()
|
||||
{
|
||||
Atr atr = new(2);
|
||||
atr.Update(new Bar(DateTime.UtcNow, 100, 100.5, 99.5, 100, 1, 100, 1));
|
||||
|
||||
// Gaps up to 110: true range is 110.5 - 100 = 10.5, not the 1.0 intraday range.
|
||||
atr.Update(new Bar(DateTime.UtcNow, 110, 110.5, 109.5, 110, 1, 110, 1));
|
||||
|
||||
Assert.True(atr.IsReady);
|
||||
Assert.Equal((1.0 + 10.5) / 2, atr.Value, 6);
|
||||
}
|
||||
}
|
||||
|
||||
public class DonchianTests
|
||||
{
|
||||
[Fact]
|
||||
public void TracksRollingExtremes()
|
||||
{
|
||||
Donchian channel = new(3);
|
||||
channel.Update(10, 5);
|
||||
channel.Update(12, 6);
|
||||
channel.Update(11, 4);
|
||||
|
||||
Assert.True(channel.IsReady);
|
||||
Assert.Equal(12, channel.Upper);
|
||||
Assert.Equal(4, channel.Lower);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RescansWhenTheExtremeFallsOutOfTheWindow()
|
||||
{
|
||||
Donchian channel = new(3);
|
||||
channel.Update(20, 1);
|
||||
channel.Update(12, 6);
|
||||
channel.Update(11, 4);
|
||||
|
||||
Assert.Equal(20, channel.Upper);
|
||||
Assert.Equal(1, channel.Lower);
|
||||
|
||||
// The 20/1 bar rolls out; extremes must be recomputed from the survivors.
|
||||
channel.Update(13, 7);
|
||||
Assert.Equal(13, channel.Upper);
|
||||
Assert.Equal(4, channel.Lower);
|
||||
}
|
||||
}
|
||||
|
||||
public class BollingerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstantSeriesCollapsesTheBands()
|
||||
{
|
||||
BollingerBands bands = new(5, 2.0);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bands.Update(50);
|
||||
}
|
||||
|
||||
Assert.True(bands.IsReady);
|
||||
Assert.Equal(50, bands.Value, 10);
|
||||
Assert.Equal(50, bands.Upper, 10);
|
||||
Assert.Equal(50, bands.Lower, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandsSurroundTheMean()
|
||||
{
|
||||
BollingerBands bands = new(4, 2.0);
|
||||
double[] samples = [10, 12, 14, 16];
|
||||
foreach (double s in samples)
|
||||
{
|
||||
bands.Update(s);
|
||||
}
|
||||
|
||||
Assert.Equal(13, bands.Value, 10);
|
||||
Assert.True(bands.Upper > bands.Value);
|
||||
Assert.True(bands.Lower < bands.Value);
|
||||
Assert.InRange(bands.PercentB(bands.Upper), 0.99, 1.01);
|
||||
}
|
||||
}
|
||||
|
||||
public class StdDevTests
|
||||
{
|
||||
[Fact]
|
||||
public void MatchesTheSampleStandardDeviation()
|
||||
{
|
||||
RollingStdDev sd = new(4);
|
||||
foreach (double s in new double[] { 2, 4, 4, 6 })
|
||||
{
|
||||
sd.Update(s);
|
||||
}
|
||||
|
||||
// mean 4; sample variance = (4 + 0 + 0 + 4) / 3
|
||||
Assert.Equal(Math.Sqrt(8.0 / 3.0), sd.Value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
public class SessionVwapTests
|
||||
{
|
||||
[Fact]
|
||||
public void WeightsPricesByVolume()
|
||||
{
|
||||
SessionVwap vwap = new();
|
||||
vwap.Update(new Bar(DateTime.UtcNow, 10, 10, 10, 10, 100, 10, 1));
|
||||
vwap.Update(new Bar(DateTime.UtcNow, 20, 20, 20, 20, 300, 20, 1));
|
||||
|
||||
// (10*100 + 20*300) / 400
|
||||
Assert.Equal(17.5, vwap.Value, 10);
|
||||
|
||||
vwap.Reset();
|
||||
Assert.False(vwap.IsReady);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user