Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b7d59f40d | |||
| 0894c40f07 | |||
| 676496b77d | |||
| 6974b2075b | |||
| 7060f0f7b1 | |||
| 5c95dd182c | |||
| a4959658a2 | |||
| f43ce2b7e9 | |||
| 12e7b57b16 | |||
| 600ccf299e | |||
| 8112598602 | |||
| 44b161d509 | |||
| 6ab84fe1de | |||
| 5497cc5de7 | |||
| 3972507cb0 | |||
| 75a2510b39 | |||
| aaef272f4e | |||
| d8ba28a810 | |||
| b5cc70c08c |
@@ -23,3 +23,16 @@
|
|||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
|
## Exported Docker image archives — never needed inside a build context (~2.7 GB)
|
||||||
|
Docker/
|
||||||
|
**/*.tar
|
||||||
|
|
||||||
|
## Flutter client — not referenced by any Dockerfile (~450 MB).
|
||||||
|
## The compiled web bundle ships via FinlyticBackend/wwwroot instead.
|
||||||
|
FinlyticApp/
|
||||||
|
**/.dart_tool/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
## dotnet publish output on the host (final stage copies from the publish stage)
|
||||||
|
**/publish/
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
## Build outputs
|
## Build outputs
|
||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
|
**/publish/
|
||||||
|
TestResults/
|
||||||
|
|
||||||
## Rider / JetBrains / VS Code / Visual Studio
|
## Rider / JetBrains / VS Code / Visual Studio
|
||||||
.idea/
|
.idea/
|
||||||
@@ -22,6 +24,8 @@ assets/
|
|||||||
|
|
||||||
## Secrets & local environment files
|
## Secrets & local environment files
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.bak*
|
||||||
*.env.local
|
*.env.local
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
|
||||||
@@ -36,4 +40,5 @@ appsettings.Development.json
|
|||||||
## Temporary data & scratch
|
## Temporary data & scratch
|
||||||
Yahoo finance data/
|
Yahoo finance data/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
*.tar
|
||||||
FinlyticApp/lib/tickers_grep.json
|
FinlyticApp/lib/tickers_grep.json
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Finlytic is a personal financial analysis/trading platform: a fleet of .NET 10 microservices communicating
|
||||||
|
over MQTT, one HTTP/WebSocket gateway (`FinlyticBackend`), and a Flutter app (`FinlyticApp`) as the only
|
||||||
|
client. There is no CI/multi-developer setup — this is a single-developer repo backed by one shared
|
||||||
|
PostgreSQL instance ("OmniDB") and one MQTT broker, both external to this repo.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (.NET)
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build Finlytic.sln # build everything
|
||||||
|
dotnet build FinlyticEngine # build a single project
|
||||||
|
dotnet test FinlyticEngine.Tests # run one test project
|
||||||
|
dotnet test FinlyticEngine.Tests --filter "FullyQualifiedName~TradeLifecycleServiceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Solution-wide `dotnet test` will also try to run `FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs`,
|
||||||
|
which connects to a real, throwaway local Postgres container on `localhost:55987` and has no `[Skip]`/trait
|
||||||
|
to exclude it. It fails outside that one-off setup — run test projects individually, or filter it out with
|
||||||
|
`--filter "FullyQualifiedName!~_Verify"`, rather than running the whole solution's tests.
|
||||||
|
- `FinlyticBot.Tests` currently has no test files (scaffolding only).
|
||||||
|
- EF Core migrations are created via the CLI only (never hand-written) from inside the owning service project,
|
||||||
|
e.g.: `dotnet ef migrations add <Name> --project FinlyticAssets`.
|
||||||
|
|
||||||
|
### Frontend (Flutter, `FinlyticApp/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter pub get
|
||||||
|
flutter run # or -d chrome for the web dashboard
|
||||||
|
flutter test
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose (`compose.yaml`)
|
||||||
|
|
||||||
|
The compose file only defines this repo's own services — Postgres and the MQTT broker are external
|
||||||
|
infrastructure (see the comment block at the top of `compose.yaml` for required env vars: `DB_HOST`,
|
||||||
|
`DB_PORT`, `DB_PASSWORD`, `MQTT_HOST`, `MQTT_PORT`, plus per-service secrets like `JWT_SECRET_KEY`,
|
||||||
|
`ADMIN_DEFAULT_PASSWORD`, `ALPACA_KEY_ID`/`ALPACA_SECRET_KEY`). Set these in a local `.env` file.
|
||||||
|
|
||||||
|
`FinlyticNews` and `FinlyticFundamentals` build from a shared Playwright base image that must exist first:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile build-base build finlytic-playwright-base
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the `PLAYWRIGHT_VERSION` build arg in `compose.yaml` in sync with the `Microsoft.Playwright` NuGet
|
||||||
|
package version in `FinlyticCore/FinlyticCore.csproj`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Service topology
|
||||||
|
|
||||||
|
Nine .NET projects share one solution (`Finlytic.sln`): `FinlyticCore` (shared library, no entry point) plus
|
||||||
|
eight deployable services — `FinlyticAssets`, `FinlyticNews`, `FinlyticFundamentals`, `FinlyticSentiment`,
|
||||||
|
`FinlyticTechnicals`, `FinlyticEngine`, `FinlyticSimulation`, `FinlyticBot`, and `FinlyticBackend`. Each
|
||||||
|
non-Core service is its own Docker image, has its own PostgreSQL database (`finlytic_assets`,
|
||||||
|
`finlytic_news`, ... one per service — never shared tables across services), and runs its own EF Core
|
||||||
|
migrations independently at startup via `DbContext.MigrateWithBootstrapAsync` (`FinlyticCore/Database/DatabaseBootstrapper.cs`).
|
||||||
|
|
||||||
|
- **`FinlyticBackend`** is the *only* service allowed to host Kestrel/HTTP/WebSocket endpoints. It is a thin
|
||||||
|
aggregation gateway for `FinlyticApp`: its controllers and SignalR hubs (`Hubs/`) largely proxy to the
|
||||||
|
backing microservices over MQTT RPC and rebroadcast their MQTT events to connected clients via SignalR
|
||||||
|
(`BackendMqttBridge`). All other services are `Microsoft.NET.Sdk.Worker` background services with no HTTP
|
||||||
|
surface at all — this is an enforced rule (`Rules.md` §5), not a stylistic default. See the
|
||||||
|
`compose.yaml` comment block explaining why only `finlyticbackend` has a Docker `HEALTHCHECK`.
|
||||||
|
- Every other service follows the same internal shape: `Database/` (DbContext + migrations),
|
||||||
|
`Entities/` (EF entities), `Services/` (business logic + the service's MQTT client), `Util/`, and a
|
||||||
|
`Program.cs` that wires DI, registers the service's MQTT client as a hosted service, and migrates the DB
|
||||||
|
at startup.
|
||||||
|
|
||||||
|
### Inter-service communication (MQTT-only)
|
||||||
|
|
||||||
|
All cross-service calls go over MQTT — never direct HTTP between backend services. `FinlyticCore/Util/MqttTopics.cs`
|
||||||
|
is the single source of truth for every topic and RPC channel name; add new channels there rather than
|
||||||
|
inlining topic strings at call sites. Two patterns are used:
|
||||||
|
|
||||||
|
1. **RPC** (`services/request/{channel}/{correlationId}` → `services/response/{channel}/{correlationId}`),
|
||||||
|
implemented by `FinlyticCore/Util/ManagedMqttClient.cs` (`SendRpcRequestAsync` / `SubscribeRpcAsync`).
|
||||||
|
Used for synchronous-style query/command calls, e.g. `FinlyticBackend` asking `FinlyticAssets` to resolve
|
||||||
|
an ISIN, or `FinlyticEngine` asking `FinlyticSentiment` for a sentiment summary.
|
||||||
|
2. **Pub/sub event streams** (e.g. `finlytic/news/stream/{isin}`, `finlytic/engine/proposals/created`,
|
||||||
|
`finlytic/bot/trades/stream`, `finlytic/logs/{service}`) for fire-and-forget notifications. `FinlyticBackend`'s
|
||||||
|
`BackendMqttBridge` subscribes to the wildcard form of most of these (`*Wildcard` constants) purely to
|
||||||
|
relay them onward to SignalR clients.
|
||||||
|
|
||||||
|
Every service also implements a shared `health_Ping` RPC channel and exposes runtime-configurable settings
|
||||||
|
via a `{service}_settings_GetAll` / `{service}_settings_Update` channel pair (backed by `ISettingsService`
|
||||||
|
and `IOptionsMonitor`-style dynamic config — see `FinlyticCore/Services/Settings`) — changing a setting does
|
||||||
|
not require a service restart, and channel-level logging enable/disable follows the same mechanism (see
|
||||||
|
`IFinlyticLogger<T>` / `FinlyticLogBroadcaster` in `FinlyticCore/Services/FinlyticLogger`).
|
||||||
|
|
||||||
|
### Domain flow (roughly upstream → downstream)
|
||||||
|
|
||||||
|
`FinlyticAssets` (asset/ISIN resolution, Trade Republic price feed) and `FinlyticNews`/`FinlyticFundamentals`
|
||||||
|
(scraped/ingested data, using Playwright) feed `FinlyticSentiment` (FinBERT analysis) and `FinlyticTechnicals`
|
||||||
|
(indicators/strategy setups), which feed `FinlyticEngine` (trade proposal generation, AI-assisted validation
|
||||||
|
via an n8n webhook, trade lifecycle management) and `FinlyticSimulation` (backtesting/strategy reliability
|
||||||
|
scoring). `FinlyticBot` executes accepted proposals as paper trades (via Alpaca or a synthetic ledger).
|
||||||
|
`FinlyticBackend` sits above all of them as the gateway `FinlyticApp` talks to.
|
||||||
|
|
||||||
|
### Flutter app (`FinlyticApp/`)
|
||||||
|
|
||||||
|
Feature-folder structure under `lib/features/*` (auth, dashboard, discovery, trades, bot, simulation, news,
|
||||||
|
calendar, favorites, search, asset_detail, proposals, admin), with `lib/core/` for cross-cutting network
|
||||||
|
(Dio + interceptors), theme, and services, and `lib/shared/` for shared widgets. State management is
|
||||||
|
`flutter_bloc`. Every outgoing request must carry the JWT via a central Dio interceptor, and a 401/403 must
|
||||||
|
trigger an immediate client-side logout — this is an enforced rule (`Rules.md` §8), not optional handling.
|
||||||
|
|
||||||
|
## Project-wide rules (`Rules.md`)
|
||||||
|
|
||||||
|
`Rules.md` is a binding rules document for this repo, not a style guide — treat every rule as an
|
||||||
|
architectural constraint to actively check against, not background reading. Full text is in `Rules.md`;
|
||||||
|
key points, since they shape most non-trivial changes:
|
||||||
|
|
||||||
|
- Every service interface + implementation lives in one file, named after the implementation class.
|
||||||
|
- Every method (any visibility) needs XML doc comments in English; implementations of interface/base
|
||||||
|
methods use `/// <inheritdoc />` (custom helper methods still need their own explicit docs).
|
||||||
|
- Strongly-typed data classes only for state passing, API payloads, and MQTT messages — no
|
||||||
|
`Dictionary<string, object>`, `dynamic`, or raw `JObject`/`JsonDocument` in internal logic. Shared
|
||||||
|
DTOs/entities/enums used by more than one service belong in `FinlyticCore`; service-specific models
|
||||||
|
(e.g. raw third-party API shapes) stay in that service.
|
||||||
|
- No mock/demo/fallback data anywhere, backend or frontend. Empty results are either a real empty set or an
|
||||||
|
explicit exception — the Flutter UI must show an explicit empty state, never placeholder content.
|
||||||
|
- Async all the way (no `.Result`/`.Wait()`/`.GetAwaiter().GetResult()`); async DB/MQTT/network methods take
|
||||||
|
a trailing `CancellationToken` and pass it through.
|
||||||
|
- Every `FinlyticBackend` HTTP/WebSocket endpoint requires `[Authorize]` unless explicitly `[AllowAnonymous]`
|
||||||
|
(only `/api/v1/auth/login`-style endpoints and the `/health` check are exempt).
|
||||||
|
- No hardcoded secrets; bind config via `IOptions<T>`/`IOptionsMonitor<T>`, and `FinlyticBackend` fails fast
|
||||||
|
at startup if `JWT_SECRET_KEY`/`ADMIN_DEFAULT_PASSWORD` are missing or weak (see `Program.cs`).
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports all required Finlytic Docker images to .tar archives and saves/transfers them directly to the network share.
|
||||||
|
.DESCRIPTION
|
||||||
|
Checks all 9 Finlytic microservice images, verifies network path availability,
|
||||||
|
exports each image directly (or with copy) to \\SONA\appdata\finlytic\images,
|
||||||
|
and displays progress and total transferred size.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param (
|
||||||
|
[string]$DestinationPath = "\\SONA\appdata\finlytic\images",
|
||||||
|
[switch]$BuildFirst = $false
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$images = @(
|
||||||
|
"finlyticassets",
|
||||||
|
"finlyticnews",
|
||||||
|
"finlyticfundamentals",
|
||||||
|
"finlyticsentiment",
|
||||||
|
"finlytictechnicals",
|
||||||
|
"finlyticengine",
|
||||||
|
"finlyticsimulation",
|
||||||
|
"finlyticbot",
|
||||||
|
"finlyticbackend"
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "============================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Finlytic Docker Images Export & Server Transfer" -ForegroundColor Cyan
|
||||||
|
Write-Host "============================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Target Server Share : $DestinationPath" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Check destination share connectivity
|
||||||
|
if (-not (Test-Path -Path $DestinationPath)) {
|
||||||
|
Write-Host "[INFO] Target directory does not exist. Attempting to create it..." -ForegroundColor Gray
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null
|
||||||
|
Write-Host "[OK] Destination folder successfully created." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "[ERROR] Could not access or create network share: $DestinationPath" -ForegroundColor Red
|
||||||
|
Write-Host "Please make sure \\SONA is online and credentials/permissions are valid." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "[OK] Target server share is accessible." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Optional: Build images first
|
||||||
|
if ($BuildFirst) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[BUILD] Building all Docker images from compose.yaml..." -ForegroundColor Cyan
|
||||||
|
docker compose -f (Join-Path $PSScriptRoot "..\compose.yaml") build
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Docker build failed. Aborting export." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Starting export of $($images.Count) service images..." -ForegroundColor Cyan
|
||||||
|
Write-Host "------------------------------------------------------------" -ForegroundColor Gray
|
||||||
|
|
||||||
|
$exported = 0
|
||||||
|
$failed = @()
|
||||||
|
$missing = @()
|
||||||
|
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
|
||||||
|
foreach ($img in $images) {
|
||||||
|
$ref = "$img`:latest"
|
||||||
|
$targetTar = Join-Path $DestinationPath "$img.tar"
|
||||||
|
|
||||||
|
# Verify if image exists locally in Docker
|
||||||
|
docker image inspect $ref *> $null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[SKIP] Image '$ref' not found locally in Docker." -ForegroundColor Yellow
|
||||||
|
$missing += $img
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$imgWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
Write-Host "[EXPORT] Saving $ref -> $targetTar ... " -NoNewline -ForegroundColor White
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Export directly to network share
|
||||||
|
docker save -o $targetTar $ref
|
||||||
|
$imgWatch.Stop()
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $targetTar)) {
|
||||||
|
$fileSizeMB = [math]::Round((Get-Item $targetTar).Length / 1MB, 2)
|
||||||
|
Write-Host "DONE! ($fileSizeMB MB in $($imgWatch.Elapsed.ToString('mm\:ss')))" -ForegroundColor Green
|
||||||
|
$exported++
|
||||||
|
} else {
|
||||||
|
Write-Host "FAILED!" -ForegroundColor Red
|
||||||
|
$failed += $img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: $_" -ForegroundColor Red
|
||||||
|
$failed += $img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stopwatch.Stop()
|
||||||
|
|
||||||
|
Write-Host "------------------------------------------------------------" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================= SUMMARY ============================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Successfully Exported : $exported / $($images.Count)" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($missing.Count -gt 0) {
|
||||||
|
Write-Host "Missing locally : $($missing -join ', ')" -ForegroundColor Yellow
|
||||||
|
Write-Host " -> Tip: Run 'docker compose build' to build all images." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Write-Host "Failed to Export : $($failed -join ', ')" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Total Duration : $($stopwatch.Elapsed.ToString('mm\:ss'))" -ForegroundColor Cyan
|
||||||
|
Write-Host "============================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To load these images on your server, run on the server:" -ForegroundColor White
|
||||||
|
Write-Host ' for f in /pfad/zu/appdata/finlytic/images/*.tar; do docker load -i "$f"; done' -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
+82
-26
@@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -15,14 +15,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticFundamentals", "Fin
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSentiment", "FinlyticSentiment\FinlyticSentiment.csproj", "{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSentiment", "FinlyticSentiment\FinlyticSentiment.csproj", "{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicalAnalysis", "FinlyticTechnicalAnalysis\FinlyticTechnicalAnalysis.csproj", "{A1C82F63-4482-4E99-9231-1184FA2E001F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicals", "FinlyticTechnicals\FinlyticTechnicals.csproj", "{A1C82F63-4482-4E99-9231-1184FA2E001F}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAnalyzer", "FinlyticAnalyzer\FinlyticAnalyzer.csproj", "{E9F7C091-62C4-417A-B981-8977DF82A1B0}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTrades", "FinlyticTrades\FinlyticTrades.csproj", "{57D84C2E-73E1-4231-A91B-6B620FCE5289}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine", "FinlyticEngine\FinlyticEngine.csproj", "{8112DE84-695D-489B-9568-C531B34C63F8}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSimulation", "FinlyticSimulation\FinlyticSimulation.csproj", "{1407B23D-3B7F-4673-9548-AA2AFF2D8011}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot", "FinlyticBot\FinlyticBot.csproj", "{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine.Tests", "FinlyticEngine.Tests\FinlyticEngine.Tests.csproj", "{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -69,18 +75,6 @@ Global
|
|||||||
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x64.Build.0 = Release|Any CPU
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.ActiveCfg = Release|Any CPU
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.Build.0 = Release|Any CPU
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -107,18 +101,80 @@ Global
|
|||||||
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x86.Build.0 = Release|Any CPU
|
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using FinlyticAnalyzer.Entities;
|
|
||||||
using FinlyticAnalyzer.Services;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Models.Trades;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Controllers;
|
|
||||||
|
|
||||||
public class ManualAnalysisRequest
|
|
||||||
{
|
|
||||||
public string Symbol { get; set; } = string.Empty;
|
|
||||||
public string Isin { get; set; } = string.Empty;
|
|
||||||
public string Sector { get; set; } = "Technology";
|
|
||||||
public string Headline { get; set; } = "Manual User Request";
|
|
||||||
public decimal CurrentPrice { get; set; } = 100.0m;
|
|
||||||
public int RiskScore { get; set; } = 50; // 0 to 100
|
|
||||||
public int MinTimeframeValue { get; set; } = 4;
|
|
||||||
public int MaxTimeframeValue { get; set; } = 6;
|
|
||||||
public string TimeframeUnit { get; set; } = "Tage";
|
|
||||||
public string InstrumentType { get; set; } = "Stock";
|
|
||||||
public string UserNotes { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/v1/analyze")]
|
|
||||||
public class ManualAnalysisController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IVixTrackerService _vixTracker;
|
|
||||||
private readonly IN8nEvaluationService _n8nService;
|
|
||||||
private readonly IWinRateCalculator _winRateCalculator;
|
|
||||||
private readonly AnalyzerDbContext _dbContext;
|
|
||||||
private readonly IFinlyticLogger<ManualAnalysisController> _finlyticLogger;
|
|
||||||
|
|
||||||
public ManualAnalysisController(
|
|
||||||
IVixTrackerService vixTracker,
|
|
||||||
IN8nEvaluationService n8nService,
|
|
||||||
IWinRateCalculator winRateCalculator,
|
|
||||||
AnalyzerDbContext dbContext,
|
|
||||||
IFinlyticLogger<ManualAnalysisController> finlyticLogger)
|
|
||||||
{
|
|
||||||
_vixTracker = vixTracker;
|
|
||||||
_n8nService = n8nService;
|
|
||||||
_winRateCalculator = winRateCalculator;
|
|
||||||
_dbContext = dbContext;
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Runs a manual analysis based on the provided request.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("manual")]
|
|
||||||
public async Task<IActionResult> RunManualAnalysis([FromBody] ManualAnalysisRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Symbol) && string.IsNullOrWhiteSpace(request.Isin))
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Symbol or ISIN is required." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var regime = _vixTracker.GetCurrentRegime();
|
|
||||||
var currentVix = _vixTracker.GetCurrentVix();
|
|
||||||
string analysisId = Guid.NewGuid().ToString("N");
|
|
||||||
double winRate = _winRateCalculator.CalculateWinRate(request.Sector, request.Symbol, regime);
|
|
||||||
|
|
||||||
string riskLabel = request.RiskScore > 70 ? $"Aggressiv ({request.RiskScore}/100)" : (request.RiskScore > 30 ? $"Balanced ({request.RiskScore}/100)" : $"Konservativ ({request.RiskScore}/100)");
|
|
||||||
string timeframeFormatted = $"{request.MinTimeframeValue}-{request.MaxTimeframeValue} {request.TimeframeUnit}";
|
|
||||||
|
|
||||||
var n8nRequest = new N8nAnalysisRequestDto
|
|
||||||
{
|
|
||||||
RequestId = analysisId,
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
TriggerType = "Manual",
|
|
||||||
TargetAsset = new TargetAssetInfo
|
|
||||||
{
|
|
||||||
Symbol = request.Symbol.ToUpperInvariant(),
|
|
||||||
Isin = request.Isin.ToUpperInvariant(),
|
|
||||||
Sector = request.Sector
|
|
||||||
},
|
|
||||||
MarketContext = new MarketContextInfo
|
|
||||||
{
|
|
||||||
Vix = currentVix,
|
|
||||||
MarketRegime = regime.ToString()
|
|
||||||
},
|
|
||||||
FilterContext = new FilterContextInfo
|
|
||||||
{
|
|
||||||
ImpactScore = 1.0,
|
|
||||||
RawNewsHeadline = string.IsNullOrWhiteSpace(request.Headline) ? "Manual User Trigger" : request.Headline
|
|
||||||
},
|
|
||||||
UserPreferences = new UserPreferencesInfo
|
|
||||||
{
|
|
||||||
RiskScore = request.RiskScore,
|
|
||||||
RiskTolerance = riskLabel,
|
|
||||||
MinTimeframeValue = request.MinTimeframeValue,
|
|
||||||
MaxTimeframeValue = request.MaxTimeframeValue,
|
|
||||||
TimeframeUnit = request.TimeframeUnit,
|
|
||||||
TimeframeFormatted = timeframeFormatted,
|
|
||||||
InstrumentType = request.InstrumentType,
|
|
||||||
UserNotes = request.UserNotes
|
|
||||||
},
|
|
||||||
TradeFeedback = new TradeFeedbackInfo
|
|
||||||
{
|
|
||||||
TotalAssetTrades = 0,
|
|
||||||
AssetWinRate = winRate,
|
|
||||||
AvgReturnPercent = 0.0,
|
|
||||||
LastTradeResult = "UNKNOWN"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
|
||||||
bool shouldProceed = n8nResponse != null && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate(
|
|
||||||
request.Sector,
|
|
||||||
request.Symbol,
|
|
||||||
regime,
|
|
||||||
n8nEvalScore: n8nResponse?.EvalScore,
|
|
||||||
signalType: n8nResponse?.SuggestedDirection ?? "BUY");
|
|
||||||
|
|
||||||
TradeProposalDto? proposal = null;
|
|
||||||
if (shouldProceed && n8nResponse != null)
|
|
||||||
{
|
|
||||||
proposal = new TradeProposalDto
|
|
||||||
{
|
|
||||||
TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
EventId = analysisId,
|
|
||||||
Sector = request.Sector,
|
|
||||||
Symbol = request.Symbol.ToUpperInvariant(),
|
|
||||||
Isin = request.Isin.ToUpperInvariant(),
|
|
||||||
CompanyName = request.Symbol,
|
|
||||||
EntryPrice = request.CurrentPrice,
|
|
||||||
SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
|
||||||
RiskTolerance = n8nResponse.SuggestedRisk,
|
|
||||||
Timeframe = timeframeFormatted,
|
|
||||||
InstrumentType = request.InstrumentType,
|
|
||||||
WinRate = dynamicWinRate,
|
|
||||||
VixRegime = regime,
|
|
||||||
VixValue = currentVix,
|
|
||||||
TtlMinutes = 60,
|
|
||||||
Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}",
|
|
||||||
StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0,
|
|
||||||
TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0,
|
|
||||||
EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min,
|
|
||||||
EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max,
|
|
||||||
TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets,
|
|
||||||
RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio,
|
|
||||||
MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage,
|
|
||||||
TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty,
|
|
||||||
FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty,
|
|
||||||
RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var analysisEntity = new AnalysisEntity
|
|
||||||
{
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
EventId = analysisId,
|
|
||||||
Sector = request.Sector,
|
|
||||||
Symbol = request.Symbol.ToUpperInvariant(),
|
|
||||||
Isin = request.Isin.ToUpperInvariant(),
|
|
||||||
VixRegime = regime,
|
|
||||||
VixValue = currentVix,
|
|
||||||
ImpactScore = 1.0,
|
|
||||||
WinRate = dynamicWinRate,
|
|
||||||
RawDataJson = JsonSerializer.Serialize(request),
|
|
||||||
AiOutputJson = proposal != null ? JsonSerializer.Serialize(proposal) : "{}",
|
|
||||||
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
|
|
||||||
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
|
|
||||||
N8nDecision = n8nResponse?.AiDecision ?? "Rejected",
|
|
||||||
IsTradeProposed = shouldProceed,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
_dbContext.Analyses.Add(analysisEntity);
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalysisController] Manual analysis completed for {Symbol} (TradeProposed: {Proposed})", request.Symbol, shouldProceed);
|
|
||||||
|
|
||||||
if (!shouldProceed)
|
|
||||||
{
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
analysisId,
|
|
||||||
isTradeProposed = false,
|
|
||||||
status = "Rejected",
|
|
||||||
recommendation = "NOT_RECOMMENDED",
|
|
||||||
reasoning = n8nResponse?.AiReasoning ?? "Die KI stuft diesen Trade als zu riskant ein und empfiehlt keine Positionierung.",
|
|
||||||
n8nResponse,
|
|
||||||
proposal = (object?)null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
analysisId,
|
|
||||||
isTradeProposed = true,
|
|
||||||
status = "Success",
|
|
||||||
recommendation = "RECOMMENDED",
|
|
||||||
n8nResponse,
|
|
||||||
proposal
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using FinlyticAnalyzer.Entities;
|
|
||||||
using FinlyticCore.Database;
|
|
||||||
using FinlyticCore.Entities.Settings;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Database;
|
|
||||||
|
|
||||||
public class AnalyzerDbContext : DbContext, ISettingsDbContext
|
|
||||||
{
|
|
||||||
public AnalyzerDbContext(DbContextOptions<AnalyzerDbContext> options) : base(options) { }
|
|
||||||
|
|
||||||
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
|
||||||
public DbSet<AnalysisEntity> Analyses => Set<AnalysisEntity>();
|
|
||||||
public DbSet<AnalyzerSettingsEntity> Settings => Set<AnalyzerSettingsEntity>();
|
|
||||||
public DbSet<TradeProposalEntity> TradeProposals => Set<TradeProposalEntity>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
base.OnModelCreating(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity<SettingEntity>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(e => e.Id);
|
|
||||||
entity.HasIndex(e => e.Key).IsUnique();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<AnalysisEntity>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasIndex(e => e.AnalysisId).IsUnique();
|
|
||||||
entity.HasIndex(e => e.EventId);
|
|
||||||
entity.HasIndex(e => e.Isin);
|
|
||||||
entity.HasIndex(e => e.Sector);
|
|
||||||
entity.HasIndex(e => e.CreatedAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<TradeProposalEntity>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasIndex(e => e.Isin);
|
|
||||||
entity.HasIndex(e => e.ExpiresAt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AnalyzerDbContextFactory : IDesignTimeDbContextFactory<AnalyzerDbContext>
|
|
||||||
{
|
|
||||||
public AnalyzerDbContext CreateDbContext(string[] args)
|
|
||||||
{
|
|
||||||
var optionsBuilder = new DbContextOptionsBuilder<AnalyzerDbContext>();
|
|
||||||
optionsBuilder.UseNpgsql("Host=localhost;Database=analyzer;Username=postgres;Password=postgres");
|
|
||||||
return new AnalyzerDbContext(optionsBuilder.Options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|
||||||
WORKDIR /src
|
|
||||||
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
|
|
||||||
COPY ["FinlyticAnalyzer/FinlyticAnalyzer.csproj", "FinlyticAnalyzer/"]
|
|
||||||
RUN dotnet restore "FinlyticAnalyzer/FinlyticAnalyzer.csproj"
|
|
||||||
COPY . .
|
|
||||||
WORKDIR "/src/FinlyticAnalyzer"
|
|
||||||
RUN dotnet build "FinlyticAnalyzer.csproj" -c Release -o /app/build
|
|
||||||
|
|
||||||
FROM build AS publish
|
|
||||||
RUN dotnet publish "FinlyticAnalyzer.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=publish /app/publish .
|
|
||||||
ENTRYPOINT ["dotnet", "FinlyticAnalyzer.dll"]
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persisted raw news, market context, AI prompt payload & response in PostgreSQL.
|
|
||||||
/// </summary>
|
|
||||||
[Table("analyses")]
|
|
||||||
public class AnalysisEntity
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string AnalysisId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string EventId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(50)]
|
|
||||||
public string Sector { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string Symbol { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string Isin { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public VixMarketRegime VixRegime { get; set; }
|
|
||||||
public decimal VixValue { get; set; }
|
|
||||||
|
|
||||||
public double ImpactScore { get; set; }
|
|
||||||
public double WinRate { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")]
|
|
||||||
public string RawDataJson { get; set; } = "{}";
|
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")]
|
|
||||||
public string AiOutputJson { get; set; } = "{}";
|
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")]
|
|
||||||
public string N8nResponseJson { get; set; } = "{}";
|
|
||||||
|
|
||||||
public double N8nEvalScore { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string N8nDecision { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public bool IsTradeProposed { get; set; }
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Entities;
|
|
||||||
|
|
||||||
public class AnalyzerSettingsEntity
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
public string ScanCronSchedule { get; set; } = "0 */1 * * *";
|
|
||||||
public double MinSignalScore { get; set; } = 75.0;
|
|
||||||
|
|
||||||
public bool EnableLogMqttHealthPing { get; set; } = false;
|
|
||||||
public bool EnableLogMqttGeneral { get; set; } = true;
|
|
||||||
public bool EnableLogAnalyzerAuto { get; set; } = true;
|
|
||||||
public bool EnableLogAnalyzerManual { get; set; } = true;
|
|
||||||
public bool EnableLogDatabaseOps { get; set; } = true;
|
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Models.Assets;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Entities;
|
|
||||||
|
|
||||||
[Table("trade_proposals")]
|
|
||||||
public class TradeProposalEntity
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string AnalysisId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string EventId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string Isin { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string Symbol { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[MaxLength(150)]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[MaxLength(50)]
|
|
||||||
public string Sector { get; set; } = "General";
|
|
||||||
|
|
||||||
public AssetType Type { get; set; } = AssetType.Stock;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// KI-Entscheidung ("BUY", "SELL", "HOLD", "REJECTED")
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(20)]
|
|
||||||
public string ProposedAction { get; set; } = "BUY";
|
|
||||||
|
|
||||||
public double ConfidenceScore { get; set; }
|
|
||||||
|
|
||||||
// --- KI Execution Plan (Vorgeschlagene Preismarken) ---
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal EntryPrice { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal StopLoss { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal TakeProfit { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal? EntryZoneMin { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal? EntryZoneMax { get; set; }
|
|
||||||
|
|
||||||
public string? TakeProfitTargets { get; set; } // Comma-separated or JSON
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal? RiskRewardRatio { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal? MaxLeverage { get; set; }
|
|
||||||
|
|
||||||
// --- Kontext aus Request & KI ---
|
|
||||||
public string ReasonSummary { get; set; } = string.Empty;
|
|
||||||
public string TechnicalRationale { get; set; } = string.Empty;
|
|
||||||
public string FundamentalRationale { get; set; } = string.Empty;
|
|
||||||
public string RiskWarning { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string RiskTolerance { get; set; } = "Balanced";
|
|
||||||
|
|
||||||
[MaxLength(20)]
|
|
||||||
public string Timeframe { get; set; } = "1-7 Tage";
|
|
||||||
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string InstrumentType { get; set; } = "KnockOut";
|
|
||||||
|
|
||||||
public VixMarketRegime VixRegime { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "decimal(18,4)")]
|
|
||||||
public decimal VixValue { get; set; }
|
|
||||||
|
|
||||||
public double WinRate { get; set; }
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(3);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
|
||||||
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AnalyzerDbContext))]
|
|
||||||
[Migration("20260801073402_Init")]
|
|
||||||
partial class Init
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("AiOutputJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("AnalysisId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("EventId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<double>("ImpactScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<bool>("IsTradeProposed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("N8nDecision")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<double>("N8nEvalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("N8nResponseJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("RawDataJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Sector")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<int>("VixRegime")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("VixValue")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<double>("WinRate")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AnalysisId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
|
||||||
|
|
||||||
b.HasIndex("EventId");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.HasIndex("Sector");
|
|
||||||
|
|
||||||
b.ToTable("analyses");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<double>("MinSignalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("ScanCronSchedule")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Settings");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class Init : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "analyses",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
AnalysisId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
EventId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
Sector = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
|
||||||
Isin = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
|
||||||
VixRegime = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
VixValue = table.Column<decimal>(type: "numeric", nullable: false),
|
|
||||||
ImpactScore = table.Column<double>(type: "double precision", nullable: false),
|
|
||||||
WinRate = table.Column<double>(type: "double precision", nullable: false),
|
|
||||||
RawDataJson = table.Column<string>(type: "jsonb", nullable: false),
|
|
||||||
AiOutputJson = table.Column<string>(type: "jsonb", nullable: false),
|
|
||||||
N8nResponseJson = table.Column<string>(type: "jsonb", nullable: false),
|
|
||||||
N8nEvalScore = table.Column<double>(type: "double precision", nullable: false),
|
|
||||||
N8nDecision = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
|
||||||
IsTradeProposed = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_analyses", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Settings",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
ScanCronSchedule = table.Column<string>(type: "text", nullable: false),
|
|
||||||
MinSignalScore = table.Column<double>(type: "double precision", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Settings", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_analyses_AnalysisId",
|
|
||||||
table: "analyses",
|
|
||||||
column: "AnalysisId",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_analyses_CreatedAt",
|
|
||||||
table: "analyses",
|
|
||||||
column: "CreatedAt");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_analyses_EventId",
|
|
||||||
table: "analyses",
|
|
||||||
column: "EventId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_analyses_Isin",
|
|
||||||
table: "analyses",
|
|
||||||
column: "Isin");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_analyses_Sector",
|
|
||||||
table: "analyses",
|
|
||||||
column: "Sector");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "analyses");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Settings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-151
@@ -1,151 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AnalyzerDbContext))]
|
|
||||||
[Migration("20260803185020_AddLogFilterSettings")]
|
|
||||||
partial class AddLogFilterSettings
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("AiOutputJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("AnalysisId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("EventId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<double>("ImpactScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<bool>("IsTradeProposed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("N8nDecision")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<double>("N8nEvalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("N8nResponseJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("RawDataJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Sector")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<int>("VixRegime")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("VixValue")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<double>("WinRate")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AnalysisId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
|
||||||
|
|
||||||
b.HasIndex("EventId");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.HasIndex("Sector");
|
|
||||||
|
|
||||||
b.ToTable("analyses");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerManual")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogDatabaseOps")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttGeneral")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttHealthPing")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<double>("MinSignalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("ScanCronSchedule")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Settings");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddLogFilterSettings : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "EnableLogAnalyzerAuto",
|
|
||||||
table: "Settings",
|
|
||||||
type: "boolean",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "EnableLogAnalyzerManual",
|
|
||||||
table: "Settings",
|
|
||||||
type: "boolean",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "EnableLogDatabaseOps",
|
|
||||||
table: "Settings",
|
|
||||||
type: "boolean",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "EnableLogMqttGeneral",
|
|
||||||
table: "Settings",
|
|
||||||
type: "boolean",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "EnableLogMqttHealthPing",
|
|
||||||
table: "Settings",
|
|
||||||
type: "boolean",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EnableLogAnalyzerAuto",
|
|
||||||
table: "Settings");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EnableLogAnalyzerManual",
|
|
||||||
table: "Settings");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EnableLogDatabaseOps",
|
|
||||||
table: "Settings");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EnableLogMqttGeneral",
|
|
||||||
table: "Settings");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EnableLogMqttHealthPing",
|
|
||||||
table: "Settings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-151
@@ -1,151 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AnalyzerDbContext))]
|
|
||||||
[Migration("20260804184350_CheckPendingMigrations")]
|
|
||||||
partial class CheckPendingMigrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("AiOutputJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("AnalysisId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("EventId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<double>("ImpactScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<bool>("IsTradeProposed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("N8nDecision")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<double>("N8nEvalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("N8nResponseJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("RawDataJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Sector")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<int>("VixRegime")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("VixValue")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<double>("WinRate")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AnalysisId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
|
||||||
|
|
||||||
b.HasIndex("EventId");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.HasIndex("Sector");
|
|
||||||
|
|
||||||
b.ToTable("analyses");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerManual")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogDatabaseOps")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttGeneral")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttHealthPing")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<double>("MinSignalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("ScanCronSchedule")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Settings");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class CheckPendingMigrations : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AnalyzerDbContext))]
|
|
||||||
[Migration("20260805184638_AddTradeProposals")]
|
|
||||||
partial class AddTradeProposals
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("AiOutputJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("AnalysisId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("EventId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<double>("ImpactScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<bool>("IsTradeProposed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("N8nDecision")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<double>("N8nEvalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("N8nResponseJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("RawDataJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Sector")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<int>("VixRegime")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("VixValue")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<double>("WinRate")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AnalysisId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
|
||||||
|
|
||||||
b.HasIndex("EventId");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.HasIndex("Sector");
|
|
||||||
|
|
||||||
b.ToTable("analyses");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerManual")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogDatabaseOps")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttGeneral")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttHealthPing")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<double>("MinSignalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("ScanCronSchedule")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<double>("ConfidenceScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProposedAction")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ReasonSummary")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("ExpiresAt");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.ToTable("TradeProposals");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddTradeProposals : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "TradeProposals",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Isin = table.Column<string>(type: "text", nullable: false),
|
|
||||||
Name = table.Column<string>(type: "text", nullable: false),
|
|
||||||
Type = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
ProposedAction = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ConfidenceScore = table.Column<double>(type: "double precision", nullable: false),
|
|
||||||
ReasonSummary = table.Column<string>(type: "text", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_TradeProposals", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_TradeProposals_ExpiresAt",
|
|
||||||
table: "TradeProposals",
|
|
||||||
column: "ExpiresAt");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_TradeProposals_Isin",
|
|
||||||
table: "TradeProposals",
|
|
||||||
column: "Isin");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "TradeProposals");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-277
@@ -1,277 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AnalyzerDbContext))]
|
|
||||||
[Migration("20260813202556_CheckPendingAnalyzer")]
|
|
||||||
partial class CheckPendingAnalyzer
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("AiOutputJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("AnalysisId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("EventId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<double>("ImpactScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<bool>("IsTradeProposed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("N8nDecision")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<double>("N8nEvalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("N8nResponseJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("RawDataJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Sector")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<int>("VixRegime")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("VixValue")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<double>("WinRate")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AnalysisId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
|
||||||
|
|
||||||
b.HasIndex("EventId");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.HasIndex("Sector");
|
|
||||||
|
|
||||||
b.ToTable("analyses");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogAnalyzerManual")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogDatabaseOps")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttGeneral")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("EnableLogMqttHealthPing")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<double>("MinSignalScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("ScanCronSchedule")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("AnalysisId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<double>("ConfidenceScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<decimal>("EntryPrice")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<decimal?>("EntryZoneMax")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<decimal?>("EntryZoneMin")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<string>("EventId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("FundamentalRationale")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("InstrumentType")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("Isin")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<decimal?>("MaxLeverage")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(150)
|
|
||||||
.HasColumnType("character varying(150)");
|
|
||||||
|
|
||||||
b.Property<string>("ProposedAction")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<string>("ReasonSummary")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<decimal?>("RiskRewardRatio")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<string>("RiskTolerance")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<string>("RiskWarning")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Sector")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<decimal>("StopLoss")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<string>("Symbol")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<decimal>("TakeProfit")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<string>("TakeProfitTargets")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("TechnicalRationale")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Timeframe")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("VixRegime")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("VixValue")
|
|
||||||
.HasColumnType("decimal(18,4)");
|
|
||||||
|
|
||||||
b.Property<double>("WinRate")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("ExpiresAt");
|
|
||||||
|
|
||||||
b.HasIndex("Isin");
|
|
||||||
|
|
||||||
b.ToTable("trade_proposals");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class CheckPendingAnalyzer : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropPrimaryKey(
|
|
||||||
name: "PK_TradeProposals",
|
|
||||||
table: "TradeProposals");
|
|
||||||
|
|
||||||
migrationBuilder.RenameTable(
|
|
||||||
name: "TradeProposals",
|
|
||||||
newName: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_TradeProposals_Isin",
|
|
||||||
table: "trade_proposals",
|
|
||||||
newName: "IX_trade_proposals_Isin");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_TradeProposals_ExpiresAt",
|
|
||||||
table: "trade_proposals",
|
|
||||||
newName: "IX_trade_proposals_ExpiresAt");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "ProposedAction",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(20)",
|
|
||||||
maxLength: 20,
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(150)",
|
|
||||||
maxLength: 150,
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Isin",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(30)",
|
|
||||||
maxLength: 30,
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "text");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "AnalysisId",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(100)",
|
|
||||||
maxLength: 100,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "EntryPrice",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0m);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "EntryZoneMax",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "EntryZoneMin",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "EventId",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(100)",
|
|
||||||
maxLength: 100,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "FundamentalRationale",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "InstrumentType",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(30)",
|
|
||||||
maxLength: 30,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "MaxLeverage",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "RiskRewardRatio",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "RiskTolerance",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(30)",
|
|
||||||
maxLength: 30,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "RiskWarning",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Sector",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(50)",
|
|
||||||
maxLength: 50,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "StopLoss",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0m);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Symbol",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(30)",
|
|
||||||
maxLength: 30,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "TakeProfit",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0m);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "TakeProfitTargets",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "TechnicalRationale",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Timeframe",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "character varying(20)",
|
|
||||||
maxLength: 20,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "VixRegime",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<decimal>(
|
|
||||||
name: "VixValue",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "numeric(18,4)",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0m);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<double>(
|
|
||||||
name: "WinRate",
|
|
||||||
table: "trade_proposals",
|
|
||||||
type: "double precision",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0.0);
|
|
||||||
|
|
||||||
migrationBuilder.AddPrimaryKey(
|
|
||||||
name: "PK_trade_proposals",
|
|
||||||
table: "trade_proposals",
|
|
||||||
column: "Id");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropPrimaryKey(
|
|
||||||
name: "PK_trade_proposals",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "AnalysisId",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EntryPrice",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EntryZoneMax",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EntryZoneMin",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "EventId",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "FundamentalRationale",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "InstrumentType",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "MaxLeverage",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "RiskRewardRatio",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "RiskTolerance",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "RiskWarning",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Sector",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "StopLoss",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Symbol",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "TakeProfit",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "TakeProfitTargets",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "TechnicalRationale",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Timeframe",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "VixRegime",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "VixValue",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "WinRate",
|
|
||||||
table: "trade_proposals");
|
|
||||||
|
|
||||||
migrationBuilder.RenameTable(
|
|
||||||
name: "trade_proposals",
|
|
||||||
newName: "TradeProposals");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_trade_proposals_Isin",
|
|
||||||
table: "TradeProposals",
|
|
||||||
newName: "IX_TradeProposals_Isin");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_trade_proposals_ExpiresAt",
|
|
||||||
table: "TradeProposals",
|
|
||||||
newName: "IX_TradeProposals_ExpiresAt");
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "ProposedAction",
|
|
||||||
table: "TradeProposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "character varying(20)",
|
|
||||||
oldMaxLength: 20);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Name",
|
|
||||||
table: "TradeProposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "character varying(150)",
|
|
||||||
oldMaxLength: 150);
|
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
|
||||||
name: "Isin",
|
|
||||||
table: "TradeProposals",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
oldClrType: typeof(string),
|
|
||||||
oldType: "character varying(30)",
|
|
||||||
oldMaxLength: 30);
|
|
||||||
|
|
||||||
migrationBuilder.AddPrimaryKey(
|
|
||||||
name: "PK_TradeProposals",
|
|
||||||
table: "TradeProposals",
|
|
||||||
column: "Id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddDynamicSettings : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "DynamicSettings",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
|
||||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_DynamicSettings_Key",
|
|
||||||
table: "DynamicSettings",
|
|
||||||
column: "Key",
|
|
||||||
unique: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "DynamicSettings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
using System;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using FinlyticAnalyzer.Services;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Database;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
using FinlyticCore.Services.Yahoo;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
|
||||||
|
|
||||||
// Register DB Context
|
|
||||||
builder.Services.AddDbContext<AnalyzerDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
|
||||||
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<AnalyzerDbContext>());
|
|
||||||
|
|
||||||
// Register Core Services
|
|
||||||
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
|
||||||
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
|
||||||
|
|
||||||
// Register HTTP Clients for external webhooks (HttpClientFactory manages pool)
|
|
||||||
builder.Services.AddHttpClient<IN8nEvaluationService, N8nEvaluationService>();
|
|
||||||
|
|
||||||
// Register Domain Services
|
|
||||||
builder.Services.AddSingleton<IVixTrackerService, VixTrackerService>();
|
|
||||||
builder.Services.AddSingleton<IThreeLayerFilterEngine, ThreeLayerFilterEngine>();
|
|
||||||
builder.Services.AddSingleton<IWinRateCalculator, WinRateCalculator>();
|
|
||||||
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
|
|
||||||
builder.Services.AddSingleton<YahooFinanceClient>();
|
|
||||||
|
|
||||||
// Unified MQTT Client (Handles both Events and RPC)
|
|
||||||
builder.Services.AddSingleton<AnalyzerMqttClient>();
|
|
||||||
builder.Services.AddHostedService(provider => provider.GetRequiredService<AnalyzerMqttClient>());
|
|
||||||
|
|
||||||
// Register Active Trade Monitor
|
|
||||||
builder.Services.AddHostedService<ActiveTradeMonitorWorker>();
|
|
||||||
|
|
||||||
var host = builder.Build();
|
|
||||||
|
|
||||||
// Run DB Migrations
|
|
||||||
using (var scope = host.Services.CreateScope())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var context = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
|
||||||
await context.Database.MigrateAsync();
|
|
||||||
Console.WriteLine("Database migrations successfully executed for FinlyticAnalyzer.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Critical error during database migration for FinlyticAnalyzer: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial VIX Poll
|
|
||||||
using (var scope = host.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var vixService = scope.ServiceProvider.GetRequiredService<IVixTrackerService>();
|
|
||||||
await vixService.PollVixAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
await host.RunAsync();
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Finlytic Analyzer Service
|
|
||||||
|
|
||||||
Finlytic Analyzer is the core quantitative decision engine of the Finlytic ecosystem. It evaluates multi-layered market filters, tracks VIX volatility regimes, evaluates AI win rates, and generates actionable trade proposals.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Features & Architecture
|
|
||||||
|
|
||||||
1. **3-Layer Filter Engine (`IThreeLayerFilterEngine`)**:
|
|
||||||
- **Layer 1 (Macro VIX Regime)**: Evaluates overall volatility conditions via `IVixTrackerService`.
|
|
||||||
- **Layer 2 (Asset Technical Analysis & Indicators)**: Evaluates RSI, MACD, Moving Averages, and Supertrend alignment.
|
|
||||||
- **Layer 3 (AI Sentiment & Event Context)**: Evaluates FinBERT news sentiment scores and corporate earnings proximity.
|
|
||||||
|
|
||||||
2. **VIX Volatility Tracker (`IVixTrackerService`)**:
|
|
||||||
- Polls external VIX volatility sources and categorizes market regimes (`Low`, `Normal`, `Elevated`, `High`).
|
|
||||||
|
|
||||||
3. **Win-Rate Calculator (`IWinRateCalculator`)**:
|
|
||||||
- Calculates historical probability of success based on trade feedback records.
|
|
||||||
|
|
||||||
4. **MQTT Signal Publisher (`AnalyzerMqttClient`)**:
|
|
||||||
- Publishes generated trade proposals to `finlytic/trades/proposed/{symbol}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Status
|
|
||||||
|
|
||||||
### Implemented Features
|
|
||||||
- [x] 3-Layer Quantitative Filter Engine (`ThreeLayerFilterEngine`).
|
|
||||||
- [x] VIX Volatility Regime Tracker (`VixTrackerService`).
|
|
||||||
- [x] Win-Rate Probability Calculator (`WinRateCalculator`).
|
|
||||||
- [x] n8n AI Evaluation Integration (`N8nEvaluationService`).
|
|
||||||
- [x] Pure Worker Service Architecture (`Host.CreateApplicationBuilder`, Kestrel webserver removed).
|
|
||||||
- [x] Zero-Allocation MQTT Signal Publishing (`AnalyzerMqttClient`).
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
- [ ] Multi-year historical Backtesting Engine with Monte Carlo simulation.
|
|
||||||
- [ ] Portfolio Risk Allocation & Kelly Criterion Position Sizing Engine.
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Dtos;
|
|
||||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Models.Trades;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
using FinlyticCore.Util;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public class ActiveTradeMonitorWorker : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IFinlyticLogger<ActiveTradeMonitorWorker> _finlyticLogger;
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
|
||||||
private readonly AnalyzerMqttClient _mqttClient;
|
|
||||||
|
|
||||||
public ActiveTradeMonitorWorker(
|
|
||||||
IFinlyticLogger<ActiveTradeMonitorWorker> finlyticLogger,
|
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
AnalyzerMqttClient mqttClient)
|
|
||||||
{
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
_scopeFactory = scopeFactory;
|
|
||||||
_mqttClient = mqttClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] ActiveTradeMonitorWorker started.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await MonitorActiveTradesAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[ActiveTradeMonitorWorker] Error in ActiveTradeMonitorWorker loop.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(60), stoppingToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] ActiveTradeMonitorWorker stopped.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!_mqttClient.IsConnected)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Skipping trade monitoring. RPC client not connected.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var activeTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
|
||||||
"trades_Get",
|
|
||||||
new GetTradesRequest(null, "Active"),
|
|
||||||
TimeSpan.FromSeconds(10));
|
|
||||||
|
|
||||||
var proposedTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
|
||||||
"trades_Get",
|
|
||||||
new GetTradesRequest(null, "Proposed"),
|
|
||||||
TimeSpan.FromSeconds(10));
|
|
||||||
|
|
||||||
var trades = new List<TradeProposalDto>();
|
|
||||||
if (activeTrades != null) trades.AddRange(activeTrades);
|
|
||||||
if (proposedTrades != null) trades.AddRange(proposedTrades.Where(t => t.IsGlobalProposal));
|
|
||||||
|
|
||||||
if (trades.Count == 0)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] No active or proposed global trades found to monitor.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Found {Count} trades to monitor. Starting evaluation...", trades.Count);
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var n8nService = scope.ServiceProvider.GetRequiredService<IN8nEvaluationService>();
|
|
||||||
var vixService = scope.ServiceProvider.GetRequiredService<IVixTrackerService>();
|
|
||||||
|
|
||||||
foreach (var trade in trades)
|
|
||||||
{
|
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ProcessTradeAsync(trade, n8nService, vixService, cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[ActiveTradeMonitorWorker] Failed to monitor trade {TradeId} ({Symbol}).", trade.TradeId, trade.Symbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService,
|
|
||||||
IVixTrackerService vixService, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var livePriceReq = new IsinRequest(trade.Isin);
|
|
||||||
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
|
|
||||||
"tr_GetLivePrice", livePriceReq, TimeSpan.FromSeconds(3));
|
|
||||||
|
|
||||||
decimal currentPrice = livePriceDto?.CurrentPrice > 0 ? livePriceDto.CurrentPrice : trade.EntryPrice;
|
|
||||||
|
|
||||||
bool isLong = string.Equals(trade.SignalType, "BUY", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe);
|
|
||||||
double daysOpen = (DateTime.UtcNow - trade.CreatedAt).TotalDays;
|
|
||||||
|
|
||||||
if (daysOpen > (maxHoldingDays * 1.5))
|
|
||||||
{
|
|
||||||
await SendUpdateAsync(trade, currentPrice, "Close",
|
|
||||||
$"Time-Stop getriggert: Setup ist invalidiert. Der Trade bewegt sich zu lange seitwärts (Offen seit {(int)daysOpen} Tagen, anvisiert waren max. {maxHoldingDays} Tage).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLong)
|
|
||||||
{
|
|
||||||
if (trade.StopLoss > 0 && currentPrice <= trade.StopLoss)
|
|
||||||
{
|
|
||||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trade.TakeProfit > 0 && currentPrice >= trade.TakeProfit)
|
|
||||||
{
|
|
||||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (trade.StopLoss > 0 && currentPrice >= trade.StopLoss)
|
|
||||||
{
|
|
||||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trade.TakeProfit > 0 && currentPrice <= trade.TakeProfit)
|
|
||||||
{
|
|
||||||
await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var taResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
|
||||||
"ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
var latestIndicator = taResult?.Indicators?.LastOrDefault();
|
|
||||||
|
|
||||||
var taInfo = new TechnicalContextInfo
|
|
||||||
{
|
|
||||||
Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "N/A",
|
|
||||||
SupertrendStatus = latestIndicator?.SupertrendDirection ?? "N/A",
|
|
||||||
Atr = latestIndicator?.Atr14?.ToString("F2") ?? "N/A",
|
|
||||||
Sma50 = (double?)latestIndicator?.Sma50,
|
|
||||||
Sma200 = (double?)latestIndicator?.Sma200,
|
|
||||||
DetectedPatterns = taResult?.Patterns?.Select(p => new PatternContextInfo
|
|
||||||
{
|
|
||||||
PatternName = p.Type,
|
|
||||||
BreakoutDirection = p.BreakoutSignal?.Direction,
|
|
||||||
TargetPrice = (double?)p.BreakoutSignal?.TargetPrice,
|
|
||||||
PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent
|
|
||||||
}).ToList() ?? new List<PatternContextInfo>()
|
|
||||||
};
|
|
||||||
|
|
||||||
var n8nReq = new N8nAnalysisRequestDto
|
|
||||||
{
|
|
||||||
RequestId = Guid.NewGuid().ToString("N"),
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
TriggerType = "HourlyMonitor",
|
|
||||||
TargetAsset = new TargetAssetInfo
|
|
||||||
{
|
|
||||||
Symbol = trade.Symbol,
|
|
||||||
Isin = trade.Isin,
|
|
||||||
Sector = trade.Sector
|
|
||||||
},
|
|
||||||
MarketContext = new MarketContextInfo
|
|
||||||
{
|
|
||||||
Vix = vixService.GetCurrentVix(),
|
|
||||||
MarketRegime = vixService.GetCurrentRegime().ToString()
|
|
||||||
},
|
|
||||||
UserPreferences = new UserPreferencesInfo
|
|
||||||
{
|
|
||||||
InstrumentType = trade.InstrumentType,
|
|
||||||
TimeframeFormatted = trade.Timeframe
|
|
||||||
},
|
|
||||||
TechnicalContext = taInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken);
|
|
||||||
if (aiResponse == null)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] AI evaluation returned null for {TradeId}. Skipping update.", trade.TradeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string newRecommendation = "Hold";
|
|
||||||
string reasoning = aiResponse.AiReasoning;
|
|
||||||
decimal? newStopLoss = trade.StopLoss;
|
|
||||||
decimal? newTakeProfit = trade.TakeProfit;
|
|
||||||
|
|
||||||
bool aiSuggestsShort =
|
|
||||||
string.Equals(aiResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(aiResponse.SuggestedDirection, "Sell", StringComparison.OrdinalIgnoreCase);
|
|
||||||
bool aiSuggestsLong =
|
|
||||||
string.Equals(aiResponse.SuggestedDirection, "Long", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(aiResponse.SuggestedDirection, "Buy", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if ((isLong && aiSuggestsShort) || (!isLong && aiSuggestsLong))
|
|
||||||
{
|
|
||||||
newRecommendation = "Close";
|
|
||||||
reasoning =
|
|
||||||
$"Trendwende detektiert: KI empfiehlt {aiResponse.SuggestedDirection}, Trade ist aber {(isLong ? "Long" : "Short")}.";
|
|
||||||
}
|
|
||||||
else if (string.Equals(aiResponse.AiDecision, "Reject", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
newRecommendation = "Close";
|
|
||||||
reasoning = $"Risiko zu hoch: KI empfiehlt Exit. ({aiResponse.AiReasoning})";
|
|
||||||
}
|
|
||||||
else if (aiResponse.ExecutionPlan != null)
|
|
||||||
{
|
|
||||||
if (aiResponse.ExecutionPlan.StopLoss > 0)
|
|
||||||
{
|
|
||||||
var proposedSl = aiResponse.ExecutionPlan.StopLoss;
|
|
||||||
if (isLong)
|
|
||||||
{
|
|
||||||
if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss)
|
|
||||||
{
|
|
||||||
newStopLoss = proposedSl;
|
|
||||||
if (proposedSl > trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss)
|
|
||||||
{
|
|
||||||
newStopLoss = proposedSl;
|
|
||||||
if (proposedSl < trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aiResponse.ExecutionPlan.TakeProfitTargets != null &&
|
|
||||||
aiResponse.ExecutionPlan.TakeProfitTargets.Count > 0)
|
|
||||||
{
|
|
||||||
var proposedTp = aiResponse.ExecutionPlan.TakeProfitTargets[0];
|
|
||||||
if (proposedTp > 0 && proposedTp != trade.TakeProfit)
|
|
||||||
{
|
|
||||||
newTakeProfit = proposedTp;
|
|
||||||
if (newRecommendation == "Hold") newRecommendation = "AdjustTP";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SendUpdateAsync(trade, currentPrice, newRecommendation, reasoning, newStopLoss, newTakeProfit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendUpdateAsync(TradeProposalDto trade, decimal currentPrice, string recommendation,
|
|
||||||
string reasoning, decimal? suggestedStopLoss = null, decimal? suggestedTakeProfit = null)
|
|
||||||
{
|
|
||||||
var update = new TradeHourlyUpdateDto
|
|
||||||
{
|
|
||||||
TradeId = trade.TradeId,
|
|
||||||
Recommendation = recommendation,
|
|
||||||
CurrentPrice = currentPrice,
|
|
||||||
SuggestedStopLoss = suggestedStopLoss,
|
|
||||||
SuggestedTakeProfit = suggestedTakeProfit,
|
|
||||||
VixValue = trade.VixValue,
|
|
||||||
Reasoning = reasoning,
|
|
||||||
Timestamp = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
string topic = $"finlytic/trades/updates/{trade.Isin}";
|
|
||||||
await _mqttClient.PublishAsync(topic, update);
|
|
||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}",
|
|
||||||
trade.TradeId, topic, recommendation, reasoning);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int EstimateMaxHoldingDays(string timeframe)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(timeframe)) return 14;
|
|
||||||
|
|
||||||
string tfLower = timeframe.ToLowerInvariant();
|
|
||||||
int multiplier = 1;
|
|
||||||
|
|
||||||
if (tfLower.Contains("woche") || tfLower.Contains("week")) multiplier = 7;
|
|
||||||
else if (tfLower.Contains("monat") || tfLower.Contains("month")) multiplier = 30;
|
|
||||||
else if (tfLower.Contains("jahr") || tfLower.Contains("year")) multiplier = 365;
|
|
||||||
|
|
||||||
var numbers = new List<int>();
|
|
||||||
string currentNum = "";
|
|
||||||
|
|
||||||
foreach (char c in timeframe)
|
|
||||||
{
|
|
||||||
if (char.IsDigit(c))
|
|
||||||
{
|
|
||||||
currentNum += c;
|
|
||||||
}
|
|
||||||
else if (currentNum.Length > 0)
|
|
||||||
{
|
|
||||||
if (int.TryParse(currentNum, out int n)) numbers.Add(n);
|
|
||||||
currentNum = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentNum.Length > 0 && int.TryParse(currentNum, out int lastN)) numbers.Add(lastN);
|
|
||||||
|
|
||||||
int maxNum = numbers.Count > 0 ? numbers.Max() : 14;
|
|
||||||
|
|
||||||
if (maxNum == 0) maxNum = 14;
|
|
||||||
if (multiplier == 1 && maxNum < 3) maxNum = 3;
|
|
||||||
|
|
||||||
return maxNum * multiplier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public interface IN8nEvaluationService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates an asset asynchronously using N8n.
|
|
||||||
/// </summary>
|
|
||||||
Task<N8nAnalysisResponseDto?> EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Dtos.News;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public class FilterResult
|
|
||||||
{
|
|
||||||
public bool Passed { get; set; }
|
|
||||||
public string RejectReason { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string Sector { get; set; } = string.Empty;
|
|
||||||
public string Symbol { get; set; } = string.Empty;
|
|
||||||
public string Isin { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public double ImpactScore { get; set; }
|
|
||||||
public double ThresholdApplied { get; set; }
|
|
||||||
|
|
||||||
public string RiskTolerance { get; set; } = "Moderate";
|
|
||||||
public string Timeframe { get; set; } = "1D";
|
|
||||||
public string InstrumentType { get; set; } = "Stock";
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IThreeLayerFilterEngine
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates news based on market regime and returns a filter result.
|
|
||||||
/// </summary>
|
|
||||||
FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public interface IVixTrackerService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current VIX value.
|
|
||||||
/// </summary>
|
|
||||||
decimal GetCurrentVix();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current market regime based on VIX.
|
|
||||||
/// </summary>
|
|
||||||
VixMarketRegime GetCurrentRegime();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the VIX tracker with a new tick value.
|
|
||||||
/// </summary>
|
|
||||||
void UpdateVixFromTick(decimal vixValue);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Polls the VIX asynchronously and returns its value.
|
|
||||||
/// </summary>
|
|
||||||
Task<decimal> PollVixAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public interface IWinRateCalculator
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the win rate for a given sector and symbol under the specified market regime.
|
|
||||||
/// </summary>
|
|
||||||
double CalculateWinRate(string sector, string symbol, VixMarketRegime regime);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime.
|
|
||||||
/// </summary>
|
|
||||||
double CalculateDynamicWinRate(
|
|
||||||
string sector,
|
|
||||||
string symbol,
|
|
||||||
VixMarketRegime regime,
|
|
||||||
double? n8nEvalScore = null,
|
|
||||||
double? technicalScore = null,
|
|
||||||
double? sentimentScore = null,
|
|
||||||
double? fundamentalScore = null,
|
|
||||||
string signalType = "BUY");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public enum LogCategory
|
|
||||||
{
|
|
||||||
MqttHealthPing,
|
|
||||||
MqttGeneral,
|
|
||||||
AnalyzerAuto,
|
|
||||||
AnalyzerManual,
|
|
||||||
DatabaseOps,
|
|
||||||
General
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class LogCategoryFilter
|
|
||||||
{
|
|
||||||
public static bool EnableLogMqttHealthPing { get; set; } = false;
|
|
||||||
public static bool EnableLogMqttGeneral { get; set; } = true;
|
|
||||||
public static bool EnableLogAnalyzerAuto { get; set; } = true;
|
|
||||||
public static bool EnableLogAnalyzerManual { get; set; } = true;
|
|
||||||
public static bool EnableLogDatabaseOps { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a given log category is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public static bool IsEnabled(LogCategory category)
|
|
||||||
{
|
|
||||||
return category switch
|
|
||||||
{
|
|
||||||
LogCategory.MqttHealthPing => EnableLogMqttHealthPing,
|
|
||||||
LogCategory.MqttGeneral => EnableLogMqttGeneral,
|
|
||||||
LogCategory.AnalyzerAuto => EnableLogAnalyzerAuto,
|
|
||||||
LogCategory.AnalyzerManual => EnableLogAnalyzerManual,
|
|
||||||
LogCategory.DatabaseOps => EnableLogDatabaseOps,
|
|
||||||
_ => true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
using FinlyticCore.Util;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public class N8nEvaluationService : IN8nEvaluationService
|
|
||||||
{
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly IFinlyticLogger<N8nEvaluationService> _finlyticLogger;
|
|
||||||
private readonly string _webhookUrl;
|
|
||||||
|
|
||||||
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, IFinlyticLogger<N8nEvaluationService> finlyticLogger)
|
|
||||||
{
|
|
||||||
_httpClient = httpClient;
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
_webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? string.Empty;
|
|
||||||
if (string.IsNullOrWhiteSpace(_webhookUrl))
|
|
||||||
{
|
|
||||||
_ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] N8N:WebhookUrl configuration is missing or empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_httpClient.Timeout = TimeSpan.FromSeconds(45);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates an asset asynchronously using N8n / Gemini workflows.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<N8nAnalysisResponseDto?> EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_webhookUrl))
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Cannot execute AI evaluation for {Symbol}: N8N:WebhookUrl is not configured.", request.TargetAsset.Symbol);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...",
|
|
||||||
request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl);
|
|
||||||
|
|
||||||
using var content = JsonContent.Create(
|
|
||||||
request,
|
|
||||||
FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto);
|
|
||||||
|
|
||||||
using var response = await _httpClient.PostAsync(_webhookUrl, content, cancellationToken);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var contentStr = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(contentStr) || contentStr.Trim() == "{}" || contentStr.Trim() == "[]")
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] n8n Webhook returned an EMPTY response for Request {RequestId}. Flagging as AI Rejection (Too Risky).", request.RequestId);
|
|
||||||
return CreateRejectionFallback(request, "Die KI (n8n/Gemini) stuft den Trade als zu riskant ein und empfiehlt keine Positionierung.");
|
|
||||||
}
|
|
||||||
|
|
||||||
string jsonToDeserialize = contentStr.Trim();
|
|
||||||
if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']'))
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(jsonToDeserialize);
|
|
||||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
jsonToDeserialize = doc.RootElement[0].GetRawText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseDto = JsonSerializer.Deserialize(
|
|
||||||
jsonToDeserialize,
|
|
||||||
FinlyticJsonSerializerContext.Default.N8nAnalysisResponseDto);
|
|
||||||
|
|
||||||
if (responseDto != null && !string.IsNullOrWhiteSpace(responseDto.AiDecision))
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Received n8n AI Response for Request {RequestId}: Decision={Decision}, Score={Score:F2}, Direction={Direction}, Timeframe={Timeframe}",
|
|
||||||
request.RequestId, responseDto.AiDecision, responseDto.EvalScore, responseDto.SuggestedDirection, responseDto.SuggestedTimeframe);
|
|
||||||
|
|
||||||
return responseDto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}",
|
|
||||||
response.StatusCode, request.RequestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[N8nEvaluationService] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", request.RequestId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[N8nEvaluationService] Error calling n8n AI Evaluation Webhook for Request {RequestId}", request.RequestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static N8nAnalysisResponseDto CreateRejectionFallback(N8nAnalysisRequestDto request, string reasoning)
|
|
||||||
{
|
|
||||||
return new N8nAnalysisResponseDto
|
|
||||||
{
|
|
||||||
RequestId = request.RequestId,
|
|
||||||
AiDecision = "Rejected",
|
|
||||||
EvalScore = 0.0,
|
|
||||||
SuggestedDirection = "NONE",
|
|
||||||
SuggestedRisk = request.UserPreferences?.RiskTolerance ?? "Moderate",
|
|
||||||
SuggestedTimeframe = request.UserPreferences?.TimeframeFormatted ?? "1D",
|
|
||||||
AiReasoning = reasoning
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using FinlyticAnalyzer.Entities;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public interface ISettingsDbService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the analyzer settings asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
Task<AnalyzerSettingsEntity> GetSettingsAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves the analyzer settings asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
Task<AnalyzerSettingsEntity> SaveSettingsAsync(AnalyzerSettingsEntity settings);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates settings from a dictionary asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SettingsDbService : ISettingsDbService
|
|
||||||
{
|
|
||||||
private readonly AnalyzerDbContext _context;
|
|
||||||
|
|
||||||
public SettingsDbService(AnalyzerDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the analyzer settings asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<AnalyzerSettingsEntity> GetSettingsAsync()
|
|
||||||
{
|
|
||||||
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
|
||||||
if (settings == null)
|
|
||||||
{
|
|
||||||
settings = new AnalyzerSettingsEntity { Id = Guid.NewGuid(), UpdatedAt = DateTime.UtcNow };
|
|
||||||
_context.Settings.Add(settings);
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
_context.ChangeTracker.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronize in-memory static filter values on get
|
|
||||||
SyncLogFilters(settings);
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves the analyzer settings asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<AnalyzerSettingsEntity> SaveSettingsAsync(AnalyzerSettingsEntity settings)
|
|
||||||
{
|
|
||||||
var existing = await _context.Settings.FirstOrDefaultAsync(s => s.Id == settings.Id)
|
|
||||||
?? await _context.Settings.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (existing == null)
|
|
||||||
{
|
|
||||||
if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid();
|
|
||||||
settings.UpdatedAt = DateTime.UtcNow;
|
|
||||||
_context.Settings.Add(settings);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
existing.ScanCronSchedule = settings.ScanCronSchedule;
|
|
||||||
existing.MinSignalScore = settings.MinSignalScore;
|
|
||||||
existing.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing;
|
|
||||||
existing.EnableLogMqttGeneral = settings.EnableLogMqttGeneral;
|
|
||||||
existing.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto;
|
|
||||||
existing.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual;
|
|
||||||
existing.EnableLogDatabaseOps = settings.EnableLogDatabaseOps;
|
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
|
|
||||||
SyncLogFilters(settings);
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SyncLogFilters(AnalyzerSettingsEntity settings)
|
|
||||||
{
|
|
||||||
LogCategoryFilter.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing;
|
|
||||||
LogCategoryFilter.EnableLogMqttGeneral = settings.EnableLogMqttGeneral;
|
|
||||||
LogCategoryFilter.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto;
|
|
||||||
LogCategoryFilter.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual;
|
|
||||||
LogCategoryFilter.EnableLogDatabaseOps = settings.EnableLogDatabaseOps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates settings from a dictionary asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary)
|
|
||||||
{
|
|
||||||
var settings = await GetSettingsAsync();
|
|
||||||
|
|
||||||
foreach (var (key, value) in dictionary)
|
|
||||||
{
|
|
||||||
if (string.Equals(key, "ScanCronSchedule", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
|
|
||||||
settings.ScanCronSchedule = value.Trim();
|
|
||||||
else if (string.Equals(key, "MinSignalScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var score))
|
|
||||||
settings.MinSignalScore = score;
|
|
||||||
else if (string.Equals(key, "EnableLog_MqttHealthPing", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b1))
|
|
||||||
settings.EnableLogMqttHealthPing = b1;
|
|
||||||
else if (string.Equals(key, "EnableLog_MqttGeneral", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b2))
|
|
||||||
settings.EnableLogMqttGeneral = b2;
|
|
||||||
else if (string.Equals(key, "EnableLog_AnalyzerAuto", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b3))
|
|
||||||
settings.EnableLogAnalyzerAuto = b3;
|
|
||||||
else if (string.Equals(key, "EnableLog_AnalyzerManual", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b4))
|
|
||||||
settings.EnableLogAnalyzerManual = b4;
|
|
||||||
else if (string.Equals(key, "EnableLog_DatabaseOps", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b5))
|
|
||||||
settings.EnableLogDatabaseOps = b5;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await SaveSettingsAsync(settings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Dtos.News;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
|
||||||
{
|
|
||||||
private readonly IFinlyticLogger<ThreeLayerFilterEngine> _finlyticLogger;
|
|
||||||
private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new();
|
|
||||||
private readonly object _cleanupLock = new();
|
|
||||||
private DateTime _lastCleanupTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
public ThreeLayerFilterEngine(IFinlyticLogger<ThreeLayerFilterEngine> finlyticLogger)
|
|
||||||
{
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates news strictly based on ISIN and dynamic VIX market regime.
|
|
||||||
/// </summary>
|
|
||||||
public FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime)
|
|
||||||
{
|
|
||||||
var result = new FilterResult();
|
|
||||||
|
|
||||||
if (newsEvent == null || newsEvent.Id == Guid.Empty)
|
|
||||||
{
|
|
||||||
result.Passed = false;
|
|
||||||
result.RejectReason = "Layer 1: Missing or Empty NewsArticle / EventId";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
string eventId = newsEvent.Id.ToString();
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
|
|
||||||
{
|
|
||||||
lock (_cleanupLock)
|
|
||||||
{
|
|
||||||
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
|
|
||||||
{
|
|
||||||
CleanupSeenEvents(now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_seenEvents.TryGetValue(eventId, out var prevTime) && (now - prevTime).TotalHours < 12.0)
|
|
||||||
{
|
|
||||||
result.Passed = false;
|
|
||||||
result.RejectReason = "Layer 1: Duplicate EventId within 12h window";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
_seenEvents[eventId] = now;
|
|
||||||
|
|
||||||
string isin = string.Empty;
|
|
||||||
string assetName = string.Empty;
|
|
||||||
|
|
||||||
if (newsEvent.MatchedAssets != null && newsEvent.MatchedAssets.Count > 0)
|
|
||||||
{
|
|
||||||
var firstAsset = newsEvent.MatchedAssets[0];
|
|
||||||
isin = !string.IsNullOrWhiteSpace(firstAsset.Isin) ? firstAsset.Isin.Trim().ToUpperInvariant() : string.Empty;
|
|
||||||
assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(isin))
|
|
||||||
{
|
|
||||||
result.Passed = false;
|
|
||||||
result.RejectReason = "Layer 1: Missing mandatory ISIN for news item";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Isin = isin;
|
|
||||||
result.Symbol = isin;
|
|
||||||
result.Sector = "General";
|
|
||||||
|
|
||||||
double impactScore = newsEvent.Confidence ?? 0.75;
|
|
||||||
if (impactScore <= 0) impactScore = 0.75;
|
|
||||||
|
|
||||||
double requiredThreshold = regime switch
|
|
||||||
{
|
|
||||||
VixMarketRegime.LowVol => 0.55,
|
|
||||||
VixMarketRegime.Normal => 0.65,
|
|
||||||
VixMarketRegime.HighVol => 0.80,
|
|
||||||
VixMarketRegime.Panic => 0.90,
|
|
||||||
_ => 0.65
|
|
||||||
};
|
|
||||||
|
|
||||||
result.ImpactScore = impactScore;
|
|
||||||
result.ThresholdApplied = requiredThreshold;
|
|
||||||
|
|
||||||
if (impactScore < requiredThreshold)
|
|
||||||
{
|
|
||||||
result.Passed = false;
|
|
||||||
result.RejectReason = $"Layer 2: Impact score ({impactScore:F2}) below dynamic VIX threshold ({requiredThreshold:F2}) for regime {regime}";
|
|
||||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ThreeLayerFilterEngine] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}",
|
|
||||||
eventId, isin, impactScore, requiredThreshold, regime);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.RiskTolerance = regime switch
|
|
||||||
{
|
|
||||||
VixMarketRegime.Panic => "Conservative",
|
|
||||||
VixMarketRegime.HighVol => "Moderate",
|
|
||||||
_ => "Aggressive"
|
|
||||||
};
|
|
||||||
|
|
||||||
result.Timeframe = impactScore >= 0.85 ? "4H" : "1D";
|
|
||||||
result.InstrumentType = regime == VixMarketRegime.Panic ? "Option" : "Stock";
|
|
||||||
|
|
||||||
result.Passed = true;
|
|
||||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ThreeLayerFilterEngine] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}",
|
|
||||||
eventId, result.Isin, impactScore, regime);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CleanupSeenEvents(DateTime now)
|
|
||||||
{
|
|
||||||
_lastCleanupTime = now;
|
|
||||||
foreach (var kv in _seenEvents)
|
|
||||||
{
|
|
||||||
if ((now - kv.Value).TotalHours > 12.0)
|
|
||||||
{
|
|
||||||
_seenEvents.TryRemove(kv.Key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
using FinlyticCore.Services.Yahoo;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public class VixTrackerService : IVixTrackerService
|
|
||||||
{
|
|
||||||
private readonly YahooFinanceClient _yahooClient;
|
|
||||||
private readonly IFinlyticLogger<VixTrackerService> _finlyticLogger;
|
|
||||||
|
|
||||||
private decimal _currentVix = 18.5m;
|
|
||||||
private VixMarketRegime _currentRegime = VixMarketRegime.Normal;
|
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
public VixTrackerService(YahooFinanceClient yahooClient, IFinlyticLogger<VixTrackerService> finlyticLogger)
|
|
||||||
{
|
|
||||||
_yahooClient = yahooClient;
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public decimal GetCurrentVix()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _currentVix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public VixMarketRegime GetCurrentRegime()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _currentRegime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateVixFromTick(decimal vixValue)
|
|
||||||
{
|
|
||||||
if (vixValue <= 0m) return;
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var oldRegime = _currentRegime;
|
|
||||||
var oldVix = _currentVix;
|
|
||||||
|
|
||||||
_currentVix = vixValue;
|
|
||||||
_currentRegime = CalculateRegime(vixValue);
|
|
||||||
|
|
||||||
if (oldRegime != _currentRegime)
|
|
||||||
{
|
|
||||||
_ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[VixTrackerService] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
|
|
||||||
oldRegime, _currentRegime, _currentVix);
|
|
||||||
}
|
|
||||||
else if (Math.Abs(oldVix - vixValue) >= 0.5m)
|
|
||||||
{
|
|
||||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[VixTrackerService] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
|
|
||||||
_currentVix, _currentRegime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<decimal> PollVixAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var vix = await _yahooClient.GetLivePriceAsync("^VIX", cancellationToken);
|
|
||||||
|
|
||||||
if (vix.HasValue && vix.Value > 0m)
|
|
||||||
{
|
|
||||||
UpdateVixFromTick(vix.Value);
|
|
||||||
return vix.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[VixTrackerService] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.", GetCurrentVix());
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetCurrentVix();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static VixMarketRegime CalculateRegime(decimal vix)
|
|
||||||
{
|
|
||||||
return vix switch
|
|
||||||
{
|
|
||||||
< 15.0m => VixMarketRegime.LowVol,
|
|
||||||
>= 15.0m and < 20.0m => VixMarketRegime.Normal,
|
|
||||||
>= 20.0m and < 30.0m => VixMarketRegime.HighVol,
|
|
||||||
_ => VixMarketRegime.Panic
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using FinlyticAnalyzer.Util;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Models.Trades;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Services;
|
|
||||||
|
|
||||||
public class WinRateCalculator : IWinRateCalculator
|
|
||||||
{
|
|
||||||
private readonly IFinlyticLogger<WinRateCalculator> _finlyticLogger;
|
|
||||||
private readonly string _feedbackDir;
|
|
||||||
|
|
||||||
private readonly object _cacheLock = new();
|
|
||||||
private List<TradeFeedbackRecord>? _cachedRecords;
|
|
||||||
private DateTime _lastCacheTime = DateTime.MinValue;
|
|
||||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(3);
|
|
||||||
|
|
||||||
public WinRateCalculator(IFinlyticLogger<WinRateCalculator> finlyticLogger)
|
|
||||||
{
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
|
|
||||||
if (!Directory.Exists(_feedbackDir))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(_feedbackDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the win rate for a given sector and symbol under the specified market regime.
|
|
||||||
/// </summary>
|
|
||||||
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
|
|
||||||
{
|
|
||||||
return CalculateDynamicWinRate(sector, symbol, regime);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime.
|
|
||||||
/// </summary>
|
|
||||||
public double CalculateDynamicWinRate(
|
|
||||||
string sector,
|
|
||||||
string symbol,
|
|
||||||
VixMarketRegime regime,
|
|
||||||
double? n8nEvalScore = null,
|
|
||||||
double? technicalScore = null,
|
|
||||||
double? sentimentScore = null,
|
|
||||||
double? fundamentalScore = null,
|
|
||||||
string signalType = "BUY")
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
double n8nComponent = 62.0;
|
|
||||||
if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0)
|
|
||||||
{
|
|
||||||
n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
double taComponent = 60.0;
|
|
||||||
if (technicalScore.HasValue && technicalScore.Value > 0)
|
|
||||||
{
|
|
||||||
taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
double sentComponent = 58.0;
|
|
||||||
if (sentimentScore.HasValue)
|
|
||||||
{
|
|
||||||
if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0)
|
|
||||||
{
|
|
||||||
sentComponent = 50.0 + (sentimentScore.Value * 25.0);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sentComponent = sentimentScore.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double fundComponent = 60.0;
|
|
||||||
if (fundamentalScore.HasValue && fundamentalScore.Value > 0)
|
|
||||||
{
|
|
||||||
fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15);
|
|
||||||
|
|
||||||
double vixAdjustment = regime switch
|
|
||||||
{
|
|
||||||
VixMarketRegime.LowVol => +4.0,
|
|
||||||
VixMarketRegime.Normal => +1.5,
|
|
||||||
VixMarketRegime.HighVol => -3.5,
|
|
||||||
VixMarketRegime.Panic => -8.0,
|
|
||||||
_ => 0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
composite += vixAdjustment;
|
|
||||||
|
|
||||||
var records = GetCachedOrLoadRecords();
|
|
||||||
if (records.Count > 0)
|
|
||||||
{
|
|
||||||
var matching = records.Where(r =>
|
|
||||||
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
r.VixRegime == regime).ToList();
|
|
||||||
|
|
||||||
if (matching.Count >= 5)
|
|
||||||
{
|
|
||||||
int winningTrades = matching.Count(r => r.IsWin);
|
|
||||||
double historicalWinRate = (double)winningTrades / matching.Count * 100.0;
|
|
||||||
composite = (composite * 0.75) + (historicalWinRate * 0.25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0);
|
|
||||||
|
|
||||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[WinRateCalculator] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]",
|
|
||||||
symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime);
|
|
||||||
|
|
||||||
return finalWinRate;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[WinRateCalculator] Error calculating dynamic win-rate for {Symbol}. Fallback applied.", symbol);
|
|
||||||
return 65.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TradeFeedbackRecord> GetCachedOrLoadRecords()
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
if (_cachedRecords != null && (DateTime.UtcNow - _lastCacheTime) < CacheTtl)
|
|
||||||
{
|
|
||||||
return _cachedRecords;
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadedList = new List<TradeFeedbackRecord>();
|
|
||||||
|
|
||||||
if (Directory.Exists(_feedbackDir))
|
|
||||||
{
|
|
||||||
var jsonFiles = Directory.GetFiles(_feedbackDir, "*.json", SearchOption.AllDirectories);
|
|
||||||
foreach (var file in jsonFiles)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var content = File.ReadAllText(file);
|
|
||||||
var records = JsonSerializer.Deserialize<TradeFeedbackRecord[]>(content);
|
|
||||||
if (records != null && records.Length > 0)
|
|
||||||
{
|
|
||||||
loadedList.AddRange(records);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[WinRateCalculator] Failed to read or parse feedback file '{File}'", file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_cachedRecords = loadedList;
|
|
||||||
_lastCacheTime = DateTime.UtcNow;
|
|
||||||
return _cachedRecords;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,931 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticAnalyzer.Database;
|
|
||||||
using FinlyticAnalyzer.Entities;
|
|
||||||
using FinlyticAnalyzer.Services;
|
|
||||||
using FinlyticCore.Dtos;
|
|
||||||
using FinlyticCore.Dtos.Settings;
|
|
||||||
using FinlyticCore.Models;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Models.Trades;
|
|
||||||
using FinlyticCore.Services;
|
|
||||||
using FinlyticCore.Util;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Util;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unified Managed MQTT Client for FinlyticAnalyzer.
|
|
||||||
/// Handles event subscriptions, market screening, manual AI evaluation triggers,
|
|
||||||
/// and dispatches trade proposals via MQTT.
|
|
||||||
/// </summary>
|
|
||||||
public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
|
||||||
{
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
|
||||||
private readonly IVixTrackerService _vixTracker;
|
|
||||||
private readonly IThreeLayerFilterEngine _filterEngine;
|
|
||||||
private readonly IWinRateCalculator _winRateCalculator;
|
|
||||||
private readonly IN8nEvaluationService _n8nService;
|
|
||||||
private readonly ILogger<AnalyzerMqttClient> _logger;
|
|
||||||
|
|
||||||
public AnalyzerMqttClient(
|
|
||||||
IConfiguration configuration,
|
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
IVixTrackerService vixTracker,
|
|
||||||
IThreeLayerFilterEngine filterEngine,
|
|
||||||
IWinRateCalculator winRateCalculator,
|
|
||||||
IN8nEvaluationService n8nService,
|
|
||||||
ILogger<AnalyzerMqttClient> logger) : base(logger)
|
|
||||||
{
|
|
||||||
_configuration = configuration;
|
|
||||||
_scopeFactory = scopeFactory;
|
|
||||||
_vixTracker = vixTracker;
|
|
||||||
_filterEngine = filterEngine;
|
|
||||||
_winRateCalculator = winRateCalculator;
|
|
||||||
_n8nService = n8nService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var config = new MqttConfiguration
|
|
||||||
{
|
|
||||||
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
|
|
||||||
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
|
|
||||||
Username = _configuration["MQTT:Username"] ?? _configuration["MQTT__Username"],
|
|
||||||
Password = _configuration["MQTT:Password"] ?? _configuration["MQTT__Password"],
|
|
||||||
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_analyzer")}_{Guid.NewGuid():N}"
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogInformation("Starting Unified Analyzer MQTT Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
|
||||||
await ConnectAsync(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Stopping Unified Analyzer MQTT Client.");
|
|
||||||
await DisconnectAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnConnectedAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Analyzer MQTT Client connected. Subscribing to topics and RPC response channels...");
|
|
||||||
|
|
||||||
// Incoming Event Topics
|
|
||||||
await SubscribeAsync("services/news/#");
|
|
||||||
await SubscribeAsync("finlytic/news/raw/#");
|
|
||||||
await SubscribeAsync("finlytic/market/ticks/#");
|
|
||||||
await SubscribeAsync("services/config/updated/#");
|
|
||||||
await SubscribeAsync("services/request/health_Ping/#");
|
|
||||||
await SubscribeAsync("services/request/analyzer_TriggerManual/#");
|
|
||||||
await SubscribeAsync("services/request/analyzer_settings_GetAll/#");
|
|
||||||
await SubscribeAsync("services/request/analyzer_settings_Update/#");
|
|
||||||
await SubscribeAsync("finlytic/trades/closed/#");
|
|
||||||
|
|
||||||
// RPC Response Channels
|
|
||||||
await SubscribeAsync("services/response/ta_GetAnalysis/#");
|
|
||||||
await SubscribeAsync("services/response/fundamentals_Get/#");
|
|
||||||
await SubscribeAsync("services/response/sentiment_GetIsin/#");
|
|
||||||
await SubscribeAsync("services/response/sentiment_Analyze/#");
|
|
||||||
await SubscribeAsync("services/response/trades_Get/#");
|
|
||||||
await SubscribeAsync("services/response/tr_GetLivePrice/#");
|
|
||||||
await SubscribeAsync("services/response/events_GetByMonth/#");
|
|
||||||
await SubscribeAsync("services/response/events_GetAll/#");
|
|
||||||
|
|
||||||
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
|
||||||
{
|
|
||||||
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await PublishAsync("finlytic/logs/FinlyticAnalyzer", logDto);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogInformation("Successfully subscribed to all event and RPC channels.");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var segments = topic.Split('/');
|
|
||||||
bool isForMe = segments.Length >= 5
|
|
||||||
? segments[3].Equals("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase)
|
|
||||||
: topic.Contains("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (isForMe)
|
|
||||||
{
|
|
||||||
var correlationId = segments[^1];
|
|
||||||
string respTopic = $"services/response/health_Ping/{correlationId}";
|
|
||||||
var healthResp = new ServiceHealthResponse("FinlyticAnalyzer", "Online", DateTime.UtcNow, "Connected");
|
|
||||||
await PublishAsync(respTopic, healthResp);
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (topic.EndsWith("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var configUpdate = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
|
|
||||||
if (configUpdate?.Settings != null && configUpdate.Settings.Count > 0)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
|
||||||
var dict = configUpdate.Settings.ToDictionary(k => k.Key, v => (object?)v.Value);
|
|
||||||
await settings.UpdateSettingsAsync(dict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Error processing MQTT config update event.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic.StartsWith("finlytic/market/ticks/"))
|
|
||||||
{
|
|
||||||
ProcessTickMessage(topic, payloadStr);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("finlytic/news/raw/", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
topic.StartsWith("services/news/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await ProcessNewsMessageAsync(payloadStr, CancellationToken.None);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/analyzer_TriggerManual/"))
|
|
||||||
{
|
|
||||||
var correlationId = topic.Split('/').Last();
|
|
||||||
await HandleManualTriggerAsync(correlationId, payloadStr, CancellationToken.None);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/analyzer_settings_GetAll", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var correlationId = topic.Split('/').Last();
|
|
||||||
await HandleSettingsGetAllAsync(correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/analyzer_settings_Update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var correlationId = topic.Split('/').Last();
|
|
||||||
await HandleSettingsUpdateAsync(payloadStr, correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("finlytic/trades/closed/"))
|
|
||||||
{
|
|
||||||
await HandleClosedTradeFeedbackAsync(payloadStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Error processing incoming MQTT message on topic {Topic}", topic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleSettingsGetAllAsync(string correlationId)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
|
||||||
var responseTopic = $"services/response/analyzer_settings_GetAll/{correlationId}";
|
|
||||||
|
|
||||||
await PublishAsync(responseTopic, settings);
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticAnalyzer] [Settings_GetAll] Failed to retrieve settings.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleSettingsUpdateAsync(string payload, string correlationId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(payload)) return;
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Dictionary<string, object?>? updates = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
|
|
||||||
if (list != null)
|
|
||||||
{
|
|
||||||
updates = new Dictionary<string, object?>();
|
|
||||||
foreach (var item in list) updates[item.Key] = item.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates != null && updates.Count > 0)
|
|
||||||
{
|
|
||||||
await settingsService.UpdateSettingsAsync(updates);
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
|
||||||
var responseTopic = $"services/response/analyzer_settings_Update/{correlationId}";
|
|
||||||
await PublishAsync(responseTopic, currentSettings);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticAnalyzer] [Settings_Update] Failed to update settings.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleClosedTradeFeedbackAsync(string payloadStr)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
||||||
var closedDto = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr, options);
|
|
||||||
|
|
||||||
if (closedDto != null && !string.IsNullOrWhiteSpace(closedDto.TradeId))
|
|
||||||
{
|
|
||||||
bool isWin = closedDto.Status?.Contains("Profit", StringComparison.OrdinalIgnoreCase) == true ||
|
|
||||||
closedDto.Status?.Contains("Win", StringComparison.OrdinalIgnoreCase) == true;
|
|
||||||
|
|
||||||
var feedback = new TradeFeedbackRecord
|
|
||||||
{
|
|
||||||
TradeId = closedDto.TradeId,
|
|
||||||
AnalysisId = closedDto.AnalysisId,
|
|
||||||
Sector = closedDto.Sector,
|
|
||||||
Symbol = closedDto.Symbol,
|
|
||||||
Isin = closedDto.Isin,
|
|
||||||
EntryPrice = closedDto.EntryPrice,
|
|
||||||
StopLoss = closedDto.StopLoss,
|
|
||||||
TakeProfit = closedDto.TakeProfit,
|
|
||||||
IsWin = isWin,
|
|
||||||
VixRegime = closedDto.VixRegime,
|
|
||||||
VixValue = closedDto.VixValue,
|
|
||||||
CreatedAt = closedDto.CreatedAt,
|
|
||||||
ClosedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
string feedbackDir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
|
|
||||||
if (!System.IO.Directory.Exists(feedbackDir))
|
|
||||||
{
|
|
||||||
System.IO.Directory.CreateDirectory(feedbackDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
string filePath = System.IO.Path.Combine(feedbackDir, $"{closedDto.TradeId}.json");
|
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(new[] { feedback }, options));
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AnalyzerMqttClient] Processed closed trade feedback for {TradeId}. Saved to {FilePath}", closedDto.TradeId, filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Error processing closed trade feedback.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleManualTriggerAsync(string correlationId, string payloadStr, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var manualReq = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ManualAnalysisRpcRequest);
|
|
||||||
if (manualReq == null || string.IsNullOrWhiteSpace(manualReq.Isin))
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[AnalyzerMqttClient] Manual trigger received without valid request or ISIN.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", manualReq.Isin, manualReq.Symbol, correlationId);
|
|
||||||
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
|
||||||
|
|
||||||
var regime = _vixTracker.GetCurrentRegime();
|
|
||||||
var currentVix = _vixTracker.GetCurrentVix();
|
|
||||||
string analysisId = Guid.NewGuid().ToString("N");
|
|
||||||
double winRate = _winRateCalculator.CalculateWinRate(manualReq.Sector, manualReq.Symbol, regime);
|
|
||||||
|
|
||||||
string riskLabel = manualReq.RiskScore > 70 ? $"Aggressiv ({manualReq.RiskScore}/100)" : (manualReq.RiskScore > 30 ? $"Balanced ({manualReq.RiskScore}/100)" : $"Konservativ ({manualReq.RiskScore}/100)");
|
|
||||||
string timeframeFormatted = $"{manualReq.MinTimeframeValue}-{manualReq.MaxTimeframeValue} {manualReq.TimeframeUnit}";
|
|
||||||
|
|
||||||
var n8nRequest = new N8nAnalysisRequestDto
|
|
||||||
{
|
|
||||||
RequestId = analysisId,
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
TriggerType = "Manual",
|
|
||||||
TargetAsset = new TargetAssetInfo
|
|
||||||
{
|
|
||||||
Symbol = manualReq.FundamentalsData?.Fundamentals?.Ticker?.Ticker ?? manualReq.FundamentalsData?.Asset?.PrimaryTicker?.Ticker ?? manualReq.Symbol.ToUpperInvariant(),
|
|
||||||
Name = !string.IsNullOrWhiteSpace(manualReq.FundamentalsData?.Asset?.Name) ? manualReq.FundamentalsData.Asset.Name : manualReq.Isin.ToUpperInvariant(),
|
|
||||||
Isin = manualReq.Isin.ToUpperInvariant(),
|
|
||||||
Sector = manualReq.Sector
|
|
||||||
},
|
|
||||||
MarketContext = new MarketContextInfo
|
|
||||||
{
|
|
||||||
Vix = currentVix,
|
|
||||||
MarketRegime = regime.ToString()
|
|
||||||
},
|
|
||||||
FilterContext = new FilterContextInfo
|
|
||||||
{
|
|
||||||
ImpactScore = 1.0,
|
|
||||||
RawNewsHeadline = string.IsNullOrWhiteSpace(manualReq.Headline) ? "Manual User Trigger" : manualReq.Headline
|
|
||||||
},
|
|
||||||
UserPreferences = new UserPreferencesInfo
|
|
||||||
{
|
|
||||||
RiskScore = manualReq.RiskScore,
|
|
||||||
RiskTolerance = riskLabel,
|
|
||||||
MinTimeframeValue = manualReq.MinTimeframeValue,
|
|
||||||
MaxTimeframeValue = manualReq.MaxTimeframeValue,
|
|
||||||
TimeframeUnit = manualReq.TimeframeUnit,
|
|
||||||
TimeframeFormatted = timeframeFormatted,
|
|
||||||
InstrumentType = manualReq.InstrumentType,
|
|
||||||
UserNotes = manualReq.UserNotes
|
|
||||||
},
|
|
||||||
TradeFeedback = new TradeFeedbackInfo
|
|
||||||
{
|
|
||||||
TotalAssetTrades = 0,
|
|
||||||
AssetWinRate = winRate,
|
|
||||||
AvgReturnPercent = 0.0,
|
|
||||||
LastTradeResult = "UNKNOWN"
|
|
||||||
},
|
|
||||||
TechnicalContext = new TechnicalContextInfo
|
|
||||||
{
|
|
||||||
Rsi = manualReq.TaData?.Indicators?.LastOrDefault()?.Rsi14?.ToString("F1") ?? "N/A",
|
|
||||||
SupertrendStatus = manualReq.TaData?.Indicators?.LastOrDefault()?.SupertrendDirection ?? "NEUTRAL",
|
|
||||||
Atr = manualReq.TaData?.Indicators?.LastOrDefault()?.Atr14?.ToString("F2") ?? "N/A",
|
|
||||||
Sma50 = (double?)manualReq.TaData?.Indicators?.LastOrDefault()?.Sma50,
|
|
||||||
Sma200 = (double?)manualReq.TaData?.Indicators?.LastOrDefault()?.Sma200,
|
|
||||||
DetectedPatterns = manualReq.TaData?.Patterns?.Select(p => new PatternContextInfo
|
|
||||||
{
|
|
||||||
PatternName = p.Type,
|
|
||||||
BreakoutDirection = p.BreakoutSignal?.Direction,
|
|
||||||
TargetPrice = (double?)p.BreakoutSignal?.TargetPrice,
|
|
||||||
PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent
|
|
||||||
}).ToList() ?? new List<PatternContextInfo>()
|
|
||||||
},
|
|
||||||
SentimentContext = new SentimentContextInfo
|
|
||||||
{
|
|
||||||
AssetSentimentScore = manualReq.SentimentData?.CurrentSummary?.CompoundScore ?? 0.0,
|
|
||||||
SectorSentimentScore = 0.0,
|
|
||||||
NewsSentimentSummary = manualReq.SentimentData?.CurrentSummary?.SentimentLabel ?? "Neutral"
|
|
||||||
},
|
|
||||||
FundamentalContext = new FundamentalContextInfo
|
|
||||||
{
|
|
||||||
PeRatio = (double?)manualReq.FundamentalsData?.Fundamentals?.TrailingPe,
|
|
||||||
ForwardPeRatio = (double?)manualReq.FundamentalsData?.Fundamentals?.ForwardPe,
|
|
||||||
PegRatio = (double?)manualReq.FundamentalsData?.Fundamentals?.PegRatio,
|
|
||||||
MarketCap = (double?)manualReq.FundamentalsData?.Fundamentals?.MarketCap,
|
|
||||||
DebtToEquity = (double?)manualReq.FundamentalsData?.Fundamentals?.DebtToEquity,
|
|
||||||
GrossMargin = (double?)manualReq.FundamentalsData?.Fundamentals?.GrossProfit,
|
|
||||||
NetProfitMargin = (double?)manualReq.FundamentalsData?.Fundamentals?.NetIncome,
|
|
||||||
ReturnOnEquity = (double?)manualReq.FundamentalsData?.Fundamentals?.ReturnOnEquity,
|
|
||||||
DividendYield = (double?)manualReq.FundamentalsData?.Fundamentals?.ForwardDividendYield,
|
|
||||||
ShortPercentOfFloat = null,
|
|
||||||
AnalystTargetMedian = null,
|
|
||||||
EvToEbitda = (double?)manualReq.FundamentalsData?.Fundamentals?.EvToEbitda
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
|
||||||
|
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
|
||||||
double minSignalScore = await settingsService.GetSettingAsync(SettingKeys.MinWinRateThreshold, cancellationToken);
|
|
||||||
|
|
||||||
double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate(
|
|
||||||
manualReq.Sector,
|
|
||||||
manualReq.Symbol,
|
|
||||||
regime,
|
|
||||||
n8nEvalScore: n8nResponse?.EvalScore,
|
|
||||||
sentimentScore: manualReq.SentimentData?.CurrentSummary?.CompoundScore,
|
|
||||||
signalType: n8nResponse?.SuggestedDirection ?? "BUY");
|
|
||||||
|
|
||||||
double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : (dynamicWinRate / 100.0);
|
|
||||||
bool shouldProceed = n8nResponse != null &&
|
|
||||||
string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
(confidenceScore * 100.0) >= minSignalScore &&
|
|
||||||
dynamicWinRate >= minSignalScore;
|
|
||||||
|
|
||||||
TradeProposalDto? proposalDto = null;
|
|
||||||
if (n8nResponse != null)
|
|
||||||
{
|
|
||||||
proposalDto = new TradeProposalDto
|
|
||||||
{
|
|
||||||
TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
EventId = analysisId,
|
|
||||||
Sector = manualReq.Sector,
|
|
||||||
Symbol = manualReq.Symbol.ToUpperInvariant(),
|
|
||||||
Isin = manualReq.Isin.ToUpperInvariant(),
|
|
||||||
CompanyName = !string.IsNullOrWhiteSpace(manualReq.FundamentalsData?.Asset?.Name) ? manualReq.FundamentalsData.Asset.Name : manualReq.Symbol,
|
|
||||||
EntryPrice = manualReq.CurrentPrice,
|
|
||||||
SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
|
||||||
Status = shouldProceed ? "Proposed" : "Rejected",
|
|
||||||
RiskTolerance = n8nResponse.SuggestedRisk,
|
|
||||||
Timeframe = timeframeFormatted,
|
|
||||||
InstrumentType = manualReq.InstrumentType,
|
|
||||||
WinRate = dynamicWinRate,
|
|
||||||
VixRegime = regime,
|
|
||||||
VixValue = currentVix,
|
|
||||||
TtlMinutes = 60,
|
|
||||||
Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}",
|
|
||||||
|
|
||||||
StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0,
|
|
||||||
TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0,
|
|
||||||
EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min,
|
|
||||||
EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max,
|
|
||||||
TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets,
|
|
||||||
RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio,
|
|
||||||
MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage,
|
|
||||||
TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty,
|
|
||||||
FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty,
|
|
||||||
RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty,
|
|
||||||
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var analysisEntity = new AnalysisEntity
|
|
||||||
{
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
EventId = analysisId,
|
|
||||||
Sector = manualReq.Sector,
|
|
||||||
Symbol = manualReq.Symbol.ToUpperInvariant(),
|
|
||||||
Isin = manualReq.Isin.ToUpperInvariant(),
|
|
||||||
VixRegime = regime,
|
|
||||||
VixValue = currentVix,
|
|
||||||
ImpactScore = 1.0,
|
|
||||||
WinRate = dynamicWinRate,
|
|
||||||
RawDataJson = JsonSerializer.Serialize(manualReq),
|
|
||||||
AiOutputJson = proposalDto != null ? JsonSerializer.Serialize(proposalDto) : "{}",
|
|
||||||
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
|
|
||||||
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
|
|
||||||
N8nDecision = n8nResponse?.AiDecision ?? "Rejected",
|
|
||||||
IsTradeProposed = shouldProceed,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Analyses.Add(analysisEntity);
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
var responseTopic = $"services/response/analyzer_TriggerManual/{correlationId}";
|
|
||||||
var responsePayload = new ManualAnalysisResponseDto
|
|
||||||
{
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
IsTradeProposed = shouldProceed,
|
|
||||||
Status = shouldProceed ? "Success" : "Rejected",
|
|
||||||
Recommendation = shouldProceed ? "RECOMMENDED" : "NOT_RECOMMENDED",
|
|
||||||
N8nResponse = n8nResponse,
|
|
||||||
Proposal = proposalDto
|
|
||||||
};
|
|
||||||
|
|
||||||
await PublishAsync(responseTopic, responsePayload);
|
|
||||||
|
|
||||||
if (proposalDto != null && shouldProceed)
|
|
||||||
{
|
|
||||||
string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(manualReq.Sector) ? "general" : manualReq.Sector.ToLowerInvariant())}/{manualReq.Symbol.ToLowerInvariant()}";
|
|
||||||
await PublishAsync(propTopic, proposalDto);
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalyzer] [DISPATCHED] Dispatched Manual Trade Proposal {AnalysisId} to topic {Topic}", analysisId, propTopic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Failed to handle manual trigger for correlation {CorrelationId}.", correlationId);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var errorResponse = new ManualAnalysisResponseDto
|
|
||||||
{
|
|
||||||
Status = "ERROR",
|
|
||||||
Message = $"Analysis failed: {ex.Message}"
|
|
||||||
};
|
|
||||||
await PublishAsync($"services/response/analyzer_TriggerManual/{correlationId}", errorResponse);
|
|
||||||
}
|
|
||||||
catch (Exception pubEx)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, pubEx, "[AnalyzerMqttClient] Failed to publish error response for correlation {CorrelationId}.", correlationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ProcessTickMessage(string topic, string payloadStr)
|
|
||||||
{
|
|
||||||
if (topic.EndsWith("VIX", StringComparison.OrdinalIgnoreCase) || topic.EndsWith("^VIX", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var tick = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TickMessageDto);
|
|
||||||
if (tick != null && tick.Price > 0)
|
|
||||||
{
|
|
||||||
_vixTracker.UpdateVixFromTick(tick.Price);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to parse VIX tick message.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessNewsMessageAsync(string payloadStr, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
|
||||||
|
|
||||||
var newsArticle = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto);
|
|
||||||
if (newsArticle == null) return;
|
|
||||||
|
|
||||||
var regime = _vixTracker.GetCurrentRegime();
|
|
||||||
var currentVix = _vixTracker.GetCurrentVix();
|
|
||||||
|
|
||||||
var filterResult = _filterEngine.EvaluateNews(newsArticle, regime);
|
|
||||||
if (!filterResult.Passed)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", filterResult.Isin, filterResult.RejectReason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", filterResult.Isin);
|
|
||||||
|
|
||||||
string analysisId = Guid.NewGuid().ToString("N");
|
|
||||||
string eventId = newsArticle.Id != Guid.Empty ? newsArticle.Id.ToString() : analysisId;
|
|
||||||
string rawHeadline = newsArticle.Title ?? string.Empty;
|
|
||||||
|
|
||||||
double winRate = _winRateCalculator.CalculateWinRate(filterResult.Sector, filterResult.Symbol, regime);
|
|
||||||
|
|
||||||
int riskScore = 50;
|
|
||||||
string riskTolerance = "Balanced (50/100)";
|
|
||||||
int minTf = 4;
|
|
||||||
int maxTf = 7;
|
|
||||||
|
|
||||||
if (winRate < 45.0)
|
|
||||||
{
|
|
||||||
riskScore = 30;
|
|
||||||
riskTolerance = "Konservativ (30/100)";
|
|
||||||
minTf = 7;
|
|
||||||
maxTf = 14;
|
|
||||||
}
|
|
||||||
else if (winRate >= 65.0)
|
|
||||||
{
|
|
||||||
riskScore = 75;
|
|
||||||
riskTolerance = "Aggressiv (75/100)";
|
|
||||||
minTf = 1;
|
|
||||||
maxTf = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
TechnicalContextInfo taInfo = new();
|
|
||||||
FundamentalContextInfo fundInfo = new();
|
|
||||||
SentimentContextInfo sentInfo = new();
|
|
||||||
|
|
||||||
string resolvedSymbol = filterResult.Symbol;
|
|
||||||
string resolvedName = filterResult.Symbol;
|
|
||||||
|
|
||||||
if (newsArticle.MatchedAssets != null && newsArticle.MatchedAssets.Count > 0)
|
|
||||||
{
|
|
||||||
var firstAsset = newsArticle.MatchedAssets[0];
|
|
||||||
if (!string.IsNullOrWhiteSpace(firstAsset.Name))
|
|
||||||
{
|
|
||||||
resolvedName = firstAsset.Name;
|
|
||||||
if (resolvedSymbol == "UNKNOWN" || resolvedSymbol == filterResult.Isin)
|
|
||||||
{
|
|
||||||
resolvedSymbol = resolvedName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? taResp = null;
|
|
||||||
FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? fundResp = null;
|
|
||||||
FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto? livePriceResp = null;
|
|
||||||
FinlyticCore.Dtos.Sentiment.IsinSentimentSummaryDto? sentResp = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (IsConnected)
|
|
||||||
{
|
|
||||||
var isinReq = new IsinRequest(filterResult.Isin);
|
|
||||||
|
|
||||||
var livePriceTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto, IsinRequest>(
|
|
||||||
"tr_GetLivePrice", isinReq, TimeSpan.FromSeconds(5));
|
|
||||||
var taTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto, IsinRequest>(
|
|
||||||
"ta_GetAnalysis", isinReq, TimeSpan.FromSeconds(5));
|
|
||||||
var fundTask = SendRpcRequestAsync<FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto, IsinRequest>(
|
|
||||||
"fundamentals_Get", isinReq, TimeSpan.FromSeconds(5));
|
|
||||||
var sentTask = SendRpcRequestAsync<FinlyticCore.Dtos.Sentiment.IsinSentimentSummaryDto, IsinRequest>(
|
|
||||||
"sentiment_GetIsin", isinReq, TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
await Task.WhenAll(livePriceTask, taTask, fundTask, sentTask);
|
|
||||||
|
|
||||||
livePriceResp = livePriceTask.Result;
|
|
||||||
taResp = taTask.Result;
|
|
||||||
fundResp = fundTask.Result;
|
|
||||||
sentResp = sentTask.Result;
|
|
||||||
|
|
||||||
if (taResp?.Indicators != null)
|
|
||||||
{
|
|
||||||
var latestIndicator = taResp.Indicators.LastOrDefault();
|
|
||||||
taInfo = new TechnicalContextInfo
|
|
||||||
{
|
|
||||||
Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "50.0",
|
|
||||||
SupertrendStatus = latestIndicator?.SupertrendDirection ?? "NEUTRAL",
|
|
||||||
Atr = latestIndicator?.Atr14?.ToString("F2") ?? "0.0",
|
|
||||||
Sma50 = (double?)latestIndicator?.Sma50,
|
|
||||||
Sma200 = (double?)latestIndicator?.Sma200,
|
|
||||||
DetectedPatterns = taResp.Patterns?.Select(p => new PatternContextInfo
|
|
||||||
{
|
|
||||||
PatternName = p.Type,
|
|
||||||
BreakoutDirection = p.BreakoutSignal?.Direction,
|
|
||||||
TargetPrice = (double?)p.BreakoutSignal?.TargetPrice,
|
|
||||||
PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent
|
|
||||||
}).ToList() ?? new List<PatternContextInfo>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fundResp != null)
|
|
||||||
{
|
|
||||||
string? fundTicker = fundResp.Fundamentals?.Ticker?.Ticker ?? fundResp.Asset?.PrimaryTicker?.Ticker;
|
|
||||||
resolvedSymbol = !string.IsNullOrWhiteSpace(fundTicker) ? fundTicker : resolvedSymbol;
|
|
||||||
resolvedName = !string.IsNullOrWhiteSpace(fundResp.Asset?.Name) ? fundResp.Asset.Name : resolvedName;
|
|
||||||
|
|
||||||
fundInfo = new FundamentalContextInfo
|
|
||||||
{
|
|
||||||
PeRatio = (double?)fundResp.Fundamentals?.TrailingPe,
|
|
||||||
ForwardPeRatio = (double?)fundResp.Fundamentals?.ForwardPe,
|
|
||||||
PegRatio = (double?)fundResp.Fundamentals?.PegRatio,
|
|
||||||
MarketCap = (double?)fundResp.Fundamentals?.MarketCap,
|
|
||||||
DebtToEquity = (double?)fundResp.Fundamentals?.DebtToEquity,
|
|
||||||
GrossMargin = (double?)fundResp.Fundamentals?.GrossProfit,
|
|
||||||
NetProfitMargin = (double?)fundResp.Fundamentals?.NetIncome,
|
|
||||||
ReturnOnEquity = (double?)fundResp.Fundamentals?.ReturnOnEquity,
|
|
||||||
DividendYield = (double?)fundResp.Fundamentals?.ForwardDividendYield,
|
|
||||||
ShortPercentOfFloat = null,
|
|
||||||
AnalystTargetMedian = null,
|
|
||||||
EvToEbitda = (double?)fundResp.Fundamentals?.EvToEbitda
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sentResp != null)
|
|
||||||
{
|
|
||||||
double compound = sentResp.CurrentSummary?.CompoundScore ?? 0.0;
|
|
||||||
double normalizedScore = Math.Clamp((compound + 1.0) / 2.0, 0.0, 1.0);
|
|
||||||
|
|
||||||
sentInfo = new SentimentContextInfo
|
|
||||||
{
|
|
||||||
AssetSentimentScore = Math.Round(normalizedScore, 2),
|
|
||||||
SectorSentimentScore = Math.Round(normalizedScore, 2),
|
|
||||||
NewsSentimentSummary = string.IsNullOrWhiteSpace(sentResp.CurrentSummary?.SentimentLabel) ? "Neutral" : sentResp.CurrentSummary.SentimentLabel
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Failed to fetch context data for auto screener analysis.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var n8nRequest = new N8nAnalysisRequestDto
|
|
||||||
{
|
|
||||||
RequestId = analysisId,
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
TriggerType = "AutoScreener",
|
|
||||||
TargetAsset = new TargetAssetInfo
|
|
||||||
{
|
|
||||||
Symbol = resolvedSymbol.ToUpperInvariant(),
|
|
||||||
Name = resolvedName,
|
|
||||||
Isin = filterResult.Isin.ToUpperInvariant(),
|
|
||||||
Sector = filterResult.Sector
|
|
||||||
},
|
|
||||||
MarketContext = new MarketContextInfo
|
|
||||||
{
|
|
||||||
Vix = currentVix,
|
|
||||||
MarketRegime = regime.ToString()
|
|
||||||
},
|
|
||||||
FilterContext = new FilterContextInfo
|
|
||||||
{
|
|
||||||
ImpactScore = filterResult.ImpactScore,
|
|
||||||
RawNewsHeadline = rawHeadline
|
|
||||||
},
|
|
||||||
UserPreferences = new UserPreferencesInfo
|
|
||||||
{
|
|
||||||
RiskScore = riskScore,
|
|
||||||
RiskTolerance = riskTolerance,
|
|
||||||
MinTimeframeValue = minTf,
|
|
||||||
MaxTimeframeValue = maxTf,
|
|
||||||
TimeframeUnit = "Tage",
|
|
||||||
TimeframeFormatted = $"{minTf}-{maxTf} Tage",
|
|
||||||
InstrumentType = "KnockOut",
|
|
||||||
UserNotes = "High-Conviction Screener Mode: Evaluate underlying data for strong reliable chart moves."
|
|
||||||
},
|
|
||||||
TradeFeedback = new TradeFeedbackInfo
|
|
||||||
{
|
|
||||||
TotalAssetTrades = 0,
|
|
||||||
AssetWinRate = winRate,
|
|
||||||
AvgReturnPercent = 0.0,
|
|
||||||
LastTradeResult = "UNKNOWN"
|
|
||||||
},
|
|
||||||
TechnicalContext = taInfo,
|
|
||||||
SentimentContext = sentInfo,
|
|
||||||
FundamentalContext = fundInfo
|
|
||||||
};
|
|
||||||
|
|
||||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
|
||||||
|
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
|
||||||
double minSignalScore = await settingsService.GetSettingAsync(SettingKeys.MinWinRateThreshold, cancellationToken);
|
|
||||||
|
|
||||||
double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75;
|
|
||||||
bool isHighConviction = n8nResponse != null &&
|
|
||||||
string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
(confidenceScore * 100.0) >= minSignalScore &&
|
|
||||||
winRate >= minSignalScore;
|
|
||||||
|
|
||||||
string finalSymbol = !string.IsNullOrWhiteSpace(resolvedSymbol) && resolvedSymbol != "UNKNOWN"
|
|
||||||
? resolvedSymbol
|
|
||||||
: (!string.IsNullOrWhiteSpace(filterResult.Symbol) && filterResult.Symbol != "UNKNOWN" ? filterResult.Symbol : filterResult.Isin);
|
|
||||||
|
|
||||||
string finalName = !string.IsNullOrWhiteSpace(resolvedName) && resolvedName != "UNKNOWN"
|
|
||||||
? resolvedName
|
|
||||||
: finalSymbol;
|
|
||||||
|
|
||||||
string marketRegion = filterResult.Isin.StartsWith("DE", StringComparison.OrdinalIgnoreCase) ? "GERMAN_EQUITIES" : "US_EQUITIES";
|
|
||||||
|
|
||||||
var supportLevels = new List<double>();
|
|
||||||
var resistanceLevels = new List<double>();
|
|
||||||
|
|
||||||
double currentPrice = (double)(livePriceResp?.CurrentPrice > 0 ? livePriceResp.CurrentPrice : 0.0m);
|
|
||||||
if (currentPrice > 0)
|
|
||||||
{
|
|
||||||
supportLevels.Add(Math.Round(currentPrice * 0.98, 2));
|
|
||||||
supportLevels.Add(Math.Round(currentPrice * 0.95, 2));
|
|
||||||
resistanceLevels.Add(Math.Round(currentPrice * 1.03, 2));
|
|
||||||
resistanceLevels.Add(Math.Round(currentPrice * 1.06, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (n8nResponse?.ExecutionPlan?.EntryZone != null)
|
|
||||||
{
|
|
||||||
if (n8nResponse.ExecutionPlan.EntryZone.Min > 0) supportLevels.Insert(0, (double)n8nResponse.ExecutionPlan.EntryZone.Min);
|
|
||||||
if (n8nResponse.ExecutionPlan.EntryZone.Max > 0) resistanceLevels.Insert(0, (double)n8nResponse.ExecutionPlan.EntryZone.Max);
|
|
||||||
}
|
|
||||||
|
|
||||||
var recommendation = new AssetRecommendationDto
|
|
||||||
{
|
|
||||||
Mode = "AUTO_SCREENER",
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
RecommendedAsset = new RecommendedAssetInfo
|
|
||||||
{
|
|
||||||
Symbol = finalSymbol,
|
|
||||||
CompanyName = finalName,
|
|
||||||
Isin = filterResult.Isin,
|
|
||||||
Market = marketRegion,
|
|
||||||
Bias = string.Equals(n8nResponse?.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "BEARISH" : "BULLISH",
|
|
||||||
ConfidenceScore = Math.Round(confidenceScore, 2),
|
|
||||||
Timeframe = !string.IsNullOrWhiteSpace(n8nResponse?.SuggestedTimeframe) ? n8nResponse.SuggestedTimeframe : "1D"
|
|
||||||
},
|
|
||||||
Rationale = new RecommendationRationaleInfo
|
|
||||||
{
|
|
||||||
PatternDetected = taInfo.DetectedPatterns?.Count > 0
|
|
||||||
? string.Join(", ", taInfo.DetectedPatterns.Select(p => p.PatternName))
|
|
||||||
: (!string.IsNullOrWhiteSpace(n8nResponse?.DetailedAnalysis?.TechnicalRationale) ? n8nResponse.DetailedAnalysis.TechnicalRationale : "Multi-Timeframe Trend & Volume Confluence"),
|
|
||||||
VixContext = $"VIX at {currentVix:F1} ({regime} volatility environment)",
|
|
||||||
KeyTechnicalLevels = new KeyTechnicalLevelsInfo
|
|
||||||
{
|
|
||||||
Support = supportLevels.Distinct().ToList(),
|
|
||||||
Resistance = resistanceLevels.Distinct().ToList()
|
|
||||||
},
|
|
||||||
Summary = !string.IsNullOrWhiteSpace(n8nReasoning(n8nResponse))
|
|
||||||
? n8nResponse!.AiReasoning
|
|
||||||
: "High conviction setup based on multi-timeframe technical confluence, sentiment, and fundamental data."
|
|
||||||
},
|
|
||||||
ActionRequired = isHighConviction ? "PROMPT_USER_FOR_MANUAL_TRADE" : "NO_ACTION"
|
|
||||||
};
|
|
||||||
|
|
||||||
double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate(
|
|
||||||
filterResult.Sector,
|
|
||||||
finalSymbol,
|
|
||||||
regime,
|
|
||||||
n8nEvalScore: n8nResponse?.EvalScore,
|
|
||||||
sentimentScore: sentResp?.CurrentSummary?.CompoundScore,
|
|
||||||
signalType: n8nResponse?.SuggestedDirection ?? "BUY");
|
|
||||||
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
|
||||||
|
|
||||||
bool hasRecentProposal = await dbContext.Analyses.AnyAsync(a =>
|
|
||||||
a.Isin == filterResult.Isin &&
|
|
||||||
a.IsTradeProposed &&
|
|
||||||
a.CreatedAt >= DateTime.UtcNow.AddHours(-4),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (hasRecentProposal && isHighConviction)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] Asset {Symbol} ({Isin}) already has an active trade proposal in the last 4 hours. Skipping duplicate trade proposal generation.",
|
|
||||||
finalSymbol, filterResult.Isin);
|
|
||||||
isHighConviction = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var analysisEntity = new AnalysisEntity
|
|
||||||
{
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
EventId = eventId,
|
|
||||||
Sector = filterResult.Sector,
|
|
||||||
Symbol = finalSymbol,
|
|
||||||
Isin = filterResult.Isin,
|
|
||||||
VixRegime = regime,
|
|
||||||
VixValue = currentVix,
|
|
||||||
ImpactScore = filterResult.ImpactScore,
|
|
||||||
WinRate = dynamicWinRate,
|
|
||||||
RawDataJson = payloadStr,
|
|
||||||
AiOutputJson = JsonSerializer.Serialize(recommendation),
|
|
||||||
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
|
|
||||||
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
|
|
||||||
N8nDecision = n8nResponse?.AiDecision ?? "None",
|
|
||||||
IsTradeProposed = isHighConviction,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Analyses.Add(analysisEntity);
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (isHighConviction && n8nResponse != null)
|
|
||||||
{
|
|
||||||
var autoProposalDto = new TradeProposalDto
|
|
||||||
{
|
|
||||||
TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
|
|
||||||
AnalysisId = analysisId,
|
|
||||||
EventId = eventId,
|
|
||||||
Sector = filterResult.Sector,
|
|
||||||
Symbol = finalSymbol,
|
|
||||||
Isin = filterResult.Isin,
|
|
||||||
CompanyName = finalName,
|
|
||||||
EntryPrice = (decimal)currentPrice,
|
|
||||||
SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY",
|
|
||||||
Status = "Proposed",
|
|
||||||
RiskTolerance = n8nResponse.SuggestedRisk ?? "Balanced",
|
|
||||||
Timeframe = $"{minTf}-{maxTf} Tage",
|
|
||||||
InstrumentType = "KnockOut",
|
|
||||||
WinRate = dynamicWinRate,
|
|
||||||
VixRegime = regime,
|
|
||||||
VixValue = currentVix,
|
|
||||||
TtlMinutes = 180,
|
|
||||||
Reasoning = n8nResponse.AiReasoning ?? "Auto-Screener High Conviction Trade",
|
|
||||||
StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0,
|
|
||||||
TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0,
|
|
||||||
EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min,
|
|
||||||
EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max,
|
|
||||||
TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets,
|
|
||||||
RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio,
|
|
||||||
MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage,
|
|
||||||
TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty,
|
|
||||||
FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty,
|
|
||||||
RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}";
|
|
||||||
await PublishAsync(propTopic, autoProposalDto);
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] Dispatched High-Conviction Proposal {TradeId} to topic {Topic}", autoProposalDto.TradeId, propTopic);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isHighConviction)
|
|
||||||
{
|
|
||||||
string recTopic = $"finlytic/recommendations/auto/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}";
|
|
||||||
await PublishAsync(recTopic, recommendation);
|
|
||||||
await PublishAsync("finlytic/recommendations/auto", recommendation);
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [RECOMMENDED] High-Conviction Opportunity found for {Symbol} (Bias: {Bias}, Confidence: {Score:F2}). Published to {Topic}",
|
|
||||||
finalSymbol, recommendation.RecommendedAsset.Bias, recommendation.RecommendedAsset.ConfidenceScore, recTopic);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
|
|
||||||
finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string n8nReasoning(N8nAnalysisResponseDto? resp) => resp?.AiReasoning ?? string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using FinlyticCore.Models.Settings;
|
|
||||||
|
|
||||||
namespace FinlyticAnalyzer.Util;
|
|
||||||
|
|
||||||
public static class SettingKeys
|
|
||||||
{
|
|
||||||
// --- Logging-Kanäle ---
|
|
||||||
public static readonly SettingKey<bool> AnalyzerChannel = new("Logging.Channel.Analyzer", true);
|
|
||||||
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
|
||||||
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
|
||||||
|
|
||||||
// --- Makro & VIX Schwellenwerte ---
|
|
||||||
public static readonly SettingKey<double> VixPanicThreshold = new("Macro.VixPanicThreshold", 28.0);
|
|
||||||
public static readonly SettingKey<double> VixElevatedThreshold = new("Macro.VixElevatedThreshold", 20.0);
|
|
||||||
public static readonly SettingKey<int> VixPollIntervalSeconds = new("Macro.VixPollIntervalSeconds", 60);
|
|
||||||
|
|
||||||
// --- Filter & Winrate-Logik ---
|
|
||||||
public static readonly SettingKey<double> MinWinRateThreshold = new("Filter.MinWinRateThreshold", 60.0);
|
|
||||||
public static readonly SettingKey<double> WeightMacro = new("Filter.WeightMacro", 0.30);
|
|
||||||
public static readonly SettingKey<double> WeightFundamental = new("Filter.WeightFundamental", 0.30);
|
|
||||||
public static readonly SettingKey<double> WeightSentiment = new("Filter.WeightSentiment", 0.20);
|
|
||||||
public static readonly SettingKey<double> WeightTechnical = new("Filter.WeightTechnical", 0.20);
|
|
||||||
|
|
||||||
// --- Trade & Risiko-Parameter ---
|
|
||||||
public static readonly SettingKey<double> DefaultTakeProfitPercent = new("Trade.DefaultTakeProfitPercent", 15.0);
|
|
||||||
public static readonly SettingKey<double> DefaultStopLossPercent = new("Trade.DefaultStopLossPercent", 5.0);
|
|
||||||
public static readonly SettingKey<int> MaxAllowedLeverage = new("Trade.MaxAllowedLeverage", 10);
|
|
||||||
public static readonly SettingKey<double> MaxRiskPerTradePercent = new("Trade.MaxRiskPerTradePercent", 2.0);
|
|
||||||
public static readonly SettingKey<int> ProposalValidityHours = new("Trade.ProposalValidityHours", 24);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Host=localhost;Database=finlytic_analyzer;Username=admin;Password=admin"
|
|
||||||
},
|
|
||||||
"MQTT": {
|
|
||||||
"Host": "localhost",
|
|
||||||
"Port": "1883",
|
|
||||||
"ClientId": "finlytic_analyzer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,86 +0,0 @@
|
|||||||
# FinlyticApp — Architecture Guidelines & Engineering Standards
|
|
||||||
|
|
||||||
Dieses Dokument definiert die verbindlichen Architektur- und Entwicklungsstandards für die **FinlyticApp** (Dart / Flutter Cross-Platform Client für Android, iOS und Web).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Grundprinzipien & Dateistruktur
|
|
||||||
|
|
||||||
Das Projekt folgt einer strengen **Feature-Driven Clean Architecture**. Dadurch wird eine vollständige Entkopplung von Benutzeroberfläche, Geschäftslogik und Datenquellen gewährleistet.
|
|
||||||
|
|
||||||
### 📏 Dateigröße & Struktur-Limits
|
|
||||||
* **Maximal 150 bis 200 Zeilen pro Datei:** Wenn eine Datei diese Grenze überschreitet, muss sie in kleinere, fokussierte Einheiten refactored werden (*Single Responsibility Principle*).
|
|
||||||
* **Eine Hauptklasse pro Datei:** Helper-Klassen gehören in eigene Dateien, es sei denn, sie sind `private` und ausschließlich lokal relevant.
|
|
||||||
* **Kompakte `build()`-Methoden:** Die `build()`-Methode dient nur der Anordnung von Sub-Widgets und darf selten länger als 30–40 Zeilen sein.
|
|
||||||
|
|
||||||
### 📁 Verzeichnisstruktur (`lib/`)
|
|
||||||
|
|
||||||
```text
|
|
||||||
lib/
|
|
||||||
├── core/
|
|
||||||
│ ├── network/ # ApiClient, MQTT-Service, WebSockets
|
|
||||||
│ ├── theme/ # Finlytic Dark Glassmorphism, Typography, Colors
|
|
||||||
│ ├── utils/ # Formatierer (Währungen, Prozentangaben, Datum)
|
|
||||||
│ └── widgets/ # App-weit genutzte UI-Komponenten (Buttons, Modals)
|
|
||||||
├── features/
|
|
||||||
│ ├── dashboard/ # Dashboard-Overview, Analytics-Cards
|
|
||||||
│ ├── news/ # Feed, Pagination, Sentiment-Analysen
|
|
||||||
│ ├── favorites/ # Favoriten-Grid, Watchlist
|
|
||||||
│ ├── calendar/ # Corporate Calendar, Earnings, Dividenden
|
|
||||||
│ ├── trades/ # Trade-Signale, Automatische Trades
|
|
||||||
│ ├── admin/ # User-Verwaltung, System-Settings (Admin-Only)
|
|
||||||
│ └── asset_detail/ # Fundamentaldaten, TA-Chart, Finance-Metrics
|
|
||||||
└── main.dart # Entry Point & Service Locator Initialization
|
|
||||||
|
|
||||||
2. Clean Architecture Layering
|
|
||||||
Jedes Feature im features/-Ordner wird intern strikt in drei Layer unterteilt:LayerVerantwortlichkeitErlaubte Abhängigkeiten1. PresentationUI-Komponenten, Screen-Layouts, Consumer von States.Greift nur auf Logic (BLoC/Notifier) zu. Keine direkten API/DB-Calls!2. Domain / LogicBusiness-Logik, State Management, UseCases, Entities.Absolut frei von flutter/material.dart! Nutzt Repositories als Abstraktion.3. DataAPI-Clients, DTOs, Local Caching, MQTT-Stream Handlers.Implementiert Repository-Interfaces aus dem Domain Layer.
|
|
||||||
|
|
||||||
3. Widget-Architektur & Sub-Widget Auslagerung
|
|
||||||
❌ VERBOTEN: Helper-Methoden für Widgets (_buildX())Unter keinen Umständen dürfen Methoden innerhalb von Widget-Klassen definiert werden, die ein Widget zurückgeben:Dart// ❌ FALSCH: Baut keinen eigenen BuildContext/Lifecycle auf und erfordert Rebuilding des gesamten Mutter-Widgets!
|
|
||||||
Widget _buildHeader() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: const Text('Dashboard'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
✅ PFLICHT: Auslagerung in eigene StatelessWidget KlassenJedes logische Teilsegment der Benutzeroberfläche muss als eigene Klasse ausgegliedert werden:Dart// ✅ KORREKT: Saubere Performance, eigener BuildContext, optimierter Element-Tree
|
|
||||||
class DashboardHeader extends StatelessWidget {
|
|
||||||
const DashboardHeader({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: const Text('Dashboard'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
⚡ Performance-Regeln für Widgets:const Konstruktoren: Jedes Sub-Widget muss wenn möglich einen const Konstruktor haben, um unnötige Re-Renders im Widget-Tree zu verhindern.Keine Business-Logik im UI-Widget: Widgets reagieren ausschließlich auf übergebene Daten oder States und leiten Nutzerinteraktionen über Callbacks / BLoC-Events weiter.
|
|
||||||
|
|
||||||
4. Data Classes & Code-Generierung
|
|
||||||
Immutability: Alle Models, DTOs und States müssen unbeeinflussbar (immutable) sein.Freezed & JSON Serializable: Das manuelle Schreiben von fromJson, toJson oder copyWith ist untersagt. Es wird freezed zusammen mit build_runner eingesetzt.Dartimport 'package:freezed_annotation/freezed_annotation.dart';
|
|
||||||
|
|
||||||
part 'asset_model.freezed.dart';
|
|
||||||
part 'asset_model.g.dart';
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
class Asset with _$Asset {
|
|
||||||
const factory Asset({
|
|
||||||
required String isin,
|
|
||||||
required String name,
|
|
||||||
required double currentPrice,
|
|
||||||
required String currency,
|
|
||||||
}) = _Asset;
|
|
||||||
|
|
||||||
factory Asset.fromJson(Map<String, dynamic> json) => _$AssetFromJson(json);
|
|
||||||
}
|
|
||||||
5. Responsive & Adaptive Navigation LayoutFinlyticApp wird plattformübergreifend betrieben und muss sich dem Screen-Format anpassen:Mobile Breakpoint (< 800px): Anforderung von Material NavigationBar (Android) / Cupertino Tab Bar (iOS) am unteren Bildschirmrand.Web / Desktop Breakpoint (>= 800px): Automatische Skalierung auf ein linkes Tab-Menü (NavigationRail / Sidebar).Keine starren Dimensionen: Nutzung von LayoutBuilder, Flexible und Expanded, um Überläufe (Pixel Overflow) auf schmalen Displays zu verhindern.6. Linter Standard (analysis_options.yaml)Alle Entwickler müssen die folgenden Linter-Regeln in der analysis_options.yaml einhalten:YAMLlinter:
|
|
||||||
rules:
|
|
||||||
- prefer_const_constructors
|
|
||||||
- prefer_const_declarations
|
|
||||||
- prefer_final_fields
|
|
||||||
- prefer_final_locals
|
|
||||||
- avoid_unnecessary_containers
|
|
||||||
- sizedbox_for_whitespace
|
|
||||||
- use_build_context_synchronously
|
|
||||||
- always_declare_return_types
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# FinlyticApp (Flutter Application)
|
|
||||||
|
|
||||||
FinlyticApp is the cross-platform mobile and desktop application for the Finlytic Enterprise Financial Intelligence Platform, built with Flutter, BLoC State Management, and Clean Architecture.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Clean Architecture Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/
|
|
||||||
├── core/
|
|
||||||
│ ├── network/ # ApiClient (Dio), SignalRService
|
|
||||||
│ ├── services/ # SecureStorageService
|
|
||||||
│ ├── theme/ # AppTheme (Glassmorphism, Dark Emerald Theme)
|
|
||||||
│ └── widgets/ # GlassContainer, StatusBadge, AssetLogoWidget
|
|
||||||
└── features/
|
|
||||||
├── trades/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
├── asset_detail/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
├── favorites/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
├── auth/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
├── admin/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
├── calendar/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
└── search/ # models/, repositories/, bloc/, views/, widgets/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Features & Modules
|
|
||||||
|
|
||||||
### 1. Trades Module
|
|
||||||
- **`TradeModel`**, **`TradeRepository`**, **`TradeBloc`**: Manages real-time trade signals, entry/exit prices, stop-loss/take-profit, and position closure.
|
|
||||||
- **`TradesFeedScreen`**: Interactive trade cards with live profit/loss indicators.
|
|
||||||
|
|
||||||
### 2. Assets & Technical Analysis Module
|
|
||||||
- **`AssetModel`**, **`FundamentalDataModel`**, **`TechnicalAnalysisModel`**, **`AssetRepository`**, **`AssetDetailBloc`**.
|
|
||||||
- **`AssetDetailScreen`** & **`TechnicalAnalysisTabView`**: Interactive chart indicators (EMA 20, SMA 50, SMA 200, Supertrend, RSI, MACD).
|
|
||||||
|
|
||||||
### 3. Favorites Module
|
|
||||||
- **`FavoriteAssetModel`**, **`FavoritesRepository`**, **`FavoritesBloc`**.
|
|
||||||
- **`FavoritesScreen`** & **`WatchlistCard`**: Real-time price tracking and watchlist management.
|
|
||||||
|
|
||||||
### 4. Auth Module
|
|
||||||
- **`UserModel`** (with `Equatable`), **`AuthRepository`**, **`AuthBloc`**.
|
|
||||||
- **`LoginScreen`**, **`RegisterScreen`**, **`ForgotPasswordDialog`**: Secure JWT authentication and persistent local session storage.
|
|
||||||
|
|
||||||
### 5. Admin Module
|
|
||||||
- **`AdminUserModel`**, **`AdminRepository`**, **`AdminBloc`**.
|
|
||||||
- **`AdminUsersScreen`**: User role management and AI pipeline cutoff settings.
|
|
||||||
|
|
||||||
### 6. Calendar Module
|
|
||||||
- **`CorporateEventModel`**, **`CalendarRepository`**, **`CalendarBloc`**.
|
|
||||||
- **`CorporateCalendarScreen`**: Monthly calendar grid, date filter, and category chips (Earnings, ExDividend, Payout).
|
|
||||||
|
|
||||||
### 7. Search Module
|
|
||||||
- **`SearchResultModel`**, **`SearchRepository`**, **`SearchBloc`** (with Debounce).
|
|
||||||
- **`AssetSearchDialog`**: Reactive omnibox asset search with ISIN lookup and instant logo resolution.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Status
|
|
||||||
|
|
||||||
### Implemented Features
|
|
||||||
- [x] Full Clean Architecture migration across all 7 feature modules (`models/`, `repositories/`, `bloc/`).
|
|
||||||
- [x] Strongly typed data models extending `Equatable` for zero redundant widget rebuilds.
|
|
||||||
- [x] Complete removal of all mock/demo fallbacks in repositories and screens.
|
|
||||||
- [x] SignalR WebSocket integration (`NewsHub`, `TradeHub`).
|
|
||||||
- [x] Premium glassmorphism dark mode design system (`AppTheme`).
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
- [ ] Push Notifications integration via Firebase Cloud Messaging for instant trade alerts.
|
|
||||||
- [ ] Biometric Authentication (FaceID / TouchID / Fingerprint) unlock.
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../services/secure_storage_service.dart';
|
import '../services/secure_storage_service.dart';
|
||||||
|
|
||||||
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
|
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
|
||||||
@@ -7,7 +8,15 @@ class ApiClient {
|
|||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
Function()? onUnauthorized;
|
Function()? onUnauthorized;
|
||||||
|
|
||||||
static const String baseUrl = 'http://localhost:5000';
|
static String get baseUrl {
|
||||||
|
if (kIsWeb) {//todo on release
|
||||||
|
final origin = Uri.base.origin;
|
||||||
|
if (origin.isNotEmpty && !origin.contains('null') && !origin.startsWith('file:')) {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const String.fromEnvironment('BACKEND_URL', defaultValue: 'http://localhost:5000');
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient(this._storageService) {
|
ApiClient(this._storageService) {
|
||||||
_dio = Dio(
|
_dio = Dio(
|
||||||
@@ -29,7 +38,7 @@ class ApiClient {
|
|||||||
return handler.next(options);
|
return handler.next(options);
|
||||||
},
|
},
|
||||||
onError: (DioException error, handler) async {
|
onError: (DioException error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401 || error.response?.statusCode == 403) {
|
||||||
await _storageService.clearAll();
|
await _storageService.clearAll();
|
||||||
onUnauthorized?.call();
|
onUnauthorized?.call();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:finlytic_app/core/network/api_client.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:signalr_core/signalr_core.dart';
|
import 'package:signalr_core/signalr_core.dart';
|
||||||
import '../services/secure_storage_service.dart';
|
import '../services/secure_storage_service.dart';
|
||||||
|
|
||||||
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
|
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
|
||||||
/// Connects persistently to `/hubs/health` and `/hubs/favorites-prices` WebSockets.
|
/// Connects persistently to `/hubs/health`, `/hubs/favorites-prices`, `/hubs/trade-stream`, `/hubs/logs`, and `/hubs/news`.
|
||||||
class SignalRService extends ChangeNotifier {
|
class SignalRService extends ChangeNotifier {
|
||||||
final SecureStorageService storageService;
|
final SecureStorageService storageService;
|
||||||
|
|
||||||
HubConnection? _healthConnection;
|
HubConnection? _healthConnection;
|
||||||
HubConnection? _favoritesConnection;
|
HubConnection? _favoritesConnection;
|
||||||
|
HubConnection? _tradeStreamConnection;
|
||||||
|
HubConnection? _logsConnection;
|
||||||
|
HubConnection? _newsConnection;
|
||||||
|
|
||||||
bool _isConnected = false;
|
bool _isConnected = false;
|
||||||
final _statusController = StreamController<bool>.broadcast();
|
final _statusController = StreamController<bool>.broadcast();
|
||||||
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
|
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
|
||||||
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
|
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _tradeProposalController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _tradeUpdateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _botPositionController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _portfolioSummaryController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _logMessageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _newsArticleController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
|
||||||
bool get isConnected => _isConnected;
|
bool get isConnected => _isConnected;
|
||||||
Stream<bool> get connectionStream => _statusController.stream;
|
Stream<bool> get connectionStream => _statusController.stream;
|
||||||
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
|
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
|
||||||
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
|
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get tradeProposalStream => _tradeProposalController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get tradeUpdateStream => _tradeUpdateController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get botPositionStream => _botPositionController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get portfolioSummaryStream => _portfolioSummaryController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get logMessageStream => _logMessageController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get newsArticleStream => _newsArticleController.stream;
|
||||||
|
|
||||||
static const String baseUrl = 'http://localhost:5000';
|
static String get baseUrl => ApiClient.baseUrl;
|
||||||
|
|
||||||
SignalRService(this.storageService);
|
SignalRService(this.storageService);
|
||||||
|
|
||||||
@@ -29,14 +45,18 @@ class SignalRService extends ChangeNotifier {
|
|||||||
if (_isConnected) return;
|
if (_isConnected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await storageService.getToken();
|
// Reads the token fresh from secure storage on every connection attempt
|
||||||
|
// (initial connect AND every automatic reconnect), so a token refreshed
|
||||||
|
// mid-session is always picked up instead of being pinned to the value
|
||||||
|
// read at initSignalR() time.
|
||||||
|
Future<String?> tokenFactory() => storageService.getToken();
|
||||||
|
|
||||||
// 1. Connect SystemHealthHub over WebSockets
|
// 1. Connect SystemHealthHub over WebSockets
|
||||||
_healthConnection = HubConnectionBuilder()
|
_healthConnection = HubConnectionBuilder()
|
||||||
.withUrl(
|
.withUrl(
|
||||||
'$baseUrl/hubs/health',
|
'$baseUrl/hubs/health',
|
||||||
HttpConnectionOptions(
|
HttpConnectionOptions(
|
||||||
accessTokenFactory: () async => token,
|
accessTokenFactory: tokenFactory,
|
||||||
transport: HttpTransportType.webSockets,
|
transport: HttpTransportType.webSockets,
|
||||||
logging: (level, message) {
|
logging: (level, message) {
|
||||||
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
|
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
|
||||||
@@ -58,19 +78,12 @@ class SignalRService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_healthConnection!.onclose((error) {
|
|
||||||
if (kDebugMode) debugPrint('[SignalR Health WS] Closed: $error');
|
|
||||||
});
|
|
||||||
|
|
||||||
await _healthConnection!.start();
|
|
||||||
if (kDebugMode) debugPrint('[SignalR Health WS] Connected via WebSocket to /hubs/health.');
|
|
||||||
|
|
||||||
// 2. Connect FavoritesPriceHub over WebSockets
|
// 2. Connect FavoritesPriceHub over WebSockets
|
||||||
_favoritesConnection = HubConnectionBuilder()
|
_favoritesConnection = HubConnectionBuilder()
|
||||||
.withUrl(
|
.withUrl(
|
||||||
'$baseUrl/hubs/favorites-prices',
|
'$baseUrl/hubs/favorites-prices',
|
||||||
HttpConnectionOptions(
|
HttpConnectionOptions(
|
||||||
accessTokenFactory: () async => token,
|
accessTokenFactory: tokenFactory,
|
||||||
transport: HttpTransportType.webSockets,
|
transport: HttpTransportType.webSockets,
|
||||||
logging: (level, message) {
|
logging: (level, message) {
|
||||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
|
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
|
||||||
@@ -91,8 +104,126 @@ class SignalRService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await _favoritesConnection!.start();
|
// 3. Connect TradeStreamHub over WebSockets (Engine & Bot Real-Time Streams)
|
||||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] Connected via WebSocket to /hubs/favorites-prices.');
|
_tradeStreamConnection = HubConnectionBuilder()
|
||||||
|
.withUrl(
|
||||||
|
'$baseUrl/hubs/trade-stream',
|
||||||
|
HttpConnectionOptions(
|
||||||
|
accessTokenFactory: tokenFactory,
|
||||||
|
transport: HttpTransportType.webSockets,
|
||||||
|
logging: (level, message) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR TradeStream WS] $message');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceiveTradeProposal', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_tradeProposalController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR Proposal Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceiveTradeUpdate', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_tradeUpdateController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR TradeUpdate Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceiveBotPositionUpdate', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_botPositionController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR BotPosition Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceivePortfolioSummary', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_portfolioSummaryController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR PortfolioSummary Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Connect LogStreamHub over WebSockets
|
||||||
|
_logsConnection = HubConnectionBuilder()
|
||||||
|
.withUrl(
|
||||||
|
'$baseUrl/hubs/logs',
|
||||||
|
HttpConnectionOptions(
|
||||||
|
accessTokenFactory: tokenFactory,
|
||||||
|
transport: HttpTransportType.webSockets,
|
||||||
|
logging: (level, message) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR Logs WS] $message');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_logsConnection!.on('ReceiveLogMessage', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_logMessageController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR Log Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Connect NewsHub over WebSockets (Live News Feed)
|
||||||
|
_newsConnection = HubConnectionBuilder()
|
||||||
|
.withUrl(
|
||||||
|
'$baseUrl/hubs/news',
|
||||||
|
HttpConnectionOptions(
|
||||||
|
accessTokenFactory: tokenFactory,
|
||||||
|
transport: HttpTransportType.webSockets,
|
||||||
|
logging: (level, message) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR News WS] $message');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_newsConnection!.on('ReceiveNewArticle', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_newsArticleController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR News Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
_healthConnection!.start() ?? Future.value(),
|
||||||
|
_favoritesConnection!.start() ?? Future.value(),
|
||||||
|
_tradeStreamConnection!.start() ?? Future.value(),
|
||||||
|
_logsConnection!.start() ?? Future.value(),
|
||||||
|
_newsConnection!.start() ?? Future.value(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (kDebugMode) debugPrint('[SignalR WS] All 5 Real-Time WebSockets successfully connected.');
|
||||||
|
|
||||||
_isConnected = true;
|
_isConnected = true;
|
||||||
_statusController.add(true);
|
_statusController.add(true);
|
||||||
@@ -105,20 +236,13 @@ class SignalRService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void broadcastHealthUpdate(List<Map<String, dynamic>> healthData) {
|
|
||||||
_healthController.add(healthData);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void broadcastFavoritePrices(Map<String, dynamic> priceMap) {
|
|
||||||
_favoritePricesController.add(priceMap);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void disconnect() async {
|
void disconnect() async {
|
||||||
try {
|
try {
|
||||||
await _healthConnection?.stop();
|
await _healthConnection?.stop();
|
||||||
await _favoritesConnection?.stop();
|
await _favoritesConnection?.stop();
|
||||||
|
await _tradeStreamConnection?.stop();
|
||||||
|
await _logsConnection?.stop();
|
||||||
|
await _newsConnection?.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
_statusController.add(false);
|
_statusController.add(false);
|
||||||
@@ -131,6 +255,12 @@ class SignalRService extends ChangeNotifier {
|
|||||||
_statusController.close();
|
_statusController.close();
|
||||||
_healthController.close();
|
_healthController.close();
|
||||||
_favoritePricesController.close();
|
_favoritePricesController.close();
|
||||||
|
_tradeProposalController.close();
|
||||||
|
_tradeUpdateController.close();
|
||||||
|
_botPositionController.close();
|
||||||
|
_portfolioSummaryController.close();
|
||||||
|
_logMessageController.close();
|
||||||
|
_newsArticleController.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../repositories/admin_repository.dart';
|
||||||
|
import 'admin_evaluation_history_event.dart';
|
||||||
|
import 'admin_evaluation_history_state.dart';
|
||||||
|
|
||||||
|
export 'admin_evaluation_history_event.dart';
|
||||||
|
export 'admin_evaluation_history_state.dart';
|
||||||
|
|
||||||
|
class AdminEvaluationHistoryBloc extends Bloc<AdminEvaluationHistoryEvent, AdminEvaluationHistoryState> {
|
||||||
|
final AdminRepository repository;
|
||||||
|
|
||||||
|
AdminEvaluationHistoryBloc({required this.repository}) : super(AdminEvaluationHistoryInitial()) {
|
||||||
|
on<FetchEvaluationHistory>(_onFetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onFetch(FetchEvaluationHistory event, Emitter<AdminEvaluationHistoryState> emit) async {
|
||||||
|
emit(AdminEvaluationHistoryLoading());
|
||||||
|
try {
|
||||||
|
final response = await repository.fetchEvaluationHistory(
|
||||||
|
fromUtc: event.fromUtc,
|
||||||
|
toUtc: event.toUtc,
|
||||||
|
outcome: event.outcome,
|
||||||
|
triggerSource: event.triggerSource,
|
||||||
|
search: event.search,
|
||||||
|
page: event.page,
|
||||||
|
pageSize: event.pageSize,
|
||||||
|
);
|
||||||
|
emit(AdminEvaluationHistoryLoaded(response: response, page: event.page, pageSize: event.pageSize));
|
||||||
|
} catch (e) {
|
||||||
|
emit(AdminEvaluationHistoryError(e.toString().replaceFirst('Exception: ', '')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../models/evaluation_history_enums.dart';
|
||||||
|
|
||||||
|
abstract class AdminEvaluationHistoryEvent extends Equatable {
|
||||||
|
const AdminEvaluationHistoryEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches (or re-fetches) one page of evaluation history for the given filter
|
||||||
|
/// set. There is deliberately no separate "change page" event — every fetch is
|
||||||
|
/// a full filter snapshot, so the bloc never has to guess which filters were
|
||||||
|
/// active on a previously-loaded page when the caller asks for the next one.
|
||||||
|
class FetchEvaluationHistory extends AdminEvaluationHistoryEvent {
|
||||||
|
final DateTime? fromUtc;
|
||||||
|
final DateTime? toUtc;
|
||||||
|
final OutcomeReason? outcome;
|
||||||
|
final TriggerSource? triggerSource;
|
||||||
|
final String? search;
|
||||||
|
final int page;
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
const FetchEvaluationHistory({
|
||||||
|
this.fromUtc,
|
||||||
|
this.toUtc,
|
||||||
|
this.outcome,
|
||||||
|
this.triggerSource,
|
||||||
|
this.search,
|
||||||
|
this.page = 1,
|
||||||
|
this.pageSize = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [fromUtc, toUtc, outcome, triggerSource, search, page, pageSize];
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../models/evaluation_history_response_model.dart';
|
||||||
|
|
||||||
|
abstract class AdminEvaluationHistoryState extends Equatable {
|
||||||
|
const AdminEvaluationHistoryState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminEvaluationHistoryInitial extends AdminEvaluationHistoryState {}
|
||||||
|
|
||||||
|
class AdminEvaluationHistoryLoading extends AdminEvaluationHistoryState {}
|
||||||
|
|
||||||
|
class AdminEvaluationHistoryLoaded extends AdminEvaluationHistoryState {
|
||||||
|
final EvaluationHistoryResponseModel response;
|
||||||
|
final int page;
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
const AdminEvaluationHistoryLoaded({
|
||||||
|
required this.response,
|
||||||
|
required this.page,
|
||||||
|
required this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasPreviousPage => page > 1;
|
||||||
|
|
||||||
|
bool get hasNextPage => page * pageSize < response.totalCount;
|
||||||
|
|
||||||
|
int get rangeStart => response.totalCount == 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
|
|
||||||
|
int get rangeEnd {
|
||||||
|
final end = page * pageSize;
|
||||||
|
return end > response.totalCount ? response.totalCount : end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [response, page, pageSize];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminEvaluationHistoryError extends AdminEvaluationHistoryState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const AdminEvaluationHistoryError(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'evaluation_history_enums.dart';
|
||||||
|
|
||||||
|
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto` — one row
|
||||||
|
/// of `GET /api/v1/admin/evaluations`. Every score field is the real,
|
||||||
|
/// already-computed value the server persisted (including the honest 0/default
|
||||||
|
/// values recorded for [OutcomeReason.noTechnicalSetups]) — nothing here is
|
||||||
|
/// fabricated client-side (Rules.md §4).
|
||||||
|
class EvaluationHistoryEntryModel extends Equatable {
|
||||||
|
final String id;
|
||||||
|
final String isin;
|
||||||
|
final String symbol;
|
||||||
|
final double technicalScore;
|
||||||
|
final double sentimentScore;
|
||||||
|
final double fundamentalScore;
|
||||||
|
final double compositeOpportunityScore;
|
||||||
|
final double reliabilityBonus;
|
||||||
|
final bool passedEarningsLockout;
|
||||||
|
final int? daysToNextEarnings;
|
||||||
|
final bool passedDividendGate;
|
||||||
|
final int? daysToNextExDividend;
|
||||||
|
final UniverseSource? universeSource;
|
||||||
|
final DateTime? universeEnteredAtUtc;
|
||||||
|
final bool passedSimulationVeto;
|
||||||
|
final bool passedAiValidation;
|
||||||
|
final String aiThesisSummary;
|
||||||
|
final OutcomeReason outcomeReason;
|
||||||
|
final TriggerSource triggerSource;
|
||||||
|
final String? triggeredByUserId;
|
||||||
|
final String? proposalId;
|
||||||
|
final DateTime evaluatedAtUtc;
|
||||||
|
|
||||||
|
const EvaluationHistoryEntryModel({
|
||||||
|
required this.id,
|
||||||
|
required this.isin,
|
||||||
|
required this.symbol,
|
||||||
|
required this.technicalScore,
|
||||||
|
required this.sentimentScore,
|
||||||
|
required this.fundamentalScore,
|
||||||
|
required this.compositeOpportunityScore,
|
||||||
|
required this.reliabilityBonus,
|
||||||
|
required this.passedEarningsLockout,
|
||||||
|
this.daysToNextEarnings,
|
||||||
|
required this.passedDividendGate,
|
||||||
|
this.daysToNextExDividend,
|
||||||
|
this.universeSource,
|
||||||
|
this.universeEnteredAtUtc,
|
||||||
|
required this.passedSimulationVeto,
|
||||||
|
required this.passedAiValidation,
|
||||||
|
required this.aiThesisSummary,
|
||||||
|
required this.outcomeReason,
|
||||||
|
required this.triggerSource,
|
||||||
|
this.triggeredByUserId,
|
||||||
|
this.proposalId,
|
||||||
|
required this.evaluatedAtUtc,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True exactly when this evaluation resulted in a trade proposal.
|
||||||
|
bool get hasProposal => proposalId != null && proposalId!.isNotEmpty;
|
||||||
|
|
||||||
|
factory EvaluationHistoryEntryModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic val) {
|
||||||
|
if (val == null) return 0.0;
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
return double.tryParse(val.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime parseDate(dynamic val) {
|
||||||
|
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
|
return DateTime.tryParse(val.toString())?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EvaluationHistoryEntryModel(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
isin: json['isin']?.toString() ?? '',
|
||||||
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
|
technicalScore: parseDbl(json['technicalScore']),
|
||||||
|
sentimentScore: parseDbl(json['sentimentScore']),
|
||||||
|
fundamentalScore: parseDbl(json['fundamentalScore']),
|
||||||
|
compositeOpportunityScore: parseDbl(json['compositeOpportunityScore']),
|
||||||
|
reliabilityBonus: parseDbl(json['reliabilityBonus']),
|
||||||
|
passedEarningsLockout: json['passedEarningsLockout'] == true,
|
||||||
|
daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null,
|
||||||
|
passedDividendGate: json['passedDividendGate'] == true,
|
||||||
|
daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null,
|
||||||
|
universeSource: UniverseSource.fromJson(json['universeSource']?.toString()),
|
||||||
|
universeEnteredAtUtc: json['universeEnteredAtUtc'] == null ? null : parseDate(json['universeEnteredAtUtc']),
|
||||||
|
passedSimulationVeto: json['passedSimulationVeto'] == true,
|
||||||
|
passedAiValidation: json['passedAiValidation'] == true,
|
||||||
|
aiThesisSummary: json['aiThesisSummary']?.toString() ?? '',
|
||||||
|
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
|
||||||
|
triggerSource: TriggerSource.fromJson(json['triggerSource']?.toString()),
|
||||||
|
triggeredByUserId: json['triggeredByUserId']?.toString(),
|
||||||
|
proposalId: json['proposalId']?.toString(),
|
||||||
|
evaluatedAtUtc: parseDate(json['evaluatedAtUtc']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
symbol,
|
||||||
|
technicalScore,
|
||||||
|
sentimentScore,
|
||||||
|
fundamentalScore,
|
||||||
|
compositeOpportunityScore,
|
||||||
|
reliabilityBonus,
|
||||||
|
passedEarningsLockout,
|
||||||
|
daysToNextEarnings,
|
||||||
|
passedDividendGate,
|
||||||
|
daysToNextExDividend,
|
||||||
|
universeSource,
|
||||||
|
universeEnteredAtUtc,
|
||||||
|
passedSimulationVeto,
|
||||||
|
passedAiValidation,
|
||||||
|
aiThesisSummary,
|
||||||
|
outcomeReason,
|
||||||
|
triggerSource,
|
||||||
|
triggeredByUserId,
|
||||||
|
proposalId,
|
||||||
|
evaluatedAtUtc,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Mirrors `FinlyticCore.Dtos.Trading.OutcomeReason` (`TradeEnums.cs`), which the
|
||||||
|
/// `/api/v1/admin/evaluations` endpoint serializes as a `JsonStringEnumConverter`
|
||||||
|
/// string using the exact C# member name (e.g. `"Approved"`, `"BelowScoreThreshold"`).
|
||||||
|
///
|
||||||
|
/// [unknown] is the fallback both for the server's own `Unknown = 0` default (an
|
||||||
|
/// honest "we don't know" rather than a fabricated reason, Rules.md §4) and for any
|
||||||
|
/// future server-side member this client doesn't recognize yet.
|
||||||
|
enum OutcomeReason {
|
||||||
|
unknown,
|
||||||
|
approved,
|
||||||
|
belowScoreThreshold,
|
||||||
|
earningsLockout,
|
||||||
|
simulationVeto,
|
||||||
|
aiRejected,
|
||||||
|
noTechnicalSetups,
|
||||||
|
duplicateActiveProposal,
|
||||||
|
dividendGate;
|
||||||
|
|
||||||
|
static OutcomeReason fromJson(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Approved':
|
||||||
|
return OutcomeReason.approved;
|
||||||
|
case 'BelowScoreThreshold':
|
||||||
|
return OutcomeReason.belowScoreThreshold;
|
||||||
|
case 'EarningsLockout':
|
||||||
|
return OutcomeReason.earningsLockout;
|
||||||
|
case 'SimulationVeto':
|
||||||
|
return OutcomeReason.simulationVeto;
|
||||||
|
case 'AiRejected':
|
||||||
|
return OutcomeReason.aiRejected;
|
||||||
|
case 'NoTechnicalSetups':
|
||||||
|
return OutcomeReason.noTechnicalSetups;
|
||||||
|
case 'DuplicateActiveProposal':
|
||||||
|
return OutcomeReason.duplicateActiveProposal;
|
||||||
|
case 'DividendGate':
|
||||||
|
return OutcomeReason.dividendGate;
|
||||||
|
case 'Unknown':
|
||||||
|
default:
|
||||||
|
return OutcomeReason.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact server-side enum member name. `[FromQuery] OutcomeReason?` on
|
||||||
|
/// `AdminEvaluationHistoryController` model-binds a bare enum member name from
|
||||||
|
/// the query string (ASP.NET Core's default `Enum.TryParse`-based binder), not a
|
||||||
|
/// JSON string — so this is what must be sent back as the `outcome` filter value.
|
||||||
|
String toApiValue() {
|
||||||
|
switch (this) {
|
||||||
|
case OutcomeReason.approved:
|
||||||
|
return 'Approved';
|
||||||
|
case OutcomeReason.belowScoreThreshold:
|
||||||
|
return 'BelowScoreThreshold';
|
||||||
|
case OutcomeReason.earningsLockout:
|
||||||
|
return 'EarningsLockout';
|
||||||
|
case OutcomeReason.simulationVeto:
|
||||||
|
return 'SimulationVeto';
|
||||||
|
case OutcomeReason.aiRejected:
|
||||||
|
return 'AiRejected';
|
||||||
|
case OutcomeReason.noTechnicalSetups:
|
||||||
|
return 'NoTechnicalSetups';
|
||||||
|
case OutcomeReason.duplicateActiveProposal:
|
||||||
|
return 'DuplicateActiveProposal';
|
||||||
|
case OutcomeReason.dividendGate:
|
||||||
|
return 'DividendGate';
|
||||||
|
case OutcomeReason.unknown:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case OutcomeReason.approved:
|
||||||
|
return 'Freigegeben';
|
||||||
|
case OutcomeReason.belowScoreThreshold:
|
||||||
|
return 'Score zu niedrig';
|
||||||
|
case OutcomeReason.earningsLockout:
|
||||||
|
return 'Earnings-Sperre';
|
||||||
|
case OutcomeReason.simulationVeto:
|
||||||
|
return 'Simulation-Veto';
|
||||||
|
case OutcomeReason.aiRejected:
|
||||||
|
return 'KI abgelehnt';
|
||||||
|
case OutcomeReason.noTechnicalSetups:
|
||||||
|
return 'Kein Setup';
|
||||||
|
case OutcomeReason.duplicateActiveProposal:
|
||||||
|
return 'Bereits aktiver Vorschlag';
|
||||||
|
case OutcomeReason.dividendGate:
|
||||||
|
return 'Dividend-Sperre';
|
||||||
|
case OutcomeReason.unknown:
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color-coding for the history-list badge, reusing only colors already
|
||||||
|
/// established elsewhere in the app (`AppTheme.primaryEmerald`/`accentRed` plus
|
||||||
|
/// the `Colors.amber`/`Colors.purpleAccent` already used by
|
||||||
|
/// `EvaluationScoreBreakdownSheet`) rather than introducing a new palette.
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case OutcomeReason.approved:
|
||||||
|
return AppTheme.primaryEmerald;
|
||||||
|
case OutcomeReason.aiRejected:
|
||||||
|
case OutcomeReason.simulationVeto:
|
||||||
|
return AppTheme.accentRed;
|
||||||
|
case OutcomeReason.belowScoreThreshold:
|
||||||
|
case OutcomeReason.earningsLockout:
|
||||||
|
case OutcomeReason.duplicateActiveProposal:
|
||||||
|
case OutcomeReason.dividendGate:
|
||||||
|
return Colors.amber;
|
||||||
|
case OutcomeReason.noTechnicalSetups:
|
||||||
|
case OutcomeReason.unknown:
|
||||||
|
return AppTheme.textMuted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `FinlyticCore.Dtos.Trading.TriggerSource`.
|
||||||
|
enum TriggerSource {
|
||||||
|
unknown,
|
||||||
|
automatic,
|
||||||
|
manual;
|
||||||
|
|
||||||
|
static TriggerSource fromJson(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Automatic':
|
||||||
|
return TriggerSource.automatic;
|
||||||
|
case 'Manual':
|
||||||
|
return TriggerSource.manual;
|
||||||
|
case 'Unknown':
|
||||||
|
default:
|
||||||
|
return TriggerSource.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String toApiValue() {
|
||||||
|
switch (this) {
|
||||||
|
case TriggerSource.automatic:
|
||||||
|
return 'Automatic';
|
||||||
|
case TriggerSource.manual:
|
||||||
|
return 'Manual';
|
||||||
|
case TriggerSource.unknown:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case TriggerSource.automatic:
|
||||||
|
return 'Automatisch';
|
||||||
|
case TriggerSource.manual:
|
||||||
|
return 'Manuell';
|
||||||
|
case TriggerSource.unknown:
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case TriggerSource.automatic:
|
||||||
|
return AppTheme.accentCyan;
|
||||||
|
case TriggerSource.manual:
|
||||||
|
return Colors.purpleAccent;
|
||||||
|
case TriggerSource.unknown:
|
||||||
|
return AppTheme.textMuted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource` - which recurring
|
||||||
|
/// FinlyticTechnicals selection mechanism added the ISIN to the continuously
|
||||||
|
/// scanned universe before this evaluation ran. `null` on the Dart side (not
|
||||||
|
/// modeled as its own enum value here) means the evaluation happened outside
|
||||||
|
/// that universe entirely (e.g. a manual "Analyze now" call).
|
||||||
|
enum UniverseSource {
|
||||||
|
sentimentSpike,
|
||||||
|
userFavorite,
|
||||||
|
discovery;
|
||||||
|
|
||||||
|
static UniverseSource? fromJson(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'SentimentSpike':
|
||||||
|
return UniverseSource.sentimentSpike;
|
||||||
|
case 'UserFavorite':
|
||||||
|
return UniverseSource.userFavorite;
|
||||||
|
case 'Discovery':
|
||||||
|
return UniverseSource.discovery;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case UniverseSource.sentimentSpike:
|
||||||
|
return 'Sentiment-Spike';
|
||||||
|
case UniverseSource.userFavorite:
|
||||||
|
return 'Nutzer-Favorit';
|
||||||
|
case UniverseSource.discovery:
|
||||||
|
return 'Discovery-Liste';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'evaluation_history_entry_model.dart';
|
||||||
|
import 'evaluation_history_summary_model.dart';
|
||||||
|
|
||||||
|
/// Typed mirror of `FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse` — the
|
||||||
|
/// full response body of `GET /api/v1/admin/evaluations`.
|
||||||
|
class EvaluationHistoryResponseModel extends Equatable {
|
||||||
|
final int totalCount;
|
||||||
|
final List<EvaluationHistoryEntryModel> entries;
|
||||||
|
final EvaluationHistorySummaryModel summary;
|
||||||
|
|
||||||
|
const EvaluationHistoryResponseModel({
|
||||||
|
required this.totalCount,
|
||||||
|
required this.entries,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EvaluationHistoryResponseModel.empty() => EvaluationHistoryResponseModel(
|
||||||
|
totalCount: 0,
|
||||||
|
entries: const [],
|
||||||
|
summary: EvaluationHistorySummaryModel.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
factory EvaluationHistoryResponseModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawEntries = json['entries'];
|
||||||
|
final entries = rawEntries is List
|
||||||
|
? rawEntries.whereType<Map<String, dynamic>>().map(EvaluationHistoryEntryModel.fromJson).toList()
|
||||||
|
: <EvaluationHistoryEntryModel>[];
|
||||||
|
|
||||||
|
final rawSummary = json['summary'];
|
||||||
|
final summary = rawSummary is Map<String, dynamic>
|
||||||
|
? EvaluationHistorySummaryModel.fromJson(rawSummary)
|
||||||
|
: EvaluationHistorySummaryModel.empty();
|
||||||
|
|
||||||
|
return EvaluationHistoryResponseModel(
|
||||||
|
totalCount: json['totalCount'] is num ? (json['totalCount'] as num).toInt() : 0,
|
||||||
|
entries: entries,
|
||||||
|
summary: summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [totalCount, entries, summary];
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'evaluation_history_enums.dart';
|
||||||
|
|
||||||
|
/// Typed mirror of `FinlyticCore.Dtos.Trading.OutcomeReasonCountDto`.
|
||||||
|
class OutcomeReasonCountModel extends Equatable {
|
||||||
|
final OutcomeReason outcomeReason;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const OutcomeReasonCountModel({required this.outcomeReason, required this.count});
|
||||||
|
|
||||||
|
factory OutcomeReasonCountModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OutcomeReasonCountModel(
|
||||||
|
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
|
||||||
|
count: json['count'] is num ? (json['count'] as num).toInt() : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [outcomeReason, count];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistorySummaryDto` — the
|
||||||
|
/// pre-aggregated headline numbers for the admin evaluation-history tab. Every
|
||||||
|
/// field except [lastProposalCreatedAtUtc] is scoped to the same filters as the
|
||||||
|
/// paginated entry list it accompanies; [lastProposalCreatedAtUtc] deliberately
|
||||||
|
/// ignores the from/to filters (see the server-side DTO doc comment) so the admin
|
||||||
|
/// always sees "how long since the last real proposal" regardless of which
|
||||||
|
/// historical window is currently selected.
|
||||||
|
class EvaluationHistorySummaryModel extends Equatable {
|
||||||
|
final int totalEvaluations;
|
||||||
|
final List<OutcomeReasonCountModel> countsByOutcome;
|
||||||
|
final double averageCompositeScore;
|
||||||
|
final int proposalsCreated;
|
||||||
|
final DateTime? lastProposalCreatedAtUtc;
|
||||||
|
|
||||||
|
const EvaluationHistorySummaryModel({
|
||||||
|
required this.totalEvaluations,
|
||||||
|
required this.countsByOutcome,
|
||||||
|
required this.averageCompositeScore,
|
||||||
|
required this.proposalsCreated,
|
||||||
|
this.lastProposalCreatedAtUtc,
|
||||||
|
});
|
||||||
|
|
||||||
|
int countFor(OutcomeReason reason) {
|
||||||
|
for (final c in countsByOutcome) {
|
||||||
|
if (c.outcomeReason == reason) return c.count;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory EvaluationHistorySummaryModel.empty() => const EvaluationHistorySummaryModel(
|
||||||
|
totalEvaluations: 0,
|
||||||
|
countsByOutcome: [],
|
||||||
|
averageCompositeScore: 0,
|
||||||
|
proposalsCreated: 0,
|
||||||
|
lastProposalCreatedAtUtc: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory EvaluationHistorySummaryModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic val) {
|
||||||
|
if (val == null) return 0.0;
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
return double.tryParse(val.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawCounts = json['countsByOutcome'];
|
||||||
|
final counts = rawCounts is List
|
||||||
|
? rawCounts.whereType<Map<String, dynamic>>().map(OutcomeReasonCountModel.fromJson).toList()
|
||||||
|
: <OutcomeReasonCountModel>[];
|
||||||
|
|
||||||
|
final rawLast = json['lastProposalCreatedAtUtc'];
|
||||||
|
|
||||||
|
return EvaluationHistorySummaryModel(
|
||||||
|
totalEvaluations: json['totalEvaluations'] is num ? (json['totalEvaluations'] as num).toInt() : 0,
|
||||||
|
countsByOutcome: counts,
|
||||||
|
averageCompositeScore: parseDbl(json['averageCompositeScore']),
|
||||||
|
proposalsCreated: json['proposalsCreated'] is num ? (json['proposalsCreated'] as num).toInt() : 0,
|
||||||
|
lastProposalCreatedAtUtc: rawLast != null ? DateTime.tryParse(rawLast.toString())?.toUtc() : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [totalEvaluations, countsByOutcome, averageCompositeScore, proposalsCreated, lastProposalCreatedAtUtc];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Typed mirror of the fields the admin UI needs from
|
||||||
|
/// `FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto`, as returned by
|
||||||
|
/// `GET /api/v1/admin/evaluations/watchlist/{isin}/history` — the last N
|
||||||
|
/// technical-analysis setups computed for one ISIN, most recent first, so the
|
||||||
|
/// score trend (improving/worsening, and whether it ever cleared the engine's
|
||||||
|
/// top-pick bar) is visible even for setups too weak to ever reach the engine.
|
||||||
|
class RecentSetupModel extends Equatable {
|
||||||
|
final String strategyName;
|
||||||
|
final double qualityScore;
|
||||||
|
final bool isTopPick;
|
||||||
|
final String rating;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const RecentSetupModel({
|
||||||
|
required this.strategyName,
|
||||||
|
required this.qualityScore,
|
||||||
|
required this.isTopPick,
|
||||||
|
required this.rating,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RecentSetupModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic val) {
|
||||||
|
if (val == null) return 0.0;
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
return double.tryParse(val.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RecentSetupModel(
|
||||||
|
strategyName: json['strategyName']?.toString() ?? '',
|
||||||
|
qualityScore: parseDbl(json['qualityScore']),
|
||||||
|
isTopPick: json['isTopPick'] == true,
|
||||||
|
rating: json['rating']?.toString() ?? '',
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [strategyName, qualityScore, isTopPick, rating, createdAt];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'evaluation_history_enums.dart';
|
||||||
|
|
||||||
|
/// Typed mirror of `FinlyticCore.Dtos.TechnicalAnalysis.WatchlistEntryDto` — one
|
||||||
|
/// row of `GET /api/v1/admin/evaluations/watchlist`: an asset FinlyticTechnicals'
|
||||||
|
/// background scanner is actually evaluating every cycle, independent of
|
||||||
|
/// whether it has produced any evaluation the engine ever saw.
|
||||||
|
class WatchlistEntryModel extends Equatable {
|
||||||
|
final String isin;
|
||||||
|
final String? symbol;
|
||||||
|
final UniverseSource? source;
|
||||||
|
final int priority;
|
||||||
|
final DateTime addedAtUtc;
|
||||||
|
final DateTime? expiresAtUtc;
|
||||||
|
|
||||||
|
const WatchlistEntryModel({
|
||||||
|
required this.isin,
|
||||||
|
this.symbol,
|
||||||
|
this.source,
|
||||||
|
required this.priority,
|
||||||
|
required this.addedAtUtc,
|
||||||
|
this.expiresAtUtc,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WatchlistEntryModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
DateTime parseDate(dynamic val) {
|
||||||
|
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return WatchlistEntryModel(
|
||||||
|
isin: json['isin']?.toString() ?? '',
|
||||||
|
symbol: json['symbol']?.toString(),
|
||||||
|
source: UniverseSource.fromJson(json['source']?.toString()),
|
||||||
|
priority: json['priority'] is num ? (json['priority'] as num).toInt() : 0,
|
||||||
|
addedAtUtc: parseDate(json['addedAtUtc']),
|
||||||
|
expiresAtUtc: json['expiresAtUtc'] == null ? null : parseDate(json['expiresAtUtc']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [isin, symbol, source, priority, addedAtUtc, expiresAtUtc];
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:finlytic_app/core/network/api_client.dart';
|
import 'package:finlytic_app/core/network/api_client.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
|
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
|
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
|
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/evaluation_history_enums.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/evaluation_history_response_model.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/recent_setup_model.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
|
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/watchlist_entry_model.dart';
|
||||||
|
|
||||||
class AdminRepository {
|
class AdminRepository {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
@@ -60,4 +65,86 @@ class AdminRepository {
|
|||||||
throw Exception('Einstellungen konnten nicht gespeichert werden');
|
throw Exception('Einstellungen konnten nicht gespeichert werden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches a filtered, paginated page of the evaluation history plus its
|
||||||
|
/// accompanying summary from `GET /api/v1/admin/evaluations`
|
||||||
|
/// (`AdminEvaluationHistoryController`). All filter parameters are optional —
|
||||||
|
/// omitting one means "do not filter on this field", mirroring the server
|
||||||
|
/// contract exactly (`GetEvaluationHistoryRequest`).
|
||||||
|
///
|
||||||
|
/// `[Authorize(Roles = "Admin")]` on the server means a non-admin caller (or an
|
||||||
|
/// expired/invalid token) gets a `401`/`403`, which `ApiClient`'s interceptor
|
||||||
|
/// already turns into an auto-logout (Rules.md §8) before this method's
|
||||||
|
/// `catch` even runs — this method only has to turn the remaining
|
||||||
|
/// error responses (engine unreachable `503`, RPC timeout `502`, unexpected
|
||||||
|
/// `500` — all `ProblemDetails` bodies per the controller) into a readable
|
||||||
|
/// message instead of letting a raw `DioException` reach the UI.
|
||||||
|
Future<EvaluationHistoryResponseModel> fetchEvaluationHistory({
|
||||||
|
DateTime? fromUtc,
|
||||||
|
DateTime? toUtc,
|
||||||
|
OutcomeReason? outcome,
|
||||||
|
TriggerSource? triggerSource,
|
||||||
|
String? search,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 50,
|
||||||
|
}) async {
|
||||||
|
final query = <String, dynamic>{
|
||||||
|
'page': page,
|
||||||
|
'pageSize': pageSize,
|
||||||
|
};
|
||||||
|
if (fromUtc != null) query['fromUtc'] = fromUtc.toUtc().toIso8601String();
|
||||||
|
if (toUtc != null) query['toUtc'] = toUtc.toUtc().toIso8601String();
|
||||||
|
if (outcome != null) query['outcome'] = outcome.toApiValue();
|
||||||
|
if (triggerSource != null) query['triggerSource'] = triggerSource.toApiValue();
|
||||||
|
if (search != null && search.trim().isNotEmpty) query['search'] = search.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await apiClient.get('/api/v1/admin/evaluations', queryParameters: query);
|
||||||
|
if (res.data is Map<String, dynamic>) {
|
||||||
|
return EvaluationHistoryResponseModel.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
return EvaluationHistoryResponseModel.empty();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||||
|
throw Exception(title ?? 'Evaluierungs-Historie konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches FinlyticTechnicals' current scan universe ("watchlist") from
|
||||||
|
/// `GET /api/v1/admin/evaluations/watchlist` — the assets actually being
|
||||||
|
/// evaluated every cycle in the background, independent of the (filtered)
|
||||||
|
/// evaluation history above.
|
||||||
|
Future<List<WatchlistEntryModel>> fetchWatchlist() async {
|
||||||
|
try {
|
||||||
|
final res = await apiClient.get('/api/v1/admin/evaluations/watchlist');
|
||||||
|
if (res.data is List) {
|
||||||
|
return (res.data as List).whereType<Map<String, dynamic>>().map(WatchlistEntryModel.fromJson).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||||
|
throw Exception(title ?? 'Watchlist konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the last [limit] technical-analysis setups computed for [isin]
|
||||||
|
/// (most recent first) from `GET /api/v1/admin/evaluations/watchlist/{isin}/history`.
|
||||||
|
Future<List<RecentSetupModel>> fetchWatchlistEntryHistory(String isin, {int limit = 8}) async {
|
||||||
|
try {
|
||||||
|
final res = await apiClient.get(
|
||||||
|
'/api/v1/admin/evaluations/watchlist/${Uri.encodeComponent(isin)}/history',
|
||||||
|
queryParameters: {'limit': limit},
|
||||||
|
);
|
||||||
|
if (res.data is List) {
|
||||||
|
return (res.data as List).whereType<Map<String, dynamic>>().map(RecentSetupModel.fromJson).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||||
|
throw Exception(title ?? 'Score-Verlauf konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||||
|
import '../bloc/admin_evaluation_history_bloc.dart';
|
||||||
|
import '../models/evaluation_history_entry_model.dart';
|
||||||
|
import '../models/evaluation_history_enums.dart';
|
||||||
|
import '../models/evaluation_history_summary_model.dart';
|
||||||
|
import '../repositories/admin_repository.dart';
|
||||||
|
import '../widgets/evaluation_history_filter_bar.dart';
|
||||||
|
import '../widgets/evaluation_history_kpi_header.dart';
|
||||||
|
import '../widgets/evaluation_history_list_item.dart';
|
||||||
|
|
||||||
|
/// Admin-only tab showing the full history of every asset evaluation the
|
||||||
|
/// engine ever ran — approved or not, automatic or manual — so an admin can
|
||||||
|
/// see directly *why* no new trade proposal appeared instead of having to
|
||||||
|
/// query the database by hand. Backed by `GET /api/v1/admin/evaluations`
|
||||||
|
/// (`AdminEvaluationHistoryController`, `[Authorize(Roles = "Admin")]`).
|
||||||
|
///
|
||||||
|
/// This screen is only ever mounted from `ResponsiveScaffold` behind an
|
||||||
|
/// `if (widget.user.isAdmin)` guard, same as the Bot Panel/Backtest/Admin
|
||||||
|
/// Panel tabs — that guard is UX only, not a security boundary. The real
|
||||||
|
/// boundary is the server-side `[Authorize(Roles = "Admin")]`: if a non-admin
|
||||||
|
/// (or an expired-token admin) somehow still reaches this screen, the 401/403
|
||||||
|
/// response is caught by `ApiClient`'s central interceptor, which clears the
|
||||||
|
/// stored token and triggers auto-logout (Rules.md §8) — the bloc below just
|
||||||
|
/// has to not crash on the `AdminEvaluationHistoryError` that results in the
|
||||||
|
/// meantime, which it doesn't (it renders a normal retryable error state).
|
||||||
|
class AdminEvaluationHistoryScreen extends StatelessWidget {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const AdminEvaluationHistoryScreen({super.key, required this.apiClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => AdminEvaluationHistoryBloc(
|
||||||
|
repository: AdminRepository(apiClient: apiClient),
|
||||||
|
)..add(const FetchEvaluationHistory()),
|
||||||
|
child: _AdminEvaluationHistoryScreenContent(apiClient: apiClient),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminEvaluationHistoryScreenContent extends StatefulWidget {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const _AdminEvaluationHistoryScreenContent({required this.apiClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AdminEvaluationHistoryScreenContent> createState() => _AdminEvaluationHistoryScreenContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminEvaluationHistoryScreenContentState extends State<_AdminEvaluationHistoryScreenContent> {
|
||||||
|
static const int _pageSize = 50;
|
||||||
|
|
||||||
|
DateTime? _fromUtc;
|
||||||
|
DateTime? _toUtc;
|
||||||
|
OutcomeReason? _outcome;
|
||||||
|
TriggerSource? _triggerSource;
|
||||||
|
String _search = '';
|
||||||
|
int _page = 1;
|
||||||
|
|
||||||
|
void _fetch() {
|
||||||
|
context.read<AdminEvaluationHistoryBloc>().add(FetchEvaluationHistory(
|
||||||
|
fromUtc: _fromUtc,
|
||||||
|
toUtc: _toUtc,
|
||||||
|
outcome: _outcome,
|
||||||
|
triggerSource: _triggerSource,
|
||||||
|
search: _search,
|
||||||
|
page: _page,
|
||||||
|
pageSize: _pageSize,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFilterChanged({
|
||||||
|
required DateTime? fromUtc,
|
||||||
|
required DateTime? toUtc,
|
||||||
|
required OutcomeReason? outcome,
|
||||||
|
required TriggerSource? triggerSource,
|
||||||
|
required String search,
|
||||||
|
}) {
|
||||||
|
setState(() {
|
||||||
|
_fromUtc = fromUtc;
|
||||||
|
_toUtc = toUtc;
|
||||||
|
_outcome = outcome;
|
||||||
|
_triggerSource = triggerSource;
|
||||||
|
_search = search;
|
||||||
|
_page = 1;
|
||||||
|
});
|
||||||
|
_fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToPage(int page) {
|
||||||
|
setState(() => _page = page);
|
||||||
|
_fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDetail(EvaluationHistoryEntryModel entry) {
|
||||||
|
final approvedLike = entry.outcomeReason == OutcomeReason.approved || entry.passedAiValidation;
|
||||||
|
|
||||||
|
EvaluationScoreBreakdownSheet.show(
|
||||||
|
context,
|
||||||
|
title: '${entry.symbol.isNotEmpty ? entry.symbol : entry.isin} · ${entry.outcomeReason.label}',
|
||||||
|
subtitle: 'Evaluiert am ${_formatFullTimestamp(entry.evaluatedAtUtc)} · Ausgelöst: ${entry.triggerSource.label}.',
|
||||||
|
headerIcon: approvedLike ? Icons.psychology_outlined : Icons.block_outlined,
|
||||||
|
headerColor: approvedLike ? AppTheme.primaryEmerald : entry.outcomeReason.color,
|
||||||
|
compositeScore: entry.compositeOpportunityScore,
|
||||||
|
technicalScore: entry.technicalScore,
|
||||||
|
sentimentScore: entry.sentimentScore,
|
||||||
|
fundamentalScore: entry.fundamentalScore,
|
||||||
|
reliabilityBonus: entry.reliabilityBonus,
|
||||||
|
passedEarningsLockout: entry.passedEarningsLockout,
|
||||||
|
daysToNextEarnings: entry.daysToNextEarnings,
|
||||||
|
passedDividendGate: entry.passedDividendGate,
|
||||||
|
daysToNextExDividend: entry.daysToNextExDividend,
|
||||||
|
universeSourceLabel: entry.universeSource?.label,
|
||||||
|
universeEnteredAtUtc: entry.universeEnteredAtUtc,
|
||||||
|
passedSimulationVeto: entry.passedSimulationVeto,
|
||||||
|
reasoningLabel: entry.passedAiValidation ? 'KI-These' : 'Ablehnungsgrund',
|
||||||
|
reasoningText: entry.aiThesisSummary,
|
||||||
|
footer: entry.hasProposal
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.rocket_launch_outlined, size: 16, color: AppTheme.primaryEmerald),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Aus dieser Analyse entstand ein Trade-Vorschlag (Proposal-ID: ${entry.proposalId}).',
|
||||||
|
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, height: 1.4, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFullTimestamp(DateTime utc) {
|
||||||
|
final local = utc.toLocal();
|
||||||
|
final d = local.day.toString().padLeft(2, '0');
|
||||||
|
final m = local.month.toString().padLeft(2, '0');
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '$d.$m.${local.year} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Evaluierungs-Historie',
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Jede Analyse, jeder Filter, jedes Ergebnis – nachvollziehbar ohne DB-Zugriff.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _fetch,
|
||||||
|
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||||
|
tooltip: 'Neu laden',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: BlocBuilder<AdminEvaluationHistoryBloc, AdminEvaluationHistoryState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final summary = state is AdminEvaluationHistoryLoaded ? state.response.summary : EvaluationHistorySummaryModel.empty();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
EvaluationHistoryKpiHeader(summary: summary, apiClient: widget.apiClient),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
EvaluationHistoryFilterBar(
|
||||||
|
fromUtc: _fromUtc,
|
||||||
|
toUtc: _toUtc,
|
||||||
|
outcome: _outcome,
|
||||||
|
triggerSource: _triggerSource,
|
||||||
|
search: _search,
|
||||||
|
onChanged: _onFilterChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildBody(state),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(AdminEvaluationHistoryState state) {
|
||||||
|
if (state is AdminEvaluationHistoryLoading || state is AdminEvaluationHistoryInitial) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is AdminEvaluationHistoryError) {
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _fetch,
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
|
||||||
|
child: const Text('Erneut versuchen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final loaded = state as AdminEvaluationHistoryLoaded;
|
||||||
|
final entries = loaded.response.entries;
|
||||||
|
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
// Explicit empty state (Rules.md §4) — never a silent blank list, so an
|
||||||
|
// admin who set a narrow filter knows the filter matched nothing rather
|
||||||
|
// than wondering whether the tab itself is broken.
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.inbox_outlined, size: 44, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Keine Analysen im gewählten Zeitraum/Filter gefunden.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Server already returns each page sorted by EvaluatedAtUtc descending
|
||||||
|
// (EvaluationHistoryService.GetHistoryAsync: .OrderByDescending(s =>
|
||||||
|
// s.EvaluatedAtUtc)) — rendered in received order, no client re-sort needed.
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: entries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = entries[index];
|
||||||
|
return EvaluationHistoryListItem(entry: entry, onTap: () => _showDetail(entry));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
loaded.response.totalCount == 0
|
||||||
|
? '0 Einträge'
|
||||||
|
: '${loaded.rangeStart}–${loaded.rangeEnd} von ${loaded.response.totalCount}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: loaded.hasPreviousPage ? () => _goToPage(_page - 1) : null,
|
||||||
|
child: const Text('Zurück'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: loaded.hasNextPage ? () => _goToPage(_page + 1) : null,
|
||||||
|
child: const Text('Weiter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _formatLabel(String key) {
|
String _formatLabel(String key) {
|
||||||
return key
|
// Strip the "Logging.Channel." prefix for display - the section header already says "Logging-Kanäle",
|
||||||
|
// repeating it on every single chip label added visual noise without any extra information.
|
||||||
|
final withoutChannelPrefix = key.startsWith('Logging.Channel.') ? key.substring('Logging.Channel.'.length) : key;
|
||||||
|
|
||||||
|
return withoutChannelPrefix
|
||||||
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
||||||
.replaceAll('Minutes', '(Minuten)')
|
.replaceAll('Minutes', '(Minuten)')
|
||||||
.replaceAll('Seconds', '(Sekunden)')
|
.replaceAll('Seconds', '(Sekunden)')
|
||||||
@@ -143,6 +147,187 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
.replaceAll('Multiplier', 'Multiplikator');
|
.replaceAll('Multiplier', 'Multiplikator');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Groups settings by kind so the settings card reads as organized sections instead of one long,
|
||||||
|
/// unstructured list mixing logging toggles, feature switches, numeric thresholds, and free text together.
|
||||||
|
/// Order is fixed (not alphabetical) so the most-scanned category (logging channels, usually the most
|
||||||
|
/// numerous) sits first.
|
||||||
|
static const List<String> _categoryOrder = ['Logging-Kanäle', 'Umschalter', 'Zahlenwerte', 'Text'];
|
||||||
|
|
||||||
|
String _categoryFor(ServiceSettingDto s) {
|
||||||
|
if (s.key.startsWith('Logging.Channel.')) return 'Logging-Kanäle';
|
||||||
|
|
||||||
|
final type = s.type.toLowerCase();
|
||||||
|
final looksBoolean = type == 'bool' || s.value.toLowerCase() == 'true' || s.value.toLowerCase() == 'false';
|
||||||
|
if (looksBoolean) return 'Umschalter';
|
||||||
|
|
||||||
|
final looksNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||||
|
if (looksNumeric) return 'Zahlenwerte';
|
||||||
|
|
||||||
|
return 'Text';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<ServiceSettingDto>> get _groupedSettings {
|
||||||
|
final groups = <String, List<ServiceSettingDto>>{};
|
||||||
|
for (final s in _settings) {
|
||||||
|
groups.putIfAbsent(_categoryFor(s), () => []).add(s);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title, IconData icon) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 15, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textMuted, letterSpacing: 0.4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact toggle "chip" for a single boolean setting - used for logging channels, which can easily number
|
||||||
|
/// a dozen+ per service, so a full-width `SwitchListTile` per entry (the previous, only, layout for every
|
||||||
|
/// setting regardless of category or count) made the card feel "gequetscht"/cramped and pushed the actually
|
||||||
|
/// important numeric settings far down the page.
|
||||||
|
Widget _buildToggleChip(ServiceSettingDto s, TextEditingController controller) {
|
||||||
|
final boolVal = controller.text.toLowerCase() == 'true';
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: s.description.isNotEmpty ? s.description : _formatLabel(s.key),
|
||||||
|
triggerMode: TooltipTriggerMode.tap,
|
||||||
|
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () => setState(() => controller.text = (!boolVal).toString()),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : AppTheme.glassSurface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.5) : AppTheme.glassBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
boolVal ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: boolVal ? AppTheme.primaryEmerald : AppTheme.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_formatLabel(s.key),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: boolVal ? Colors.white : AppTheme.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSwitchSetting(ServiceSettingDto s, TextEditingController controller) {
|
||||||
|
final boolVal = controller.text.toLowerCase() == 'true';
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.glassSurface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : AppTheme.glassBorder),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(_formatLabel(s.key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
|
subtitle: s.description.isNotEmpty ? Text(s.description, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||||
|
value: boolVal,
|
||||||
|
activeThumbColor: AppTheme.primaryEmerald,
|
||||||
|
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||||
|
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextFieldSetting(ServiceSettingDto s, TextEditingController controller, {required bool isNumeric}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _formatLabel(s.key),
|
||||||
|
helperText: s.description.isNotEmpty ? s.description : null,
|
||||||
|
helperMaxLines: 2,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.primaryEmerald,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildGroupedSettingsSections() {
|
||||||
|
final grouped = _groupedSettings;
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
for (final category in _categoryOrder) {
|
||||||
|
final items = grouped[category];
|
||||||
|
if (items == null || items.isEmpty) continue;
|
||||||
|
|
||||||
|
widgets.add(_buildSectionHeader(
|
||||||
|
'$category (${items.length})',
|
||||||
|
switch (category) {
|
||||||
|
'Logging-Kanäle' => Icons.terminal_rounded,
|
||||||
|
'Umschalter' => Icons.toggle_on_outlined,
|
||||||
|
'Zahlenwerte' => Icons.numbers_outlined,
|
||||||
|
_ => Icons.tune_outlined,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
if (category == 'Logging-Kanäle') {
|
||||||
|
final chips = <Widget>[];
|
||||||
|
for (final s in items) {
|
||||||
|
final controller = _controllers[s.key];
|
||||||
|
if (controller != null) chips.add(_buildToggleChip(s, controller));
|
||||||
|
}
|
||||||
|
widgets.add(Wrap(spacing: 8, runSpacing: 8, children: chips));
|
||||||
|
} else if (category == 'Umschalter') {
|
||||||
|
for (final s in items) {
|
||||||
|
final controller = _controllers[s.key];
|
||||||
|
if (controller != null) widgets.add(_buildSwitchSetting(s, controller));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (final s in items) {
|
||||||
|
final controller = _controllers[s.key];
|
||||||
|
if (controller != null) {
|
||||||
|
widgets.add(_buildTextFieldSetting(s, controller, isNumeric: category == 'Zahlenwerte'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.add(const SizedBox(height: 14));
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -179,66 +364,7 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
if (_settings.isEmpty)
|
if (_settings.isEmpty)
|
||||||
const Text('Keine spezifischen Einstellungen gefunden.')
|
const Text('Keine spezifischen Einstellungen gefunden.')
|
||||||
else
|
else
|
||||||
..._settings.map((s) {
|
..._buildGroupedSettingsSections(),
|
||||||
final key = s.key;
|
|
||||||
final desc = s.description;
|
|
||||||
final type = s.type.toLowerCase();
|
|
||||||
final controller = _controllers[key];
|
|
||||||
if (controller == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
final isBoolean = type == 'bool' ||
|
|
||||||
controller.text.toLowerCase() == 'true' ||
|
|
||||||
controller.text.toLowerCase() == 'false';
|
|
||||||
|
|
||||||
if (isBoolean) {
|
|
||||||
final boolVal = controller.text.toLowerCase() == 'true';
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.glassSurface,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: boolVal
|
|
||||||
? AppTheme.primaryEmerald.withValues(alpha: 0.4)
|
|
||||||
: AppTheme.glassBorder,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(_formatLabel(key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
|
||||||
subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
|
||||||
value: boolVal,
|
|
||||||
activeThumbColor: AppTheme.primaryEmerald,
|
|
||||||
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
|
||||||
onChanged: (val) => setState(() => controller.text = val.toString()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
|
||||||
child: TextField(
|
|
||||||
controller: controller,
|
|
||||||
keyboardType: isNumeric
|
|
||||||
? const TextInputType.numberWithOptions(decimal: true)
|
|
||||||
: TextInputType.text,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: _formatLabel(key),
|
|
||||||
helperText: desc.isNotEmpty ? desc : null,
|
|
||||||
helperMaxLines: 2,
|
|
||||||
prefixIcon: Icon(
|
|
||||||
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
|
||||||
size: 18,
|
|
||||||
color: AppTheme.primaryEmerald,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (_settings.isNotEmpty)
|
if (_settings.isNotEmpty)
|
||||||
@@ -269,18 +395,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
serviceName: widget.serviceName,
|
serviceName: widget.serviceName,
|
||||||
apiClient: widget.apiClient,
|
apiClient: widget.apiClient,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
GlassContainer(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('Statistiken', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Live-Statistiken werden noch implementiert...', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.white54)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../models/evaluation_history_enums.dart';
|
||||||
|
|
||||||
|
/// Full filter snapshot emitted by [EvaluationHistoryFilterBar.onChanged] on
|
||||||
|
/// every single control change — deliberately not a partial/sparse update, so
|
||||||
|
/// there is no ambiguity between "caller didn't touch this field" and "caller
|
||||||
|
/// explicitly cleared this field" on the receiving end.
|
||||||
|
typedef EvaluationHistoryFilterChanged = void Function({
|
||||||
|
required DateTime? fromUtc,
|
||||||
|
required DateTime? toUtc,
|
||||||
|
required OutcomeReason? outcome,
|
||||||
|
required TriggerSource? triggerSource,
|
||||||
|
required String search,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Filter bar for the admin evaluation-history tab: a from/to date range (plain
|
||||||
|
/// `showDatePicker` — a full calendar-range widget is overkill for "roughly which
|
||||||
|
/// days"), an [OutcomeReason] dropdown, a [TriggerSource] dropdown, and an
|
||||||
|
/// ISIN/symbol search field. All four map 1:1 onto the server's optional query
|
||||||
|
/// filters (`fromUtc`/`toUtc`/`outcome`/`triggerSource`/`search`).
|
||||||
|
class EvaluationHistoryFilterBar extends StatefulWidget {
|
||||||
|
final DateTime? fromUtc;
|
||||||
|
final DateTime? toUtc;
|
||||||
|
final OutcomeReason? outcome;
|
||||||
|
final TriggerSource? triggerSource;
|
||||||
|
final String search;
|
||||||
|
final EvaluationHistoryFilterChanged onChanged;
|
||||||
|
|
||||||
|
const EvaluationHistoryFilterBar({
|
||||||
|
super.key,
|
||||||
|
required this.fromUtc,
|
||||||
|
required this.toUtc,
|
||||||
|
required this.outcome,
|
||||||
|
required this.triggerSource,
|
||||||
|
required this.search,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EvaluationHistoryFilterBar> createState() => _EvaluationHistoryFilterBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EvaluationHistoryFilterBarState extends State<EvaluationHistoryFilterBar> {
|
||||||
|
late final TextEditingController _searchController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController = TextEditingController(text: widget.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDate({required bool isFrom}) async {
|
||||||
|
final initial = (isFrom ? widget.fromUtc : widget.toUtc)?.toLocal() ?? DateTime.now();
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: initial,
|
||||||
|
firstDate: DateTime(2020, 1, 1),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
|
||||||
|
if (isFrom) {
|
||||||
|
_emit(fromUtc: DateTime.utc(picked.year, picked.month, picked.day));
|
||||||
|
} else {
|
||||||
|
// Inclusive upper bound on the whole selected day.
|
||||||
|
_emit(toUtc: DateTime.utc(picked.year, picked.month, picked.day, 23, 59, 59));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits the full filter snapshot, overriding only the field(s) that
|
||||||
|
/// actually changed and carrying every other field through unchanged from
|
||||||
|
/// `widget.*` — see [EvaluationHistoryFilterChanged].
|
||||||
|
void _emit({
|
||||||
|
Object? fromUtc = _unset,
|
||||||
|
Object? toUtc = _unset,
|
||||||
|
Object? outcome = _unset,
|
||||||
|
Object? triggerSource = _unset,
|
||||||
|
String? search,
|
||||||
|
}) {
|
||||||
|
widget.onChanged(
|
||||||
|
fromUtc: fromUtc == _unset ? widget.fromUtc : fromUtc as DateTime?,
|
||||||
|
toUtc: toUtc == _unset ? widget.toUtc : toUtc as DateTime?,
|
||||||
|
outcome: outcome == _unset ? widget.outcome : outcome as OutcomeReason?,
|
||||||
|
triggerSource: triggerSource == _unset ? widget.triggerSource : triggerSource as TriggerSource?,
|
||||||
|
search: search ?? widget.search,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime? dt) {
|
||||||
|
if (dt == null) return 'Egal';
|
||||||
|
final local = dt.toLocal();
|
||||||
|
return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onSubmitted: (val) => _emit(search: val),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'ISIN oder Symbol suchen...',
|
||||||
|
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
_emit(search: '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||||
|
onPressed: () => _emit(search: _searchController.text),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_dateChip(label: 'Von: ${_formatDate(widget.fromUtc)}', onTap: () => _pickDate(isFrom: true)),
|
||||||
|
_dateChip(label: 'Bis: ${_formatDate(widget.toUtc)}', onTap: () => _pickDate(isFrom: false)),
|
||||||
|
if (widget.fromUtc != null || widget.toUtc != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _emit(fromUtc: null, toUtc: null),
|
||||||
|
child: const Text('Zeitraum zurücksetzen', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 190,
|
||||||
|
child: DropdownButtonFormField<OutcomeReason?>(
|
||||||
|
initialValue: widget.outcome,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Ergebnis', isDense: true),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<OutcomeReason?>(value: null, child: Text('Alle Ergebnisse')),
|
||||||
|
...OutcomeReason.values.map(
|
||||||
|
(r) => DropdownMenuItem<OutcomeReason?>(value: r, child: Text(r.label)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (val) => _emit(outcome: val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 170,
|
||||||
|
child: DropdownButtonFormField<TriggerSource?>(
|
||||||
|
initialValue: widget.triggerSource,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Ausgelöst durch', isDense: true),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<TriggerSource?>(value: null, child: Text('Alle Quellen')),
|
||||||
|
...TriggerSource.values.map(
|
||||||
|
(t) => DropdownMenuItem<TriggerSource?>(value: t, child: Text(t.label)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (val) => _emit(triggerSource: val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateChip({required String label, required VoidCallback onTap}) {
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: onTap,
|
||||||
|
icon: Icon(Icons.calendar_today_outlined, size: 14, color: AppTheme.textSecondary),
|
||||||
|
label: Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: AppTheme.glassBorder),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel distinguishing "caller of [_EvaluationHistoryFilterBarState._emit]
|
||||||
|
/// didn't touch this field" (default) from "caller explicitly passed `null`"
|
||||||
|
/// (clear this field) — needed because `Object?`'s own null is one of the two
|
||||||
|
/// values this default has to be distinguishable from.
|
||||||
|
const Object _unset = Object();
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/utils/time_utils.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../models/evaluation_history_enums.dart';
|
||||||
|
import '../models/evaluation_history_summary_model.dart';
|
||||||
|
import 'watchlist_card.dart';
|
||||||
|
|
||||||
|
/// Headline KPI row for the admin evaluation-history tab: how many analyses ran
|
||||||
|
/// in the current filter window, the outcome breakdown (this is what makes a
|
||||||
|
/// "why are there no new proposals" question answerable at a glance — e.g. most
|
||||||
|
/// assets sitting in [OutcomeReason.belowScoreThreshold]), the average composite
|
||||||
|
/// score, and how long ago the last trade proposal was actually created.
|
||||||
|
///
|
||||||
|
/// Every number here comes straight from `EvaluationHistorySummaryModel`
|
||||||
|
/// (server-aggregated over the same filtered set as the entry list) — nothing is
|
||||||
|
/// computed client-side from the current page alone (Rules.md §4).
|
||||||
|
class EvaluationHistoryKpiHeader extends StatelessWidget {
|
||||||
|
final EvaluationHistorySummaryModel summary;
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const EvaluationHistoryKpiHeader({super.key, required this.summary, required this.apiClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||||
|
|
||||||
|
final lastProposalText = summary.lastProposalCreatedAtUtc == null
|
||||||
|
? 'Noch nie'
|
||||||
|
: TimeUtils.formatRelativeTime(summary.lastProposalCreatedAtUtc!.toIso8601String());
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
isMobile
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
_kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_kpiCard('Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_kpiCard(
|
||||||
|
'Letzter Vorschlag',
|
||||||
|
lastProposalText,
|
||||||
|
Icons.rocket_launch_outlined,
|
||||||
|
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _kpiCard(
|
||||||
|
'Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _kpiCard(
|
||||||
|
'Letzter Vorschlag',
|
||||||
|
lastProposalText,
|
||||||
|
Icons.rocket_launch_outlined,
|
||||||
|
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
isMobile
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
_buildBreakdownCard(),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
WatchlistCard(apiClient: apiClient),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 2, child: _buildBreakdownCard()),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: WatchlistCard(apiClient: apiClient)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBreakdownCard() {
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('AUFSCHLÜSSELUNG NACH GRUND', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
summary.totalEvaluations == 0
|
||||||
|
? Text('Keine Analysen im gewählten Filter.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12))
|
||||||
|
: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: OutcomeReason.values
|
||||||
|
.map((reason) => _outcomeChip(reason, summary.countFor(reason)))
|
||||||
|
.where((w) => w != null)
|
||||||
|
.cast<Widget>()
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _outcomeChip(OutcomeReason reason, int count) {
|
||||||
|
if (count == 0) return null;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: reason.color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: reason.color.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${reason.label}: $count',
|
||||||
|
style: TextStyle(color: reason.color, fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _kpiCard(String title, String value, IconData icon, Color color) {
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(title, style: TextStyle(fontSize: 11, color: AppTheme.textMuted, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(fontSize: 15, color: AppTheme.textPrimary, fontWeight: FontWeight.bold),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../../../core/widgets/status_badge.dart';
|
||||||
|
import '../models/evaluation_history_entry_model.dart';
|
||||||
|
|
||||||
|
/// One row of the paginated evaluation-history list: symbol/ISIN, timestamp,
|
||||||
|
/// composite score, and color-coded [OutcomeReason]/[TriggerSource] badges.
|
||||||
|
/// Tapping opens the full score/reasoning breakdown via the caller-supplied
|
||||||
|
/// [onTap] (wired to the shared `EvaluationScoreBreakdownSheet` by the screen).
|
||||||
|
class EvaluationHistoryListItem extends StatelessWidget {
|
||||||
|
final EvaluationHistoryEntryModel entry;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const EvaluationHistoryListItem({super.key, required this.entry, required this.onTap});
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime utc) {
|
||||||
|
final local = utc.toLocal();
|
||||||
|
final d = local.day.toString().padLeft(2, '0');
|
||||||
|
final m = local.month.toString().padLeft(2, '0');
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '$d.$m.${local.year} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scoreColor = entry.compositeOpportunityScore >= 70
|
||||||
|
? AppTheme.primaryEmerald
|
||||||
|
: (entry.compositeOpportunityScore >= 40 ? Colors.amber : AppTheme.accentRed);
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
entry.compositeOpportunityScore.toStringAsFixed(0),
|
||||||
|
style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold, fontSize: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.symbol.isNotEmpty ? entry.symbol : entry.isin,
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
if (entry.symbol.isNotEmpty && entry.isin.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
],
|
||||||
|
if (entry.hasProposal) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(Icons.link_rounded, size: 13, color: AppTheme.primaryEmerald),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(_formatTimestamp(entry.evaluatedAtUtc), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
StatusBadge(label: entry.outcomeReason.label, color: entry.outcomeReason.color),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
StatusBadge(label: entry.triggerSource.label, color: entry.triggerSource.color),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,9 +157,33 @@ class _LiveLogConsoleState extends State<LiveLogConsole> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collapses the backend's full `Microsoft.Extensions.Logging.LogLevel` names ("Information", "Warning",
|
||||||
|
/// "Trace", "Critical", ...) down to the 4 short codes the filter chips use ("INFO", "WARN", "DEBUG",
|
||||||
|
/// "ERROR"). The filter previously compared `log.level.toUpperCase()` ("INFORMATION") directly against the
|
||||||
|
/// chip value ("INFO") - which never matched anything but "ALL", so selecting any specific level silently
|
||||||
|
/// hid every log line instead of actually filtering.
|
||||||
|
String _normalizeLevel(String level) {
|
||||||
|
switch (level.toUpperCase()) {
|
||||||
|
case 'INFORMATION':
|
||||||
|
case 'INFO':
|
||||||
|
return 'INFO';
|
||||||
|
case 'WARNING':
|
||||||
|
case 'WARN':
|
||||||
|
return 'WARN';
|
||||||
|
case 'ERROR':
|
||||||
|
case 'CRITICAL':
|
||||||
|
return 'ERROR';
|
||||||
|
case 'DEBUG':
|
||||||
|
case 'TRACE':
|
||||||
|
return 'DEBUG';
|
||||||
|
default:
|
||||||
|
return level.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<LogMessageDto> get _filteredLogs {
|
List<LogMessageDto> get _filteredLogs {
|
||||||
return _logs.where((log) {
|
return _logs.where((log) {
|
||||||
if (_selectedLevel != 'ALL' && log.level.toUpperCase() != _selectedLevel) {
|
if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _selectedLevel) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
|||||||
@@ -67,16 +67,9 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
accentColor: Color(0xFFEC4899),
|
accentColor: Color(0xFFEC4899),
|
||||||
),
|
),
|
||||||
ServiceConfigMeta(
|
ServiceConfigMeta(
|
||||||
key: 'FinlyticAnalyzer',
|
key: 'FinlyticEngine',
|
||||||
displayName: 'Analyzer Signal Engine',
|
displayName: 'Trading Engine',
|
||||||
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
|
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
|
||||||
icon: Icons.analytics_outlined,
|
|
||||||
accentColor: Color(0xFFF59E0B),
|
|
||||||
),
|
|
||||||
ServiceConfigMeta(
|
|
||||||
key: 'FinlyticTrades',
|
|
||||||
displayName: 'Trade Manager',
|
|
||||||
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
|
|
||||||
icon: Icons.candlestick_chart_outlined,
|
icon: Icons.candlestick_chart_outlined,
|
||||||
accentColor: Color(0xFF10B981),
|
accentColor: Color(0xFF10B981),
|
||||||
),
|
),
|
||||||
@@ -87,6 +80,13 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
icon: Icons.corporate_fare_outlined,
|
icon: Icons.corporate_fare_outlined,
|
||||||
accentColor: Color(0xFF06B6D4),
|
accentColor: Color(0xFF06B6D4),
|
||||||
),
|
),
|
||||||
|
ServiceConfigMeta(
|
||||||
|
key: 'FinlyticBot',
|
||||||
|
displayName: 'FinlyticBot (Paper)',
|
||||||
|
description: 'Alpaca Paper Trading, Risikomanagement & Sizing Engine',
|
||||||
|
icon: Icons.smart_toy_outlined,
|
||||||
|
accentColor: Color(0xFF10B981),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final Map<String, Map<String, TextEditingController>> _controllers = {
|
final Map<String, Map<String, TextEditingController>> _controllers = {
|
||||||
@@ -113,24 +113,35 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
'MinConfidenceScore': TextEditingController(text: '0.70'),
|
'MinConfidenceScore': TextEditingController(text: '0.70'),
|
||||||
'MaxBatchSize': TextEditingController(text: '50'),
|
'MaxBatchSize': TextEditingController(text: '50'),
|
||||||
},
|
},
|
||||||
'FinlyticAnalyzer': {
|
'FinlyticEngine': {
|
||||||
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
|
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
|
||||||
'MinSignalScore': TextEditingController(text: '75'),
|
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
|
||||||
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
|
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
|
||||||
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
|
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
|
||||||
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
|
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
|
||||||
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
|
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
|
||||||
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
|
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
|
||||||
},
|
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
|
||||||
'FinlyticTrades': {
|
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
|
||||||
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
|
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
|
||||||
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
|
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
|
||||||
'MaxOpenPositions': TextEditingController(text: '5'),
|
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
|
||||||
},
|
},
|
||||||
'FinlyticFundamentals': {
|
'FinlyticFundamentals': {
|
||||||
'CacheTtlHours': TextEditingController(text: '24'),
|
'CacheTtlHours': TextEditingController(text: '24'),
|
||||||
'EnableYahooFallback': TextEditingController(text: 'true'),
|
'EnableYahooFallback': TextEditingController(text: 'true'),
|
||||||
},
|
},
|
||||||
|
'FinlyticBot': {
|
||||||
|
'Alpaca.KeyId': TextEditingController(text: ''),
|
||||||
|
'Alpaca.SecretKey': TextEditingController(text: ''),
|
||||||
|
'Alpaca.IsPaper': TextEditingController(text: 'true'),
|
||||||
|
'Bot.EnableAutoExecution': TextEditingController(text: 'true'),
|
||||||
|
'Bot.RiskPerTradePercent': TextEditingController(text: '1.0'),
|
||||||
|
'Bot.MaxPositionAllocationPercent': TextEditingController(text: '20.0'),
|
||||||
|
'Bot.MaxConcurrentPositions': TextEditingController(text: '5'),
|
||||||
|
'Bot.DailyLossLimitPercent': TextEditingController(text: '3.0'),
|
||||||
|
'Bot.MonitoringIntervalSeconds': TextEditingController(text: '15'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData _getServiceIcon(String name) {
|
||||||
|
switch (name) {
|
||||||
|
case 'FinlyticBackend':
|
||||||
|
return Icons.hub_outlined;
|
||||||
|
case 'FinlyticAssets':
|
||||||
|
return Icons.inventory_2_outlined;
|
||||||
|
case 'FinlyticNews':
|
||||||
|
return Icons.newspaper_outlined;
|
||||||
|
case 'FinlyticTechnicals':
|
||||||
|
case 'FinlyticTechnicalAnalysis':
|
||||||
|
return Icons.show_chart_outlined;
|
||||||
|
case 'FinlyticSentiment':
|
||||||
|
return Icons.psychology_outlined;
|
||||||
|
case 'FinlyticEngine':
|
||||||
|
case 'FinlyticAnalyzer':
|
||||||
|
case 'FinlyticTrades':
|
||||||
|
return Icons.candlestick_chart_outlined;
|
||||||
|
case 'FinlyticFundamentals':
|
||||||
|
return Icons.corporate_fare_outlined;
|
||||||
|
case 'FinlyticBot':
|
||||||
|
return Icons.smart_toy_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.dns_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final int totalCount = _serviceStatuses.length;
|
final int totalCount = _serviceStatuses.length;
|
||||||
@@ -191,7 +217,7 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
|
_getServiceIcon(name),
|
||||||
size: 18,
|
size: 18,
|
||||||
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../models/recent_setup_model.dart';
|
||||||
|
import '../models/watchlist_entry_model.dart';
|
||||||
|
import '../repositories/admin_repository.dart';
|
||||||
|
|
||||||
|
/// Card sitting next to "AUFSCHLÜSSELUNG NACH GRUND" showing how many assets
|
||||||
|
/// FinlyticTechnicals' background scanner is currently watching. Tapping opens
|
||||||
|
/// a dialog listing every entry — this directly answers "is anything even
|
||||||
|
/// being checked in the background right now", independent of whether any of
|
||||||
|
/// those checks have (yet) produced a proposal-worthy evaluation the engine
|
||||||
|
/// history tab above would show.
|
||||||
|
class WatchlistCard extends StatefulWidget {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const WatchlistCard({super.key, required this.apiClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WatchlistCard> createState() => _WatchlistCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistCardState extends State<WatchlistCard> {
|
||||||
|
late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient);
|
||||||
|
List<WatchlistEntryModel>? _entries;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final entries = await _repository.fetchWatchlist();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_entries = entries;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _WatchlistDialog(repository: _repository, initialEntries: _entries ?? const []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final count = _entries?.length;
|
||||||
|
final value = _error != null ? '—' : (count?.toString() ?? '…');
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
onTap: _showDialog,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('WATCHLIST', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.list_alt_rounded, size: 16, color: AppTheme.accentCyan),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(value, style: TextStyle(fontSize: 22, color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text('überwachte Assets', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 11)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistDialog extends StatefulWidget {
|
||||||
|
final AdminRepository repository;
|
||||||
|
final List<WatchlistEntryModel> initialEntries;
|
||||||
|
|
||||||
|
const _WatchlistDialog({required this.repository, required this.initialEntries});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_WatchlistDialog> createState() => _WatchlistDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistDialogState extends State<_WatchlistDialog> {
|
||||||
|
late List<WatchlistEntryModel> _entries = widget.initialEntries;
|
||||||
|
bool _refreshing = false;
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
setState(() => _refreshing = true);
|
||||||
|
try {
|
||||||
|
final fresh = await widget.repository.fetchWatchlist();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_entries = fresh;
|
||||||
|
_refreshing = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _refreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime utc) {
|
||||||
|
final local = utc.toLocal();
|
||||||
|
final d = local.day.toString().padLeft(2, '0');
|
||||||
|
final m = local.month.toString().padLeft(2, '0');
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '$d.$m.${local.year} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final activeTheme = AppTheme.activePreset;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: activeTheme.cardSurface,
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
|
||||||
|
child: GlassContainer(
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.list_alt_rounded, color: AppTheme.accentCyan, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text('Watchlist (${_entries.length})', style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _refreshing ? null : _refresh,
|
||||||
|
icon: _refreshing
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||||
|
tooltip: 'Neu laden',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Assets, die FinlyticTechnicals derzeit im Hintergrund fortlaufend überprüft. Eintrag antippen für die letzten Bewertungen.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_entries.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(
|
||||||
|
child: Text('Die Watchlist ist derzeit leer.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: _entries.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white12),
|
||||||
|
itemBuilder: (context, index) => _WatchlistEntryTile(
|
||||||
|
entry: _entries[index],
|
||||||
|
repository: widget.repository,
|
||||||
|
formatTimestamp: _formatTimestamp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistEntryTile extends StatefulWidget {
|
||||||
|
final WatchlistEntryModel entry;
|
||||||
|
final AdminRepository repository;
|
||||||
|
final String Function(DateTime) formatTimestamp;
|
||||||
|
|
||||||
|
const _WatchlistEntryTile({required this.entry, required this.repository, required this.formatTimestamp});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_WatchlistEntryTile> createState() => _WatchlistEntryTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistEntryTileState extends State<_WatchlistEntryTile> {
|
||||||
|
List<RecentSetupModel>? _history;
|
||||||
|
bool _loading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
if (_history != null || _loading) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final history = await widget.repository.fetchWatchlistEntryHistory(widget.entry.isin);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_history = history;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entry = widget.entry;
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
|
child: ExpansionTile(
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
|
if (expanded) _loadHistory();
|
||||||
|
},
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.symbol?.isNotEmpty == true ? entry.symbol! : entry.isin,
|
||||||
|
style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (entry.source != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentCyan.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Text(entry.source!.label, style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Seit ${widget.formatTimestamp(entry.addedAtUtc)}'
|
||||||
|
'${entry.expiresAtUtc != null ? ' · Läuft ab ${widget.formatTimestamp(entry.expiresAtUtc!)}' : ''}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _buildHistoryBody(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryBody() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12));
|
||||||
|
}
|
||||||
|
final history = _history ?? const [];
|
||||||
|
if (history.isEmpty) {
|
||||||
|
return Text(
|
||||||
|
'Noch keine technische Bewertung für dieses Asset erfasst.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('LETZTE BEWERTUNGEN', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...history.map((setup) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 90,
|
||||||
|
child: Text(widget.formatTimestamp(setup.createdAt), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(setup.strategyName, style: TextStyle(color: AppTheme.textPrimary, fontSize: 11), overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber).withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
setup.qualityScore.toStringAsFixed(1),
|
||||||
|
style: TextStyle(
|
||||||
|
color: setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../trades/models/trade_model.dart';
|
|
||||||
import 'asset_trades_event.dart';
|
import 'asset_trades_event.dart';
|
||||||
import 'asset_trades_state.dart';
|
import 'asset_trades_state.dart';
|
||||||
import '../../repositories/asset_repository.dart';
|
import '../../repositories/asset_repository.dart';
|
||||||
@@ -20,28 +19,23 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
|||||||
on<TriggerManualAnalysis>((event, emit) async {
|
on<TriggerManualAnalysis>((event, emit) async {
|
||||||
emit(AssetTradesLoading());
|
emit(AssetTradesLoading());
|
||||||
try {
|
try {
|
||||||
final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
// Server contract: always 200 -> AssetEvaluationResultDto, whether the
|
||||||
|
// pipeline produced a proposal or rejected the opportunity. The trade
|
||||||
|
// list itself is unaffected until the user actually accepts a
|
||||||
|
// proposal, so it is simply reloaded as-is; the analysis result is
|
||||||
|
// surfaced separately for the UI to react to exactly once.
|
||||||
|
final result = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||||
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
||||||
|
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
|
||||||
final list = List<TradeModel>.from(existingTrades);
|
|
||||||
final newProposal = analysisRes?.proposal;
|
|
||||||
if (newProposal != null) {
|
|
||||||
final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId));
|
|
||||||
if (!isDuplicate) {
|
|
||||||
list.insert(0, newProposal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit(AssetTradesLoaded(list));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
on<RejectTradeEvent>((event, emit) async {
|
on<DismissTradeEvent>((event, emit) {
|
||||||
try {
|
// Purely local: no server call, see DismissTradeEvent doc comment.
|
||||||
await repository.rejectTrade(event.tradeId);
|
final current = state;
|
||||||
add(LoadAssetTrades(event.isin));
|
if (current is AssetTradesLoaded) {
|
||||||
} catch (e) {
|
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
|
||||||
emit(AssetTradesError("Failed to reject trade: $e"));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
on<AcceptTradeEvent>((event, emit) async {
|
on<AcceptTradeEvent>((event, emit) async {
|
||||||
@@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
|||||||
emit(AssetTradesError("Failed to accept trade: $e"));
|
emit(AssetTradesError("Failed to accept trade: $e"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
on<AddTradeFillEvent>((event, emit) async {
|
||||||
|
try {
|
||||||
|
await repository.addTradeFill(event.tradeId, executedPrice: event.executedPrice, quantity: event.quantity);
|
||||||
|
add(LoadAssetTrades(event.isin));
|
||||||
|
} catch (e) {
|
||||||
|
emit(AssetTradesError("Failed to update trade execution: $e"));
|
||||||
|
}
|
||||||
|
});
|
||||||
on<CloseTradeEvent>((event, emit) async {
|
on<CloseTradeEvent>((event, emit) async {
|
||||||
try {
|
try {
|
||||||
await repository.closeTrade(event.tradeId, event.exitPrice);
|
await repository.closeTrade(event.tradeId, event.exitPrice);
|
||||||
|
|||||||
@@ -13,16 +13,34 @@ class TriggerManualAnalysis extends AssetTradesEvent {
|
|||||||
final ManualAnalysisRequestDto? payload;
|
final ManualAnalysisRequestDto? payload;
|
||||||
TriggerManualAnalysis(this.isin, {this.payload});
|
TriggerManualAnalysis(this.isin, {this.payload});
|
||||||
}
|
}
|
||||||
class RejectTradeEvent extends AssetTradesEvent {
|
/// Dismisses a trade proposal from the locally displayed list only.
|
||||||
|
///
|
||||||
|
/// There is no server-side "reject" anymore: a proposal is a system-wide
|
||||||
|
/// opportunity that any user may accept independently, so rejecting it has
|
||||||
|
/// no server-side meaning. This purely removes the card from the current
|
||||||
|
/// in-memory list; the proposal keeps existing server-side until its 24h
|
||||||
|
/// TTL expires, so it can reappear after the next reload (Rules.md §4 —
|
||||||
|
/// no fabricated "permanently rejected" state is invented).
|
||||||
|
class DismissTradeEvent extends AssetTradesEvent {
|
||||||
final String tradeId;
|
final String tradeId;
|
||||||
final String isin;
|
DismissTradeEvent(this.tradeId);
|
||||||
RejectTradeEvent(this.tradeId, this.isin);
|
|
||||||
}
|
}
|
||||||
class AcceptTradeEvent extends AssetTradesEvent {
|
class AcceptTradeEvent extends AssetTradesEvent {
|
||||||
final TradeAcceptanceDto tradeAcceptanceDto;
|
final TradeAcceptanceDto tradeAcceptanceDto;
|
||||||
final String isin;
|
final String isin;
|
||||||
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
|
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records a corrective/additional fill against an already-active trade
|
||||||
|
/// (review-execution path). Distinct from [AcceptTradeEvent], which targets
|
||||||
|
/// a proposal, not an existing trade — see `AssetRepository.addTradeFill`.
|
||||||
|
class AddTradeFillEvent extends AssetTradesEvent {
|
||||||
|
final String tradeId;
|
||||||
|
final String isin;
|
||||||
|
final double executedPrice;
|
||||||
|
final double quantity;
|
||||||
|
AddTradeFillEvent(this.tradeId, this.isin, this.executedPrice, this.quantity);
|
||||||
|
}
|
||||||
class CloseTradeEvent extends AssetTradesEvent {
|
class CloseTradeEvent extends AssetTradesEvent {
|
||||||
final String tradeId;
|
final String tradeId;
|
||||||
final String isin;
|
final String isin;
|
||||||
|
|||||||
@@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {}
|
|||||||
class AssetTradesLoading extends AssetTradesState {}
|
class AssetTradesLoading extends AssetTradesState {}
|
||||||
class AssetTradesLoaded extends AssetTradesState {
|
class AssetTradesLoaded extends AssetTradesState {
|
||||||
final List<TradeModel> data;
|
final List<TradeModel> data;
|
||||||
AssetTradesLoaded(this.data);
|
|
||||||
|
/// Transient result of a just-triggered manual analysis. Only set on the
|
||||||
|
/// state instance emitted directly by `TriggerManualAnalysis` — a plain
|
||||||
|
/// reload/dismiss/accept emits a fresh `AssetTradesLoaded` without it, so a
|
||||||
|
/// `BlocConsumer` listener naturally reacts to it exactly once instead of
|
||||||
|
/// on every rebuild.
|
||||||
|
///
|
||||||
|
/// Always fully populated when set: the server contract no longer has a
|
||||||
|
/// silent "204, no proposal" outcome, so unlike the old
|
||||||
|
/// `manualAnalysisProposal`/`manualAnalysisEmpty` pair, a single non-null
|
||||||
|
/// value here already tells the caller everything — check
|
||||||
|
/// `manualAnalysisResult!.hasProposal` to distinguish an accepted
|
||||||
|
/// opportunity from a rejected one with real scores/AI reasoning attached
|
||||||
|
/// (Rules.md §4).
|
||||||
|
final AssetEvaluationResultModel? manualAnalysisResult;
|
||||||
|
|
||||||
|
AssetTradesLoaded(this.data, {this.manualAnalysisResult});
|
||||||
}
|
}
|
||||||
class AssetTradesError extends AssetTradesState {
|
class AssetTradesError extends AssetTradesState {
|
||||||
final String message;
|
final String message;
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import '../../trades/models/trade_model.dart';
|
|
||||||
|
|
||||||
class ExecutionPlanModel extends Equatable {
|
|
||||||
final double stopLoss;
|
|
||||||
final List<double> takeProfitTargets;
|
|
||||||
final double riskRewardRatio;
|
|
||||||
final double maxLeverage;
|
|
||||||
|
|
||||||
const ExecutionPlanModel({
|
|
||||||
this.stopLoss = 0.0,
|
|
||||||
this.takeProfitTargets = const [],
|
|
||||||
this.riskRewardRatio = 0.0,
|
|
||||||
this.maxLeverage = 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory ExecutionPlanModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0;
|
|
||||||
return ExecutionPlanModel(
|
|
||||||
stopLoss: parseDbl(json['stopLoss']),
|
|
||||||
takeProfitTargets: (json['takeProfitTargets'] as List<dynamic>? ?? []).map((e) => parseDbl(e)).toList(),
|
|
||||||
riskRewardRatio: parseDbl(json['riskRewardRatio']),
|
|
||||||
maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [stopLoss, takeProfitTargets, riskRewardRatio, maxLeverage];
|
|
||||||
}
|
|
||||||
|
|
||||||
class DetailedAnalysisModel extends Equatable {
|
|
||||||
final String technicalRationale;
|
|
||||||
final String fundamentalRationale;
|
|
||||||
final String riskWarning;
|
|
||||||
|
|
||||||
const DetailedAnalysisModel({
|
|
||||||
this.technicalRationale = '',
|
|
||||||
this.fundamentalRationale = '',
|
|
||||||
this.riskWarning = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
factory DetailedAnalysisModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return DetailedAnalysisModel(
|
|
||||||
technicalRationale: json['technicalRationale']?.toString() ?? '',
|
|
||||||
fundamentalRationale: json['fundamentalRationale']?.toString() ?? '',
|
|
||||||
riskWarning: json['riskWarning']?.toString() ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [technicalRationale, fundamentalRationale, riskWarning];
|
|
||||||
}
|
|
||||||
|
|
||||||
class N8nAnalysisResponseDto extends Equatable {
|
|
||||||
final String aiDecision; // "Proceed", "Rejected", "Hold"
|
|
||||||
final String aiReasoning;
|
|
||||||
final int evalScore;
|
|
||||||
final String suggestedDirection; // "Long", "Short"
|
|
||||||
final String suggestedRisk;
|
|
||||||
final String suggestedTimeframe;
|
|
||||||
final ExecutionPlanModel? executionPlan;
|
|
||||||
final DetailedAnalysisModel? detailedAnalysis;
|
|
||||||
|
|
||||||
const N8nAnalysisResponseDto({
|
|
||||||
this.aiDecision = 'Rejected',
|
|
||||||
this.aiReasoning = '',
|
|
||||||
this.evalScore = 0,
|
|
||||||
this.suggestedDirection = 'Long',
|
|
||||||
this.suggestedRisk = 'Moderate',
|
|
||||||
this.suggestedTimeframe = '1D',
|
|
||||||
this.executionPlan,
|
|
||||||
this.detailedAnalysis,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory N8nAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
|
||||||
ExecutionPlanModel? execPlan;
|
|
||||||
if (json['executionPlan'] != null && json['executionPlan'] is Map<String, dynamic>) {
|
|
||||||
execPlan = ExecutionPlanModel.fromJson(json['executionPlan']);
|
|
||||||
}
|
|
||||||
|
|
||||||
DetailedAnalysisModel? detailAnalysis;
|
|
||||||
if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map<String, dynamic>) {
|
|
||||||
detailAnalysis = DetailedAnalysisModel.fromJson(json['detailedAnalysis']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return N8nAnalysisResponseDto(
|
|
||||||
aiDecision: json['aiDecision']?.toString() ?? 'Rejected',
|
|
||||||
aiReasoning: json['aiReasoning']?.toString() ?? '',
|
|
||||||
evalScore: (json['evalScore'] as num?)?.toInt() ?? 0,
|
|
||||||
suggestedDirection: json['suggestedDirection']?.toString() ?? 'Long',
|
|
||||||
suggestedRisk: json['suggestedRisk']?.toString() ?? 'Moderate',
|
|
||||||
suggestedTimeframe: json['suggestedTimeframe']?.toString() ?? '1D',
|
|
||||||
executionPlan: execPlan,
|
|
||||||
detailedAnalysis: detailAnalysis,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
aiDecision,
|
|
||||||
aiReasoning,
|
|
||||||
evalScore,
|
|
||||||
suggestedDirection,
|
|
||||||
suggestedRisk,
|
|
||||||
suggestedTimeframe,
|
|
||||||
executionPlan,
|
|
||||||
detailedAnalysis,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ManualAnalysisResponseDto extends Equatable {
|
|
||||||
final String analysisId;
|
|
||||||
final bool isTradeProposed;
|
|
||||||
final String status;
|
|
||||||
final String recommendation; // "RECOMMENDED", "NOT_RECOMMENDED"
|
|
||||||
final N8nAnalysisResponseDto? n8nResponse;
|
|
||||||
final TradeModel? proposal;
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
const ManualAnalysisResponseDto({
|
|
||||||
required this.analysisId,
|
|
||||||
this.isTradeProposed = false,
|
|
||||||
this.status = 'Success',
|
|
||||||
this.recommendation = 'NOT_RECOMMENDED',
|
|
||||||
this.n8nResponse,
|
|
||||||
this.proposal,
|
|
||||||
this.message = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
factory ManualAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
|
||||||
N8nAnalysisResponseDto? n8n;
|
|
||||||
if (json['n8nResponse'] != null && json['n8nResponse'] is Map<String, dynamic>) {
|
|
||||||
n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']);
|
|
||||||
}
|
|
||||||
|
|
||||||
TradeModel? prop;
|
|
||||||
if (json['proposal'] != null && json['proposal'] is Map<String, dynamic>) {
|
|
||||||
prop = TradeModel.fromJson(json['proposal']);
|
|
||||||
} else if (n8n != null) {
|
|
||||||
final exec = n8n.executionPlan;
|
|
||||||
final det = n8n.detailedAnalysis;
|
|
||||||
final isProceed = n8n.aiDecision.toLowerCase() == 'proceed';
|
|
||||||
final analysisIdStr = (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '';
|
|
||||||
final tradeIdStr = 'PROP-${analysisIdStr.length > 10 ? analysisIdStr.substring(0, 10).toUpperCase() : 'MANUAL'}';
|
|
||||||
|
|
||||||
prop = TradeModel(
|
|
||||||
id: tradeIdStr,
|
|
||||||
analysisId: analysisIdStr,
|
|
||||||
symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? '',
|
|
||||||
isin: (json['isin'] ?? json['Isin'])?.toString() ?? '',
|
|
||||||
status: isProceed ? 'Proposed' : 'Rejected',
|
|
||||||
signalType: n8n.suggestedDirection.toUpperCase() == 'SHORT' ? 'SELL' : 'BUY',
|
|
||||||
entryPrice: 0.0,
|
|
||||||
stopLoss: exec?.stopLoss ?? 0.0,
|
|
||||||
takeProfit: (exec?.takeProfitTargets.isNotEmpty ?? false) ? exec!.takeProfitTargets.first : 0.0,
|
|
||||||
reasoning: n8n.aiReasoning,
|
|
||||||
technicalRationale: det?.technicalRationale ?? '',
|
|
||||||
fundamentalRationale: det?.fundamentalRationale ?? '',
|
|
||||||
riskWarning: det?.riskWarning ?? '',
|
|
||||||
takeProfitTargets: exec?.takeProfitTargets ?? const [],
|
|
||||||
maxLeverage: exec?.maxLeverage ?? 1.0,
|
|
||||||
riskTolerance: n8n.suggestedRisk,
|
|
||||||
timeframe: n8n.suggestedTimeframe,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ManualAnalysisResponseDto(
|
|
||||||
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
|
|
||||||
isTradeProposed: json['isTradeProposed'] == true || json['IsTradeProposed'] == true,
|
|
||||||
status: (json['status'] ?? json['Status'])?.toString() ?? 'Success',
|
|
||||||
recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'NOT_RECOMMENDED',
|
|
||||||
n8nResponse: n8n,
|
|
||||||
proposal: prop,
|
|
||||||
message: (json['message'] ?? json['Message'])?.toString() ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message];
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import 'package:finlytic_app/core/network/api_client.dart';
|
|||||||
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
|
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
|
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart';
|
|
||||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
|
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
|
||||||
@@ -102,23 +101,47 @@ class AssetRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<double?> getLivePrice(String isin) async {
|
||||||
|
try {
|
||||||
|
final res = await apiClient.get('/api/v1/assets/$isin/live');
|
||||||
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
|
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
if (val != null) return double.tryParse(val.toString());
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||||
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
/// Triggers an on-demand manual analysis for [isin] via `POST /api/v1/analyze/manual`.
|
||||||
|
///
|
||||||
|
/// Server contract: always `200 OK` with a full `AssetEvaluationResultDto`
|
||||||
|
/// body — even when the analysis ran but did not clear the bar for a trade
|
||||||
|
/// proposal (`AssetEvaluationResultModel.proposal == null`), the response
|
||||||
|
/// still carries the real, already-computed scores and AI reasoning, so
|
||||||
|
/// there is no more silent `204 No Content` outcome to handle here
|
||||||
|
/// (Rules.md §4). A non-2xx status (missing ISIN, engine unreachable, no
|
||||||
|
/// RPC response, unexpected error) surfaces as a `DioException` that
|
||||||
|
/// propagates to the caller instead of being swallowed into `null`.
|
||||||
|
Future<AssetEvaluationResultModel> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||||
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
if (res.data != null && res.data is Map<String, dynamic>) {
|
||||||
return ManualAnalysisResponseDto.fromJson(res.data);
|
return AssetEvaluationResultModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
return null;
|
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
|
|
||||||
|
|
||||||
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
||||||
|
|
||||||
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
||||||
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
||||||
|
|
||||||
|
Future<TradeModel> addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async =>
|
||||||
|
_tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.dart';
|
import '../../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
|
import '../../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||||
|
import '../../../bot/repositories/bot_repository.dart';
|
||||||
|
import '../../../proposals/views/proposal_decision_screen.dart';
|
||||||
import '../../../trades/models/trade_model.dart';
|
import '../../../trades/models/trade_model.dart';
|
||||||
import '../../../trades/widgets/trade_execution_cockpit.dart';
|
import '../../../trades/widgets/trade_execution_cockpit.dart';
|
||||||
import '../../../trades/widgets/trade_closing_cockpit.dart';
|
import '../../../trades/widgets/trade_closing_cockpit.dart';
|
||||||
@@ -22,11 +26,12 @@ class TradesTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TradesTabState extends State<TradesTab> {
|
class _TradesTabState extends State<TradesTab> {
|
||||||
bool _justTriggeredAnalysis = false;
|
late final BotRepository _botRepository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +44,23 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
defaultSymbol: widget.symbol,
|
defaultSymbol: widget.symbol,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
onAccept: (dto) {
|
onAccept: (dto) {
|
||||||
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
|
|
||||||
final tId = trade.id;
|
final tId = trade.id;
|
||||||
|
// `TradeExecutionCockpit._buildDto()` already picks the right identifier
|
||||||
|
// (trade.id for isActive, trade.proposalId otherwise) and always fills
|
||||||
|
// actualEntryPrice/quantity from the two fields the dialog actually
|
||||||
|
// collects — but the two identifiers target different server-side
|
||||||
|
// operations: accepting a *proposal* vs. recording a fill against an
|
||||||
|
// already-*existing* trade (`UserTradesController.AcceptTrade` looks
|
||||||
|
// `dto.tradeId` up as a proposal id, which fails for an active trade's
|
||||||
|
// own id). Route accordingly instead of always calling AcceptTradeEvent.
|
||||||
|
if (isActive) {
|
||||||
|
final price = dto.actualEntryPrice ?? dto.entryPrice;
|
||||||
|
final qty = dto.quantity ?? dto.positionSize;
|
||||||
|
if (price == null || qty == null) return;
|
||||||
|
tradesBloc.add(AddTradeFillEvent(tId, widget.symbol, price, qty));
|
||||||
|
} else {
|
||||||
|
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
|
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
|
||||||
@@ -50,10 +70,15 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onReject: (tId) {
|
onReject: (tId) {
|
||||||
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
|
// Purely local dismissal — there is no server-side rejection (a
|
||||||
|
// proposal is a system-wide opportunity anyone may still accept).
|
||||||
|
// Wording must not claim a permanence the backend doesn't provide.
|
||||||
|
tradesBloc.add(DismissTradeEvent(tId));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Trade $tId abgelehnt.'),
|
content: const Text(
|
||||||
|
'Vorschlag ausgeblendet – er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
|
||||||
|
),
|
||||||
backgroundColor: AppTheme.textSecondary,
|
backgroundColor: AppTheme.textSecondary,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -62,20 +87,101 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _executeProposalViaBot(BuildContext context, TradeProposalModel proposal) async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
try {
|
||||||
|
await _botRepository.executeProposal(proposal.proposalId);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
|
||||||
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Fehler bei der Bot-Übergabe: $e'),
|
||||||
|
backgroundColor: AppTheme.accentRed,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showProposalDecision(BuildContext context, TradeProposalModel proposal) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ProposalDecisionScreen(
|
||||||
|
proposal: proposal,
|
||||||
|
onExecuteBot: () => _executeProposalViaBot(context, proposal),
|
||||||
|
onManualTrade: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the real, already-computed score breakdown and AI reasoning for a
|
||||||
|
/// manual analysis that ran but did not produce a trade proposal
|
||||||
|
/// ([AssetEvaluationResultModel.proposal] is `null`). Replaces the old bare
|
||||||
|
/// "kein Vorschlag" snackbar: the user gets to see *why* the opportunity
|
||||||
|
/// was rejected, not just *that* it was (Rules.md §4). Every value shown
|
||||||
|
/// here comes straight from the server response — nothing is invented, and
|
||||||
|
/// [AssetEvaluationResultModel.daysToNextEarnings] is only rendered when
|
||||||
|
/// the server actually sent a value.
|
||||||
|
void _showEvaluationRejectedSheet(BuildContext context, AssetEvaluationResultModel result) {
|
||||||
|
EvaluationScoreBreakdownSheet.show(
|
||||||
|
context,
|
||||||
|
title: 'Analyse abgeschlossen – kein Vorschlag',
|
||||||
|
subtitle:
|
||||||
|
'Für ${widget.symbol} wurde keine aktive Trade-Empfehlung erzeugt. Die berechneten Werte und die KI-Begründung stehen unten.',
|
||||||
|
headerIcon: result.aiApproved ? Icons.psychology_outlined : Icons.block_outlined,
|
||||||
|
headerColor: result.aiApproved ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
compositeScore: result.compositeScore,
|
||||||
|
technicalScore: result.technicalScore,
|
||||||
|
sentimentScore: result.sentimentScore,
|
||||||
|
fundamentalScore: result.fundamentalScore,
|
||||||
|
passedEarningsLockout: result.passedEarningsLockout,
|
||||||
|
daysToNextEarnings: result.daysToNextEarnings,
|
||||||
|
passedDividendGate: result.passedDividendGate,
|
||||||
|
daysToNextExDividend: result.daysToNextExDividend,
|
||||||
|
reasoningLabel: result.aiApproved ? 'KI-These' : 'Ablehnungsgrund',
|
||||||
|
reasoningText: result.aiThesisSummary,
|
||||||
|
identifiedRisks: result.aiIdentifiedRisks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
|
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
|
if (state is! AssetTradesLoaded) return;
|
||||||
final List<TradeModel> tradesList = state.data;
|
|
||||||
if (tradesList.isNotEmpty) {
|
final result = state.manualAnalysisResult;
|
||||||
_justTriggeredAnalysis = false;
|
if (result == null) return;
|
||||||
final latestTrade = tradesList.first;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_showEditTradeExecutionDialog(context, latestTrade);
|
if (!mounted) return;
|
||||||
|
if (result.hasProposal) {
|
||||||
|
_showProposalDecision(context, result.proposal!);
|
||||||
|
} else {
|
||||||
|
// Rejected (or no technical setup at all) - show the real, already
|
||||||
|
// computed scores and AI reasoning instead of a bare "no proposal"
|
||||||
|
// snackbar, so the user understands *why*, not just *that*
|
||||||
|
// (Rules.md §4).
|
||||||
|
_showEvaluationRejectedSheet(context, result);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
|
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
|
||||||
@@ -117,11 +223,10 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
symbol: widget.symbol,
|
symbol: widget.symbol,
|
||||||
initialRiskScore: 50.0,
|
initialRiskScore: 50.0,
|
||||||
onTrigger: (payload) {
|
onTrigger: (payload) {
|
||||||
setState(() => _justTriggeredAnalysis = true);
|
|
||||||
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
|
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'),
|
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
|
||||||
backgroundColor: AppTheme.accentCyan,
|
backgroundColor: AppTheme.accentCyan,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -153,18 +258,12 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
else if (state is AssetTradesLoaded) ...[
|
else if (state is AssetTradesLoaded) ...[
|
||||||
_buildTradeList(
|
_buildTradeList(
|
||||||
'Aktive Trade-Signale & Positionen',
|
'Aktive Trade-Signale & Positionen',
|
||||||
tradesList.where((t) {
|
tradesList.where((t) => t.isActive || t.isProposed).toList(),
|
||||||
final s = t.status.toUpperCase();
|
|
||||||
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildTradeList(
|
_buildTradeList(
|
||||||
'Historische Trades & KI-Bewertungen',
|
'Historische Trades & KI-Bewertungen',
|
||||||
tradesList.where((t) {
|
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
|
||||||
final s = t.status.toUpperCase();
|
|
||||||
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -208,8 +307,7 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
itemCount: trades.length,
|
itemCount: trades.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final trade = trades[index];
|
final trade = trades[index];
|
||||||
final s = trade.status.toUpperCase();
|
final isActive = trade.isActive;
|
||||||
final isActive = s == 'ACTIVE';
|
|
||||||
|
|
||||||
return AssetTradeItemCard(
|
return AssetTradeItemCard(
|
||||||
trade: trade,
|
trade: trade,
|
||||||
@@ -223,7 +321,7 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
trade: trade,
|
trade: trade,
|
||||||
defaultSymbol: widget.symbol,
|
defaultSymbol: widget.symbol,
|
||||||
onClose: (dto) {
|
onClose: (dto) {
|
||||||
final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
|
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
|
||||||
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
|
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import '../../../../core/widgets/glass_container.dart';
|
|||||||
import '../../../../core/widgets/status_badge.dart';
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
import '../../../trades/models/trade_model.dart';
|
import '../../../trades/models/trade_model.dart';
|
||||||
|
|
||||||
|
/// Trade summary card for the asset-detail "Trades" tab.
|
||||||
|
///
|
||||||
|
/// Migrated onto `ActiveTradeDto` (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
|
||||||
|
/// A number of fields this card used to show no longer exist server-side at
|
||||||
|
/// all (reasoning/technicalRationale/fundamentalRationale/riskWarning,
|
||||||
|
/// hasPendingExitAlert/pendingExitReason, entryZoneMin/Max, maxLeverage,
|
||||||
|
/// timeframe/riskTolerance/companyName, closeReason) — those sections were
|
||||||
|
/// removed rather than kept alive showing an empty/zero placeholder
|
||||||
|
/// (Rules.md §4).
|
||||||
class AssetTradeItemCard extends StatelessWidget {
|
class AssetTradeItemCard extends StatelessWidget {
|
||||||
final TradeModel trade;
|
final TradeModel trade;
|
||||||
final String defaultSymbol;
|
final String defaultSymbol;
|
||||||
@@ -20,43 +29,42 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
this.onClose,
|
this.onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _fmt(dynamic val) {
|
String _fmt(double val) => val.toStringAsFixed(2);
|
||||||
if (val == null) return 'N/A';
|
|
||||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
|
||||||
return n != null ? n.toStringAsFixed(2) : val.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol;
|
final isin = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : defaultSymbol;
|
||||||
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
|
final isBuy = trade.direction.isLong;
|
||||||
final status = trade.status.toUpperCase();
|
final isActive = trade.isActive;
|
||||||
final isBuy = side == 'BUY' || side == 'LONG';
|
|
||||||
final isActive = status == 'ACTIVE';
|
|
||||||
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
|
||||||
final entryZoneMin = trade.entryZoneMin;
|
// Entry price: the real fill-weighted average the engine already
|
||||||
final entryZoneMax = trade.entryZoneMax;
|
// computed, not a planned/target zone (that concept no longer exists
|
||||||
final entryPrice = trade.entryPrice;
|
// server-side).
|
||||||
final stopLoss = trade.stopLoss;
|
final entryPrice = trade.averageBuyIn;
|
||||||
final takeProfit = trade.takeProfit;
|
|
||||||
final takeProfitTargets = trade.takeProfitTargets;
|
|
||||||
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0)
|
|
||||||
? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2)
|
|
||||||
: null;
|
|
||||||
final maxLeverage = trade.maxLeverage;
|
|
||||||
|
|
||||||
final actualEntry = trade.actualEntryPrice;
|
// Live protective stop: `currentStopLoss` (not `initialStopLoss`) is
|
||||||
final posSize = trade.positionSize;
|
// used here because this card shows the trade's live state — the
|
||||||
final levUsed = trade.leverageUsed;
|
// current stop already reflects any break-even/trailing adjustment the
|
||||||
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
|
// engine has made. `initialStopLoss` (the original plan value) is only
|
||||||
final entryFee = trade.entryFee;
|
// relevant historically and is shown in the trade detail view instead.
|
||||||
final exitFee = trade.exitFee;
|
final stopLoss = trade.currentStopLoss;
|
||||||
|
|
||||||
final reasoning = trade.reasoning;
|
final tpStages = trade.exitPlan.takeProfitStages;
|
||||||
final techRationale = trade.technicalRationale;
|
// Server-computed reward:risk multiple for the first take-profit stage —
|
||||||
final fundRationale = trade.fundamentalRationale;
|
// used instead of a client-side recomputation from raw prices.
|
||||||
final riskWarning = trade.riskWarning;
|
final primaryRMultiple = tpStages.isNotEmpty ? tpStages.first.rMultiple : null;
|
||||||
|
|
||||||
|
final investedCapital = entryPrice > 0 && trade.totalQuantity > 0 ? entryPrice * trade.totalQuantity : null;
|
||||||
|
|
||||||
|
// Never recomputed from raw prices client-side — always the server's
|
||||||
|
// own figure (realized once resolved, otherwise its live unrealized
|
||||||
|
// value; see `TradeModel.pnlEur`).
|
||||||
|
final pnlEur = trade.pnlEur;
|
||||||
|
final pnlPercent = trade.unrealizedPnlPercent;
|
||||||
|
final isPnlWin = pnlEur >= 0;
|
||||||
|
final pnlColor = isPnlWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
final showPnl = isActive || trade.isClosed;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
@@ -71,14 +79,13 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
StatusBadge(label: side, color: sideColor),
|
StatusBadge(label: trade.direction.label, color: sideColor),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
label: status,
|
label: trade.status.label,
|
||||||
color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted),
|
color: isActive ? AppTheme.primaryEmerald : (trade.isProposed ? AppTheme.accentCyan : AppTheme.textMuted),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (trade.instrumentType.isNotEmpty)
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -86,7 +93,9 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
trade.derivativeIsin.isNotEmpty ? '${trade.instrumentType} (${trade.derivativeIsin})' : trade.instrumentType,
|
trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty
|
||||||
|
? '${trade.instrumentType.label} (${trade.derivativeIsin})'
|
||||||
|
: trade.instrumentType.label,
|
||||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
|
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -120,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
|
] else if (trade.isProposed) ...[
|
||||||
if (onAccept != null)
|
if (onAccept != null)
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: onAccept,
|
onPressed: onAccept,
|
||||||
@@ -146,55 +155,14 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
_buildDriftRadarBar(trade),
|
_buildDriftRadarBar(trade),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Pending Exit Alert Banner
|
|
||||||
if (isActive && trade.hasPendingExitAlert) ...[
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('KI-Guardian Ratschlag: Position schließen!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12)),
|
|
||||||
if (trade.pendingExitReason.isNotEmpty)
|
|
||||||
Text(trade.pendingExitReason, style: const TextStyle(color: Colors.white70, fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onClose != null)
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: onClose,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.accentRed,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}',
|
'${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol} ($isin)',
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Target Price Metrics Grid
|
// Price Metrics Grid
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -207,22 +175,26 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white),
|
_buildTradeStat('Einstiegskurs', '€${_fmt(entryPrice)}', Colors.white),
|
||||||
_buildTradeStat(
|
_buildTradeStat(
|
||||||
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||||
'€${_fmt(stopLoss)}',
|
'€${_fmt(stopLoss)}',
|
||||||
AppTheme.accentRed,
|
AppTheme.accentRed,
|
||||||
),
|
),
|
||||||
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '€${_fmt(t)}').join(' / ') : '€${_fmt(takeProfit)}', AppTheme.primaryEmerald),
|
_buildTradeStat(
|
||||||
|
'Take-Profit',
|
||||||
|
tpStages.isNotEmpty ? tpStages.map((s) => '€${_fmt(s.targetPrice)}').join(' / ') : 'Kein Fixziel (Trailing-Exit)',
|
||||||
|
AppTheme.primaryEmerald,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (crv != null || maxLeverage > 0) ...[
|
if (primaryRMultiple != null) ...[
|
||||||
const Divider(color: Colors.white12, height: 16),
|
const Divider(color: Colors.white10, height: 16),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
|
_buildTradeStat('Chance-Risiko (TP1, R-Multiple)', '${_fmt(primaryRMultiple)}R', AppTheme.accentCyan),
|
||||||
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
|
if (investedCapital != null) _buildTradeStat('Eingesetztes Kapital', '€${_fmt(investedCapital)}', Colors.white70),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -230,8 +202,8 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Execution Details if active
|
// Position size / quantity
|
||||||
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
|
if (trade.totalQuantity > 0) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -240,84 +212,62 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
|
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
|
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
|
Expanded(
|
||||||
],
|
child: Text(
|
||||||
|
'Stückzahl: ${_fmt(trade.totalQuantity)}${trade.isDerivative ? ' (Derivat)' : ''}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
_buildTradeStat('Tatsächl. Einstieg', '€${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
|
|
||||||
_buildTradeStat('Investition', posSize > 0 ? '€${_fmt(posSize)}' : 'N/A', Colors.white),
|
|
||||||
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
|
|
||||||
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
if (entryFee > 0 || exitFee > 0) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Realized PnL if closed
|
// PnL (server-computed, never recalculated client-side)
|
||||||
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
|
if (showPnl) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Builder(
|
Container(
|
||||||
builder: (context) {
|
|
||||||
final pnlVal = trade.calculatedPnlAbs;
|
|
||||||
final pnlPctVal = trade.calculatedPnlPct;
|
|
||||||
final isWin = pnlVal >= 0;
|
|
||||||
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.12),
|
color: pnlColor.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: color),
|
border: Border.all(color: pnlColor),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color),
|
Icon(isPnlWin ? Icons.trending_up : Icons.trending_down, size: 16, color: pnlColor),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
Text(
|
||||||
|
trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL (unrealisiert):',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '€${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white),
|
_buildTradeStat('Aktueller Kurs', '€${_fmt(trade.currentPrice)}', Colors.white),
|
||||||
_buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color),
|
_buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor),
|
||||||
_buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed),
|
_buildTradeStat('PnL (%)', '${pnlPercent >= 0 ? "+" : ""}${_fmt(pnlPercent)}%', pnlColor),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (trade.closeReason.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text('Grund: ${trade.closeReason}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// KI Timeline Expansion
|
// Execution history (replaces the removed AI-Guardian hourly
|
||||||
if (trade.hourlyUpdates.isNotEmpty) ...[
|
// check-in timeline, which no backend DTO produces anymore —
|
||||||
|
// this is the trade's real fill history instead).
|
||||||
|
if (trade.fills.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ExpansionTile(
|
ExpansionTile(
|
||||||
tilePadding: EdgeInsets.zero,
|
tilePadding: EdgeInsets.zero,
|
||||||
@@ -325,10 +275,10 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
|
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
|
||||||
title: Text(
|
title: Text(
|
||||||
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
|
'Ausführungshistorie (${trade.fills.length} Fills)',
|
||||||
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
children: trade.hourlyUpdates.reversed.take(4).map((u) {
|
children: trade.fills.reversed.map((f) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -339,62 +289,27 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
'${f.executedAtUtc.day.toString().padLeft(2, '0')}.${f.executedAtUtc.month.toString().padLeft(2, '0')} '
|
||||||
|
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: (u.recommendation.toLowerCase().contains('close')
|
|
||||||
? AppTheme.accentRed
|
|
||||||
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
|
||||||
.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(u.recommendation, style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
|
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' (Gebühr €${_fmt(f.fee)})' : ''}',
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (f.note != null && f.note!.isNotEmpty)
|
||||||
|
Text(f.note!, style: TextStyle(color: AppTheme.textMuted, fontSize: 10, fontStyle: FontStyle.italic)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// AI Analysis Expansion
|
|
||||||
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ExpansionTile(
|
|
||||||
tilePadding: EdgeInsets.zero,
|
|
||||||
childrenPadding: EdgeInsets.zero,
|
|
||||||
dense: true,
|
|
||||||
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
|
|
||||||
children: [
|
|
||||||
if (reasoning.isNotEmpty) ...[
|
|
||||||
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
if (techRationale.isNotEmpty) ...[
|
|
||||||
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
if (fundRationale.isNotEmpty) ...[
|
|
||||||
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
if (riskWarning.isNotEmpty) _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -407,11 +322,6 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
IconData icon;
|
IconData icon;
|
||||||
|
|
||||||
switch (t.driftStatus) {
|
switch (t.driftStatus) {
|
||||||
case DriftStatus.exitAlert:
|
|
||||||
col = AppTheme.accentRed;
|
|
||||||
label = 'Drift-Radar: Ausstieg empfohlen';
|
|
||||||
icon = Icons.warning_rounded;
|
|
||||||
break;
|
|
||||||
case DriftStatus.trailingActive:
|
case DriftStatus.trailingActive:
|
||||||
col = AppTheme.accentCyan;
|
col = AppTheme.accentCyan;
|
||||||
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
|
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
|
||||||
@@ -424,7 +334,7 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
break;
|
break;
|
||||||
case DriftStatus.onTrack:
|
case DriftStatus.onTrack:
|
||||||
col = AppTheme.primaryEmerald;
|
col = AppTheme.primaryEmerald;
|
||||||
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
|
label = 'Drift-Radar: Prognose intakt';
|
||||||
icon = Icons.radar;
|
icon = Icons.radar;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -458,16 +368,4 @@ class AssetTradeItemCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRationaleBlock(String title, String text, Color col) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class CloseTradeDialog {
|
|||||||
required String defaultSymbol,
|
required String defaultSymbol,
|
||||||
required void Function(CloseTradeRequestDto) onClose,
|
required void Function(CloseTradeRequestDto) onClose,
|
||||||
}) {
|
}) {
|
||||||
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
|
final entry = trade.averageBuyIn;
|
||||||
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
|
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -37,14 +37,20 @@ class CloseTradeDialog {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
Text(
|
||||||
|
(trade.derivativeIsin?.isNotEmpty ?? false)
|
||||||
|
? 'Trade-ID: ${trade.id} | Derivat: ${trade.derivativeIsin} (${trade.instrumentType.label}) | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}'
|
||||||
|
: 'Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
|
||||||
|
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: exitController,
|
controller: exitController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Tatsächlicher Ausstiegskurs (€)',
|
labelText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Derivat-Verkaufskurs (€)' : 'Tatsächlicher Ausstiegskurs (€)',
|
||||||
hintText: 'Z.B. 105.50',
|
hintText: 'Gekauft zu €${entry.toStringAsFixed(2)}',
|
||||||
|
helperText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Gib den Verkaufskurs des Derivats/Zertifikats ein' : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
super(AuthInitial()) {
|
super(AuthInitial()) {
|
||||||
on<CheckAuthStatus>(_onCheckAuthStatus);
|
on<CheckAuthStatus>(_onCheckAuthStatus);
|
||||||
on<LoginRequested>(_onLoginRequested);
|
on<LoginRequested>(_onLoginRequested);
|
||||||
on<RegisterRequested>(_onRegisterRequested);
|
|
||||||
on<LogoutRequested>(_onLogoutRequested);
|
on<LogoutRequested>(_onLogoutRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +43,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRegisterRequested(RegisterRequested event, Emitter<AuthState> emit) async {
|
|
||||||
emit(AuthLoading());
|
|
||||||
try {
|
|
||||||
final user = await repository.register(event.email, event.password, event.fullName);
|
|
||||||
emit(Authenticated(user));
|
|
||||||
} catch (e) {
|
|
||||||
emit(AuthFailure(e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onLogoutRequested(LogoutRequested event, Emitter<AuthState> emit) async {
|
Future<void> _onLogoutRequested(LogoutRequested event, Emitter<AuthState> emit) async {
|
||||||
await repository.logout();
|
await repository.logout();
|
||||||
emit(Unauthenticated());
|
emit(Unauthenticated());
|
||||||
|
|||||||
@@ -19,15 +19,4 @@ class LoginRequested extends AuthEvent {
|
|||||||
List<Object?> get props => [email, password];
|
List<Object?> get props => [email, password];
|
||||||
}
|
}
|
||||||
|
|
||||||
class RegisterRequested extends AuthEvent {
|
|
||||||
final String email;
|
|
||||||
final String password;
|
|
||||||
final String fullName;
|
|
||||||
|
|
||||||
const RegisterRequested(this.email, this.password, this.fullName);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [email, password, fullName];
|
|
||||||
}
|
|
||||||
|
|
||||||
class LogoutRequested extends AuthEvent {}
|
class LogoutRequested extends AuthEvent {}
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ class AuthRepository {
|
|||||||
'password': password,
|
'password': password,
|
||||||
});
|
});
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
if (res.statusCode == 200 && res.data != null) {
|
||||||
|
final token = res.data['token']?.toString() ?? '';
|
||||||
|
|
||||||
if (res.data['requiresPasswordChange'] == true) {
|
if (res.data['requiresPasswordChange'] == true) {
|
||||||
|
// The backend already issues a valid JWT even when a password change is required, so it must be
|
||||||
|
// persisted here: the subsequent change-initial-password call is an [Authorize]-protected endpoint
|
||||||
|
// and has no other way to authenticate itself.
|
||||||
|
await storageService.saveToken(token);
|
||||||
throw RequiresPasswordChangeException(res.data['userId']?.toString() ?? '');
|
throw RequiresPasswordChangeException(res.data['userId']?.toString() ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
final token = res.data['token']?.toString() ?? '';
|
|
||||||
final user = UserModel.fromJson(res.data, token: token);
|
final user = UserModel.fromJson(res.data, token: token);
|
||||||
await storageService.saveToken(token);
|
await storageService.saveToken(token);
|
||||||
await storageService.saveUserEmail(user.email);
|
await storageService.saveUserEmail(user.email);
|
||||||
@@ -50,23 +55,6 @@ class AuthRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UserModel> register(String email, String password, String fullName) async {
|
|
||||||
final res = await apiClient.post('/api/v1/auth/register', data: {
|
|
||||||
'email': email,
|
|
||||||
'password': password,
|
|
||||||
'fullName': fullName,
|
|
||||||
});
|
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
|
||||||
final token = res.data['token']?.toString() ?? '';
|
|
||||||
final user = UserModel.fromJson(res.data, token: token);
|
|
||||||
await storageService.saveToken(token);
|
|
||||||
await storageService.saveUserEmail(user.email);
|
|
||||||
return user;
|
|
||||||
} else {
|
|
||||||
throw Exception('Registrierung fehlgeschlagen');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> changeInitialPassword(String userId, String newPassword) async {
|
Future<bool> changeInitialPassword(String userId, String newPassword) async {
|
||||||
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
|
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
|
|||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _confirmPasswordController = TextEditingController();
|
final _confirmPasswordController = TextEditingController();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
|
||||||
void _onChangePassword() async {
|
void _onChangePassword() async {
|
||||||
if (_formKey.currentState?.validate() ?? false) {
|
if (_formKey.currentState?.validate() ?? false) {
|
||||||
@@ -96,15 +98,29 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: _obscurePassword,
|
||||||
decoration: const InputDecoration(labelText: 'Neues Passwort', prefixIcon: Icon(Icons.lock)),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Neues Passwort',
|
||||||
|
prefixIcon: const Icon(Icons.lock),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||||||
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||||
|
),
|
||||||
|
),
|
||||||
validator: (v) => v == null || v.length < 6 ? 'Passwort muss mindestens 6 Zeichen lang sein' : null,
|
validator: (v) => v == null || v.length < 6 ? 'Passwort muss mindestens 6 Zeichen lang sein' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _confirmPasswordController,
|
controller: _confirmPasswordController,
|
||||||
obscureText: true,
|
obscureText: _obscureConfirmPassword,
|
||||||
decoration: const InputDecoration(labelText: 'Passwort bestätigen', prefixIcon: Icon(Icons.lock_outline)),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Passwort bestätigen',
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_obscureConfirmPassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||||||
|
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||||
|
),
|
||||||
|
),
|
||||||
validator: (v) => v != _passwordController.text ? 'Passwörter stimmen nicht überein' : null,
|
validator: (v) => v != _passwordController.text ? 'Passwörter stimmen nicht überein' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ class LoginScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController(text: 'admin@finlytic.com');
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController(text: 'AdminDefaultPassword2026!');
|
final _passwordController = TextEditingController();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _onLogin() {
|
void _onLogin() {
|
||||||
if (_formKey.currentState?.validate() ?? false) {
|
if (_formKey.currentState?.validate() ?? false) {
|
||||||
@@ -68,8 +76,15 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: _obscurePassword,
|
||||||
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Passwort',
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined),
|
||||||
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||||
|
),
|
||||||
|
),
|
||||||
validator: (v) => v == null || v.isEmpty ? 'Passwort erforderlich' : null,
|
validator: (v) => v == null || v.isEmpty ? 'Passwort erforderlich' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -93,6 +108,12 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
"Konten werden ausschließlich vom Administrator angelegt.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import '../../../core/theme/app_theme.dart';
|
|
||||||
import '../bloc/auth_bloc.dart';
|
|
||||||
|
|
||||||
/// User registration screen widget.
|
|
||||||
class RegisterScreen extends StatefulWidget {
|
|
||||||
const RegisterScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RegisterScreenState extends State<RegisterScreen> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _nameController = TextEditingController();
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
|
|
||||||
void _submit() {
|
|
||||||
if (_formKey.currentState?.validate() ?? false) {
|
|
||||||
context.read<AuthBloc>().add(RegisterRequested(
|
|
||||||
_emailController.text.trim(),
|
|
||||||
_passwordController.text.trim(),
|
|
||||||
_nameController.text.trim(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Konto Registrieren')),
|
|
||||||
body: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.cardSurface,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppTheme.glassBorder),
|
|
||||||
),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.person_add_outlined, size: 48, color: AppTheme.primaryEmerald),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Neues Konto erstellen',
|
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
TextFormField(
|
|
||||||
controller: _nameController,
|
|
||||||
decoration: const InputDecoration(labelText: 'Vollständiger Name', prefixIcon: Icon(Icons.person_outline)),
|
|
||||||
validator: (v) => v == null || v.isEmpty ? 'Name erforderlich' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
decoration: const InputDecoration(labelText: 'E-Mail', prefixIcon: Icon(Icons.email_outlined)),
|
|
||||||
validator: (v) => v == null || !v.contains('@') ? 'Gültige E-Mail erforderlich' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
|
|
||||||
validator: (v) => v == null || v.length < 6 ? 'Mind. 6 Zeichen' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 48,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: _submit,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
),
|
|
||||||
child: const Text('Registrieren', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/network/signalr_service.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
import '../repositories/bot_repository.dart';
|
||||||
|
import 'bot_event.dart';
|
||||||
|
import 'bot_state.dart';
|
||||||
|
|
||||||
|
class BotBloc extends Bloc<BotEvent, BotState> {
|
||||||
|
final BotRepository repository;
|
||||||
|
final SignalRService? signalRService;
|
||||||
|
|
||||||
|
StreamSubscription? _botPositionSub;
|
||||||
|
StreamSubscription? _portfolioSummarySub;
|
||||||
|
|
||||||
|
BotBloc({
|
||||||
|
required this.repository,
|
||||||
|
this.signalRService,
|
||||||
|
}) : super(const BotInitial()) {
|
||||||
|
on<FetchBotDashboard>(_onFetchBotDashboard);
|
||||||
|
on<OnBotPositionStreamReceived>(_onBotPositionStreamReceived);
|
||||||
|
on<OnPortfolioSummaryStreamReceived>(_onPortfolioSummaryStreamReceived);
|
||||||
|
on<TriggerBotPanicClose>(_onTriggerBotPanicClose);
|
||||||
|
on<ExecuteManualBotProposal>(_onExecuteManualBotProposal);
|
||||||
|
on<UpdateBotConfigSettings>(_onUpdateBotConfigSettings);
|
||||||
|
|
||||||
|
_initSignalRListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initSignalRListeners() {
|
||||||
|
if (signalRService != null) {
|
||||||
|
_botPositionSub = signalRService!.botPositionStream.listen((data) {
|
||||||
|
try {
|
||||||
|
final position = BotTradeOrderModel.fromJson(data);
|
||||||
|
add(OnBotPositionStreamReceived(position));
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
_portfolioSummarySub = signalRService!.portfolioSummaryStream.listen((data) {
|
||||||
|
try {
|
||||||
|
final summary = AccountSummaryModel.fromJson(data);
|
||||||
|
add(OnPortfolioSummaryStreamReceived(summary));
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onFetchBotDashboard(FetchBotDashboard event, Emitter<BotState> emit) async {
|
||||||
|
emit(const BotLoading());
|
||||||
|
try {
|
||||||
|
final results = await Future.wait([
|
||||||
|
repository.fetchStatus(),
|
||||||
|
repository.fetchSummary(),
|
||||||
|
repository.fetchActivePositions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final status = results[0] as BotStatusModel;
|
||||||
|
final summary = results[1] as AccountSummaryModel;
|
||||||
|
final positions = results[2] as List<BotTradeOrderModel>;
|
||||||
|
|
||||||
|
emit(BotLoaded(
|
||||||
|
status: status,
|
||||||
|
summary: summary,
|
||||||
|
positions: positions,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(BotError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onBotPositionStreamReceived(OnBotPositionStreamReceived event, Emitter<BotState> emit) {
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
final current = state as BotLoaded;
|
||||||
|
final updatedList = List<BotTradeOrderModel>.from(current.positions);
|
||||||
|
|
||||||
|
final index = updatedList.indexWhere((p) => p.orderId == event.position.orderId);
|
||||||
|
if (index != -1) {
|
||||||
|
updatedList[index] = event.position;
|
||||||
|
} else {
|
||||||
|
updatedList.insert(0, event.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(current.copyWith(positions: updatedList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPortfolioSummaryStreamReceived(OnPortfolioSummaryStreamReceived event, Emitter<BotState> emit) {
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
final current = state as BotLoaded;
|
||||||
|
emit(current.copyWith(summary: event.summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTriggerBotPanicClose(TriggerBotPanicClose event, Emitter<BotState> emit) async {
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
final current = state as BotLoaded;
|
||||||
|
emit(current.copyWith(isPanicClosing: true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await repository.panicCloseAll();
|
||||||
|
final updatedPositions = await repository.fetchActivePositions();
|
||||||
|
final updatedSummary = await repository.fetchSummary();
|
||||||
|
|
||||||
|
// A partial result (some Alpaca positions could not be confirmed as closed by the broker) must
|
||||||
|
// never be presented as a full success (Rules.md §4) - surface the skipped count explicitly.
|
||||||
|
final message = result.skippedCount > 0
|
||||||
|
? '${result.closedCount} Position(en) geschlossen, aber ${result.skippedCount} konnte(n) NICHT bestätigt geschlossen werden (Broker nicht erreichbar/konfiguriert). Bitte manuell prüfen!'
|
||||||
|
: '${result.closedCount} Position(en) erfolgreich geschlossen.';
|
||||||
|
|
||||||
|
emit(current.copyWith(
|
||||||
|
isPanicClosing: false,
|
||||||
|
positions: updatedPositions,
|
||||||
|
summary: updatedSummary,
|
||||||
|
actionMessage: message,
|
||||||
|
actionIsWarning: result.skippedCount > 0,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(current.copyWith(
|
||||||
|
isPanicClosing: false,
|
||||||
|
actionMessage: 'Fehler beim Notverkauf: $e',
|
||||||
|
actionIsWarning: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onExecuteManualBotProposal(ExecuteManualBotProposal event, Emitter<BotState> emit) async {
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
final current = state as BotLoaded;
|
||||||
|
try {
|
||||||
|
final order = await repository.executeProposal(
|
||||||
|
event.proposalId,
|
||||||
|
venue: event.venue,
|
||||||
|
quantity: event.quantity,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedList = List<BotTradeOrderModel>.from(current.positions);
|
||||||
|
final index = updatedList.indexWhere((p) => p.orderId == order.orderId);
|
||||||
|
if (index != -1) {
|
||||||
|
updatedList[index] = order;
|
||||||
|
} else {
|
||||||
|
updatedList.insert(0, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(current.copyWith(
|
||||||
|
positions: updatedList,
|
||||||
|
actionMessage: 'Trade ${order.symbol} erfolgreich ausgeführt (${order.venue}).',
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(current.copyWith(
|
||||||
|
actionMessage: 'Ausführungsfehler: $e',
|
||||||
|
actionIsWarning: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onUpdateBotConfigSettings(UpdateBotConfigSettings event, Emitter<BotState> emit) async {
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
final current = state as BotLoaded;
|
||||||
|
try {
|
||||||
|
final updatedStatus = await repository.updateSettings(
|
||||||
|
autoExecutionEnabled: event.autoExecutionEnabled,
|
||||||
|
maxPositions: event.maxPositions,
|
||||||
|
riskPerTradePercent: event.riskPerTradePercent,
|
||||||
|
minCompositeScore: event.minCompositeScore,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(current.copyWith(
|
||||||
|
status: updatedStatus,
|
||||||
|
actionMessage: 'Bot-Konfiguration aktualisiert.',
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(current.copyWith(
|
||||||
|
actionMessage: 'Fehler beim Speichern der Einstellungen: $e',
|
||||||
|
actionIsWarning: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_botPositionSub?.cancel();
|
||||||
|
_portfolioSummarySub?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
|
||||||
|
abstract class BotEvent extends Equatable {
|
||||||
|
const BotEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FetchBotDashboard extends BotEvent {
|
||||||
|
const FetchBotDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
class OnBotPositionStreamReceived extends BotEvent {
|
||||||
|
final BotTradeOrderModel position;
|
||||||
|
|
||||||
|
const OnBotPositionStreamReceived(this.position);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [position];
|
||||||
|
}
|
||||||
|
|
||||||
|
class OnPortfolioSummaryStreamReceived extends BotEvent {
|
||||||
|
final AccountSummaryModel summary;
|
||||||
|
|
||||||
|
const OnPortfolioSummaryStreamReceived(this.summary);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [summary];
|
||||||
|
}
|
||||||
|
|
||||||
|
class TriggerBotPanicClose extends BotEvent {
|
||||||
|
const TriggerBotPanicClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExecuteManualBotProposal extends BotEvent {
|
||||||
|
final String proposalId;
|
||||||
|
final String? venue;
|
||||||
|
final double? quantity;
|
||||||
|
|
||||||
|
const ExecuteManualBotProposal({
|
||||||
|
required this.proposalId,
|
||||||
|
this.venue,
|
||||||
|
this.quantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [proposalId, venue, quantity];
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateBotConfigSettings extends BotEvent {
|
||||||
|
final bool? autoExecutionEnabled;
|
||||||
|
final int? maxPositions;
|
||||||
|
final double? riskPerTradePercent;
|
||||||
|
final int? minCompositeScore;
|
||||||
|
|
||||||
|
const UpdateBotConfigSettings({
|
||||||
|
this.autoExecutionEnabled,
|
||||||
|
this.maxPositions,
|
||||||
|
this.riskPerTradePercent,
|
||||||
|
this.minCompositeScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [autoExecutionEnabled, maxPositions, riskPerTradePercent, minCompositeScore];
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
|
||||||
|
abstract class BotState extends Equatable {
|
||||||
|
const BotState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotInitial extends BotState {
|
||||||
|
const BotInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotLoading extends BotState {
|
||||||
|
const BotLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotLoaded extends BotState {
|
||||||
|
final BotStatusModel status;
|
||||||
|
final AccountSummaryModel summary;
|
||||||
|
final List<BotTradeOrderModel> positions;
|
||||||
|
final bool isPanicClosing;
|
||||||
|
final String? actionMessage;
|
||||||
|
|
||||||
|
/// True when [actionMessage] describes a failure or a partial success (e.g. a panic-close that could not
|
||||||
|
/// confirm every position was closed) rather than a full, unqualified success - the UI must not present
|
||||||
|
/// this the same way as a genuine success (Rules.md §4).
|
||||||
|
final bool actionIsWarning;
|
||||||
|
|
||||||
|
const BotLoaded({
|
||||||
|
required this.status,
|
||||||
|
required this.summary,
|
||||||
|
required this.positions,
|
||||||
|
this.isPanicClosing = false,
|
||||||
|
this.actionMessage,
|
||||||
|
this.actionIsWarning = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get activePositionsCount => positions.where((p) => p.isActive).length;
|
||||||
|
double get totalUnrealizedPnL => positions.where((p) => p.isActive).fold(0.0, (sum, p) => sum + p.unrealizedPnlEur);
|
||||||
|
double get totalRealizedPnL => positions.fold(0.0, (sum, p) => sum + p.realizedPnlEur);
|
||||||
|
|
||||||
|
BotLoaded copyWith({
|
||||||
|
BotStatusModel? status,
|
||||||
|
AccountSummaryModel? summary,
|
||||||
|
List<BotTradeOrderModel>? positions,
|
||||||
|
bool? isPanicClosing,
|
||||||
|
String? actionMessage,
|
||||||
|
bool actionIsWarning = false,
|
||||||
|
}) {
|
||||||
|
return BotLoaded(
|
||||||
|
status: status ?? this.status,
|
||||||
|
summary: summary ?? this.summary,
|
||||||
|
positions: positions ?? this.positions,
|
||||||
|
isPanicClosing: isPanicClosing ?? this.isPanicClosing,
|
||||||
|
actionMessage: actionMessage,
|
||||||
|
actionIsWarning: actionIsWarning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, summary, positions, isPanicClosing, actionMessage, actionIsWarning];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotError extends BotState {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const BotError(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
enum BotExecutionVenue {
|
||||||
|
alpacaPaperTrading,
|
||||||
|
syntheticPaperBroker,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BotPositionStatus {
|
||||||
|
pending,
|
||||||
|
active,
|
||||||
|
breakEvenTriggered,
|
||||||
|
tp1Hit,
|
||||||
|
tp2Hit,
|
||||||
|
closed,
|
||||||
|
stoppedOut,
|
||||||
|
knockedOut,
|
||||||
|
canceled,
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotTradeOrderModel extends Equatable {
|
||||||
|
final String orderId;
|
||||||
|
final String proposalId;
|
||||||
|
final String isin;
|
||||||
|
final String symbol;
|
||||||
|
final String venue;
|
||||||
|
final String direction;
|
||||||
|
final double requestedQuantity;
|
||||||
|
final double filledQuantity;
|
||||||
|
final double entryPrice;
|
||||||
|
final double averageBuyIn;
|
||||||
|
final double initialStopLoss;
|
||||||
|
final double currentStopLoss;
|
||||||
|
final double takeProfit1;
|
||||||
|
final double takeProfit2;
|
||||||
|
final double currentPrice;
|
||||||
|
final double unrealizedPnlEur;
|
||||||
|
final double realizedPnlEur;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? filledAt;
|
||||||
|
final DateTime? closedAt;
|
||||||
|
|
||||||
|
const BotTradeOrderModel({
|
||||||
|
required this.orderId,
|
||||||
|
required this.proposalId,
|
||||||
|
required this.isin,
|
||||||
|
required this.symbol,
|
||||||
|
required this.venue,
|
||||||
|
required this.direction,
|
||||||
|
required this.requestedQuantity,
|
||||||
|
required this.filledQuantity,
|
||||||
|
required this.entryPrice,
|
||||||
|
required this.averageBuyIn,
|
||||||
|
required this.initialStopLoss,
|
||||||
|
required this.currentStopLoss,
|
||||||
|
required this.takeProfit1,
|
||||||
|
required this.takeProfit2,
|
||||||
|
required this.currentPrice,
|
||||||
|
required this.unrealizedPnlEur,
|
||||||
|
required this.realizedPnlEur,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
this.filledAt,
|
||||||
|
this.closedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isLong => direction.toUpperCase() == 'BUY' || direction.toUpperCase() == 'LONG';
|
||||||
|
bool get isActive => status.toLowerCase() == 'active' || status.toLowerCase() == 'breakeventriggered' || status.toLowerCase() == 'tp1hit';
|
||||||
|
bool get isBreakEven => status.toLowerCase() == 'breakeventriggered';
|
||||||
|
bool get isTp1Hit => status.toLowerCase() == 'tp1hit';
|
||||||
|
bool get isClosed => status.toLowerCase() == 'closed' || status.toLowerCase() == 'stoppedout' || status.toLowerCase() == 'knockedout';
|
||||||
|
|
||||||
|
double get pnlPercent {
|
||||||
|
if (averageBuyIn <= 0) return 0.0;
|
||||||
|
return isLong
|
||||||
|
? ((currentPrice - averageBuyIn) / averageBuyIn) * 100.0
|
||||||
|
: ((averageBuyIn - currentPrice) / averageBuyIn) * 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get rMultiple {
|
||||||
|
final risk = (entryPrice - initialStopLoss).abs();
|
||||||
|
if (risk <= 0) return 0.0;
|
||||||
|
final reward = isLong ? (currentPrice - entryPrice) : (entryPrice - currentPrice);
|
||||||
|
return reward / risk;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory BotTradeOrderModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BotTradeOrderModel(
|
||||||
|
orderId: json['orderId']?.toString() ?? '',
|
||||||
|
proposalId: json['proposalId']?.toString() ?? '',
|
||||||
|
isin: json['isin']?.toString() ?? '',
|
||||||
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
|
venue: json['venue']?.toString() ?? 'SyntheticPaperBroker',
|
||||||
|
direction: json['direction']?.toString() ?? 'BUY',
|
||||||
|
requestedQuantity: (json['requestedQuantity'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
filledQuantity: (json['filledQuantity'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
entryPrice: (json['entryPrice'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
averageBuyIn: (json['averageBuyIn'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
initialStopLoss: (json['initialStopLoss'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
currentStopLoss: (json['currentStopLoss'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
takeProfit1: (json['takeProfit1'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
takeProfit2: (json['takeProfit2'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
currentPrice: (json['currentPrice'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
unrealizedPnlEur: (json['unrealizedPnlEur'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
realizedPnlEur: (json['realizedPnlEur'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
status: json['status']?.toString() ?? 'Active',
|
||||||
|
createdAt: json['createdAtUtc'] != null ? DateTime.tryParse(json['createdAtUtc'].toString()) ?? DateTime.now() : DateTime.now(),
|
||||||
|
filledAt: json['filledAtUtc'] != null ? DateTime.tryParse(json['filledAtUtc'].toString()) : null,
|
||||||
|
closedAt: json['closedAtUtc'] != null ? DateTime.tryParse(json['closedAtUtc'].toString()) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
orderId, proposalId, isin, symbol, venue, direction,
|
||||||
|
requestedQuantity, filledQuantity, entryPrice, averageBuyIn,
|
||||||
|
currentPrice, unrealizedPnlEur, realizedPnlEur, status, currentStopLoss
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountSummaryModel extends Equatable {
|
||||||
|
/// Nullable: a `null` value means the server did not report this field
|
||||||
|
/// (e.g. broker/account service unavailable). The UI MUST show an explicit
|
||||||
|
/// "not available" state in that case rather than a fabricated number
|
||||||
|
/// (Rules.md §4).
|
||||||
|
final double? equity;
|
||||||
|
final double? cash;
|
||||||
|
final double? buyingPower;
|
||||||
|
final String currency;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
const AccountSummaryModel({
|
||||||
|
required this.equity,
|
||||||
|
required this.cash,
|
||||||
|
required this.buyingPower,
|
||||||
|
required this.currency,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasAccountData => equity != null && buyingPower != null;
|
||||||
|
|
||||||
|
factory AccountSummaryModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AccountSummaryModel(
|
||||||
|
equity: (json['equity'] as num?)?.toDouble(),
|
||||||
|
cash: (json['cash'] as num?)?.toDouble(),
|
||||||
|
buyingPower: (json['buyingPower'] as num?)?.toDouble(),
|
||||||
|
currency: json['currency']?.toString() ?? 'EUR',
|
||||||
|
status: json['status']?.toString() ?? 'Active',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [equity, cash, buyingPower, currency, status];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an emergency "panic close" (`POST /api/v1/bot/orders/panic-close`). [skippedCount] is non-zero
|
||||||
|
/// whenever an Alpaca position could not be confirmed as liquidated by the broker (not configured, or the
|
||||||
|
/// broker call failed) - the UI MUST surface that count rather than only celebrating [closedCount] as if the
|
||||||
|
/// whole operation fully succeeded (Rules.md §4: no fabricated full success on a partial result).
|
||||||
|
class PanicCloseResultModel extends Equatable {
|
||||||
|
final int closedCount;
|
||||||
|
final int skippedCount;
|
||||||
|
final List<BotTradeOrderModel> closedOrders;
|
||||||
|
|
||||||
|
const PanicCloseResultModel({
|
||||||
|
required this.closedCount,
|
||||||
|
required this.skippedCount,
|
||||||
|
required this.closedOrders,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PanicCloseResultModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final List<dynamic> orders = json['closedOrders'] as List<dynamic>? ?? const [];
|
||||||
|
return PanicCloseResultModel(
|
||||||
|
closedCount: (json['closedCount'] as num?)?.toInt() ?? 0,
|
||||||
|
skippedCount: (json['skippedCount'] as num?)?.toInt() ?? 0,
|
||||||
|
closedOrders: orders.map((o) => BotTradeOrderModel.fromJson(o as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [closedCount, skippedCount, closedOrders];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotStatusModel extends Equatable {
|
||||||
|
final bool isRunning;
|
||||||
|
final bool autoExecutionEnabled;
|
||||||
|
final int activePositionsCount;
|
||||||
|
final int maxPositions;
|
||||||
|
final double riskPerTradePercent;
|
||||||
|
final int minCompositeScore;
|
||||||
|
final String venuesActive;
|
||||||
|
|
||||||
|
const BotStatusModel({
|
||||||
|
required this.isRunning,
|
||||||
|
required this.autoExecutionEnabled,
|
||||||
|
required this.activePositionsCount,
|
||||||
|
required this.maxPositions,
|
||||||
|
required this.riskPerTradePercent,
|
||||||
|
required this.minCompositeScore,
|
||||||
|
required this.venuesActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BotStatusModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BotStatusModel(
|
||||||
|
isRunning: json['isRunning'] == true,
|
||||||
|
autoExecutionEnabled: json['autoExecutionEnabled'] == true,
|
||||||
|
activePositionsCount: (json['activePositionsCount'] as num?)?.toInt() ?? 0,
|
||||||
|
maxPositions: (json['maxPositions'] as num?)?.toInt() ?? 5,
|
||||||
|
riskPerTradePercent: (json['riskPerTradePercent'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
minCompositeScore: (json['minCompositeScore'] as num?)?.toInt() ?? 75,
|
||||||
|
venuesActive: json['venuesActive']?.toString() ?? 'AlpacaPaper/Synthetic',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
isRunning, autoExecutionEnabled, activePositionsCount,
|
||||||
|
maxPositions, riskPerTradePercent, minCompositeScore, venuesActive
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:finlytic_app/core/network/api_client.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
|
||||||
|
class BotRepository {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const BotRepository({required this.apiClient});
|
||||||
|
|
||||||
|
Future<BotStatusModel> fetchStatus() async {
|
||||||
|
final response = await apiClient.get('/api/v1/bot/status');
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
return BotStatusModel.fromJson(response.data);
|
||||||
|
}
|
||||||
|
throw Exception('Failed to fetch bot status');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AccountSummaryModel> fetchSummary() async {
|
||||||
|
final response = await apiClient.get('/api/v1/bot/portfolio/summary');
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
return AccountSummaryModel.fromJson(response.data);
|
||||||
|
}
|
||||||
|
throw Exception('Failed to fetch account summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<BotTradeOrderModel>> fetchActivePositions() async {
|
||||||
|
final response = await apiClient.get('/api/v1/bot/positions/active');
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
final List<dynamic> list = response.data;
|
||||||
|
return list.map((json) => BotTradeOrderModel.fromJson(json)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BotTradeOrderModel> executeProposal(String proposalId, {String? venue, double? quantity}) async {
|
||||||
|
final response = await apiClient.post('/api/v1/bot/orders/execute', data: {
|
||||||
|
'proposalId': proposalId,
|
||||||
|
if (venue != null) 'preferredVenue': venue,
|
||||||
|
if (quantity != null) 'customQuantity': quantity,
|
||||||
|
});
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
return BotTradeOrderModel.fromJson(response.data);
|
||||||
|
}
|
||||||
|
throw Exception('Failed to execute bot proposal');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PanicCloseResultModel> panicCloseAll() async {
|
||||||
|
final response = await apiClient.post('/api/v1/bot/orders/panic-close');
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
return PanicCloseResultModel.fromJson(response.data);
|
||||||
|
}
|
||||||
|
// A non-200 (e.g. 503 when FinlyticBot is unreachable) must NOT be swallowed into a fake "0
|
||||||
|
// closed / 0 skipped" result for an emergency action - the caller needs to know the attempt did
|
||||||
|
// not even go through (Rules.md §4).
|
||||||
|
throw Exception('Failed to trigger panic close');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates FinlyticBot's dynamic settings. The backend now responds with the raw list of persisted
|
||||||
|
/// `DynamicSettingDto` entries (see FinlyticBackend BotController.UpdateBotSettings), not a BotStatusModel,
|
||||||
|
/// so the canonical status is re-fetched afterward instead of being reconstructed from that list.
|
||||||
|
Future<BotStatusModel> updateSettings({
|
||||||
|
bool? autoExecutionEnabled,
|
||||||
|
int? maxPositions,
|
||||||
|
double? riskPerTradePercent,
|
||||||
|
int? minCompositeScore,
|
||||||
|
}) async {
|
||||||
|
final response = await apiClient.post('/api/v1/bot/settings/update', data: {
|
||||||
|
if (autoExecutionEnabled != null) 'autoExecutionEnabled': autoExecutionEnabled,
|
||||||
|
if (maxPositions != null) 'maxPositions': maxPositions,
|
||||||
|
if (riskPerTradePercent != null) 'riskPerTradePercent': riskPerTradePercent,
|
||||||
|
if (minCompositeScore != null) 'minCompositeScore': minCompositeScore,
|
||||||
|
});
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to update bot settings');
|
||||||
|
}
|
||||||
|
return fetchStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/network/signalr_service.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../bloc/bot_bloc.dart';
|
||||||
|
import '../bloc/bot_event.dart';
|
||||||
|
import '../bloc/bot_state.dart';
|
||||||
|
import '../repositories/bot_repository.dart';
|
||||||
|
import '../widgets/bot_kpi_header.dart';
|
||||||
|
import '../widgets/bot_position_card.dart';
|
||||||
|
import '../widgets/bot_settings_sheet.dart';
|
||||||
|
|
||||||
|
class BotControlPanelScreen extends StatelessWidget {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
final SignalRService signalRService;
|
||||||
|
|
||||||
|
const BotControlPanelScreen({
|
||||||
|
super.key,
|
||||||
|
required this.apiClient,
|
||||||
|
required this.signalRService,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => BotBloc(
|
||||||
|
repository: BotRepository(apiClient: apiClient),
|
||||||
|
signalRService: signalRService,
|
||||||
|
)..add(const FetchBotDashboard()),
|
||||||
|
child: const _BotControlPanelContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BotControlPanelContent extends StatelessWidget {
|
||||||
|
const _BotControlPanelContent();
|
||||||
|
|
||||||
|
void _openSettings(BuildContext context, BotLoaded state) {
|
||||||
|
final botBloc = context.read<BotBloc>();
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (ctx) => BotSettingsSheet(
|
||||||
|
currentStatus: state.status,
|
||||||
|
onSave: (autoExec, maxPos, risk, minScore) {
|
||||||
|
botBloc.add(UpdateBotConfigSettings(
|
||||||
|
autoExecutionEnabled: autoExec,
|
||||||
|
maxPositions: maxPos,
|
||||||
|
riskPerTradePercent: risk,
|
||||||
|
minCompositeScore: minScore,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
onPanicClose: () {
|
||||||
|
botBloc.add(const TriggerBotPanicClose());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.darkBackground,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('FinlyticBot Control Panel', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
actions: [
|
||||||
|
BlocBuilder<BotBloc, BotState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () => _openSettings(context, state),
|
||||||
|
icon: const Icon(Icons.settings, color: Colors.white70),
|
||||||
|
tooltip: 'Bot Einstellungen & Kill-Switch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.read<BotBloc>().add(const FetchBotDashboard()),
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||||
|
tooltip: 'Neu laden',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocConsumer<BotBloc, BotState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is BotLoaded && state.actionMessage != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.actionMessage!),
|
||||||
|
// A failed or partially-successful action (e.g. a panic-close that could not confirm every
|
||||||
|
// position was closed) must never be shown in the same "all good" green as a full success
|
||||||
|
// (Rules.md §4).
|
||||||
|
backgroundColor: state.actionIsWarning ? AppTheme.accentRed : AppTheme.primaryEmerald,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is BotLoading) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is BotError) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 48, color: AppTheme.accentRed),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(state.message, style: TextStyle(color: AppTheme.textMuted)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => context.read<BotBloc>().add(const FetchBotDashboard()),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
|
||||||
|
child: const Text('Erneut Versuchen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is BotLoaded) {
|
||||||
|
final activePositions = state.positions.where((p) => p.isActive).toList();
|
||||||
|
final closedPositions = state.positions.where((p) => p.isClosed).toList();
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
context.read<BotBloc>().add(const FetchBotDashboard());
|
||||||
|
},
|
||||||
|
color: AppTheme.primaryEmerald,
|
||||||
|
backgroundColor: AppTheme.cardSurface,
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate([
|
||||||
|
BotKpiHeader(
|
||||||
|
summary: state.summary,
|
||||||
|
status: state.status,
|
||||||
|
totalUnrealizedPnL: state.totalUnrealizedPnL,
|
||||||
|
totalRealizedPnL: state.totalRealizedPnL,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildAlphaDecayMonitor(state),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Aktive Positionen (${activePositions.length}/${state.status.maxPositions})',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Risk: ${(activePositions.length * state.status.riskPerTradePercent).toStringAsFixed(1)}%',
|
||||||
|
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (activePositions.isEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: AppTheme.cardSurface.withValues(alpha: 0.5),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.radar, size: 48, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Keine aktiven Positionen',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Der Bot scannt das Universum nach Setup-Konfluenzen ab Score ≥ ${state.status.minCompositeScore}.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: activePositions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final position = activePositions[index];
|
||||||
|
return BotPositionCard(
|
||||||
|
position: position,
|
||||||
|
onClosePressed: () {
|
||||||
|
// Manual emergency close of this specific position
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (closedPositions.isNotEmpty) ...[
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: const Text(
|
||||||
|
'Kürzlich Geschlossene Trades',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: closedPositions.length > 5 ? 5 : closedPositions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final position = closedPositions[index];
|
||||||
|
return Opacity(
|
||||||
|
opacity: 0.7,
|
||||||
|
child: BotPositionCard(position: position),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This used to be an "Alpha-Decay & Strategy Reliability Monitor"
|
||||||
|
// comparing hardcoded fake "Live vs Simulation" win rates / profit factors
|
||||||
|
// per strategy (Rules.md §4 violation). There is no real data source for
|
||||||
|
// that comparison: `GET /api/v1/simulation/matrix/{isin}`
|
||||||
|
// (`StrategyAssetReliabilityDto`) only provides a simulated reliability
|
||||||
|
// score per (ISIN, StrategyKey) pair — it has no "live" counterpart, and
|
||||||
|
// this screen isn't scoped to a single asset, so there's no ISIN to query
|
||||||
|
// it with in the first place. Rather than inventing numbers, or bolting on
|
||||||
|
// an asset picker that isn't part of this task, this is now an explicit
|
||||||
|
// empty state until a real live-vs-simulation data source exists.
|
||||||
|
Widget _buildAlphaDecayMonitor(BotLoaded state) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.analytics_outlined, size: 16, color: Colors.cyanAccent),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text('Alpha-Decay & Strategy Reliability Monitor', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Keine Live-vs-Simulation-Daten verfügbar.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
|
||||||
|
class BotKpiHeader extends StatelessWidget {
|
||||||
|
final AccountSummaryModel summary;
|
||||||
|
final BotStatusModel status;
|
||||||
|
final double totalUnrealizedPnL;
|
||||||
|
final double totalRealizedPnL;
|
||||||
|
|
||||||
|
const BotKpiHeader({
|
||||||
|
super.key,
|
||||||
|
required this.summary,
|
||||||
|
required this.status,
|
||||||
|
required this.totalUnrealizedPnL,
|
||||||
|
required this.totalRealizedPnL,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isPositiveUnrealized = totalUnrealizedPnL >= 0;
|
||||||
|
final isPositiveRealized = totalRealizedPnL >= 0;
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: (status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed).withValues(alpha: 0.5),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
status.isRunning ? 'AUTONOMOUS BOT ONLINE' : 'BOT PAUSED',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
color: status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.amber.withValues(alpha: 0.15),
|
||||||
|
border: Border.all(
|
||||||
|
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald : Colors.amber,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status.autoExecutionEnabled ? 'AUTO-EXECUTE ON' : 'MANUAL APPROVAL',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald : Colors.amber,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: summary.hasAccountData
|
||||||
|
? _buildMetricCard(
|
||||||
|
title: 'Portfolio Equity',
|
||||||
|
value: '€${summary.equity!.toStringAsFixed(2)}',
|
||||||
|
subtitle: 'Buying Power: €${summary.buyingPower!.toStringAsFixed(0)}',
|
||||||
|
valueColor: Colors.white,
|
||||||
|
)
|
||||||
|
: _buildUnavailableMetricCard('Portfolio Equity'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
title: 'Unrealized PnL',
|
||||||
|
value: '${isPositiveUnrealized ? '+' : ''}€${totalUnrealizedPnL.toStringAsFixed(2)}',
|
||||||
|
subtitle: 'Realized: ${isPositiveRealized ? '+' : ''}€${totalRealizedPnL.toStringAsFixed(2)}',
|
||||||
|
valueColor: isPositiveUnrealized ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUnavailableMetricCard(String title) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white.withValues(alpha: 0.03),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 14, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Portfoliodaten nicht verfügbar',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricCard({
|
||||||
|
required String title,
|
||||||
|
required String value,
|
||||||
|
required String subtitle,
|
||||||
|
required Color valueColor,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white.withValues(alpha: 0.03),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(value, style: TextStyle(color: valueColor, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(subtitle, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/asset_logo_widget.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
|
||||||
|
class BotPositionCard extends StatelessWidget {
|
||||||
|
final BotTradeOrderModel position;
|
||||||
|
final VoidCallback? onClosePressed;
|
||||||
|
|
||||||
|
const BotPositionCard({
|
||||||
|
super.key,
|
||||||
|
required this.position,
|
||||||
|
this.onClosePressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isLong = position.isLong;
|
||||||
|
final isProfitable = position.unrealizedPnlEur >= 0;
|
||||||
|
final pnlPercent = position.pnlPercent;
|
||||||
|
final rMult = position.rMultiple;
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
AssetLogoWidget(
|
||||||
|
symbolOrName: position.symbol.isNotEmpty ? position.symbol : position.isin,
|
||||||
|
imageUrl: '/api/v1/logo/${position.isin}',
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
position.symbol,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: isLong ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.accentRed.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
position.direction.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isLong ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildVenueBadge(position.venue),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
position.isin,
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${isProfitable ? '+' : ''}€${position.unrealizedPnlEur.toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isProfitable ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${isProfitable ? '+' : ''}${pnlPercent.toStringAsFixed(2)}% (${rMult >= 0 ? '+' : ''}${rMult.toStringAsFixed(1)}R)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isProfitable ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1, color: Colors.white10),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildPriceInfo('Entry / Buy-In', '€${position.averageBuyIn > 0 ? position.averageBuyIn.toStringAsFixed(2) : position.entryPrice.toStringAsFixed(2)}'),
|
||||||
|
_buildPriceInfo('Current Price', '€${position.currentPrice.toStringAsFixed(2)}'),
|
||||||
|
_buildPriceInfo('Stop Loss', '€${position.currentStopLoss.toStringAsFixed(2)}'),
|
||||||
|
_buildPriceInfo('Take Profit 1', '€${position.takeProfit1.toStringAsFixed(2)}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildDynamicStateBadge(position),
|
||||||
|
if (position.isActive && onClosePressed != null)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onClosePressed,
|
||||||
|
icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed),
|
||||||
|
label: Text('Glattstellen', style: TextStyle(fontSize: 12, color: AppTheme.accentRed)),
|
||||||
|
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPriceInfo(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(value, style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVenueBadge(String venue) {
|
||||||
|
final isAlpaca = venue.toLowerCase().contains('alpaca');
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: isAlpaca ? Colors.blue.withValues(alpha: 0.15) : Colors.purple.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isAlpaca ? 'Alpaca US' : 'Synthetic KO',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isAlpaca ? Colors.lightBlueAccent : Colors.purpleAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDynamicStateBadge(BotTradeOrderModel position) {
|
||||||
|
String text = 'Pending TP1';
|
||||||
|
Color color = Colors.amber;
|
||||||
|
|
||||||
|
if (position.isBreakEven) {
|
||||||
|
text = 'Free-Roll Active (BE)';
|
||||||
|
color = AppTheme.primaryEmerald;
|
||||||
|
} else if (position.isTp1Hit) {
|
||||||
|
text = 'TP1 Hit (Trailing Active)';
|
||||||
|
color = Colors.cyanAccent;
|
||||||
|
} else if (position.isClosed) {
|
||||||
|
text = 'Closed';
|
||||||
|
color = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: color.withValues(alpha: 0.15),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.4), width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.bolt, size: 12, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../models/bot_models.dart';
|
||||||
|
|
||||||
|
class BotSettingsSheet extends StatefulWidget {
|
||||||
|
final BotStatusModel currentStatus;
|
||||||
|
final Function(bool autoExec, int maxPos, double risk, int minScore) onSave;
|
||||||
|
final VoidCallback onPanicClose;
|
||||||
|
|
||||||
|
const BotSettingsSheet({
|
||||||
|
super.key,
|
||||||
|
required this.currentStatus,
|
||||||
|
required this.onSave,
|
||||||
|
required this.onPanicClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BotSettingsSheet> createState() => _BotSettingsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BotSettingsSheetState extends State<BotSettingsSheet> {
|
||||||
|
late bool _autoExec;
|
||||||
|
late int _maxPositions;
|
||||||
|
late double _riskPerTrade;
|
||||||
|
late int _minScore;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_autoExec = widget.currentStatus.autoExecutionEnabled;
|
||||||
|
_maxPositions = widget.currentStatus.maxPositions;
|
||||||
|
_riskPerTrade = widget.currentStatus.riskPerTradePercent;
|
||||||
|
_minScore = widget.currentStatus.minCompositeScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Bot Konfiguration & Kill-Switch',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Automatische Ausführung (Auto-Trade)', style: TextStyle(color: Colors.white)),
|
||||||
|
subtitle: Text('Führt geprüfte Signale ab Score ≥ $_minScore automatisch aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||||
|
value: _autoExec,
|
||||||
|
activeThumbColor: AppTheme.primaryEmerald,
|
||||||
|
onChanged: (val) => setState(() => _autoExec = val),
|
||||||
|
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('Max. Parallele Positionen: $_maxPositions', style: const TextStyle(color: Colors.white70)),
|
||||||
|
Slider(
|
||||||
|
value: _maxPositions.toDouble(),
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
divisions: 9,
|
||||||
|
activeColor: AppTheme.primaryEmerald,
|
||||||
|
label: '$_maxPositions',
|
||||||
|
onChanged: (val) => setState(() => _maxPositions = val.toInt()),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Risiko pro Trade: ${_riskPerTrade.toStringAsFixed(1)}% des Portfolios', style: const TextStyle(color: Colors.white70)),
|
||||||
|
Slider(
|
||||||
|
value: _riskPerTrade,
|
||||||
|
min: 0.2,
|
||||||
|
max: 3.0,
|
||||||
|
divisions: 28,
|
||||||
|
activeColor: AppTheme.primaryEmerald,
|
||||||
|
label: '${_riskPerTrade.toStringAsFixed(1)}%',
|
||||||
|
onChanged: (val) => setState(() => _riskPerTrade = val),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Mindest-Score für Einstieg: $_minScore Punkte', style: const TextStyle(color: Colors.white70)),
|
||||||
|
Slider(
|
||||||
|
value: _minScore.toDouble(),
|
||||||
|
min: 60,
|
||||||
|
max: 95,
|
||||||
|
divisions: 35,
|
||||||
|
activeColor: AppTheme.primaryEmerald,
|
||||||
|
label: '$_minScore Pkt',
|
||||||
|
onChanged: (val) => setState(() => _minScore = val.toInt()),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onSave(_autoExec, _maxPositions, _riskPerTrade, _minScore);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Speichern'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showPanicConfirmation(context);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.warning, color: Colors.white),
|
||||||
|
label: const Text('PANIC CLOSE', style: TextStyle(color: Colors.white)),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.accentRed,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPanicConfirmation(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
backgroundColor: AppTheme.cardSurface,
|
||||||
|
title: Text('🚨 Notverkauf bestätigen', style: TextStyle(color: AppTheme.accentRed)),
|
||||||
|
|
||||||
|
content: const Text(
|
||||||
|
'Möchtest du wirklich SOFORT alle offenen Bot-Positionen schließen? Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Abbrechen', style: TextStyle(color: Colors.white70)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
widget.onPanicClose();
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentRed),
|
||||||
|
child: const Text('ALLE POSITIONEN SCHLIESSEN'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ class DashboardScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TradesStreamWidget(apiClient: apiClient),
|
TradesStreamWidget(apiClient: apiClient),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
DailyNewsSnapshot(apiClient: apiClient),
|
DailyNewsSnapshot(apiClient: apiClient, signalRService: signalRService),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/network/signalr_service.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/utils/time_utils.dart';
|
import '../../../core/utils/time_utils.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
@@ -14,19 +15,25 @@ import '../../news/widgets/article_sentiment_dialog.dart';
|
|||||||
|
|
||||||
class DailyNewsSnapshot extends StatelessWidget {
|
class DailyNewsSnapshot extends StatelessWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
final String backendUrl;
|
final SignalRService? signalRService;
|
||||||
|
final String? backendUrl;
|
||||||
|
|
||||||
const DailyNewsSnapshot({
|
const DailyNewsSnapshot({
|
||||||
super.key,
|
super.key,
|
||||||
required this.apiClient,
|
required this.apiClient,
|
||||||
this.backendUrl = 'http://localhost:5000',
|
this.signalRService,
|
||||||
|
this.backendUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => NewsBloc(
|
create: (context) => NewsBloc(
|
||||||
repository: NewsRepository(apiClient: apiClient, backendUrl: backendUrl),
|
repository: NewsRepository(
|
||||||
|
apiClient: apiClient,
|
||||||
|
backendUrl: backendUrl ?? ApiClient.baseUrl,
|
||||||
|
signalRService: signalRService,
|
||||||
|
),
|
||||||
)..add(FetchNews(date: DateTime.now().toIso8601String().substring(0, 10))),
|
)..add(FetchNews(date: DateTime.now().toIso8601String().substring(0, 10))),
|
||||||
child: const _DailyNewsSnapshotContent(),
|
child: const _DailyNewsSnapshotContent(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class FavoritesCarousel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
AssetLogoWidget(
|
AssetLogoWidget(
|
||||||
symbolOrName: isin,
|
symbolOrName: isin,
|
||||||
imageUrl: fav.image.isNotEmpty ? fav.image : null,
|
imageUrl: isin.isNotEmpty ? '/api/v1/logo/$isin' : (fav.image.isNotEmpty ? fav.image : null),
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
|||||||
itemCount: proposals.length,
|
itemCount: proposals.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final p = proposals[index];
|
final p = proposals[index];
|
||||||
final isBuy = p.signalType.toUpperCase() == 'BUY' || p.signalType.toUpperCase() == 'LONG';
|
final isBuy = p.direction.isLong;
|
||||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -110,8 +110,8 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (ctx) => AssetDetailScreen(
|
builder: (ctx) => AssetDetailScreen(
|
||||||
isin: p.isin,
|
isin: p.underlyingIsin,
|
||||||
name: p.companyName,
|
name: p.symbol,
|
||||||
symbol: p.symbol.isNotEmpty ? p.symbol : null,
|
symbol: p.symbol.isNotEmpty ? p.symbol : null,
|
||||||
apiClient: context.read<TradeBloc>().repository.apiClient,
|
apiClient: context.read<TradeBloc>().repository.apiClient,
|
||||||
),
|
),
|
||||||
@@ -158,35 +158,24 @@ class _TradesStreamWidgetContent extends StatelessWidget {
|
|||||||
border: Border.all(color: signalColor.withValues(alpha: 0.3)),
|
border: Border.all(color: signalColor.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
p.signalType,
|
p.direction.label,
|
||||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
|
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (p.companyName.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
p.companyName,
|
p.underlyingIsin,
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (p.reasoning.isNotEmpty) ...[
|
|
||||||
Text(
|
|
||||||
p.reasoning,
|
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 11, fontStyle: FontStyle.italic),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
],
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'KI Score: ${(p.winRate).toStringAsFixed(0)}%',
|
p.instrumentType.label,
|
||||||
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.w600, fontSize: 13),
|
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.w600, fontSize: 13),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class WatchlistCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
AssetLogoWidget(
|
AssetLogoWidget(
|
||||||
symbolOrName: asset.isin.isNotEmpty ? asset.isin : displayName,
|
symbolOrName: asset.isin.isNotEmpty ? asset.isin : displayName,
|
||||||
imageUrl: asset.image.isNotEmpty ? asset.image : null,
|
imageUrl: asset.isin.isNotEmpty ? '/api/v1/logo/${asset.isin}' : (asset.image.isNotEmpty ? asset.image : null),
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ class NewsBloc extends Bloc<NewsEvent, NewsState> {
|
|||||||
on<LoadMoreNews>(_onLoadMoreNews);
|
on<LoadMoreNews>(_onLoadMoreNews);
|
||||||
on<ReceiveLiveNews>(_onReceiveLiveNews);
|
on<ReceiveLiveNews>(_onReceiveLiveNews);
|
||||||
|
|
||||||
// Subscribe to live news from SignalR
|
// Subscribe to live news pushed over the central SignalRService's
|
||||||
|
// `/hubs/news` connection (authenticated, managed in main.dart).
|
||||||
_liveNewsSubscription = repository.liveNewsStream.listen((article) {
|
_liveNewsSubscription = repository.liveNewsStream.listen((article) {
|
||||||
add(ReceiveLiveNews(article));
|
add(ReceiveLiveNews(article));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to SignalR
|
|
||||||
repository.connectToLiveFeed();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onFetchNews(FetchNews event, Emitter<NewsState> emit) async {
|
Future<void> _onFetchNews(FetchNews event, Emitter<NewsState> emit) async {
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:signalr_core/signalr_core.dart';
|
|
||||||
import 'package:finlytic_app/core/network/api_client.dart';
|
import 'package:finlytic_app/core/network/api_client.dart';
|
||||||
|
import 'package:finlytic_app/core/network/signalr_service.dart';
|
||||||
import 'package:finlytic_app/features/news/models/news_article_model.dart';
|
import 'package:finlytic_app/features/news/models/news_article_model.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
|
|
||||||
class NewsRepository {
|
class NewsRepository {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
final String backendUrl;
|
final String backendUrl;
|
||||||
final FlutterSecureStorage secureStorage = const FlutterSecureStorage();
|
|
||||||
|
|
||||||
HubConnection? _hubConnection;
|
/// Central Real-Time WebSocket service (shared, authenticated `/hubs/news` connection).
|
||||||
final _liveNewsController = StreamController<NewsArticleModel>.broadcast();
|
/// May be null for call-sites that only need REST access (e.g. one-off dialogs) and
|
||||||
|
/// don't require the live feed; in that case [liveNewsStream] yields no events.
|
||||||
|
final SignalRService? signalRService;
|
||||||
|
|
||||||
Stream<NewsArticleModel> get liveNewsStream => _liveNewsController.stream;
|
NewsRepository({
|
||||||
|
required this.apiClient,
|
||||||
|
required this.backendUrl,
|
||||||
|
this.signalRService,
|
||||||
|
});
|
||||||
|
|
||||||
NewsRepository({required this.apiClient, required this.backendUrl});
|
/// Live news articles pushed by the central SignalRService's `/hubs/news` connection.
|
||||||
|
/// The connection itself is established centrally (see `SignalRService.initSignalR()`),
|
||||||
|
/// so this repository only maps the already-authenticated stream to typed models.
|
||||||
|
Stream<NewsArticleModel> get liveNewsStream {
|
||||||
|
final signalR = signalRService;
|
||||||
|
if (signalR == null) return const Stream.empty();
|
||||||
|
return signalR.newsArticleStream.map(NewsArticleModel.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<NewsArticleModel>> fetchNews({
|
Future<List<NewsArticleModel>> fetchNews({
|
||||||
int page = 1,
|
int page = 1,
|
||||||
@@ -66,55 +75,9 @@ class NewsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connectToLiveFeed() async {
|
/// No-op: the underlying `/hubs/news` WebSocket connection is owned and
|
||||||
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
|
/// lifecycle-managed centrally by [SignalRService] (started once in
|
||||||
return;
|
/// `main.dart` after authentication), so this repository has nothing of
|
||||||
}
|
/// its own to dispose.
|
||||||
|
void dispose() {}
|
||||||
try {
|
|
||||||
final token = await secureStorage.read(key: 'auth_token');
|
|
||||||
final hubUrl = '$backendUrl/hubs/news${token != null ? '?access_token=$token' : ''}';
|
|
||||||
|
|
||||||
_hubConnection = HubConnectionBuilder()
|
|
||||||
.withUrl(hubUrl, HttpConnectionOptions(
|
|
||||||
logging: (level, message) => print(message),
|
|
||||||
))
|
|
||||||
.withAutomaticReconnect()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
_hubConnection!.on('ReceiveNewArticle', (arguments) {
|
|
||||||
if (arguments != null && arguments.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
// SignalR might send it as a Map or raw JSON depending on .NET format
|
|
||||||
final dynamic rawPayload = arguments[0];
|
|
||||||
final Map<String, dynamic> jsonData = rawPayload is String
|
|
||||||
? jsonDecode(rawPayload)
|
|
||||||
: Map<String, dynamic>.from(rawPayload);
|
|
||||||
|
|
||||||
final article = NewsArticleModel.fromJson(jsonData);
|
|
||||||
_liveNewsController.add(article);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error parsing live article: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await _hubConnection!.start();
|
|
||||||
print('Connected to News Live Feed');
|
|
||||||
} catch (e) {
|
|
||||||
print('Error connecting to News Live Feed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> disconnectFromLiveFeed() async {
|
|
||||||
if (_hubConnection != null) {
|
|
||||||
await _hubConnection!.stop();
|
|
||||||
_hubConnection = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_liveNewsController.close();
|
|
||||||
disconnectFromLiveFeed();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
|||||||
_hasMore = false;
|
_hasMore = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If a sentiment filter is active and no items matched yet on this page, automatically load the next page
|
||||||
|
if (_filteredNewsItems.isEmpty && _hasMore && _selectedSentimentFilter != null) {
|
||||||
|
_loadNews();
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
@@ -182,11 +187,22 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
|||||||
_loadNews(refresh: true);
|
_loadNews(refresh: true);
|
||||||
},
|
},
|
||||||
onSentimentToggleChanged: (val) {
|
onSentimentToggleChanged: (val) {
|
||||||
setState(() => _hasSentimentOnly = val);
|
setState(() {
|
||||||
|
_hasSentimentOnly = val;
|
||||||
|
if (!val) {
|
||||||
|
_selectedSentimentFilter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
_loadNews(refresh: true);
|
_loadNews(refresh: true);
|
||||||
},
|
},
|
||||||
onSentimentFilterChanged: (val) {
|
onSentimentFilterChanged: (val) {
|
||||||
setState(() => _selectedSentimentFilter = val);
|
setState(() {
|
||||||
|
_selectedSentimentFilter = val;
|
||||||
|
if (val != null) {
|
||||||
|
_hasSentimentOnly = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_loadNews(refresh: true);
|
||||||
},
|
},
|
||||||
onResetFilters: _resetFilters,
|
onResetFilters: _resetFilters,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/asset_logo_widget.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../../trades/models/trade_model.dart';
|
||||||
|
|
||||||
|
class ProposalDecisionScreen extends StatelessWidget {
|
||||||
|
final TradeProposalModel proposal;
|
||||||
|
final VoidCallback? onExecuteBot;
|
||||||
|
final VoidCallback? onManualTrade;
|
||||||
|
|
||||||
|
const ProposalDecisionScreen({
|
||||||
|
super.key,
|
||||||
|
required this.proposal,
|
||||||
|
this.onExecuteBot,
|
||||||
|
this.onManualTrade,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final symbol = proposal.symbol.isNotEmpty ? proposal.symbol : 'ASSET';
|
||||||
|
final isin = proposal.underlyingIsin;
|
||||||
|
final isLong = proposal.isLong;
|
||||||
|
final strategyKey = proposal.strategyKey.isNotEmpty ? proposal.strategyKey : 'Unbekannte Strategie';
|
||||||
|
final score = proposal.compositeScore;
|
||||||
|
final entryPrice = proposal.entryPrice;
|
||||||
|
final invalidationPrice = proposal.invalidationPrice;
|
||||||
|
|
||||||
|
final aiValidation = proposal.aiValidation;
|
||||||
|
final hasAiThesisContent = aiValidation != null && aiValidation.hasContent;
|
||||||
|
final isRuleBasedApproval = aiValidation != null && !aiValidation.isAiValidated;
|
||||||
|
final aiConfidence = aiValidation?.confidence;
|
||||||
|
final deriv = proposal.selectedDerivative;
|
||||||
|
final hasDerivative = deriv != null;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppTheme.darkBackground,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Trade Proposal: $symbol', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Top Asset Card
|
||||||
|
GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AssetLogoWidget(
|
||||||
|
symbolOrName: symbol,
|
||||||
|
imageUrl: '/api/v1/logo/$isin',
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(symbol, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
color: isLong ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.accentRed.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isLong ? 'LONG' : 'SHORT',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isLong ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
||||||
|
border: Border.all(color: AppTheme.primaryEmerald, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text('${score.toStringAsFixed(0)} Pkt', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
Text('Score', style: TextStyle(color: AppTheme.textMuted, fontSize: 9)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// AI Thesis & Catalysts Card
|
||||||
|
GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.psychology, size: 18, color: Colors.purpleAccent),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('KI-Guardian Thesis & Validierung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (aiValidation != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: isRuleBasedApproval
|
||||||
|
? Colors.amber.withValues(alpha: 0.12)
|
||||||
|
: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||||
|
border: Border.all(color: isRuleBasedApproval ? Colors.amber : AppTheme.primaryEmerald),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isRuleBasedApproval ? Icons.rule_outlined : Icons.smart_toy_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: isRuleBasedApproval ? Colors.amber : AppTheme.primaryEmerald,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
isRuleBasedApproval
|
||||||
|
? 'Regelbasierte Freigabe – keine KI-Bewertung durchgeführt'
|
||||||
|
: (aiConfidence != null
|
||||||
|
? 'KI-validiert · Konfidenz ${(aiConfidence * 100).toStringAsFixed(0)}%'
|
||||||
|
: 'KI-validiert · Konfidenz nicht verfügbar'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isRuleBasedApproval ? Colors.amber : AppTheme.primaryEmerald,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!hasAiThesisContent)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
aiValidation == null
|
||||||
|
? 'Keine KI-Validierung für diesen Vorschlag verfügbar.'
|
||||||
|
: 'Keine weiteren Details zur Freigabe hinterlegt.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
Text(aiValidation.thesisSummary, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
|
||||||
|
if (aiValidation.keyCatalysts.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('Katalysatoren & Stärken ($strategyKey @ €${entryPrice.toStringAsFixed(2)}):', style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...aiValidation.keyCatalysts.map((c) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: 12, color: AppTheme.primaryEmerald),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(child: Text(c, style: TextStyle(color: AppTheme.textMuted, fontSize: 12))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
if (aiValidation.identifiedRisks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Identifizierte Risiken:', style: TextStyle(color: Colors.amber, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...aiValidation.identifiedRisks.map((r) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber, size: 12, color: Colors.amber),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(child: Text(r, style: TextStyle(color: AppTheme.textMuted, fontSize: 12))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Knock-Out Derivative & Safety Buffer Card
|
||||||
|
GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.shield_outlined, size: 18, color: Colors.cyanAccent),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Optimaler Knock-Out Schein', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (!hasDerivative)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Kein passendes Knock-Out Produkt für diesen Vorschlag gefunden.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildDetailCol('Emittent', deriv.issuer.isNotEmpty ? deriv.issuer : '–'),
|
||||||
|
_buildDetailCol('Hebel', '${deriv.leverage.toStringAsFixed(1)}x'),
|
||||||
|
_buildDetailCol('KO-Barriere', '€${deriv.barrier.toStringAsFixed(2)}'),
|
||||||
|
_buildDetailCol('Sicherheitspuffer', '${deriv.safetyBufferPercent.toStringAsFixed(1)}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: (deriv.safetyBufferPercent / 20.0).clamp(0.0, 1.0),
|
||||||
|
backgroundColor: Colors.white10,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(deriv.safetyBufferPercent >= 5.0 ? AppTheme.primaryEmerald : Colors.amber),
|
||||||
|
minHeight: 6,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Barriere liegt ${deriv.safetyBufferPercent.toStringAsFixed(1)}% unter dem Chart Stop-Loss (€${invalidationPrice.toStringAsFixed(2)}).',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
deriv.derivativeWkn != null ? 'WKN: ${deriv.derivativeWkn}' : 'WKN: nicht verfügbar',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textMuted,
|
||||||
|
fontSize: 10,
|
||||||
|
fontStyle: deriv.derivativeWkn != null ? FontStyle.normal : FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onExecuteBot,
|
||||||
|
icon: const Icon(Icons.smart_toy_outlined),
|
||||||
|
label: const Text('An Bot übergeben'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: onManualTrade,
|
||||||
|
icon: const Icon(Icons.touch_app_outlined),
|
||||||
|
label: const Text('Manuell eröffnen'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: const BorderSide(color: Colors.white30),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailCol(String label, String val) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(val, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,12 +26,14 @@ extension SwitchMapExtension<T> on Stream<T> {
|
|||||||
onError: controller?.addError,
|
onError: controller?.addError,
|
||||||
);
|
);
|
||||||
}, onDone: () {
|
}, onDone: () {
|
||||||
// Wait for last sub to finish or close
|
outputSub?.cancel();
|
||||||
|
controller?.close();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onCancel: () {
|
onCancel: () {
|
||||||
outputSub?.cancel();
|
outputSub?.cancel();
|
||||||
inputSub?.cancel();
|
inputSub?.cancel();
|
||||||
|
controller?.close();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return controller.stream;
|
return controller.stream;
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import '../bloc/search_bloc.dart';
|
|||||||
import '../repositories/search_repository.dart';
|
import '../repositories/search_repository.dart';
|
||||||
|
|
||||||
/// Focused Spotlight Asset Search Dialog with reactive Favorite Star Button, Hero transitions, and Shimmer loading states.
|
/// Focused Spotlight Asset Search Dialog with reactive Favorite Star Button, Hero transitions, and Shimmer loading states.
|
||||||
|
///
|
||||||
|
/// By default, tapping a result navigates straight to [AssetDetailScreen] (the main search-feature use case).
|
||||||
|
/// Pass [onAssetSelected] to repurpose this as a reusable asset PICKER instead - e.g. the simulation screen's
|
||||||
|
/// ISIN input - in which case a tap pops the dialog and invokes the callback with the picked ISIN/name instead
|
||||||
|
/// of navigating anywhere.
|
||||||
class AssetSearchDialog extends StatelessWidget {
|
class AssetSearchDialog extends StatelessWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
final void Function(String isin, String name)? onAssetSelected;
|
||||||
|
|
||||||
const AssetSearchDialog({
|
const AssetSearchDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.apiClient,
|
required this.apiClient,
|
||||||
|
this.onAssetSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,15 +31,16 @@ class AssetSearchDialog extends StatelessWidget {
|
|||||||
create: (context) => SearchBloc(
|
create: (context) => SearchBloc(
|
||||||
repository: SearchRepository(apiClient: apiClient),
|
repository: SearchRepository(apiClient: apiClient),
|
||||||
)..add(const SearchQueryChanged('')),
|
)..add(const SearchQueryChanged('')),
|
||||||
child: _AssetSearchDialogContent(apiClient: apiClient),
|
child: _AssetSearchDialogContent(apiClient: apiClient, onAssetSelected: onAssetSelected),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetSearchDialogContent extends StatefulWidget {
|
class _AssetSearchDialogContent extends StatefulWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
final void Function(String isin, String name)? onAssetSelected;
|
||||||
|
|
||||||
const _AssetSearchDialogContent({required this.apiClient});
|
const _AssetSearchDialogContent({required this.apiClient, this.onAssetSelected});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_AssetSearchDialogContent> createState() => _AssetSearchDialogContentState();
|
State<_AssetSearchDialogContent> createState() => _AssetSearchDialogContentState();
|
||||||
@@ -162,6 +170,10 @@ class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
|
|||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
if (widget.onAssetSelected != null) {
|
||||||
|
widget.onAssetSelected!(isin, assetName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../models/backtest_history_entry_model.dart';
|
||||||
|
import '../models/backtest_report_model.dart';
|
||||||
|
import '../repositories/simulation_repository.dart';
|
||||||
|
|
||||||
|
class BacktestState extends Equatable {
|
||||||
|
final bool isLoading;
|
||||||
|
final BacktestReportModel? report;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// True while [report] is a past run fetched via [BacktestCubit.viewHistoricalRun] rather than a freshly
|
||||||
|
/// executed backtest - lets the UI label the summary/chart as "aus dem Verlauf" instead of implying a new
|
||||||
|
/// run just completed.
|
||||||
|
final bool isViewingHistoricalRun;
|
||||||
|
|
||||||
|
final bool isHistoryLoading;
|
||||||
|
final List<BacktestHistoryEntryModel> history;
|
||||||
|
final String? historyErrorMessage;
|
||||||
|
|
||||||
|
/// Tunable indicator-parameter overrides for the currently selected strategy, keyed by bare parameter name
|
||||||
|
/// (e.g. `"EmaFast"`, NOT `"TrendPullbackFvg.EmaFast"`) - the `"{strategyKey}."` prefix required by the
|
||||||
|
/// backend (`TechnicalContext.ParameterOverrides`) is applied only when sending/loading, so this map stays
|
||||||
|
/// meaningful regardless of which strategy is currently selected. Reset whenever the strategy changes (see
|
||||||
|
/// [BacktestCubit.resetParameterOverrides]) - the same bare name means something different per strategy.
|
||||||
|
final Map<String, double> parameterOverrides;
|
||||||
|
|
||||||
|
final bool isParametersLoading;
|
||||||
|
final String? parametersMessage;
|
||||||
|
|
||||||
|
const BacktestState({
|
||||||
|
this.isLoading = false,
|
||||||
|
this.report,
|
||||||
|
this.errorMessage,
|
||||||
|
this.isViewingHistoricalRun = false,
|
||||||
|
this.isHistoryLoading = false,
|
||||||
|
this.history = const [],
|
||||||
|
this.historyErrorMessage,
|
||||||
|
this.parameterOverrides = const {},
|
||||||
|
this.isParametersLoading = false,
|
||||||
|
this.parametersMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
BacktestState copyWith({
|
||||||
|
bool? isLoading,
|
||||||
|
BacktestReportModel? report,
|
||||||
|
String? errorMessage,
|
||||||
|
bool clearError = false,
|
||||||
|
bool? isViewingHistoricalRun,
|
||||||
|
bool? isHistoryLoading,
|
||||||
|
List<BacktestHistoryEntryModel>? history,
|
||||||
|
String? historyErrorMessage,
|
||||||
|
bool clearHistoryError = false,
|
||||||
|
Map<String, double>? parameterOverrides,
|
||||||
|
bool? isParametersLoading,
|
||||||
|
String? parametersMessage,
|
||||||
|
bool clearParametersMessage = false,
|
||||||
|
}) {
|
||||||
|
return BacktestState(
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
report: report ?? this.report,
|
||||||
|
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||||
|
isViewingHistoricalRun: isViewingHistoricalRun ?? this.isViewingHistoricalRun,
|
||||||
|
isHistoryLoading: isHistoryLoading ?? this.isHistoryLoading,
|
||||||
|
history: history ?? this.history,
|
||||||
|
historyErrorMessage: clearHistoryError ? null : (historyErrorMessage ?? this.historyErrorMessage),
|
||||||
|
parameterOverrides: parameterOverrides ?? this.parameterOverrides,
|
||||||
|
isParametersLoading: isParametersLoading ?? this.isParametersLoading,
|
||||||
|
parametersMessage: clearParametersMessage ? null : (parametersMessage ?? this.parametersMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
isLoading,
|
||||||
|
report,
|
||||||
|
errorMessage,
|
||||||
|
isViewingHistoricalRun,
|
||||||
|
isHistoryLoading,
|
||||||
|
history,
|
||||||
|
historyErrorMessage,
|
||||||
|
parameterOverrides,
|
||||||
|
isParametersLoading,
|
||||||
|
parametersMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BacktestCubit extends Cubit<BacktestState> {
|
||||||
|
final SimulationRepository repository;
|
||||||
|
|
||||||
|
BacktestCubit({required this.repository}) : super(const BacktestState());
|
||||||
|
|
||||||
|
Future<void> runBacktest({
|
||||||
|
required String isin,
|
||||||
|
required String symbol,
|
||||||
|
required String strategyKey,
|
||||||
|
required String timeframe,
|
||||||
|
}) async {
|
||||||
|
emit(state.copyWith(isLoading: true, clearError: true, isViewingHistoricalRun: false));
|
||||||
|
try {
|
||||||
|
final prefixedParams = state.parameterOverrides.isEmpty
|
||||||
|
? null
|
||||||
|
: state.parameterOverrides.map((name, value) => MapEntry('$strategyKey.$name', value));
|
||||||
|
|
||||||
|
final report = await repository.runBacktest(
|
||||||
|
isin: isin,
|
||||||
|
symbol: symbol,
|
||||||
|
strategyKey: strategyKey,
|
||||||
|
timeframe: timeframe,
|
||||||
|
strategyParameters: prefixedParams,
|
||||||
|
);
|
||||||
|
emit(state.copyWith(isLoading: false, report: report, clearError: true, isViewingHistoricalRun: false));
|
||||||
|
// The run that just completed is now the newest history entry - refresh so it shows up immediately
|
||||||
|
// instead of the user only seeing it after manually reopening the history list.
|
||||||
|
await loadHistory(isin: isin, strategyKey: strategyKey);
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isLoading: false, errorMessage: 'Fehler beim Starten des Backtests: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadHistory({required String isin, String? strategyKey}) async {
|
||||||
|
if (isin.trim().isEmpty) {
|
||||||
|
emit(state.copyWith(history: const [], clearHistoryError: true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(isHistoryLoading: true, clearHistoryError: true));
|
||||||
|
try {
|
||||||
|
final history = await repository.getBacktestHistory(isin: isin, strategyKey: strategyKey);
|
||||||
|
emit(state.copyWith(isHistoryLoading: false, history: history, clearHistoryError: true));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isHistoryLoading: false, historyErrorMessage: 'Verlauf konnte nicht geladen werden: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> viewHistoricalRun(String runId) async {
|
||||||
|
emit(state.copyWith(isLoading: true, clearError: true));
|
||||||
|
try {
|
||||||
|
final report = await repository.getBacktestRunDetail(runId);
|
||||||
|
emit(state.copyWith(isLoading: false, report: report, clearError: true, isViewingHistoricalRun: true));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isLoading: false, errorMessage: 'Backtest-Lauf konnte nicht geladen werden: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a single tunable-parameter override (bare name, e.g. `"EmaFast"`) for the currently selected strategy.
|
||||||
|
void setParameterOverride(String paramName, double value) {
|
||||||
|
final updated = Map<String, double>.from(state.parameterOverrides);
|
||||||
|
updated[paramName] = value;
|
||||||
|
emit(state.copyWith(parameterOverrides: updated, clearParametersMessage: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all overrides - called whenever the selected strategy changes, since a bare parameter name means
|
||||||
|
/// something different per strategy (e.g. `"Period"` is an RSI period for one strategy, a Donchian-channel
|
||||||
|
/// length for another).
|
||||||
|
void resetParameterOverrides() {
|
||||||
|
emit(state.copyWith(parameterOverrides: const {}, clearParametersMessage: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a previously saved parameter profile for (isin, strategyKey) into [BacktestState.parameterOverrides].
|
||||||
|
Future<void> loadSavedParameters({required String isin, required String strategyKey}) async {
|
||||||
|
emit(state.copyWith(isParametersLoading: true, clearParametersMessage: true));
|
||||||
|
try {
|
||||||
|
final saved = await repository.getStrategyParameters(isin: isin, strategyKey: strategyKey);
|
||||||
|
if (saved == null) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
isParametersLoading: false,
|
||||||
|
parametersMessage: 'Kein gespeichertes Profil für dieses Asset/diese Strategie.',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefix = '$strategyKey.';
|
||||||
|
final bare = <String, double>{};
|
||||||
|
saved.forEach((key, value) {
|
||||||
|
if (key.startsWith(prefix)) bare[key.substring(prefix.length)] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
emit(state.copyWith(
|
||||||
|
isParametersLoading: false,
|
||||||
|
parameterOverrides: bare,
|
||||||
|
parametersMessage: 'Gespeichertes Profil geladen.',
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Fehler beim Laden des Profils: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the current [BacktestState.parameterOverrides] as a reusable profile for (isin, strategyKey).
|
||||||
|
Future<void> saveCurrentParameters({required String isin, required String strategyKey}) async {
|
||||||
|
emit(state.copyWith(isParametersLoading: true, clearParametersMessage: true));
|
||||||
|
try {
|
||||||
|
final prefixed = state.parameterOverrides.map((name, value) => MapEntry('$strategyKey.$name', value));
|
||||||
|
await repository.saveStrategyParameters(isin: isin, strategyKey: strategyKey, parameters: prefixed);
|
||||||
|
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Parameter-Profil gespeichert.'));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Fehler beim Speichern des Profils: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Typed counterpart of the backend `BacktestHistoryEntryDto`
|
||||||
|
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`) - one lightweight
|
||||||
|
/// row of a past backtest run, without the full trade list/equity curve
|
||||||
|
/// (fetch those via `SimulationRepository.getBacktestRunDetail` when the
|
||||||
|
/// user drills into a specific entry).
|
||||||
|
class BacktestHistoryEntryModel extends Equatable {
|
||||||
|
final String runId;
|
||||||
|
final String isin;
|
||||||
|
final String symbol;
|
||||||
|
final String strategyKey;
|
||||||
|
final String timeframe;
|
||||||
|
final DateTime startDateUtc;
|
||||||
|
final DateTime endDateUtc;
|
||||||
|
final int totalTrades;
|
||||||
|
final double winRatePercent;
|
||||||
|
final double profitFactor;
|
||||||
|
final double maxDrawdownPercent;
|
||||||
|
final double totalReturnPercent;
|
||||||
|
final double sharpeRatio;
|
||||||
|
final DateTime createdAtUtc;
|
||||||
|
|
||||||
|
const BacktestHistoryEntryModel({
|
||||||
|
required this.runId,
|
||||||
|
required this.isin,
|
||||||
|
required this.symbol,
|
||||||
|
required this.strategyKey,
|
||||||
|
required this.timeframe,
|
||||||
|
required this.startDateUtc,
|
||||||
|
required this.endDateUtc,
|
||||||
|
required this.totalTrades,
|
||||||
|
required this.winRatePercent,
|
||||||
|
required this.profitFactor,
|
||||||
|
required this.maxDrawdownPercent,
|
||||||
|
required this.totalReturnPercent,
|
||||||
|
required this.sharpeRatio,
|
||||||
|
required this.createdAtUtc,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BacktestHistoryEntryModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic val) {
|
||||||
|
if (val == null) return 0.0;
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
return double.tryParse(val.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime parseDate(dynamic val) {
|
||||||
|
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BacktestHistoryEntryModel(
|
||||||
|
runId: json['runId']?.toString() ?? '',
|
||||||
|
isin: json['isin']?.toString() ?? '',
|
||||||
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
|
strategyKey: json['strategyKey']?.toString() ?? '',
|
||||||
|
timeframe: json['timeframe']?.toString() ?? '',
|
||||||
|
startDateUtc: parseDate(json['startDateUtc']),
|
||||||
|
endDateUtc: parseDate(json['endDateUtc']),
|
||||||
|
totalTrades: (json['totalTrades'] as num?)?.toInt() ?? 0,
|
||||||
|
winRatePercent: parseDbl(json['winRatePercent']),
|
||||||
|
profitFactor: parseDbl(json['profitFactor']),
|
||||||
|
maxDrawdownPercent: parseDbl(json['maxDrawdownPercent']),
|
||||||
|
totalReturnPercent: parseDbl(json['totalReturnPercent']),
|
||||||
|
sharpeRatio: parseDbl(json['sharpeRatio']),
|
||||||
|
createdAtUtc: parseDate(json['createdAtUtc']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
runId,
|
||||||
|
isin,
|
||||||
|
symbol,
|
||||||
|
strategyKey,
|
||||||
|
timeframe,
|
||||||
|
startDateUtc,
|
||||||
|
endDateUtc,
|
||||||
|
totalTrades,
|
||||||
|
winRatePercent,
|
||||||
|
profitFactor,
|
||||||
|
maxDrawdownPercent,
|
||||||
|
totalReturnPercent,
|
||||||
|
sharpeRatio,
|
||||||
|
createdAtUtc,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Typed counterpart of the backend `EquityPointDto`
|
||||||
|
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||||
|
class EquityPointModel extends Equatable {
|
||||||
|
final DateTime timestampUtc;
|
||||||
|
final double portfolioValue;
|
||||||
|
final double drawdownPercent;
|
||||||
|
|
||||||
|
const EquityPointModel({
|
||||||
|
required this.timestampUtc,
|
||||||
|
required this.portfolioValue,
|
||||||
|
required this.drawdownPercent,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EquityPointModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic val) {
|
||||||
|
if (val == null) return 0.0;
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
return double.tryParse(val.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return EquityPointModel(
|
||||||
|
timestampUtc: DateTime.tryParse(json['timestampUtc']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
portfolioValue: parseDbl(json['portfolioValue']),
|
||||||
|
drawdownPercent: parseDbl(json['drawdownPercent']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [timestampUtc, portfolioValue, drawdownPercent];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed counterpart of the backend `BacktestReportDto`
|
||||||
|
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||||
|
///
|
||||||
|
/// `equityCurve` is intentionally a plain (possibly empty) list rather than
|
||||||
|
/// a fallback with a synthetic starting point: an empty list means "no
|
||||||
|
/// equity curve data returned" and MUST be rendered as an explicit empty
|
||||||
|
/// state, never as an invented chart (Rules.md §4).
|
||||||
|
class BacktestReportModel extends Equatable {
|
||||||
|
final String runId;
|
||||||
|
final String isin;
|
||||||
|
final String symbol;
|
||||||
|
final String strategyKey;
|
||||||
|
final String timeframe;
|
||||||
|
final int totalTrades;
|
||||||
|
final int winningTrades;
|
||||||
|
final int losingTrades;
|
||||||
|
final double winRatePercent;
|
||||||
|
final double profitFactor;
|
||||||
|
final double maxDrawdownPercent;
|
||||||
|
final double totalReturnPercent;
|
||||||
|
final double expectancyEur;
|
||||||
|
final double sharpeRatio;
|
||||||
|
final List<EquityPointModel> equityCurve;
|
||||||
|
|
||||||
|
const BacktestReportModel({
|
||||||
|
this.runId = '',
|
||||||
|
this.isin = '',
|
||||||
|
this.symbol = '',
|
||||||
|
this.strategyKey = '',
|
||||||
|
this.timeframe = '',
|
||||||
|
this.totalTrades = 0,
|
||||||
|
this.winningTrades = 0,
|
||||||
|
this.losingTrades = 0,
|
||||||
|
this.winRatePercent = 0.0,
|
||||||
|
this.profitFactor = 0.0,
|
||||||
|
this.maxDrawdownPercent = 0.0,
|
||||||
|
this.totalReturnPercent = 0.0,
|
||||||
|
this.expectancyEur = 0.0,
|
||||||
|
this.sharpeRatio = 0.0,
|
||||||
|
this.equityCurve = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BacktestReportModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
double parseDbl(dynamic val) {
|
||||||
|
if (val == null) return 0.0;
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
return double.tryParse(val.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawCurve = json['equityCurve'];
|
||||||
|
final curve = rawCurve is List
|
||||||
|
? rawCurve
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => EquityPointModel.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList()
|
||||||
|
: const <EquityPointModel>[];
|
||||||
|
|
||||||
|
return BacktestReportModel(
|
||||||
|
runId: json['runId']?.toString() ?? '',
|
||||||
|
isin: json['isin']?.toString() ?? '',
|
||||||
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
|
strategyKey: json['strategyKey']?.toString() ?? '',
|
||||||
|
timeframe: json['timeframe']?.toString() ?? '',
|
||||||
|
totalTrades: (json['totalTrades'] as num?)?.toInt() ?? 0,
|
||||||
|
winningTrades: (json['winningTrades'] as num?)?.toInt() ?? 0,
|
||||||
|
losingTrades: (json['losingTrades'] as num?)?.toInt() ?? 0,
|
||||||
|
winRatePercent: parseDbl(json['winRatePercent']),
|
||||||
|
profitFactor: parseDbl(json['profitFactor']),
|
||||||
|
maxDrawdownPercent: parseDbl(json['maxDrawdownPercent']),
|
||||||
|
totalReturnPercent: parseDbl(json['totalReturnPercent']),
|
||||||
|
expectancyEur: parseDbl(json['expectancyEur']),
|
||||||
|
sharpeRatio: parseDbl(json['sharpeRatio']),
|
||||||
|
equityCurve: curve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
runId,
|
||||||
|
isin,
|
||||||
|
symbol,
|
||||||
|
strategyKey,
|
||||||
|
timeframe,
|
||||||
|
totalTrades,
|
||||||
|
winningTrades,
|
||||||
|
losingTrades,
|
||||||
|
winRatePercent,
|
||||||
|
profitFactor,
|
||||||
|
maxDrawdownPercent,
|
||||||
|
totalReturnPercent,
|
||||||
|
expectancyEur,
|
||||||
|
sharpeRatio,
|
||||||
|
equityCurve,
|
||||||
|
];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user