Compare commits

..

36 Commits

Author SHA1 Message Date
Kleidukos 567ddea46a chore(infra): update compose configurations, mqtt health bridge, and export scripts 2026-09-01 17:38:57 +02:00
Kleidukos 2fe8cc0dca fix(assets): safeguard against null InstrumentCategory during ETF/Fund scans 2026-09-01 17:38:46 +02:00
Kleidukos d161efb370 fix(simulation,technicals): fix EMA 200 warmup distortion, intrabar execution path, and zero slippage 2026-09-01 17:38:34 +02:00
Kleidukos cb8a169043 feat(technicals,engine): add V2 multi-timeframe scoring, SMC patterns, and COS V2 engine 2026-09-01 17:38:13 +02:00
Kleidukos c5d7d359ba feat(notify): add FinlyticNotify push notification microservice and test suite 2026-09-01 17:38:01 +02:00
Kleidukos 6a0f9af3f8 docs: add comprehensive platform STATE.md and diagnostic PROBLEMS.md 2026-09-01 17:37:49 +02:00
Kleidukos 2b7d59f40d chore: remove deprecated microservices, update solution and docker compose configurations 2026-08-24 21:37:57 +02:00
Kleidukos 0894c40f07 feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core 2026-08-24 21:37:43 +02:00
Kleidukos 676496b77d feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware 2026-08-24 21:37:32 +02:00
Kleidukos 6974b2075b refactor(assets): update asset entity mappings, database migrations, and MQTT RPC handlers 2026-08-24 21:37:21 +02:00
Kleidukos 7060f0f7b1 refactor(bot): update paper trading models, broker integration, background services, and test project 2026-08-24 21:37:10 +02:00
Kleidukos 5c95dd182c feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests 2026-08-24 21:37:05 +02:00
Kleidukos a4959658a2 feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine 2026-08-24 21:36:20 +02:00
Kleidukos f43ce2b7e9 feat(technicals): add technical analysis microservice with indicator engines, pattern detectors, and strategies 2026-08-24 21:36:05 +02:00
Kleidukos 12e7b57b16 feat(sentiment): add persistent sentiment entities, summaries, SentimentDbService and MQTT RPC refactoring 2026-08-24 21:35:48 +02:00
Kleidukos 600ccf299e feat(fundamentals): add KeyedLockPool for concurrent scraping synchronization and update MQTT RPC handlers 2026-08-24 21:35:43 +02:00
Kleidukos 8112598602 feat(news): add scraper adapters, article deduplication, blocklist service, and remove tracked publish artifacts 2026-08-24 21:35:33 +02:00
Kleidukos 44b161d509 feat(core): add shared DTOs, MqttTopics constants, DatabaseBootstrapper, and ManagedMqttClient extensions 2026-08-24 21:35:24 +02:00
Kleidukos 6ab84fe1de chore: update .gitignore and add developer documentation 2026-08-24 21:35:11 +02:00
Kleidukos 5497cc5de7 feat(bot): add FinlyticBot autonomous paper trading microservice with Alpaca Markets API integration 2026-08-17 16:32:47 +02:00
Kleidukos 3972507cb0 feat(settings): add dynamic N8N webhook URL configuration for News, Sentiment and Analyzer services 2026-08-17 16:10:31 +02:00
Kleidukos 75a2510b39 feat(frontend): make baseUrl dynamic for external network access and clear default login inputs 2026-08-16 16:59:36 +02:00
Kleidukos aaef272f4e fix(backend): resolve TR json serialization, news concurrency and asset index locks 2026-08-16 16:57:34 +02:00
Kleidukos d8ba28a810 fix(news): normalize URL and clear change tracker on insert conflict to prevent concurrency exception 2026-08-16 14:16:02 +02:00
Kleidukos b5cc70c08c fix(news): use CreateAsyncScope for Playwright service and gracefully handle DbUpdateException on duplicate URLs 2026-08-16 14:11:18 +02:00
Kleidukos 2ba54e8057 feat(fundamentals): multi-ticker DB caching, parallel html scraper, and frontend mappings 2026-08-16 14:05:57 +02:00
Kleidukos b0f8d4b78b feat(app): live terminal log console widget and service dynamic settings management 2026-08-15 21:31:00 +02:00
Kleidukos f18f75c1ab feat(backend): generic settings RPC bridge, LogStreamHub SignalR, and log ringbuffer 2026-08-15 21:30:56 +02:00
Kleidukos 1f9d66405a feat(assets): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:46 +02:00
Kleidukos 57554a9582 feat(trades): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:19 +02:00
Kleidukos 0d370d09e7 feat(analyzer): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:16 +02:00
Kleidukos 62e030e2cf feat(sentiment): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:12 +02:00
Kleidukos 1522c3480f feat(ta): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:05 +02:00
Kleidukos a94c36a878 feat(news): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:30:01 +02:00
Kleidukos a1f2b888f6 feat(fundamentals): dynamic settings, IFinlyticLogger, live log streaming, and EF migration 2026-08-15 21:29:50 +02:00
Kleidukos 3dbee36ca0 feat(core): dynamic settings service, IFinlyticLogger, log broadcaster, and persistent Yahoo auth 2026-08-15 21:29:38 +02:00
899 changed files with 153527 additions and 205480 deletions
+13
View File
@@ -23,3 +23,16 @@
**/values.dev.yaml
LICENSE
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/
+5
View File
@@ -1,6 +1,8 @@
## Build outputs
**/bin/
**/obj/
**/publish/
TestResults/
## Rider / JetBrains / VS Code / Visual Studio
.idea/
@@ -22,6 +24,8 @@ assets/
## Secrets & local environment files
.env
.env.*
.env.bak*
*.env.local
appsettings.Development.json
@@ -36,4 +40,5 @@ appsettings.Development.json
## Temporary data & scratch
Yahoo finance data/
*.tmp
*.tar
FinlyticApp/lib/tickers_grep.json
+137
View File
@@ -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`).
+128
View File
@@ -0,0 +1,128 @@
<#
.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",
"finlyticnotify",
"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 ""
+110 -26
View File
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}"
EndProject
@@ -15,14 +15,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticFundamentals", "Fin
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSentiment", "FinlyticSentiment\FinlyticSentiment.csproj", "{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicalAnalysis", "FinlyticTechnicalAnalysis\FinlyticTechnicalAnalysis.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}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicals", "FinlyticTechnicals\FinlyticTechnicals.csproj", "{A1C82F63-4482-4E99-9231-1184FA2E001F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}"
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify", "FinlyticNotify\FinlyticNotify.csproj", "{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify.Tests", "FinlyticNotify.Tests\FinlyticNotify.Tests.csproj", "{6E54FE48-A814-469C-B2E4-67C0EB575A9E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -69,18 +79,6 @@ Global
{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.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.Build.0 = Debug|Any CPU
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -107,18 +105,104 @@ Global
{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.Build.0 = Debug|Any CPU
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Release|Any CPU.Build.0 = Release|Any CPU
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Debug|Any CPU.Build.0 = Debug|Any CPU
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Release|Any CPU.ActiveCfg = Release|Any CPU
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Release|Any CPU.Build.0 = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.Build.0 = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.Build.0 = Debug|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.Build.0 = Release|Any CPU
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.ActiveCfg = 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.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.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
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.Build.0 = Debug|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.Build.0 = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.ActiveCfg = Release|Any CPU
{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.ActiveCfg = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.Build.0 = Debug|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.Build.0 = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.ActiveCfg = Release|Any CPU
{6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1,208 +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 FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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 ILogger<ManualAnalysisController> _logger;
public ManualAnalysisController(
IVixTrackerService vixTracker,
IN8nEvaluationService n8nService,
IWinRateCalculator winRateCalculator,
AnalyzerDbContext dbContext,
ILogger<ManualAnalysisController> logger)
{
_vixTracker = vixTracker;
_n8nService = n8nService;
_winRateCalculator = winRateCalculator;
_dbContext = dbContext;
_logger = logger;
}
/// <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);
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,41 +0,0 @@
using FinlyticAnalyzer.Entities;
using FinlyticCore.Entities.Settings;
using Microsoft.EntityFrameworkCore;
namespace FinlyticAnalyzer.Database;
public class AnalyzerDbContext : DbContext
{
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);
});
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);
});
}
}
-16
View File
@@ -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);
}
-26
View File
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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,274 +0,0 @@
// <auto-generated />
using System;
using FinlyticAnalyzer.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticAnalyzer.Migrations
{
[DbContext(typeof(AnalyzerDbContext))]
partial class AnalyzerDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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
}
}
}
-59
View File
@@ -1,59 +0,0 @@
using System;
using FinlyticAnalyzer.Database;
using FinlyticAnalyzer.Services;
using FinlyticAnalyzer.Util;
using FinlyticCore.Services.Yahoo;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
// Register DB Context
builder.Services.AddDbContext<AnalyzerDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// 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.");
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
await settingsService.GetSettingsAsync();
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred during database migration for FinlyticAnalyzer on startup.");
}
}
// Initial VIX Poll
using (var scope = host.Services.CreateScope())
{
var vixService = scope.ServiceProvider.GetRequiredService<IVixTrackerService>();
await vixService.PollVixAsync();
}
await host.RunAsync();
-37
View File
@@ -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,358 +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.Util;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services;
public class ActiveTradeMonitorWorker : BackgroundService
{
private readonly ILogger<ActiveTradeMonitorWorker> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly AnalyzerMqttClient _mqttClient;
public ActiveTradeMonitorWorker(ILogger<ActiveTradeMonitorWorker> logger, IServiceScopeFactory scopeFactory,
AnalyzerMqttClient mqttClient)
{
_logger = logger;
_scopeFactory = scopeFactory;
_mqttClient = mqttClient;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker started.", "AnalyzerChannel");
try
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await MonitorActiveTradesAsync(stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogError(ex, "[{Channel}] Error in ActiveTradeMonitorWorker loop.", "AnalyzerChannel");
}
try
{
await Task.Delay(TimeSpan.FromMinutes(60), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker stopped.", "AnalyzerChannel");
}
private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken)
{
if (!_mqttClient.IsConnected)
{
_logger.LogWarning("[{Channel}] Skipping trade monitoring. RPC client not connected.", "AnalyzerChannel");
return;
}
// Fetch active trades
var activeTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get",
new GetTradesRequest(null, "Active"),
TimeSpan.FromSeconds(10));
// Fetch proposed global trades
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)
{
_logger.LogInformation("[{Channel}] No active or proposed global trades found to monitor.",
"AnalyzerChannel");
return;
}
_logger.LogInformation("[{Channel}] Found {Count} trades to monitor. Starting evaluation...", "AnalyzerChannel",
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)
{
_logger.LogError(ex, "[{Channel}] Failed to monitor trade {TradeId} ({Symbol}).", "AnalyzerChannel",
trade.TradeId, trade.Symbol);
}
}
}
private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService,
IVixTrackerService vixService, CancellationToken cancellationToken)
{
// 1. Get Live Price
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;
// 2. Evaluate Hard Stops (StopLoss / TakeProfit / TimeStop)
bool isLong = string.Equals(trade.SignalType, "BUY", StringComparison.OrdinalIgnoreCase) ||
string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase);
// Time-Stop Evaluierung
int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe);
double daysOpen = (DateTime.UtcNow - trade.CreatedAt).TotalDays;
// 50% Grace Period. Bei z.B. 10 Tagen max. Haltedauer wird nach 15 Tagen ohne Zielerreichung glattgestellt.
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;
}
}
// 3. Run AI evaluation for soft/dynamic updates
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)
{
_logger.LogWarning("[{Channel}] AI evaluation returned null for {TradeId}. Skipping update.",
"AnalyzerChannel", trade.TradeId);
return;
}
string newRecommendation = "Hold";
string reasoning = aiResponse.AiReasoning;
decimal? newStopLoss = trade.StopLoss;
decimal? newTakeProfit = trade.TakeProfit;
// Check for trend reversal
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)
{
// Ratchet / Trailing Logic: StopLoss darf das Risiko nicht vergrößern!
if (aiResponse.ExecutionPlan.StopLoss > 0)
{
var proposedSl = aiResponse.ExecutionPlan.StopLoss;
if (isLong)
{
// Bei Long darf der StopLoss nur NACH OBEN angepasst werden
if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss)
{
newStopLoss = proposedSl;
if (proposedSl > trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL";
}
}
else
{
// Bei Short darf der StopLoss nur NACH UNTEN angepasst werden
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
};
// Direktes Objekt-Publishing nutzen (ManagedMqttClient serialisiert typgerecht)
string topic = $"finlytic/trades/updates/{trade.Isin}";
await _mqttClient.PublishAsync(topic, update);
_logger.LogInformation(
"[{Channel}] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}",
"AnalyzerChannel", trade.TradeId, topic, recommendation, reasoning);
}
private static int EstimateMaxHoldingDays(string timeframe)
{
if (string.IsNullOrWhiteSpace(timeframe)) return 14; // Default
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; // Mindestens 3 Tage Kulanz
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,114 +0,0 @@
using System.Text.Json;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Util;
namespace FinlyticAnalyzer.Services;
public class N8nEvaluationService : IN8nEvaluationService
{
private readonly HttpClient _httpClient;
private readonly ILogger<N8nEvaluationService> _logger;
private readonly string _webhookUrl;
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, ILogger<N8nEvaluationService> logger)
{
_httpClient = httpClient;
_logger = logger;
_webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? string.Empty;
if (string.IsNullOrWhiteSpace(_webhookUrl))
{
_logger.LogWarning("[{Channel}] N8N:WebhookUrl configuration is missing or empty.", "AnalyzerChannel");
}
// Timeout auf 45 Sekunden erhöht für komplexere LLM/Gemini Chains in n8n
_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))
{
_logger.LogError("[{Channel}] Cannot execute AI evaluation for {Symbol}: N8N:WebhookUrl is not configured.", "AnalyzerChannel", request.TargetAsset.Symbol);
return null;
}
try
{
_logger.LogInformation("[{Channel}] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...",
"AnalyzerChannel", request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl);
// Typsichere AOT-Serialisierung verwenden
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() == "[]")
{
_logger.LogWarning("[{Channel}] n8n Webhook returned an EMPTY response for Request {RequestId}. Flagging as AI Rejection (Too Risky).", "AnalyzerChannel", request.RequestId);
return CreateRejectionFallback(request, "Die KI (n8n/Gemini) stuft den Trade als zu riskant ein und empfiehlt keine Positionierung.");
}
// N8n schickt Ergebnisse manchmal als JSON-Array [{...}] zurück
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))
{
_logger.LogInformation("[{Channel}] Received n8n AI Response for Request {RequestId}: Decision={Decision}, Score={Score:F2}, Direction={Direction}, Timeframe={Timeframe}",
"AnalyzerChannel", request.RequestId, responseDto.AiDecision, responseDto.EvalScore, responseDto.SuggestedDirection, responseDto.SuggestedTimeframe);
return responseDto;
}
}
else
{
_logger.LogWarning("[{Channel}] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}",
"AnalyzerChannel", response.StatusCode, request.RequestId);
}
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogError(ex, "[{Channel}] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", "AnalyzerChannel", request.RequestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Error calling n8n AI Evaluation Webhook for Request {RequestId}", "AnalyzerChannel", request.RequestId);
}
return null; // Signals RPC/Service failure to caller
}
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,145 +0,0 @@
using System;
using System.Collections.Concurrent;
using FinlyticCore.Dtos.News;
using FinlyticCore.Models.Analyzer;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services;
public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
{
private readonly ILogger<ThreeLayerFilterEngine> _logger;
private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new();
private readonly object _cleanupLock = new();
private DateTime _lastCleanupTime = DateTime.UtcNow;
public ThreeLayerFilterEngine(ILogger<ThreeLayerFilterEngine> logger)
{
_logger = logger;
}
/// <summary>
/// Evaluates news strictly based on ISIN and dynamic VIX market regime.
/// </summary>
public FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime)
{
var result = new FilterResult();
// -------------------------------------------------------------
// Layer 1: Relevance, ISIN & Deduplication
// -------------------------------------------------------------
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;
// Safely clean up dictionary every 30 minutes (thread-safe lock)
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
{
lock (_cleanupLock)
{
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
{
CleanupSeenEvents(now);
}
}
}
// Deduplication check (keep history for 12 hours)
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;
// Extract parameters strictly from MatchedAssets
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;
}
// Mandatory check: Must have a valid ISIN
if (string.IsNullOrWhiteSpace(isin))
{
result.Passed = false;
result.RejectReason = "Layer 1: Missing mandatory ISIN for news item";
return result;
}
result.Isin = isin;
// Asset-Symbol fallback to ISIN, Name is mapped appropriately later
result.Symbol = isin;
result.Sector = "General"; // Will be enriched downstream via Fundamentals RPC if available
// -------------------------------------------------------------
// Layer 2: Impact & Dynamic VIX Threshold
// -------------------------------------------------------------
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}";
_logger.LogInformation("[{Channel}] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}",
"AnalyzerChannel", eventId, isin, impactScore, requiredThreshold, regime);
return result;
}
// -------------------------------------------------------------
// Layer 3: Dynamic Parameter & Risk Engine
// -------------------------------------------------------------
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;
_logger.LogInformation("[{Channel}] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}",
"AnalyzerChannel", 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,101 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Services.Yahoo;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services;
public class VixTrackerService : IVixTrackerService
{
private readonly YahooFinanceClient _yahooClient;
private readonly ILogger<VixTrackerService> _logger;
private decimal _currentVix = 18.5m; // Default: Normal Regime
private VixMarketRegime _currentRegime = VixMarketRegime.Normal;
private readonly object _lock = new();
public VixTrackerService(YahooFinanceClient yahooClient, ILogger<VixTrackerService> logger)
{
_yahooClient = yahooClient;
_logger = logger;
}
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)
{
_logger.LogWarning("[{Channel}] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
"AnalyzerChannel", oldRegime, _currentRegime, _currentVix);
}
else if (Math.Abs(oldVix - vixValue) >= 0.5m)
{
_logger.LogInformation("[{Channel}] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
"AnalyzerChannel", _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)
{
// Graceful shutdown
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.",
"AnalyzerChannel", 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,175 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services;
public class WinRateCalculator : IWinRateCalculator
{
private readonly ILogger<WinRateCalculator> _logger;
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(ILogger<WinRateCalculator> logger)
{
_logger = logger;
_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.
/// Uses cached feedback records (3-minute TTL) to prevent disk I/O bottlenecks.
/// </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
{
// 1. N8n AI Confidence Score (Weight: 40%)
double n8nComponent = 62.0;
if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0)
{
n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value;
}
// 2. Technical Score (Weight: 30%)
double taComponent = 60.0;
if (technicalScore.HasValue && technicalScore.Value > 0)
{
taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value;
}
// 3. Sentiment Score (Weight: 15%)
double sentComponent = 58.0;
if (sentimentScore.HasValue)
{
if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0)
{
// Map sentiment from -1.0..+1.0 into 35.0..85.0
sentComponent = 50.0 + (sentimentScore.Value * 25.0);
}
else
{
sentComponent = sentimentScore.Value;
}
}
// 4. Fundamental Score (Weight: 15%)
double fundComponent = 60.0;
if (fundamentalScore.HasValue && fundamentalScore.Value > 0)
{
fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value;
}
// Multi-factor weighted composite
double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15);
// 5. Market Regime & Volatility Adjustment
double vixAdjustment = regime switch
{
VixMarketRegime.LowVol => +4.0, // Calm trending market
VixMarketRegime.Normal => +1.5, // Normal conditions
VixMarketRegime.HighVol => -3.5, // Increased whipsaws
VixMarketRegime.Panic => -8.0, // High panic / uncertainty
_ => 0.0
};
composite += vixAdjustment;
// 6. Historical track record calibration (if available in feedback records)
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);
}
}
// Clamp between realistic financial statistical bounds (45.0% to 92.0%)
double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0);
_logger.LogInformation("[{Channel}] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]",
"AnalyzerChannel", symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime);
return finalWinRate;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Error calculating dynamic win-rate for {Symbol}. Fallback applied.", "AnalyzerChannel", 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)
{
_logger.LogWarning(ex, "[{Channel}] Failed to read or parse feedback file '{File}'", "AnalyzerChannel", file);
}
}
}
_cachedRecords = loadedList;
_lastCacheTime = DateTime.UtcNow;
return _cachedRecords;
}
}
}
-859
View File
@@ -1,859 +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.Models;
using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades;
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("[{Channel}] Starting Unified Analyzer MQTT Client. Host: {Host}, ClientId: {ClientId}", "AnalyzerChannel", config.Host, config.ClientId);
await ConnectAsync(config);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[{Channel}] Stopping Unified Analyzer MQTT Client.", "AnalyzerChannel");
await DisconnectAsync();
}
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("[{Channel}] Analyzer MQTT Client connected. Subscribing to topics and RPC response channels...", "AnalyzerChannel");
// 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("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/trades_Get/#");
await SubscribeAsync("services/response/tr_GetLivePrice/#");
_logger.LogInformation("[{Channel}] Successfully subscribed to all event and RPC channels.", "AnalyzerChannel");
}
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);
if (LogCategoryFilter.IsEnabled(LogCategory.MqttHealthPing))
{
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "AnalyzerChannel", correlationId);
}
}
return;
}
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
{
if (topic.EndsWith("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Received config update event for FinlyticAnalyzer.", "AnalyzerChannel");
try
{
var configUpdate = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
if (configUpdate?.Settings != null && configUpdate.Settings.Count > 0)
{
using var scope = _scopeFactory.CreateScope();
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
await settingsDb.UpdateSettingsFromDictionaryAsync(configUpdate.Settings);
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Persisted {Count} updated settings to FinlyticAnalyzer database.", "AnalyzerChannel", configUpdate.Settings.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] [AnalyzerMqttClient] Error processing MQTT config update event.", "AnalyzerChannel");
}
}
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("finlytic/trades/closed/"))
{
await HandleClosedTradeFeedbackAsync(payloadStr);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Error processing incoming MQTT message on topic {Topic}", "AnalyzerChannel", topic);
}
}
private async Task HandleClosedTradeFeedbackAsync(string payloadStr)
{
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));
_logger.LogInformation("[{Channel}] Processed closed trade feedback for {TradeId}. Saved to {FilePath}", "AnalyzerChannel", closedDto.TradeId, filePath);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Error processing closed trade feedback.", "AnalyzerChannel");
}
}
private async Task HandleManualTriggerAsync(string correlationId, string payloadStr, CancellationToken cancellationToken)
{
try
{
var manualReq = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ManualAnalysisRpcRequest);
if (manualReq == null || string.IsNullOrWhiteSpace(manualReq.Isin))
{
_logger.LogWarning("[{Channel}] Manual trigger received without valid request or ISIN.", "AnalyzerChannel");
return;
}
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerManual))
{
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", "AnalyzerChannel", manualReq.Isin, manualReq.Symbol, correlationId);
}
using var scope = _scopeFactory.CreateScope();
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<ISettingsDbService>();
var settings = await settingsService.GetSettingsAsync();
double minSignalScore = settings.MinSignalScore;
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);
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [DISPATCHED] Dispatched Manual Trade Proposal {AnalysisId} to topic {Topic}", "AnalyzerChannel", analysisId, propTopic);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Failed to handle manual trigger for correlation {CorrelationId}.", "AnalyzerChannel", correlationId);
try
{
var errorResponse = new ManualAnalysisResponseDto
{
Status = "ERROR",
Message = $"Analysis failed: {ex.Message}"
};
await PublishAsync($"services/response/analyzer_TriggerManual/{correlationId}", errorResponse);
}
catch (Exception pubEx)
{
_logger.LogError(pubEx, "[{Channel}] Failed to publish error response for correlation {CorrelationId}.", "AnalyzerChannel", 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, "[{Channel}] Failed to parse VIX tick message.", "AnalyzerChannel");
}
}
}
private async Task ProcessNewsMessageAsync(string payloadStr, CancellationToken cancellationToken)
{
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)
{
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
{
_logger.LogInformation("[{Channel}] [AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", "AnalyzerChannel", filterResult.Isin, filterResult.RejectReason);
}
return;
}
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
{
_logger.LogInformation("[{Channel}] [AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", "AnalyzerChannel", 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);
// Parallel RPC calls (was sequential — up to 12s latency reduced to ~3s)
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;
// FinBERT compound score is in range [-1.0, +1.0]. Normalize to [0.0, 1.0] for AI prompt context
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)
{
_logger.LogWarning(ex, "[{Channel}] Failed to fetch context data for auto screener analysis.", "AnalyzerChannel");
}
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);
double minSignalScore = 75.0;
using (var scope = _scopeFactory.CreateScope())
{
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
var settings = await settingsService.GetSettingsAsync();
minSignalScore = settings.MinSignalScore;
}
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");
using (var scope = _scopeFactory.CreateScope())
{
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)
{
_logger.LogInformation("[{Channel}] [AutoScreener] Asset {Symbol} ({Isin}) already has an active trade proposal in the last 4 hours. Skipping duplicate trade proposal generation.",
"AnalyzerChannel", 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);
_logger.LogInformation("[{Channel}] [AutoScreener] Dispatched High-Conviction Proposal {TradeId} to topic {Topic}", "AnalyzerChannel", 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);
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
{
_logger.LogInformation("[{Channel}] [AutoScreener] [RECOMMENDED] High-Conviction Opportunity found for {Symbol} (Bias: {Bias}, Confidence: {Score:F2}). Published to {Topic}",
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.Bias, recommendation.RecommendedAsset.ConfidenceScore, recTopic);
}
}
else
{
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
{
_logger.LogInformation("[{Channel}] [AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
}
}
}
private static string n8nReasoning(N8nAnalysisResponseDto? resp) => resp?.AiReasoning ?? string.Empty;
}
-16
View File
@@ -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
-86
View File
@@ -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 3040 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
-71
View File
@@ -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.
+11 -2
View File
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../services/secure_storage_service.dart';
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
@@ -7,7 +8,15 @@ class ApiClient {
late final Dio _dio;
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) {
_dio = Dio(
@@ -29,7 +38,7 @@ class ApiClient {
return handler.next(options);
},
onError: (DioException error, handler) async {
if (error.response?.statusCode == 401) {
if (error.response?.statusCode == 401 || error.response?.statusCode == 403) {
await _storageService.clearAll();
onUnauthorized?.call();
}
+155 -25
View File
@@ -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:signalr_core/signalr_core.dart';
import '../services/secure_storage_service.dart';
/// 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 {
final SecureStorageService storageService;
HubConnection? _healthConnection;
HubConnection? _favoritesConnection;
HubConnection? _tradeStreamConnection;
HubConnection? _logsConnection;
HubConnection? _newsConnection;
bool _isConnected = false;
final _statusController = StreamController<bool>.broadcast();
final _healthController = StreamController<List<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;
Stream<bool> get connectionStream => _statusController.stream;
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.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);
@@ -29,14 +45,18 @@ class SignalRService extends ChangeNotifier {
if (_isConnected) return;
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
_healthConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/health',
HttpConnectionOptions(
accessTokenFactory: () async => token,
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, 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
_favoritesConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/hubs/favorites-prices',
HttpConnectionOptions(
accessTokenFactory: () async => token,
accessTokenFactory: tokenFactory,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
@@ -91,8 +104,126 @@ class SignalRService extends ChangeNotifier {
}
});
await _favoritesConnection!.start();
if (kDebugMode) debugPrint('[SignalR Favorites WS] Connected via WebSocket to /hubs/favorites-prices.');
// 3. Connect TradeStreamHub over WebSockets (Engine & Bot Real-Time Streams)
_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;
_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 {
try {
await _healthConnection?.stop();
await _favoritesConnection?.stop();
await _tradeStreamConnection?.stop();
await _logsConnection?.stop();
await _newsConnection?.stop();
} catch (_) {}
_isConnected = false;
_statusController.add(false);
@@ -131,6 +255,12 @@ class SignalRService extends ChangeNotifier {
_statusController.close();
_healthController.close();
_favoritePricesController.close();
_tradeProposalController.close();
_tradeUpdateController.close();
_botPositionController.close();
_portfolioSummaryController.close();
_logMessageController.close();
_newsArticleController.close();
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,44 @@
class LogMessageDto {
final DateTime timestamp;
final String serviceName;
final String channel;
final String level; // 'Information', 'Warning', 'Error', 'Debug', 'Trace'
final String message;
final String? exception;
const LogMessageDto({
required this.timestamp,
required this.serviceName,
required this.channel,
required this.level,
required this.message,
this.exception,
});
factory LogMessageDto.fromJson(Map<String, dynamic> json) {
DateTime parsedTime = DateTime.now();
if (json['timestamp'] != null) {
parsedTime = DateTime.tryParse(json['timestamp'].toString()) ?? DateTime.now();
}
return LogMessageDto(
timestamp: parsedTime.toLocal(),
serviceName: json['serviceName']?.toString() ?? '',
channel: json['channel']?.toString() ?? '',
level: json['level']?.toString() ?? 'Information',
message: json['message']?.toString() ?? '',
exception: json['exception']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'timestamp': timestamp.toUtc().toIso8601String(),
'serviceName': serviceName,
'channel': channel,
'level': level,
'message': message,
'exception': exception,
};
}
}
@@ -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];
}
@@ -1,11 +1,13 @@
class ServiceSettingDto {
final String key;
final String value;
final String type; // 'bool', 'int', 'double', 'string'
final String description;
const ServiceSettingDto({
required this.key,
required this.value,
this.type = 'string',
this.description = '',
});
@@ -13,6 +15,7 @@ class ServiceSettingDto {
return ServiceSettingDto(
key: json['key']?.toString() ?? '',
value: json['value']?.toString() ?? '',
type: json['dataType']?.toString() ?? json['type']?.toString() ?? 'string',
description: json['description']?.toString() ?? '',
);
}
@@ -21,6 +24,7 @@ class ServiceSettingDto {
return {
'key': key,
'value': value,
'type': type,
'description': description,
};
}
@@ -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/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_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/watchlist_entry_model.dart';
class AdminRepository {
final ApiClient apiClient;
@@ -54,10 +59,92 @@ class AdminRepository {
return {};
}
Future<void> updateServiceSettings(String serviceName, Map<String, String> settings) async {
Future<void> updateServiceSettings(String serviceName, Map<String, dynamic> settings) async {
final res = await apiClient.put('/api/v1/admin/settings/$serviceName', data: settings);
if (res.statusCode != 200 && res.statusCode != 204) {
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'),
),
],
),
],
),
],
);
}
}
@@ -6,6 +6,7 @@ import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/service_setting_dto.dart';
import '../repositories/admin_repository.dart';
import '../widgets/live_log_console.dart';
class ServiceDetailScreen extends StatefulWidget {
final String serviceName;
@@ -75,9 +76,20 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final payload = <String, String>{};
final payload = <String, dynamic>{};
_controllers.forEach((k, v) {
payload[k] = v.text;
final text = v.text.trim();
if (text.toLowerCase() == 'true') {
payload[k] = true;
} else if (text.toLowerCase() == 'false') {
payload[k] = false;
} else if (int.tryParse(text) != null) {
payload[k] = int.parse(text);
} else if (double.tryParse(text) != null) {
payload[k] = double.parse(text);
} else {
payload[k] = text;
}
});
await _repository.updateServiceSettings(widget.serviceName, payload);
@@ -119,7 +131,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
}
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('Minutes', '(Minuten)')
.replaceAll('Seconds', '(Sekunden)')
@@ -131,6 +147,187 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
.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
Widget build(BuildContext context) {
return Scaffold(
@@ -167,49 +364,7 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
if (_settings.isEmpty)
const Text('Keine spezifischen Einstellungen gefunden.')
else
..._settings.map((s) {
final key = s.key;
final desc = s.description;
final controller = _controllers[key];
if (controller == null) return const SizedBox.shrink();
final isBoolean = 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: 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,
onChanged: (val) => setState(() => controller.text = val.toString()),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextField(
controller: controller,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: _formatLabel(key),
helperText: desc.isNotEmpty ? desc : null,
helperMaxLines: 2,
prefixIcon: Icon(Icons.tune_outlined, size: 18, color: AppTheme.primaryEmerald),
),
),
);
}).toList(),
..._buildGroupedSettingsSections(),
const SizedBox(height: 12),
if (_settings.isNotEmpty)
@@ -236,16 +391,9 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
),
),
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)),
],
),
LiveLogConsole(
serviceName: widget.serviceName,
apiClient: widget.apiClient,
),
],
),
@@ -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),
],
),
);
}
}
@@ -0,0 +1,407 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:signalr_core/signalr_core.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/services/secure_storage_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/log_message_dto.dart';
class LiveLogConsole extends StatefulWidget {
final String serviceName;
final ApiClient apiClient;
const LiveLogConsole({
super.key,
required this.serviceName,
required this.apiClient,
});
@override
State<LiveLogConsole> createState() => _LiveLogConsoleState();
}
class _LiveLogConsoleState extends State<LiveLogConsole> {
HubConnection? _hubConnection;
final List<LogMessageDto> _logs = [];
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
bool _isConnected = false;
bool _isPaused = false;
bool _autoScroll = true;
String _selectedLevel = 'ALL';
String _searchQuery = '';
@override
void initState() {
super.initState();
_fetchInitialLogs();
_connectSignalR();
}
@override
void dispose() {
_disconnectSignalR();
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _fetchInitialLogs() async {
try {
final res = await widget.apiClient.get('/api/v1/admin/settings/logs/${widget.serviceName}');
if (res.data is List) {
final list = (res.data as List).map((item) => LogMessageDto.fromJson(Map<String, dynamic>.from(item as Map))).toList();
if (mounted) {
setState(() {
_logs.addAll(list);
});
_scrollToBottomIfNeeded();
}
}
} catch (e) {
if (kDebugMode) debugPrint('[LiveLogConsole] Error fetching initial logs: $e');
}
}
Future<void> _connectSignalR() async {
try {
final storage = SecureStorageService();
final token = await storage.getToken();
_hubConnection = HubConnectionBuilder()
.withUrl(
'${SignalRService.baseUrl}/hubs/logs',
HttpConnectionOptions(
accessTokenFactory: () async => token,
transport: HttpTransportType.webSockets,
logging: (level, message) {
if (kDebugMode) debugPrint('[SignalR Logs WS] $message');
},
),
)
.withAutomaticReconnect()
.build();
_hubConnection!.on('ReceiveLogMessage', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
final map = Map<String, dynamic>.from(arguments.first as Map);
final log = LogMessageDto.fromJson(map);
if (log.serviceName.isEmpty || log.serviceName.toLowerCase() == widget.serviceName.toLowerCase()) {
if (mounted && !_isPaused) {
setState(() {
_logs.add(log);
if (_logs.length > 500) {
_logs.removeAt(0);
}
});
_scrollToBottomIfNeeded();
}
}
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Log Parse Error] $e');
}
}
});
_hubConnection!.onclose((error) {
if (mounted) setState(() => _isConnected = false);
});
_hubConnection!.onreconnected((connectionId) {
if (mounted) {
setState(() => _isConnected = true);
_hubConnection?.invoke('JoinServiceLogs', args: [widget.serviceName]);
}
});
await _hubConnection!.start();
await _hubConnection!.invoke('JoinServiceLogs', args: [widget.serviceName]);
if (mounted) {
setState(() => _isConnected = true);
}
} catch (e) {
if (kDebugMode) debugPrint('[SignalR Log Connection Error] $e');
if (mounted) setState(() => _isConnected = false);
}
}
Future<void> _disconnectSignalR() async {
try {
if (_hubConnection != null) {
await _hubConnection!.invoke('LeaveServiceLogs', args: [widget.serviceName]);
await _hubConnection!.stop();
}
} catch (_) {}
_hubConnection = null;
}
void _scrollToBottomIfNeeded() {
if (_autoScroll && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
}
/// 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 {
return _logs.where((log) {
if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _selectedLevel) {
return false;
}
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
final matchMsg = log.message.toLowerCase().contains(query);
final matchChannel = log.channel.toLowerCase().contains(query);
return matchMsg || matchChannel;
}
return true;
}).toList();
}
Color _getLevelColor(String level) {
switch (level.toUpperCase()) {
case 'ERROR':
case 'CRITICAL':
return Colors.redAccent;
case 'WARNING':
case 'WARN':
return Colors.amberAccent;
case 'DEBUG':
case 'TRACE':
return Colors.blueGrey.shade300;
case 'INFORMATION':
case 'INFO':
default:
return AppTheme.primaryEmerald;
}
}
String _formatTime(DateTime time) {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
final s = time.second.toString().padLeft(2, '0');
final ms = time.millisecond.toString().padLeft(3, '0');
return '$h:$m:$s.$ms';
}
@override
Widget build(BuildContext context) {
final filtered = _filteredLogs;
return GlassContainer(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Bar
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.terminal_rounded, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 10),
Text(
'Live Service-Logs (${widget.serviceName})',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isConnected ? AppTheme.primaryEmerald : Colors.redAccent,
),
),
const SizedBox(width: 6),
Text(
_isConnected ? 'Live WebSocket' : 'Offline',
style: TextStyle(
fontSize: 12,
color: _isConnected ? AppTheme.primaryEmerald : Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
const SizedBox(height: 14),
// Control Bar: Search + Level Filters + Actions
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Search Field
SizedBox(
width: 220,
height: 36,
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val.trim()),
style: const TextStyle(fontSize: 13, color: Colors.white),
decoration: InputDecoration(
hintText: 'Logs durchsuchen...',
hintStyle: const TextStyle(fontSize: 12, color: Colors.white38),
prefixIcon: const Icon(Icons.search, size: 16, color: Colors.white54),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 14, color: Colors.white54),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
filled: true,
fillColor: Colors.black26,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none),
),
),
),
// Filter Chips
for (final lvl in ['ALL', 'INFO', 'WARN', 'ERROR', 'DEBUG'])
ChoiceChip(
label: Text(lvl, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _selectedLevel == lvl ? Colors.black : Colors.white70)),
selected: _selectedLevel == lvl,
selectedColor: AppTheme.primaryEmerald,
backgroundColor: Colors.white10,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 4),
onSelected: (selected) {
if (selected) setState(() => _selectedLevel = lvl);
},
),
const SizedBox(width: 8),
// Action buttons
IconButton(
tooltip: _isPaused ? 'Stream Fortsetzen' : 'Stream Pausieren',
icon: Icon(_isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 20, color: _isPaused ? Colors.amberAccent : Colors.white70),
onPressed: () => setState(() => _isPaused = !_isPaused),
),
IconButton(
tooltip: _autoScroll ? 'Auto-Scroll an' : 'Auto-Scroll aus',
icon: Icon(Icons.vertical_align_bottom_rounded, size: 20, color: _autoScroll ? AppTheme.primaryEmerald : Colors.white38),
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
tooltip: 'Konsole leeren',
icon: const Icon(Icons.delete_outline_rounded, size: 20, color: Colors.white54),
onPressed: () => setState(() => _logs.clear()),
),
],
),
const SizedBox(height: 12),
// Terminal Box
Container(
height: 380,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0D1117), // Deep dark console
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: filtered.isEmpty
? Center(
child: Text(
_logs.isEmpty ? 'Warte auf Log-Nachrichten von ${widget.serviceName}...' : 'Keine Logs passend zum Filter.',
style: const TextStyle(fontSize: 12, color: Colors.white38, fontStyle: FontStyle.italic),
),
)
: ListView.builder(
controller: _scrollController,
itemCount: filtered.length,
itemBuilder: (context, index) {
final item = filtered[index];
final color = _getLevelColor(item.level);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.5),
child: SelectableText.rich(
TextSpan(
style: const TextStyle(fontFamily: 'monospace', fontSize: 11.5, height: 1.4),
children: [
TextSpan(
text: '${_formatTime(item.timestamp)} ',
style: const TextStyle(color: Colors.white38),
),
TextSpan(
text: '[${item.level.toUpperCase().padRight(5)}] ',
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
if (item.channel.isNotEmpty)
TextSpan(
text: '{${item.channel}} ',
style: TextStyle(color: Colors.cyanAccent.withValues(alpha: 0.8)),
),
TextSpan(
text: item.message,
style: const TextStyle(color: Colors.white),
),
if (item.exception != null && item.exception!.isNotEmpty)
TextSpan(
text: '\n ${item.exception}',
style: const TextStyle(color: Colors.redAccent, fontSize: 10.5),
),
],
),
),
);
},
),
),
],
),
);
}
}
@@ -67,16 +67,9 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
accentColor: Color(0xFFEC4899),
),
ServiceConfigMeta(
key: 'FinlyticAnalyzer',
displayName: 'Analyzer Signal Engine',
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
icon: Icons.analytics_outlined,
accentColor: Color(0xFFF59E0B),
),
ServiceConfigMeta(
key: 'FinlyticTrades',
displayName: 'Trade Manager',
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
key: 'FinlyticEngine',
displayName: 'Trading Engine',
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
icon: Icons.candlestick_chart_outlined,
accentColor: Color(0xFF10B981),
),
@@ -87,6 +80,13 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
icon: Icons.corporate_fare_outlined,
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 = {
@@ -113,24 +113,35 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
'MinConfidenceScore': TextEditingController(text: '0.70'),
'MaxBatchSize': TextEditingController(text: '50'),
},
'FinlyticAnalyzer': {
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
'MinSignalScore': TextEditingController(text: '75'),
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
},
'FinlyticTrades': {
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
'MaxOpenPositions': TextEditingController(text: '5'),
'FinlyticEngine': {
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
},
'FinlyticFundamentals': {
'CacheTtlHours': TextEditingController(text: '24'),
'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;
@@ -46,6 +46,32 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
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
Widget build(BuildContext context) {
final int totalCount = _serviceStatuses.length;
@@ -191,7 +217,7 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
Row(
children: [
Icon(
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
_getServiceIcon(name),
size: 18,
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 '../../../trades/models/trade_model.dart';
import 'asset_trades_event.dart';
import 'asset_trades_state.dart';
import '../../repositories/asset_repository.dart';
@@ -20,28 +19,23 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
on<TriggerManualAnalysis>((event, emit) async {
emit(AssetTradesLoading());
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 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));
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
} catch (e) {
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
}
});
on<RejectTradeEvent>((event, emit) async {
try {
await repository.rejectTrade(event.tradeId);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to reject trade: $e"));
on<DismissTradeEvent>((event, emit) {
// Purely local: no server call, see DismissTradeEvent doc comment.
final current = state;
if (current is AssetTradesLoaded) {
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
}
});
on<AcceptTradeEvent>((event, emit) async {
@@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
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 {
try {
await repository.closeTrade(event.tradeId, event.exitPrice);
@@ -13,16 +13,34 @@ class TriggerManualAnalysis extends AssetTradesEvent {
final ManualAnalysisRequestDto? 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 isin;
RejectTradeEvent(this.tradeId, this.isin);
DismissTradeEvent(this.tradeId);
}
class AcceptTradeEvent extends AssetTradesEvent {
final TradeAcceptanceDto tradeAcceptanceDto;
final String 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 {
final String tradeId;
final String isin;
@@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {}
class AssetTradesLoading extends AssetTradesState {}
class AssetTradesLoaded extends AssetTradesState {
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 {
final String message;
@@ -191,13 +191,17 @@ class FundamentalDataModel extends Equatable {
}
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']);
if (grossMarginVal == null && grossProf != null) {
if (grossProf <= 1.0 && grossProf >= 0.0) {
grossMarginVal = grossProf;
final rawGrossProfit = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargins'] ?? fundMap?['grossMargin'] ?? json['grossMargin']);
double? grossProfVal = rawGrossProfit;
if (rawGrossProfit != null) {
if (rawGrossProfit <= 1.0 && rawGrossProfit >= 0.0) {
grossMarginVal ??= rawGrossProfit;
if (totalRev != null && totalRev > 0) {
grossProfVal = rawGrossProfit * totalRev;
}
} else if (totalRev != null && totalRev > 0) {
grossMarginVal = grossProf / totalRev;
grossMarginVal ??= rawGrossProfit / totalRev;
}
}
@@ -207,6 +211,50 @@ class FundamentalDataModel extends Equatable {
evToRevVal = evVal / totalRev;
}
String? exDivDateStr = fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString();
String? nextEarningsDateStr = fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString();
final rawEvents = json['events'];
if (rawEvents is List) {
final now = DateTime.now();
final divEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
final t = e['type']?.toString().toUpperCase() ?? '';
return t == 'DIVIDEND' || t == 'EX_DIVIDEND';
}).toList();
if (exDivDateStr == null && divEvents.isNotEmpty) {
divEvents.sort((a, b) {
final da = DateTime.tryParse(a['date']?.toString() ?? '') ?? DateTime(1970);
final db = DateTime.tryParse(b['date']?.toString() ?? '') ?? DateTime(1970);
return da.compareTo(db);
});
final upcoming = divEvents.firstWhere((e) {
final d = DateTime.tryParse(e['date']?.toString() ?? '');
return d != null && d.isAfter(now.subtract(const Duration(days: 7)));
}, orElse: () => divEvents.last);
exDivDateStr = upcoming['date']?.toString();
}
final earningsEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
final t = e['type']?.toString().toUpperCase() ?? '';
return t.contains('EARNINGS');
}).toList();
if (nextEarningsDateStr == null && earningsEvents.isNotEmpty) {
earningsEvents.sort((a, b) {
final da = DateTime.tryParse(a['date']?.toString() ?? '') ?? DateTime(1970);
final db = DateTime.tryParse(b['date']?.toString() ?? '') ?? DateTime(1970);
return da.compareTo(db);
});
final upcoming = earningsEvents.firstWhere((e) {
final d = DateTime.tryParse(e['date']?.toString() ?? '');
return d != null && d.isAfter(now.subtract(const Duration(days: 1)));
}, orElse: () => earningsEvents.last);
nextEarningsDateStr = upcoming['date']?.toString();
}
}
return FundamentalDataModel(
isin: isinVal,
primaryTicker: primaryTickerVal,
@@ -228,25 +276,25 @@ class FundamentalDataModel extends Equatable {
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
enterpriseValue: evVal,
peRatioTrailing: parseNullableDouble(fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing']),
peRatioForward: parseNullableDouble(fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? json['peRatioForward']),
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing'] ?? json['trailingPe']),
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? json['peRatioForward'] ?? json['forwardPe']),
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
psRatio: parseNullableDouble(fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
evToEbitda: parseNullableDouble(fundMap?['enterpriseToEbitda'] ?? fundMap?['evToEbitda'] ?? json['evToEbitda']),
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? fundMap?['enterpriseToEbitda'] ?? json['evToEbitda']),
evToRevenue: evToRevVal,
totalRevenue: totalRev,
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowth'] ?? fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
grossProfit: grossProf,
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? fundMap?['revenueGrowth'] ?? json['revenueGrowthYoY']),
grossProfit: grossProfVal,
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
dilutedEps: parseNullableDouble(fundMap?['trailingEps'] ?? fundMap?['dilutedEps'] ?? json['dilutedEps']),
dilutedEps: parseNullableDouble(fundMap?['dilutedEps'] ?? fundMap?['trailingEps'] ?? json['dilutedEps']),
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashflow'] ?? fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
freeCashFlow: parseNullableDouble(fundMap?['freeCashflow'] ?? fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? fundMap?['operatingCashflow'] ?? json['operatingCashFlow']),
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? fundMap?['freeCashflow'] ?? json['freeCashFlow']),
grossMargin: grossMarginVal,
operatingMargin: parseNullableDouble(fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
netProfitMargin: parseNullableDouble(fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
operatingMargin: parseNullableDouble(fundMap?['operatingIncome'] ?? fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
netProfitMargin: parseNullableDouble(fundMap?['netIncome'] ?? fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
@@ -254,10 +302,10 @@ class FundamentalDataModel extends Equatable {
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
dividendYield: parseNullableDouble(fundMap?['dividendYield'] ?? json['dividendYield']),
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? fundMap?['dividendYield'] ?? json['dividendYield']),
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
exDividendDate: fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString(),
nextEarningsDate: fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString(),
exDividendDate: exDivDateStr,
nextEarningsDate: nextEarningsDateStr,
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
@@ -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/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_response_dto.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/close_trade_request_dto.dart';
@@ -102,23 +101,47 @@ class AssetRepository {
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 {
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 res = await apiClient.post('/api/v1/analyze/manual', data: body);
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return ManualAnalysisResponseDto.fromJson(res.data);
if (res.data != null && res.data is Map<String, dynamic>) {
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> closeTrade(String tradeId, double exitPrice) async =>
_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_bloc/flutter_bloc.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.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/widgets/trade_execution_cockpit.dart';
import '../../../trades/widgets/trade_closing_cockpit.dart';
@@ -22,11 +26,12 @@ class TradesTab extends StatefulWidget {
}
class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false;
late final BotRepository _botRepository;
@override
void initState() {
super.initState();
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@@ -39,8 +44,23 @@ class _TradesTabState extends State<TradesTab> {
defaultSymbol: widget.symbol,
isActive: isActive,
onAccept: (dto) {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
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(
SnackBar(
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) {
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(
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,
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
Widget build(BuildContext context) {
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
listener: (context, state) {
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
final List<TradeModel> tradesList = state.data;
if (tradesList.isNotEmpty) {
_justTriggeredAnalysis = false;
final latestTrade = tradesList.first;
if (state is! AssetTradesLoaded) return;
final result = state.manualAnalysisResult;
if (result == null) return;
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) {
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
@@ -117,11 +223,10 @@ class _TradesTabState extends State<TradesTab> {
symbol: widget.symbol,
initialRiskScore: 50.0,
onTrigger: (payload) {
setState(() => _justTriggeredAnalysis = true);
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar(
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,
behavior: SnackBarBehavior.floating,
),
@@ -153,18 +258,12 @@ class _TradesTabState extends State<TradesTab> {
else if (state is AssetTradesLoaded) ...[
_buildTradeList(
'Aktive Trade-Signale & Positionen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
}).toList(),
tradesList.where((t) => t.isActive || t.isProposed).toList(),
),
const SizedBox(height: 20),
_buildTradeList(
'Historische Trades & KI-Bewertungen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
}).toList(),
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
),
],
],
@@ -208,8 +307,7 @@ class _TradesTabState extends State<TradesTab> {
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
final s = trade.status.toUpperCase();
final isActive = s == 'ACTIVE';
final isActive = trade.isActive;
return AssetTradeItemCard(
trade: trade,
@@ -223,7 +321,7 @@ class _TradesTabState extends State<TradesTab> {
trade: trade,
defaultSymbol: widget.symbol,
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));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -45,7 +45,7 @@ class FundamentalCategoryPanels extends StatelessWidget {
String _fmtPercent(double? val) {
if (val == null) return 'N/A';
final p = (val.abs() <= 1.0 && val != 0.0) ? val * 100.0 : val;
final p = (val.abs() <= 5.0 && val != 0.0) ? val * 100.0 : val;
return '${p.toStringAsFixed(2)}%';
}
@@ -4,6 +4,15 @@ import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.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 {
final TradeModel trade;
final String defaultSymbol;
@@ -20,43 +29,42 @@ class AssetTradeItemCard extends StatelessWidget {
this.onClose,
});
String _fmt(dynamic val) {
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();
}
String _fmt(double val) => val.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol;
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
final status = trade.status.toUpperCase();
final isBuy = side == 'BUY' || side == 'LONG';
final isActive = status == 'ACTIVE';
final isin = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : defaultSymbol;
final isBuy = trade.direction.isLong;
final isActive = trade.isActive;
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
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;
// Entry price: the real fill-weighted average the engine already
// computed, not a planned/target zone (that concept no longer exists
// server-side).
final entryPrice = trade.averageBuyIn;
final actualEntry = trade.actualEntryPrice;
final posSize = trade.positionSize;
final levUsed = trade.leverageUsed;
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
final entryFee = trade.entryFee;
final exitFee = trade.exitFee;
// Live protective stop: `currentStopLoss` (not `initialStopLoss`) is
// used here because this card shows the trade's live state — the
// current stop already reflects any break-even/trailing adjustment the
// engine has made. `initialStopLoss` (the original plan value) is only
// relevant historically and is shown in the trade detail view instead.
final stopLoss = trade.currentStopLoss;
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
final tpStages = trade.exitPlan.takeProfitStages;
// Server-computed reward:risk multiple for the first take-profit stage
// used instead of a client-side recomputation from raw prices.
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(
padding: const EdgeInsets.only(bottom: 12),
@@ -71,14 +79,13 @@ class AssetTradeItemCard extends StatelessWidget {
children: [
Row(
children: [
StatusBadge(label: side, color: sideColor),
StatusBadge(label: trade.direction.label, color: sideColor),
const SizedBox(width: 8),
StatusBadge(
label: status,
color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted),
label: trade.status.label,
color: isActive ? AppTheme.primaryEmerald : (trade.isProposed ? AppTheme.accentCyan : AppTheme.textMuted),
),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
@@ -86,7 +93,9 @@ class AssetTradeItemCard extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
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),
),
),
@@ -120,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
] else if (trade.isProposed) ...[
if (onAccept != null)
ElevatedButton.icon(
onPressed: onAccept,
@@ -146,55 +155,14 @@ class AssetTradeItemCard extends StatelessWidget {
_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),
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),
),
const SizedBox(height: 12),
// Target Price Metrics Grid
// Price Metrics Grid
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -207,22 +175,26 @@ class AssetTradeItemCard extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Einstiegskurs', '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${_fmt(stopLoss)}',
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) ...[
const Divider(color: Colors.white12, height: 16),
if (primaryRMultiple != null) ...[
const Divider(color: Colors.white10, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
_buildTradeStat('Chance-Risiko (TP1, R-Multiple)', '${_fmt(primaryRMultiple)}R', AppTheme.accentCyan),
if (investedCapital != null) _buildTradeStat('Eingesetztes Kapital', '${_fmt(investedCapital)}', Colors.white70),
],
),
],
@@ -230,8 +202,8 @@ class AssetTradeItemCard extends StatelessWidget {
),
),
// Execution Details if active
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
// Position size / quantity
if (trade.totalQuantity > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
@@ -240,84 +212,62 @@ class AssetTradeItemCard extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
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
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
// PnL (server-computed, never recalculated client-side)
if (showPnl) ...[
const SizedBox(height: 12),
Builder(
builder: (context) {
final pnlVal = trade.calculatedPnlAbs;
final pnlPctVal = trade.calculatedPnlPct;
final isWin = pnlVal >= 0;
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color),
border: Border.all(color: pnlColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
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 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),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white),
_buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color),
_buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed),
_buildTradeStat('Aktueller Kurs', '${_fmt(trade.currentPrice)}', Colors.white),
_buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor),
_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
if (trade.hourlyUpdates.isNotEmpty) ...[
// Execution history (replaces the removed AI-Guardian hourly
// 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),
ExpansionTile(
tilePadding: EdgeInsets.zero,
@@ -325,10 +275,10 @@ class AssetTradeItemCard extends StatelessWidget {
dense: true,
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
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),
),
children: trade.hourlyUpdates.reversed.take(4).map((u) {
children: trade.fills.reversed.map((f) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(8),
@@ -339,62 +289,27 @@ class AssetTradeItemCard extends StatelessWidget {
child: Row(
children: [
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),
),
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(
child: Text(
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' (Gebühr €${_fmt(f.fee)})' : ''}',
style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (f.note != null && f.note!.isNotEmpty)
Text(f.note!, style: TextStyle(color: AppTheme.textMuted, fontSize: 10, fontStyle: FontStyle.italic)),
],
),
);
}).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;
switch (t.driftStatus) {
case DriftStatus.exitAlert:
col = AppTheme.accentRed;
label = 'Drift-Radar: Ausstieg empfohlen';
icon = Icons.warning_rounded;
break;
case DriftStatus.trailingActive:
col = AppTheme.accentCyan;
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
@@ -424,7 +334,7 @@ class AssetTradeItemCard extends StatelessWidget {
break;
case DriftStatus.onTrack:
col = AppTheme.primaryEmerald;
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
label = 'Drift-Radar: Prognose intakt';
icon = Icons.radar;
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 void Function(CloseTradeRequestDto) onClose,
}) {
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
final entry = trade.averageBuyIn;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
@@ -37,14 +37,20 @@ class CloseTradeDialog {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
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),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Z.B. 105.50',
decoration: InputDecoration(
labelText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Derivat-Verkaufskurs (€)' : 'Tatsächlicher Ausstiegskurs (€)',
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()) {
on<CheckAuthStatus>(_onCheckAuthStatus);
on<LoginRequested>(_onLoginRequested);
on<RegisterRequested>(_onRegisterRequested);
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 {
await repository.logout();
emit(Unauthenticated());
@@ -19,15 +19,4 @@ class LoginRequested extends AuthEvent {
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 {}
@@ -36,11 +36,16 @@ class AuthRepository {
'password': password,
});
if (res.statusCode == 200 && res.data != null) {
final token = res.data['token']?.toString() ?? '';
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() ?? '');
}
final token = res.data['token']?.toString() ?? '';
final user = UserModel.fromJson(res.data, token: token);
await storageService.saveToken(token);
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 {
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
'userId': userId,
@@ -20,6 +20,8 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
void _onChangePassword() async {
if (_formKey.currentState?.validate() ?? false) {
@@ -96,15 +98,29 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
const SizedBox(height: 24),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Neues Passwort', prefixIcon: Icon(Icons.lock)),
obscureText: _obscurePassword,
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,
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort bestätigen', prefixIcon: Icon(Icons.lock_outline)),
obscureText: _obscureConfirmPassword,
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,
),
const SizedBox(height: 24),
@@ -14,8 +14,16 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'admin@finlytic.com');
final _passwordController = TextEditingController(text: 'AdminDefaultPassword2026!');
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _onLogin() {
if (_formKey.currentState?.validate() ?? false) {
@@ -68,8 +76,15 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
obscureText: _obscurePassword,
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,
),
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),
TradesStreamWidget(apiClient: apiClient),
const SizedBox(height: 24),
DailyNewsSnapshot(apiClient: apiClient),
DailyNewsSnapshot(apiClient: apiClient, signalRService: signalRService),
],
),
),
@@ -1,6 +1,7 @@
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 '../../../core/utils/time_utils.dart';
import '../../../core/widgets/glass_container.dart';
@@ -14,19 +15,25 @@ import '../../news/widgets/article_sentiment_dialog.dart';
class DailyNewsSnapshot extends StatelessWidget {
final ApiClient apiClient;
final String backendUrl;
final SignalRService? signalRService;
final String? backendUrl;
const DailyNewsSnapshot({
super.key,
required this.apiClient,
this.backendUrl = 'http://localhost:5000',
this.signalRService,
this.backendUrl,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
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))),
child: const _DailyNewsSnapshotContent(),
);
@@ -75,7 +75,7 @@ class FavoritesCarousel extends StatelessWidget {
children: [
AssetLogoWidget(
symbolOrName: isin,
imageUrl: fav.image.isNotEmpty ? fav.image : null,
imageUrl: isin.isNotEmpty ? '/api/v1/logo/$isin' : (fav.image.isNotEmpty ? fav.image : null),
size: 24,
),
const SizedBox(width: 8),
@@ -101,7 +101,7 @@ class _TradesStreamWidgetContent extends StatelessWidget {
itemCount: proposals.length,
itemBuilder: (context, 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;
return GestureDetector(
@@ -110,8 +110,8 @@ class _TradesStreamWidgetContent extends StatelessWidget {
context,
MaterialPageRoute(
builder: (ctx) => AssetDetailScreen(
isin: p.isin,
name: p.companyName,
isin: p.underlyingIsin,
name: p.symbol,
symbol: p.symbol.isNotEmpty ? p.symbol : null,
apiClient: context.read<TradeBloc>().repository.apiClient,
),
@@ -158,35 +158,24 @@ class _TradesStreamWidgetContent extends StatelessWidget {
border: Border.all(color: signalColor.withValues(alpha: 0.3)),
),
child: Text(
p.signalType,
p.direction.label,
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
),
),
],
),
if (p.companyName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
p.companyName,
p.underlyingIsin,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
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(
children: [
Text(
'KI Score: ${(p.winRate).toStringAsFixed(0)}%',
p.instrumentType.label,
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.w600, fontSize: 13),
),
const Spacer(),
@@ -51,7 +51,7 @@ class WatchlistCard extends StatelessWidget {
children: [
AssetLogoWidget(
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,
),
const SizedBox(width: 10),
@@ -14,13 +14,11 @@ class NewsBloc extends Bloc<NewsEvent, NewsState> {
on<LoadMoreNews>(_onLoadMoreNews);
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) {
add(ReceiveLiveNews(article));
});
// Connect to SignalR
repository.connectToLiveFeed();
}
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/signalr_service.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class NewsRepository {
final ApiClient apiClient;
final String backendUrl;
final FlutterSecureStorage secureStorage = const FlutterSecureStorage();
HubConnection? _hubConnection;
final _liveNewsController = StreamController<NewsArticleModel>.broadcast();
/// Central Real-Time WebSocket service (shared, authenticated `/hubs/news` connection).
/// 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({
int page = 1,
@@ -66,55 +75,9 @@ class NewsRepository {
}
}
Future<void> connectToLiveFeed() async {
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
return;
}
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();
}
/// No-op: the underlying `/hubs/news` WebSocket connection is owned and
/// lifecycle-managed centrally by [SignalRService] (started once in
/// `main.dart` after authentication), so this repository has nothing of
/// its own to dispose.
void dispose() {}
}
@@ -92,6 +92,11 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
_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 (_) {
} finally {
setState(() => _isLoading = false);
@@ -182,11 +187,22 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
_loadNews(refresh: true);
},
onSentimentToggleChanged: (val) {
setState(() => _hasSentimentOnly = val);
setState(() {
_hasSentimentOnly = val;
if (!val) {
_selectedSentimentFilter = null;
}
});
_loadNews(refresh: true);
},
onSentimentFilterChanged: (val) {
setState(() => _selectedSentimentFilter = val);
setState(() {
_selectedSentimentFilter = val;
if (val != null) {
_hasSentimentOnly = true;
}
});
_loadNews(refresh: true);
},
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,
);
}, onDone: () {
// Wait for last sub to finish or close
outputSub?.cancel();
controller?.close();
});
},
onCancel: () {
outputSub?.cancel();
inputSub?.cancel();
controller?.close();
},
);
return controller.stream;
@@ -10,12 +10,19 @@ import '../bloc/search_bloc.dart';
import '../repositories/search_repository.dart';
/// 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 {
final ApiClient apiClient;
final void Function(String isin, String name)? onAssetSelected;
const AssetSearchDialog({
super.key,
required this.apiClient,
this.onAssetSelected,
});
@override
@@ -24,15 +31,16 @@ class AssetSearchDialog extends StatelessWidget {
create: (context) => SearchBloc(
repository: SearchRepository(apiClient: apiClient),
)..add(const SearchQueryChanged('')),
child: _AssetSearchDialogContent(apiClient: apiClient),
child: _AssetSearchDialogContent(apiClient: apiClient, onAssetSelected: onAssetSelected),
);
}
}
class _AssetSearchDialogContent extends StatefulWidget {
final ApiClient apiClient;
final void Function(String isin, String name)? onAssetSelected;
const _AssetSearchDialogContent({required this.apiClient});
const _AssetSearchDialogContent({required this.apiClient, this.onAssetSelected});
@override
State<_AssetSearchDialogContent> createState() => _AssetSearchDialogContentState();
@@ -162,6 +170,10 @@ class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
),
onTap: () {
Navigator.pop(context);
if (widget.onAssetSelected != null) {
widget.onAssetSelected!(isin, assetName);
return;
}
Navigator.push(
context,
MaterialPageRoute(

Some files were not shown because too many files have changed in this diff Show More