Compare commits

...

45 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
Kleidukos 34fa774cbf feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings 2026-08-15 19:30:25 +02:00
Kleidukos 882d24a316 feat(derivatives): fix Trade Republic derivative pagination streaming and picker modal 2026-08-15 19:30:15 +02:00
Kleidukos 7e18257e3e feat(fundamentals): preserve ticker selection and add deterministic key executives sorting 2026-08-15 19:30:04 +02:00
Kleidukos 960a4bdbd6 feat(news,calendar): improve corporate calendar filters and news sentiment dialogs 2026-08-15 19:30:00 +02:00
Kleidukos 067f1bfdd5 feat(core): add internal crypto isin resolution and subtitle mapping 2026-08-15 19:29:32 +02:00
Kleidukos 5a6a50a609 feat(chart): interactive zoom/pan controls, pattern overlays, predictions, individual pattern filtering and landscape fullscreen mode 2026-08-15 01:03:57 +02:00
Kleidukos 1ccb6b613f feat(asset_detail): modular fundamentals sections, executive salaries and logo resolution 2026-08-15 01:03:30 +02:00
Kleidukos 15f8f7896e feat(app): clean architecture with typed repositories, DTO models and calendar event logos 2026-08-15 01:03:22 +02:00
Kleidukos f08fecde23 feat(backend): support calendar image routing and fundamentals executive salary mapping 2026-08-15 01:03:07 +02:00
960 changed files with 162375 additions and 208000 deletions
+14 -1
View File
@@ -22,4 +22,17 @@
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
README.md
## Exported Docker image archives — never needed inside a build context (~2.7 GB)
Docker/
**/*.tar
## Flutter client — not referenced by any Dockerfile (~450 MB).
## The compiled web bundle ships via FinlyticBackend/wwwroot instead.
FinlyticApp/
**/.dart_tool/
**/build/
## dotnet publish output on the host (final stage copies from the publish stage)
**/publish/
+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,191 +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);
TradeProposalDto? proposal = null;
if (shouldProceed && n8nResponse != null)
{
proposal = new TradeProposalDto
{
TradeId = "PROP-" + Guid.NewGuid().ToString("N"),
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 = winRate,
VixRegime = regime,
VixValue = currentVix,
TtlMinutes = 60,
Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}",
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 = winRate,
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,11 +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);
}
@@ -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,101 +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)
{
try
{
var records = GetCachedOrLoadRecords();
if (records.Count == 0) return 65.0;
var matching = records.Where(r =>
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
r.VixRegime == regime).ToList();
if (matching.Count > 0)
{
int winningTrades = matching.Count(r => r.IsWin);
double calculatedWinRate = (double)winningTrades / matching.Count * 100.0;
_logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})",
"AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, matching.Count);
return Math.Round(calculatedWinRate, 1);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Error reading feedback files for win-rate calculation. Falling back to default.", "AnalyzerChannel");
}
return 65.0; // Default baseline win-rate
}
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;
}
}
}
-829
View File
@@ -1,829 +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 confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75;
bool shouldProceed = n8nResponse != null &&
string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) &&
(confidenceScore * 100.0) >= minSignalScore &&
winRate >= 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 = winRate,
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 = winRate,
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;
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;
var 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"
};
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
var analysisEntity = new AnalysisEntity
{
AnalysisId = analysisId,
EventId = eventId,
Sector = filterResult.Sector,
Symbol = finalSymbol,
Isin = filterResult.Isin,
VixRegime = regime,
VixValue = currentVix,
ImpactScore = filterResult.ImpactScore,
WinRate = winRate,
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 = winRate,
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,16 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Global scroll behavior enabling smooth mouse dragging, mouse wheel, and touch scrolling
/// across all platforms (especially Flutter Web on Desktop without requiring Shift-key).
class CustomAppScrollBehavior extends MaterialScrollBehavior {
const CustomAppScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.trackpad,
};
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../network/api_client.dart';
import '../theme/app_theme.dart';
@@ -24,7 +25,13 @@ class AssetLogoWidget extends StatelessWidget {
Widget build(BuildContext context) {
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
String? resolveUrl(String? url) {
if (url == null || url.isEmpty) return null;
if (url == null || url.isEmpty) {
final clean = symbolOrName.trim();
if (RegExp(r'^[A-Z]{2}[A-Z0-9]{9}[0-9]$').hasMatch(clean)) {
return '${ApiClient.baseUrl}/api/v1/logo/$clean';
}
return null;
}
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
}
@@ -65,12 +72,13 @@ class AssetLogoWidget extends StatelessWidget {
return _buildFallback(initial, colors);
},
)
: Image.network(
image,
: CachedNetworkImage(
imageUrl: image,
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
placeholder: (context, url) => _buildFallback(initial, colors),
errorWidget: (context, url, error) {
_failedUrls.add(image);
return _buildFallback(initial, colors);
},
+16 -5
View File
@@ -15,15 +15,26 @@ class StatusBadge extends StatelessWidget {
});
factory StatusBadge.sentiment(String status, {double? score}) {
Color bg = AppTheme.textMuted;
if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) {
final sUpper = status.trim().toUpperCase();
Color bg;
if (sUpper.contains('POS')) {
bg = AppTheme.primaryEmerald;
} else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) {
} else if (sUpper.contains('NEG')) {
bg = AppTheme.accentRed;
} else if (status.toUpperCase().contains('NEU')) {
} else if (sUpper.contains('NEU')) {
bg = AppTheme.accentCyan;
} else if (score != null) {
if (score > 0.15) {
bg = AppTheme.primaryEmerald;
} else if (score < -0.15) {
bg = AppTheme.accentRed;
} else {
bg = AppTheme.accentCyan;
}
} else {
bg = AppTheme.textMuted;
}
return StatusBadge(label: status.toUpperCase(), color: bg);
return StatusBadge(label: sUpper.isNotEmpty ? sUpper : 'NEUTRAL', color: bg);
}
@override
@@ -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];
}
@@ -0,0 +1,31 @@
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 = '',
});
factory ServiceSettingDto.fromJson(Map<String, dynamic> json) {
return ServiceSettingDto(
key: json['key']?.toString() ?? '',
value: json['value']?.toString() ?? '',
type: json['dataType']?.toString() ?? json['type']?.toString() ?? 'string',
description: json['description']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
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,12 +1,18 @@
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;
AdminRepository({required this.apiClient});
const AdminRepository({required this.apiClient});
Future<List<AdminUserModel>> fetchUsers() async {
try {
@@ -17,8 +23,7 @@ class AdminRepository {
}
return [];
} catch (e) {
print('Error fetching admin users: $e');
throw Exception('Nutzer konnten nicht geladen werden');
throw Exception('Nutzer konnten nicht geladen werden: $e');
}
}
@@ -35,4 +40,111 @@ class AdminRepository {
throw Exception('Aktualisieren fehlgeschlagen');
}
}
Future<Map<String, List<ServiceSettingDto>>> fetchSettings() async {
final res = await apiClient.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
final map = res.data as Map<String, dynamic>;
final result = <String, List<ServiceSettingDto>>{};
map.forEach((k, v) {
if (v is List) {
result[k] = v
.whereType<Map<String, dynamic>>()
.map((item) => ServiceSettingDto.fromJson(item))
.toList();
}
});
return result;
}
return {};
}
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'),
),
],
),
],
),
],
);
}
}
@@ -4,18 +4,16 @@ import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/admin_update_user_request_dto.dart';
import '../models/admin_user_model.dart';
import '../bloc/admin_bloc.dart';
import '../repositories/admin_repository.dart';
import '../widgets/admin_kpi_header.dart';
import '../widgets/admin_user_card_item.dart';
import '../widgets/create_user_dialog.dart';
import '../widgets/edit_user_dialog.dart';
import '../widgets/system_diagnostics_widget.dart';
/// Role-Restricted Admin Panel Screen managing users, service settings, and system health.
class AdminUsersScreen extends StatelessWidget {
final ApiClient apiClient;
final SignalRService? signalRService;
@@ -102,61 +100,52 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top Header Ribbon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Icon(Icons.admin_panel_settings_rounded, color: AppTheme.primaryEmerald, size: 22),
),
const SizedBox(width: 12),
const Text(
'Admin Control Panel',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: -0.5),
),
],
const Text(
'Administration & System',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
'Zentrales Management für Nutzer, Mikrodienste & MQTT System-Bus',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
'Finlytic Admin-Dashboard • Microservices & Benutzer',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
],
),
ElevatedButton.icon(
onPressed: () => _openCreateUser(context),
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
Row(
children: [
IconButton(
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
tooltip: 'Neu laden',
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => _openCreateUser(context),
icon: const Icon(Icons.person_add_alt_1_rounded, size: 18),
label: const Text('Neuer Benutzer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
],
),
const SizedBox(height: 16),
// KPI Header Metrics
AdminKpiHeader(
users: users,
signalRService: widget.signalRService,
),
const SizedBox(height: 16),
// Tab Selector Ribbon
Container(
decoration: BoxDecoration(
color: AppTheme.glassSurface,
@@ -182,19 +171,12 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
),
),
const SizedBox(height: 16),
// Tab Content View
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: User Management with Filter & Actions
_buildUserManagementTab(context, state, users),
// Tab 2: System Diagnostics & Microservices Health
SystemDiagnosticsWidget(
signalRService: widget.signalRService,
),
SystemDiagnosticsWidget(signalRService: widget.signalRService),
],
),
),
@@ -233,7 +215,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
);
}
// Apply Search Query & Role Filter
final filteredUsers = allUsers.where((u) {
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
@@ -243,7 +224,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
return Column(
children: [
// Filter Bar (Search Field & Role Filter Chips)
Row(
children: [
Expanded(
@@ -292,8 +272,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
],
),
const SizedBox(height: 14),
// User Cards List
Expanded(
child: filteredUsers.isEmpty
? Center(
@@ -302,10 +280,7 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
children: [
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
const SizedBox(height: 12),
Text(
'Keine passenden Benutzer gefunden.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
),
Text('Keine passenden Benutzer gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 14)),
],
),
)
@@ -313,95 +288,10 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
itemCount: filteredUsers.length,
itemBuilder: (context, index) {
final u = filteredUsers[index];
final String role = u.role;
final bool isActive = u.isActive;
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
final String initials = _getInitials(u.fullName.isNotEmpty ? u.fullName : u.email);
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
child: Row(
children: [
// Avatar Initials Circle
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: roleColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: roleColor, width: 1.5),
),
child: Text(
initials,
style: TextStyle(fontWeight: FontWeight.bold, color: roleColor, fontSize: 14),
),
),
const SizedBox(width: 14),
// User Name & Email
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
u.fullName.isNotEmpty ? u.fullName : u.email,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(width: 8),
StatusBadge(label: role, color: roleColor),
],
),
const SizedBox(height: 2),
Text(
u.email,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
// Active/Inactive Quick Switch Toggle
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
isActive ? 'Aktiv' : 'Gesperrt',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isActive ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
const SizedBox(width: 8),
Switch(
value: isActive,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: (val) => _toggleUserActiveStatus(context, u, val),
),
const SizedBox(width: 8),
IconButton.filledTonal(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Benutzer Bearbeiten',
onPressed: () => _openEditUser(context, u),
),
],
),
],
),
return AdminUserCardItem(
user: u,
onToggleActive: (val) => _toggleUserActiveStatus(context, u, val),
onEdit: () => _openEditUser(context, u),
);
},
),
@@ -409,13 +299,4 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
],
);
}
String _getInitials(String name) {
if (name.isEmpty) return 'U';
final parts = name.trim().split(' ');
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
}
}
@@ -4,27 +4,38 @@ import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
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;
final ApiClient apiClient;
final AdminRepository? repository;
const ServiceDetailScreen({super.key, required this.serviceName, required this.apiClient});
const ServiceDetailScreen({
super.key,
required this.serviceName,
required this.apiClient,
this.repository,
});
@override
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
}
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
late final AdminRepository _repository;
bool _isLoading = true;
bool _isSaving = false;
String _error = '';
List<dynamic> _settings = [];
List<ServiceSettingDto> _settings = [];
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
_repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient);
_fetchServiceDetails();
}
@@ -38,27 +49,22 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
Future<void> _fetchServiceDetails() async {
try {
final res = await widget.apiClient.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data != null) {
final groupedSettings = res.data as Map<String, dynamic>;
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
setState(() {
_settings = serviceSettings;
for (var s in _settings) {
final key = s['key']?.toString() ?? '';
final val = s['value']?.toString() ?? '';
if (!_controllers.containsKey(key)) {
_controllers[key] = TextEditingController(text: val);
} else {
_controllers[key]!.text = val;
}
final groupedSettings = await _repository.fetchSettings();
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
setState(() {
_settings = serviceSettings;
for (var s in _settings) {
final key = s.key;
final val = s.value;
if (!_controllers.containsKey(key)) {
_controllers[key] = TextEditingController(text: val);
} else {
_controllers[key]!.text = val;
}
_isLoading = false;
});
} else {
throw Exception('Failed to load settings');
}
}
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
@@ -70,40 +76,44 @@ 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;
}
});
final res = await widget.apiClient.put(
'/api/v1/admin/settings/${widget.serviceName}',
data: payload,
);
await _repository.updateServiceSettings(widget.serviceName, payload);
if (res.statusCode == 200 || res.statusCode == 204) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
],
),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
],
),
);
}
} else {
throw Exception('Server returned status code ${res.statusCode}');
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} catch (e) {
if (mounted) {
@@ -121,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)')
@@ -133,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(
@@ -169,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']?.toString() ?? '';
final desc = s['description']?.toString() ?? '';
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)
@@ -238,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,116 @@
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/admin_user_model.dart';
class AdminUserCardItem extends StatelessWidget {
final AdminUserModel user;
final ValueChanged<bool> onToggleActive;
final VoidCallback onEdit;
const AdminUserCardItem({
super.key,
required this.user,
required this.onToggleActive,
required this.onEdit,
});
String _getInitials(String name) {
if (name.isEmpty) return 'U';
final parts = name.trim().split(' ');
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
}
@override
Widget build(BuildContext context) {
final String role = user.role;
final bool isActive = user.isActive;
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
final String initials = _getInitials(user.fullName.isNotEmpty ? user.fullName : user.email);
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: roleColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: roleColor, width: 1.5),
),
child: Text(
initials,
style: TextStyle(fontWeight: FontWeight.bold, color: roleColor, fontSize: 14),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
user.fullName.isNotEmpty ? user.fullName : user.email,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(width: 8),
StatusBadge(label: role, color: roleColor),
],
),
const SizedBox(height: 2),
Text(
user.email,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
isActive ? 'Aktiv' : 'Gesperrt',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isActive ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
const SizedBox(width: 8),
Switch(
value: isActive,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: onToggleActive,
),
const SizedBox(width: 8),
IconButton.filledTonal(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Benutzer Bearbeiten',
onPressed: onEdit,
),
],
),
],
),
);
}
}
@@ -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),
),
],
),
),
);
},
),
),
],
),
);
}
}
@@ -1,10 +1,10 @@
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/status_badge.dart';
import '../repositories/admin_repository.dart';
import 'service_settings_form.dart';
/// Service Metadata Info used for Admin Config Navigation
class ServiceConfigMeta {
final String key;
final String displayName;
@@ -21,17 +21,18 @@ class ServiceConfigMeta {
});
}
/// Centralized Service Configuration Management Widget for Admin Panel.
class PipelineSettingsWidget extends StatefulWidget {
final ApiClient? apiClient;
final AdminRepository? repository;
const PipelineSettingsWidget({super.key, this.apiClient});
const PipelineSettingsWidget({super.key, this.apiClient, this.repository});
@override
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
}
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
late final AdminRepository _repository;
String _selectedServiceKey = 'FinlyticAssets';
bool _isLoading = false;
bool _isSaving = false;
@@ -66,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),
),
@@ -86,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 = {
@@ -112,59 +113,65 @@ 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;
@override
void initState() {
super.initState();
_fetchSettings();
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
final client = widget.apiClient ?? context.read<ApiClient>();
_repository = widget.repository ?? AdminRepository(apiClient: client);
_fetchSettings();
}
}
Future<void> _fetchSettings() async {
if (widget.apiClient == null) return;
setState(() => _isLoading = true);
try {
final res = await widget.apiClient!.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data is Map) {
final Map<String, dynamic> data = Map<String, dynamic>.from(res.data);
data.forEach((svc, items) {
if (items is List) {
_controllers.putIfAbsent(svc, () => {});
for (var item in items) {
final key = item['key']?.toString();
final val = item['value']?.toString();
if (key != null && val != null) {
if (_controllers[svc]!.containsKey(key)) {
_controllers[svc]![key]!.text = val;
} else {
_controllers[svc]![key] = TextEditingController(text: val);
}
}
}
final settings = await _repository.fetchSettings();
settings.forEach((svc, items) {
_controllers.putIfAbsent(svc, () => {});
for (final s in items) {
if (_controllers[svc]!.containsKey(s.key)) {
_controllers[svc]![s.key]!.text = s.value;
} else {
_controllers[svc]![s.key] = TextEditingController(text: s.value);
}
});
}
}
});
} catch (_) {
// Retain standard default in-memory values
} finally {
if (mounted) setState(() => _isLoading = false);
}
@@ -179,9 +186,7 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
payload[k] = v.text;
});
if (widget.apiClient != null) {
await widget.apiClient!.put('/api/v1/admin/settings/$_selectedServiceKey', data: payload);
}
await _repository.updateServiceSettings(_selectedServiceKey, payload);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -192,7 +197,7 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für $_selectedServiceKey gespeichert & via MQTT synchronisiert.',
'Einstellungen für $_selectedServiceKey gespeichert & synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
),
@@ -219,7 +224,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
}
}
@override
Widget build(BuildContext context) {
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
@@ -228,7 +232,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Service Selection Ribbon
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -250,15 +253,16 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(svc.icon, size: 18, color: isSelected ? svc.accentColor : AppTheme.textMuted),
Icon(svc.icon, size: 16, color: isSelected ? svc.accentColor : AppTheme.textMuted),
const SizedBox(width: 8),
Text(
svc.displayName,
style: TextStyle(
fontSize: 13,
color: isSelected ? Colors.white : AppTheme.textMuted,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
fontSize: 12,
),
),
],
@@ -269,148 +273,41 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
}).toList(),
),
),
const SizedBox(height: 8),
// Service Details & Config Panel
GlassContainer(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: activeService.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: activeService.accentColor.withValues(alpha: 0.3)),
),
child: Icon(activeService.icon, color: activeService.accentColor, size: 22),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activeService.displayName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
activeService.description,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
],
),
if (_isLoading)
SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryEmerald))
else
StatusBadge(label: 'MQTT Sync', color: AppTheme.primaryEmerald),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(activeService.displayName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 2),
Text(activeService.description, style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
],
),
ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined, size: 16),
label: Text(_isSaving ? 'Speichere...' : 'Speichern'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
// Parameter Input List
...activeControllers.entries.map((entry) {
final keyName = entry.key;
final controller = entry.value;
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(keyName), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
//subtitle: Text('Schlüssel: $keyName', style: TextStyle(fontSize: 11, color: AppTheme.textMuted)),
value: boolVal,
activeThumbColor: activeService.accentColor,
onChanged: (val) => setState(() => controller.text = val.toString()),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: _formatLabel(keyName),
//helperText: 'Schlüssel: $keyName',
prefixIcon: Icon(Icons.tune_outlined, size: 18, color: activeService.accentColor),
),
),
),
/*if (isNumeric) ...[
const SizedBox(width: 8),
IconButton.filledTonal(
icon: const Icon(Icons.remove, size: 18),
onPressed: () => setState(() => _adjustNumericValue(controller, -1.0, isDouble: isDouble)),
),
IconButton.filledTonal(
icon: const Icon(Icons.add, size: 18),
onPressed: () => setState(() => _adjustNumericValue(controller, 1.0, isDouble: isDouble)),
),
],*/
],
),
);
}),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined),
label: Text(
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen für ${activeService.displayName} Speichern',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
],
),
const SizedBox(height: 14),
if (_isLoading)
Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald))
else
ServiceSettingsForm(
controllers: activeControllers,
accentColor: activeService.accentColor,
),
],
);
}
String _formatLabel(String key) {
return key
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
.replaceAll('Minutes', '(Minuten)')
.replaceAll('Seconds', '(Sekunden)')
.replaceAll('Hours', '(Stunden)')
.replaceAll('Days', '(Tage)')
.replaceAll('Limit', 'Grenzwert')
.replaceAll('Period', 'Periode')
.replaceAll('Percentage', '(%)')
.replaceAll('Multiplier', 'Multiplikator');
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
class ServiceSettingsForm extends StatelessWidget {
final Map<String, TextEditingController> controllers;
final Color accentColor;
const ServiceSettingsForm({
super.key,
required this.controllers,
required this.accentColor,
});
Widget _buildField(String key, TextEditingController ctrl) {
final isBool = ctrl.text == 'true' || ctrl.text == 'false';
if (isBool) {
return StatefulBuilder(
builder: (ctx, setLocal) {
return SwitchListTile(
title: Text(key, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
subtitle: Text('Boolesche Konfigurationsflagge', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
value: ctrl.text == 'true',
activeThumbColor: accentColor,
onChanged: (newVal) {
setLocal(() {
ctrl.text = newVal ? 'true' : 'false';
});
},
);
},
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(key, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
TextField(
controller: ctrl,
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (controllers.isEmpty) {
return GlassContainer(
padding: const EdgeInsets.all(20),
child: Center(
child: Text('Keine konfigurierbaren Parameter für diesen Dienst vorhanden.', style: TextStyle(color: AppTheme.textMuted)),
),
);
}
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: controllers.entries.map((e) => _buildField(e.key, e.value)).toList(),
),
);
}
}
@@ -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,
),
),
),
],
),
)),
],
);
}
}
@@ -15,5 +15,32 @@ class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState>
emit(AssetTechnicalError(e.toString()));
}
});
on<TogglePatternFilter>((event, emit) {
if (state is AssetTechnicalLoaded) {
final current = state as AssetTechnicalLoaded;
final updated = Set<int>.from(current.disabledPatternIndices);
if (event.enabled) {
updated.remove(event.patternIndex);
} else {
updated.add(event.patternIndex);
}
emit(current.copyWith(disabledPatternIndices: updated));
}
});
on<ToggleIndicatorFilter>((event, emit) {
if (state is AssetTechnicalLoaded) {
final current = state as AssetTechnicalLoaded;
emit(current.copyWith(
showSma50: event.showSma50,
showSma200: event.showSma200,
showEma: event.showEma,
showSupertrend: event.showSupertrend,
showPatterns: event.showPatterns,
showSignals: event.showSignals,
));
}
});
}
}
@@ -1,7 +1,32 @@
abstract class AssetTechnicalEvent {}
class LoadAssetTechnical extends AssetTechnicalEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
}
class TogglePatternFilter extends AssetTechnicalEvent {
final int patternIndex;
final bool enabled;
TogglePatternFilter({required this.patternIndex, required this.enabled});
}
class ToggleIndicatorFilter extends AssetTechnicalEvent {
final bool? showSma50;
final bool? showSma200;
final bool? showEma;
final bool? showSupertrend;
final bool? showPatterns;
final bool? showSignals;
ToggleIndicatorFilter({
this.showSma50,
this.showSma200,
this.showEma,
this.showSupertrend,
this.showPatterns,
this.showSignals,
});
}
@@ -1,12 +1,55 @@
import '../../models/technical_analysis_model.dart';
abstract class AssetTechnicalState {}
class AssetTechnicalInitial extends AssetTechnicalState {}
class AssetTechnicalLoading extends AssetTechnicalState {}
class AssetTechnicalLoaded extends AssetTechnicalState {
final TechnicalAnalysisModel? data;
AssetTechnicalLoaded(this.data);
final Set<int> disabledPatternIndices;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSupertrend;
final bool showPatterns;
final bool showSignals;
AssetTechnicalLoaded(
this.data, {
this.disabledPatternIndices = const {},
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showSupertrend = true,
this.showPatterns = true,
this.showSignals = true,
});
AssetTechnicalLoaded copyWith({
TechnicalAnalysisModel? data,
Set<int>? disabledPatternIndices,
bool? showSma50,
bool? showSma200,
bool? showEma,
bool? showSupertrend,
bool? showPatterns,
bool? showSignals,
}) {
return AssetTechnicalLoaded(
data ?? this.data,
disabledPatternIndices: disabledPatternIndices ?? this.disabledPatternIndices,
showSma50: showSma50 ?? this.showSma50,
showSma200: showSma200 ?? this.showSma200,
showEma: showEma ?? this.showEma,
showSupertrend: showSupertrend ?? this.showSupertrend,
showPatterns: showPatterns ?? this.showPatterns,
showSignals: showSignals ?? this.showSignals,
);
}
}
class AssetTechnicalError extends AssetTechnicalState {
final String message;
AssetTechnicalError(this.message);
@@ -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;
@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
final String? payment;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
this.payment,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
double? compVal;
if (json['compensation'] != null) {
compVal = (json['compensation'] as num?)?.toDouble() ?? double.tryParse(json['compensation'].toString());
}
final rawPayment = json['payment']?.toString();
if (compVal == null && rawPayment != null && rawPayment.isNotEmpty) {
compVal = double.tryParse(rawPayment);
}
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: compVal,
payment: rawPayment,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
if (age != null) 'age': age,
if (compensation != null) 'compensation': compensation,
if (payment != null) 'payment': payment,
};
}
@override
List<Object?> get props => [name, title, age, compensation, payment];
}
@@ -0,0 +1,158 @@
import 'package:equatable/equatable.dart';
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: (json['expectedRevenue'] as num?)?.toDouble() ?? (json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null),
expectedEps: (json['expectedEps'] as num?)?.toDouble() ?? (json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null),
expectedGrowthRate: (json['expectedGrowthRate'] as num?)?.toDouble() ?? (json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null),
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
if (expectedRevenue != null) 'expectedRevenue': expectedRevenue,
if (expectedEps != null) 'expectedEps': expectedEps,
if (expectedGrowthRate != null) 'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
@@ -1,4 +1,13 @@
import 'package:equatable/equatable.dart';
import 'ticker_model.dart';
import 'company_officer_model.dart';
import 'financial_statement_model.dart';
import 'forward_estimate_model.dart';
export 'ticker_model.dart';
export 'company_officer_model.dart';
export 'financial_statement_model.dart';
export 'forward_estimate_model.dart';
class FundamentalDataModel extends Equatable {
final String isin;
@@ -16,10 +25,10 @@ class FundamentalDataModel extends Equatable {
final double currentPrice;
final double dayChangeAbsolute;
final double dayChangePercent;
final double fiftyTwoWeekHigh;
final double fiftyTwoWeekLow;
final double marketCapitalization;
final double enterpriseValue;
final double? fiftyTwoWeekHigh;
final double? fiftyTwoWeekLow;
final double? marketCapitalization;
final double? enterpriseValue;
final double? peRatioTrailing;
final double? peRatioForward;
@@ -85,10 +94,10 @@ class FundamentalDataModel extends Equatable {
required this.currentPrice,
required this.dayChangeAbsolute,
required this.dayChangePercent,
required this.fiftyTwoWeekHigh,
required this.fiftyTwoWeekLow,
required this.marketCapitalization,
required this.enterpriseValue,
this.fiftyTwoWeekHigh,
this.fiftyTwoWeekLow,
this.marketCapitalization,
this.enterpriseValue,
this.peRatioTrailing,
this.peRatioForward,
this.pegRatio,
@@ -135,12 +144,6 @@ class FundamentalDataModel extends Equatable {
});
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
double? parseNullableDouble(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
@@ -171,8 +174,8 @@ class FundamentalDataModel extends Equatable {
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
: primaryTickerVal;
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal;
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString();
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal;
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString();
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
extractExchangeStr(assetMap?['primaryTicker']) ??
@@ -181,76 +184,74 @@ class FundamentalDataModel extends Equatable {
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
List<TickerModel> availableTickersList = [];
if (rawTickers is List) {
availableTickersList = rawTickers.map((t) {
if (t is Map<String, dynamic>) {
return TickerModel.fromJson(t);
} else {
return TickerModel(ticker: t.toString());
}
}).toList();
availableTickersList = rawTickers
.whereType<Map<String, dynamic>>()
.map((t) => TickerModel.fromJson(t))
.toList();
}
// Revenue & Margins Derivation
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;
}
}
// Enterprise Value to Revenue
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
evToRevVal = evVal / totalRev;
}
// Event Dates (Ex-Dividend & Next Earnings)
String? exDividendDateVal = json['exDividendDate']?.toString() ?? fundMap?['exDividendDate']?.toString();
String? nextEarningsDateVal = json['nextEarningsDate']?.toString() ?? fundMap?['nextEarningsDate']?.toString();
String? exDivDateStr = fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString();
String? nextEarningsDateStr = fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString();
final rawEvents = json['events'];
if (rawEvents is List && rawEvents.isNotEmpty) {
if (rawEvents is List) {
final now = DateTime.now();
final parsedEvents = <Map<String, dynamic>>[];
for (final ev in rawEvents) {
if (ev is Map<String, dynamic>) {
final dtStr = ev['date']?.toString();
final dt = dtStr != null ? DateTime.tryParse(dtStr) : null;
if (dt != null) {
parsedEvents.add({
'type': ev['type']?.toString().toUpperCase() ?? '',
'date': dt,
'dateStr': dtStr,
});
}
}
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();
}
if (exDividendDateVal == null) {
final dividendEvents = parsedEvents.where((e) => e['type'] == 'DIVIDEND').toList()
..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
final futureDividends = dividendEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList();
if (futureDividends.isNotEmpty) {
exDividendDateVal = futureDividends.first['dateStr'] as String;
} else if (dividendEvents.isNotEmpty) {
exDividendDateVal = dividendEvents.last['dateStr'] as String;
}
}
final earningsEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
final t = e['type']?.toString().toUpperCase() ?? '';
return t.contains('EARNINGS');
}).toList();
if (nextEarningsDateVal == null) {
final earningsEvents = parsedEvents.where((e) => e['type'] == 'EARNINGS_RELEASE' || e['type'] == 'EARNINGS_CALL').toList()
..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
final futureEarnings = earningsEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList();
if (futureEarnings.isNotEmpty) {
nextEarningsDateVal = futureEarnings.first['dateStr'] as String;
} else if (earningsEvents.isNotEmpty) {
nextEarningsDateVal = earningsEvents.last['dateStr'] as String;
}
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();
}
}
@@ -260,38 +261,40 @@ class FundamentalDataModel extends Equatable {
ticker: tickerVal,
companyName: companyNameVal,
exchange: exchangeVal,
tradingCurrency: json['tradingCurrency']?.toString(),
tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(),
businessSummary: businessSummaryVal,
sector: json['sector']?.toString(),
industry: json['industry']?.toString(),
country: json['country']?.toString(),
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
currentPrice: parseDouble(json['currentPrice']),
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
dayChangePercent: parseDouble(json['dayChangePercent']),
fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']),
enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']),
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']),
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']),
sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(),
industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(),
country: assetMap?['country']?.toString() ?? json['country']?.toString(),
employees: (assetMap?['employees'] ?? json['employees']) is int
? (assetMap?['employees'] ?? json['employees']) as int
: int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''),
currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0,
dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0,
dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0,
fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
enterpriseValue: evVal,
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'] ?? json['pbRatio']),
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']),
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']),
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
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?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
grossProfit: grossProf,
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? fundMap?['revenueGrowth'] ?? json['revenueGrowthYoY']),
grossProfit: grossProfVal,
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
dilutedEps: parseNullableDouble(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'] ?? json['operatingCashFlow']),
freeCashFlow: parseNullableDouble(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?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']),
netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? 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']),
@@ -299,424 +302,51 @@ 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?['forwardDividendYield'] ?? json['dividendYield']),
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? fundMap?['dividendYield'] ?? json['dividendYield']),
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
exDividendDate: exDividendDateVal,
nextEarningsDate: nextEarningsDateVal,
exDividendDate: exDivDateStr,
nextEarningsDate: nextEarningsDateStr,
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
consensusRating: (fundMap?['consensusRating'] ?? json['consensusRating'])?.toString(),
consensusRating: fundMap?['consensusRating']?.toString() ?? json['consensusRating']?.toString(),
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
executives: (json['executives'] as List?)
?.map((e) => CompanyExecutiveModel.fromJson(e is Map<String, dynamic> ? e : {}))
?.whereType<Map<String, dynamic>>()
.map((e) => CompanyExecutiveModel.fromJson(e))
.toList() ??
[],
financialStatements: (json['financialStatements'] as List?)
?.map((e) => FinancialStatementModel.fromJson(e is Map<String, dynamic> ? e : {}))
?.whereType<Map<String, dynamic>>()
.map((e) => FinancialStatementModel.fromJson(e))
.toList() ??
[],
estimates: (json['estimates'] as List?)
?.map((e) => ForwardEstimateModel.fromJson(e is Map<String, dynamic> ? e : {}))
?.whereType<Map<String, dynamic>>()
.map((e) => ForwardEstimateModel.fromJson(e))
.toList() ??
[],
availableTickers: availableTickersList,
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'primaryTicker': primaryTicker,
'ticker': ticker,
'companyName': companyName,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'businessSummary': businessSummary,
'sector': sector,
'industry': industry,
'country': country,
'employees': employees,
'currentPrice': currentPrice,
'dayChangeAbsolute': dayChangeAbsolute,
'dayChangePercent': dayChangePercent,
'fiftyTwoWeekHigh': fiftyTwoWeekHigh,
'fiftyTwoWeekLow': fiftyTwoWeekLow,
'marketCapitalization': marketCapitalization,
'enterpriseValue': enterpriseValue,
'peRatioTrailing': peRatioTrailing,
'peRatioForward': peRatioForward,
'pegRatio': pegRatio,
'pbRatio': pbRatio,
'psRatio': psRatio,
'evToEbitda': evToEbitda,
'evToRevenue': evToRevenue,
'grossMargin': grossMargin,
'operatingMargin': operatingMargin,
'netProfitMargin': netProfitMargin,
'returnOnEquity': returnOnEquity,
'returnOnAssets': returnOnAssets,
'returnOnInvestedCapital': returnOnInvestedCapital,
'debtToEquity': debtToEquity,
'currentRatio': currentRatio,
'quickRatio': quickRatio,
'dividendYield': dividendYield,
'payoutRatio': payoutRatio,
'exDividendDate': exDividendDate,
'nextEarningsDate': nextEarningsDate,
'percentHeldByInstitutions': percentHeldByInstitutions,
'percentHeldByInsiders': percentHeldByInsiders,
'shortRatio': shortRatio,
'shortPercentOfFloat': shortPercentOfFloat,
'consensusRating': consensusRating,
'priceTargetLow': priceTargetLow,
'priceTargetHigh': priceTargetHigh,
'priceTargetMedian': priceTargetMedian,
'priceTargetMean': priceTargetMean,
'executives': executives.map((e) => e.toJson()).toList(),
'financialStatements': financialStatements.map((e) => e.toJson()).toList(),
'estimates': estimates.map((e) => e.toJson()).toList(),
'availableTickers': availableTickers.map((e) => e.toJson()).toList(),
};
}
@override
List<Object?> get props => [
isin,
primaryTicker,
ticker,
companyName,
exchange,
tradingCurrency,
businessSummary,
sector,
industry,
country,
employees,
currentPrice,
dayChangeAbsolute,
dayChangePercent,
fiftyTwoWeekHigh,
fiftyTwoWeekLow,
marketCapitalization,
enterpriseValue,
peRatioTrailing,
peRatioForward,
pegRatio,
pbRatio,
psRatio,
evToEbitda,
evToRevenue,
grossMargin,
operatingMargin,
netProfitMargin,
returnOnEquity,
returnOnAssets,
returnOnInvestedCapital,
debtToEquity,
currentRatio,
quickRatio,
dividendYield,
payoutRatio,
exDividendDate,
nextEarningsDate,
percentHeldByInstitutions,
percentHeldByInsiders,
shortRatio,
shortPercentOfFloat,
consensusRating,
priceTargetLow,
priceTargetHigh,
priceTargetMedian,
priceTargetMean,
executives,
financialStatements,
estimates,
availableTickers,
isin, primaryTicker, ticker, companyName, exchange, tradingCurrency,
businessSummary, sector, industry, country, employees, currentPrice,
dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow,
marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward,
pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin,
operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets,
returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio,
dividendYield, payoutRatio, exDividendDate, nextEarningsDate,
percentHeldByInstitutions, percentHeldByInsiders, shortRatio,
shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh,
priceTargetMedian, priceTargetMean, executives, financialStatements,
estimates, availableTickers,
];
}
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
double? compVal;
if (json['compensation'] != null) {
compVal = double.tryParse(json['compensation'].toString());
} else if (json['payment'] != null) {
final pStr = json['payment'].toString().trim().toUpperCase().replaceAll('\$', '').replaceAll('', '').replaceAll('£', '').replaceAll(',', '').replaceAll(' ', '');
if (pStr.endsWith('M')) {
final numPart = double.tryParse(pStr.substring(0, pStr.length - 1));
if (numPart != null) compVal = numPart * 1e6;
} else if (pStr.endsWith('K')) {
final numPart = double.tryParse(pStr.substring(0, pStr.length - 1));
if (numPart != null) compVal = numPart * 1e3;
} else if (pStr.endsWith('B')) {
final numPart = double.tryParse(pStr.substring(0, pStr.length - 1));
if (numPart != null) compVal = numPart * 1e9;
} else {
compVal = double.tryParse(pStr);
}
}
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: compVal,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
'age': age,
'compensation': compensation,
};
}
@override
List<Object?> get props => [name, title, age, compensation];
}
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null,
expectedEps: json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null,
expectedGrowthRate: json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
'expectedRevenue': expectedRevenue,
'expectedEps': expectedEps,
'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
class TickerModel extends Equatable {
final String ticker;
final String? exchange;
final String? tradingCurrency;
final double currentPrice;
const TickerModel({
required this.ticker,
this.exchange,
this.tradingCurrency,
this.currentPrice = 0.0,
});
factory TickerModel.fromJson(Map<String, dynamic> json) {
return TickerModel(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
currentPrice: json['currentPrice'] != null ? double.tryParse(json['currentPrice'].toString()) ?? 0.0 : 0.0,
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -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];
}
@@ -198,15 +198,16 @@ class ChartPatternModel extends Equatable {
class TechnicalAnalysisModel extends Equatable {
final String symbol;
final String currency;
final double? currentPrice;
final String trend;
final String rsi;
final String macd;
final String overallSignal;
final String sma50;
final String sma200;
final double vix;
final String sp500Trend;
final double dxy;
final double? vix;
final String? sp500Trend;
final double? dxy;
final double? stopLossAtr;
final List<CandleModel> candles;
final List<IndicatorModel> indicators;
@@ -216,15 +217,16 @@ class TechnicalAnalysisModel extends Equatable {
const TechnicalAnalysisModel({
required this.symbol,
this.currency = 'EUR',
this.currentPrice,
required this.trend,
required this.rsi,
required this.macd,
required this.overallSignal,
required this.sma50,
required this.sma200,
this.vix = 16.5,
this.sp500Trend = 'Bullish',
this.dxy = 104.2,
this.vix,
this.sp500Trend,
this.dxy,
this.stopLossAtr,
this.candles = const [],
this.indicators = const [],
@@ -245,7 +247,6 @@ class TechnicalAnalysisModel extends Equatable {
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
final regime = json['marketRegime'] as Map<String, dynamic>?;
@@ -259,17 +260,18 @@ class TechnicalAnalysisModel extends Equatable {
}
return TechnicalAnalysisModel(
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
currency: json['currency']?.toString() ?? 'EUR',
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
trend: parsedTrend,
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
overallSignal: parsedSignal,
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
vix: (regime?['vixValue'] as num?)?.toDouble() ?? 16.5,
sp500Trend: regime?['marketTrend']?.toString() ?? 'Bullish',
dxy: (regime?['dxyValue'] as num?)?.toDouble() ?? 104.2,
vix: (regime?['vixValue'] as num?)?.toDouble(),
sp500Trend: regime?['marketTrend']?.toString(),
dxy: (regime?['dxyValue'] as num?)?.toDouble(),
stopLossAtr: lastInd?.recommendedStopLoss,
candles: candlesList,
indicators: indicatorsList,
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class TickerModel extends Equatable {
final String ticker;
final String? exchange;
final String? tradingCurrency;
final double? currentPrice;
const TickerModel({
required this.ticker,
this.exchange,
this.tradingCurrency,
this.currentPrice,
});
factory TickerModel.fromJson(Map<String, dynamic> json) {
return TickerModel(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
if (exchange != null) 'exchange': exchange,
if (tradingCurrency != null) 'tradingCurrency': tradingCurrency,
if (currentPrice != null) 'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -1,103 +1,147 @@
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';
import 'package:finlytic_app/features/trades/repositories/trade_repository.dart';
class AssetRepository {
final ApiClient apiClient;
final TradeRepository _tradeRepository;
AssetRepository({required this.apiClient});
// In-memory request deduplication & cache
final Map<String, Future<FundamentalDataModel?>> _pendingFundamentals = {};
final Map<String, FundamentalDataModel> _fundamentalsCache = {};
final Map<String, Future<TechnicalAnalysisModel?>> _pendingTechnicals = {};
final Map<String, TechnicalAnalysisModel> _technicalsCache = {};
AssetRepository({required this.apiClient, TradeRepository? tradeRepository})
: _tradeRepository = tradeRepository ?? TradeRepository(apiClient: apiClient);
String _buildCacheKey(String isin, String? ticker) => '${isin.toUpperCase()}_${(ticker ?? '').toUpperCase()}';
Future<FundamentalDataModel?> getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
final key = _buildCacheKey(isin, ticker);
if (!forceRefresh && _fundamentalsCache.containsKey(key)) {
return _fundamentalsCache[key];
}
if (_pendingFundamentals.containsKey(key)) {
return await _pendingFundamentals[key];
}
final future = _fetchFundamentals(isin, forceRefresh, ticker: ticker);
_pendingFundamentals[key] = future;
try {
final result = await future;
if (result != null) {
_fundamentalsCache[key] = result;
}
return result;
} finally {
_pendingFundamentals.remove(key);
}
}
Future<FundamentalDataModel?> _fetchFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching fundamentals for $isin: $e');
}
} catch (_) {}
return null;
}
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
final key = _buildCacheKey(isin, ticker);
if (!forceRefresh && _technicalsCache.containsKey(key)) {
return _technicalsCache[key];
}
if (_pendingTechnicals.containsKey(key)) {
return await _pendingTechnicals[key];
}
final future = _fetchTechnicals(isin, forceRefresh, ticker: ticker);
_pendingTechnicals[key] = future;
try {
final result = await future;
if (result != null) {
_technicalsCache[key] = result;
}
return result;
} finally {
_pendingTechnicals.remove(key);
}
}
Future<TechnicalAnalysisModel?> _fetchTechnicals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching TA for $isin: $e');
}
} catch (_) {}
return null;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
Future<double?> getLivePrice(String isin) async {
try {
String url = '/api/v1/user/trades?isin=$isin';
if (status != null) url += '&status=$status';
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> list = res.data;
return list.map((json) => TradeModel.fromJson(json)).toList();
}
} catch (e) {
print('Error fetching trades for $isin: $e');
}
return [];
}
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
try {
final body = payload != null ? payload.toJson() : {'isin': isin};
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
final res = await apiClient.get('/api/v1/assets/$isin/live');
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return ManualAnalysisResponseDto.fromJson(res.data);
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
if (val is num) return val.toDouble();
if (val != null) return double.tryParse(val.toString());
}
return null;
} catch (e) {
print('Error triggering manual analysis for $isin: $e');
rethrow;
}
} catch (_) {}
return null;
}
Future<void> rejectTrade(String tradeId) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
} catch (e) {
print('Error rejecting trade $tradeId: $e');
throw e;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
return _tradeRepository.fetchTrades(isin: isin, status: status);
}
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
try {
final payload = tradeAcceptanceDto.toJson();
await apiClient.post('/api/v1/user/trades/accept', data: payload);
} catch (e) {
print('Error accepting trade: $e');
throw e;
/// 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.data != null && res.data is Map<String, dynamic>) {
return AssetEvaluationResultModel.fromJson(res.data);
}
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
}
Future<void> closeTrade(String tradeId, double exitPrice) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
} catch (e) {
print('Error closing trade $tradeId: $e');
throw e;
}
}
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);
}
@@ -125,6 +125,10 @@ class MetricExplanations {
},
};
static bool hasExplanation(String key) => data.containsKey(key);
static void showModal(BuildContext context, String key) => show(context, key);
static void show(BuildContext context, String key) {
final info = data[key];
if (info == null) return;
@@ -117,6 +117,17 @@ class PatternExplanations {
return colors[patternType.hashCode.abs() % colors.length];
}
static String getGermanName(String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
orElse: () => '',
);
if (key.isNotEmpty && dictionary.containsKey(key)) {
return dictionary[key]!['title'] ?? rawPatternType;
}
return rawPatternType;
}
static void showPatternDetails(BuildContext context, String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
@@ -0,0 +1,64 @@
import '../../favorites/models/favorite_asset_model.dart';
import '../models/fundamental_data_model.dart';
class TickerResolver {
/// Resolves the optimal ticker according to user prioritization:
/// 1. Candidate / active user-selected symbol (if valid and not equal to ISIN)
/// 2. Favorite selected ticker (if asset is in favorites and has a valid ticker)
/// 3. Primary Ticker (from Fundamentals header)
/// 4. First available ticker from AvailableTickers list
/// 5. ISIN fallback
static String? resolve({
required String isin,
String? candidateSymbol,
List<FavoriteAssetModel>? favoriteDetails,
FundamentalDataModel? fundamentals,
}) {
final cleanIsin = isin.trim().toUpperCase();
// 1. Check Candidate / User-selected symbol
if (candidateSymbol != null && candidateSymbol.trim().isNotEmpty) {
final cClean = candidateSymbol.trim();
if (cClean.toUpperCase() != cleanIsin) {
return cClean;
}
}
// 2. Check Favorite selected ticker
if (favoriteDetails != null && favoriteDetails.isNotEmpty) {
for (final f in favoriteDetails) {
final fIsin = f.isin.trim().toUpperCase();
final fSym = f.symbol.trim().toUpperCase();
if (fIsin == cleanIsin || fSym == cleanIsin) {
if (f.symbol.trim().isNotEmpty && f.symbol.trim().toUpperCase() != cleanIsin) {
return f.symbol.trim();
}
}
}
}
// 3. Check Fundamentals Primary Ticker
if (fundamentals != null) {
final primary = fundamentals.primaryTicker.trim();
if (primary.isNotEmpty && primary.toUpperCase() != cleanIsin) {
return primary;
}
final fTicker = fundamentals.ticker.trim();
if (fTicker.isNotEmpty && fTicker.toUpperCase() != cleanIsin) {
return fTicker;
}
// 4. First available ticker in availableTickers
for (final t in fundamentals.availableTickers) {
final tTick = t.ticker.trim();
if (tTick.isNotEmpty && tTick.toUpperCase() != cleanIsin) {
return tTick;
}
}
}
// 5. Fallback: ISIN
return isin.trim().isNotEmpty ? isin.trim() : null;
}
}
@@ -1,13 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../favorites/cubit/favorites_cubit.dart';
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../bloc/header/asset_header_bloc.dart';
import '../bloc/header/asset_header_event.dart';
import '../bloc/fundamentals/asset_fundamentals_event.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/trades/asset_trades_bloc.dart';
import '../bloc/trades/asset_trades_event.dart';
import '../repositories/asset_repository.dart';
import '../utils/ticker_resolver.dart';
import 'layouts/asset_page_desktop_layout.dart';
import 'layouts/asset_page_mobile_layout.dart';
@@ -29,41 +31,50 @@ class AssetDetailScreen extends StatelessWidget {
Widget build(BuildContext context) {
final repository = AssetRepository(apiClient: apiClient);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AssetHeaderBloc(repository: repository)
..add(LoadAssetHeader(isin, ticker: symbol)),
),
BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)
..add(LoadAssetTrades(isin)),
),
],
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900) {
return AssetPageDesktopLayout(
isin: isin,
name: name,
selectedTicker: symbol,
);
}
return AssetPageMobileLayout(
isin: isin,
name: name,
selectedTicker: symbol,
);
},
),
),
return BlocBuilder<FavoritesCubit, FavoritesState>(
builder: (context, favState) {
final initialTicker = TickerResolver.resolve(
isin: isin,
candidateSymbol: symbol,
favoriteDetails: favState.favoriteDetails,
);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository)
..add(LoadAssetFundamentals(isin, ticker: initialTicker)),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository)
..add(LoadAssetTechnical(isin, ticker: initialTicker)),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)
..add(LoadAssetTrades(isin)),
),
],
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900) {
return AssetPageDesktopLayout(
isin: isin,
name: name,
selectedTicker: initialTicker ?? symbol,
);
}
return AssetPageMobileLayout(
isin: isin,
name: name,
selectedTicker: initialTicker ?? symbol,
);
},
),
),
);
},
);
}
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/technical/asset_technical_state.dart';
import '../widgets/chart/candlestick_chart.dart';
import '../widgets/technical/indicator_ribbon_bar.dart';
class FullscreenChartScreen extends StatefulWidget {
final String isin;
final String? symbol;
final AssetTechnicalBloc technicalBloc;
const FullscreenChartScreen({
super.key,
required this.isin,
this.symbol,
required this.technicalBloc,
});
static Future<void> open(BuildContext context, {required String isin, String? symbol}) {
final bloc = context.read<AssetTechnicalBloc>();
return Navigator.of(context).push(
PageRouteBuilder(
opaque: true,
pageBuilder: (ctx, anim, secAnim) => FullscreenChartScreen(
isin: isin,
symbol: symbol,
technicalBloc: bloc,
),
transitionsBuilder: (ctx, anim, secAnim, child) {
return FadeTransition(opacity: anim, child: child);
},
),
);
}
@override
State<FullscreenChartScreen> createState() => _FullscreenChartScreenState();
}
class _FullscreenChartScreenState extends State<FullscreenChartScreen> {
@override
void initState() {
super.initState();
// Rotate to landscape on mobile devices
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
// Restore orientation back to default portrait/auto
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocProvider.value(
value: widget.technicalBloc,
child: Scaffold(
backgroundColor: theme.darkBackground,
body: SafeArea(
child: BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, state) {
if (state is AssetTechnicalLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is AssetTechnicalLoaded && state.data != null) {
final data = state.data!;
final activePatterns = <ChartPatternModel>[];
for (int i = 0; i < data.patterns.length; i++) {
if (!state.disabledPatternIndices.contains(i)) {
activePatterns.add(data.patterns[i]);
}
}
return Column(
children: [
_buildHeader(context, theme, state),
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
return CandlestickChart(
candles: data.candles,
indicators: data.indicators,
patterns: activePatterns,
signals: data.signals,
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
showSupertrend: state.showSupertrend,
height: constraints.maxHeight,
isFullscreen: true,
onToggleFullscreen: () => Navigator.of(context).pop(),
);
},
),
),
],
);
}
return Center(
child: Text('Keine Chartdaten verfügbar', style: TextStyle(color: theme.textMuted)),
);
},
),
),
),
);
}
Widget _buildHeader(BuildContext context, ThemePreset theme, AssetTechnicalLoaded state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
border: Border(bottom: BorderSide(color: theme.glassBorder)),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 20, color: Colors.white70),
tooltip: 'Zurück',
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 4),
Text(
widget.symbol ?? widget.isin,
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14),
),
const SizedBox(width: 12),
Expanded(
child: IndicatorRibbonBar(
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showSupertrend: state.showSupertrend,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.fullscreen_exit, size: 22, color: Colors.white70),
tooltip: 'Vollbild beenden',
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
}
@@ -4,13 +4,12 @@ import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../utils/ticker_resolver.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
@@ -21,8 +20,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
final String? name;
final String? selectedTicker;
const AssetPageDesktopLayout(
{super.key, required this.isin, this.selectedTicker, this.name});
const AssetPageDesktopLayout({
super.key,
required this.isin,
this.selectedTicker,
this.name,
});
@override
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
@@ -31,12 +34,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedExchange;
String? _selectedTicker;
@override
void initState() {
super.initState();
_selectedTicker = widget.selectedTicker;
_tabController = TabController(length: 3, vsync: this);
}
@@ -48,11 +51,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
@@ -65,10 +65,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
forceRefresh: true,
exchange: _selectedExchange,
ticker: _selectedTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
@@ -78,22 +76,33 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = widget.selectedTicker;
});
if (state is AssetFundamentalsLoaded && state.data != null) {
if (_selectedTicker == null ||
_selectedTicker!.trim().isEmpty ||
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
final bestTicker = TickerResolver.resolve(
isin: widget.isin,
candidateSymbol: widget.selectedTicker,
favoriteDetails: favList,
fundamentals: state.data,
);
if (bestTicker != null &&
bestTicker.isNotEmpty &&
bestTicker.toUpperCase() != widget.isin.toUpperCase()) {
setState(() {
_selectedTicker = bestTicker;
});
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: bestTicker,
forceRefresh: false,
));
}
}
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
}
},
child: SingleChildScrollView(
@@ -110,88 +119,87 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
onForceRefresh: _handleForceRefresh,
),
// 2. Full-Width Interactive Chart Section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 460,
),
// 2. Full-Width Interactive Chart Section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
const SizedBox(height: 8),
// 3. Detailed Sections & Fundamentals under the Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'FUNDAMENTALS & ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER & SIGNALE'),
Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 600,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
child: TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 460,
),
const SizedBox(height: 24),
],
),
),
const SizedBox(height: 8),
// 3. Detailed Sections & Fundamentals under the Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'FUNDAMENTALS & ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER & SIGNALE'),
Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 600,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
),
const SizedBox(height: 24),
],
),
);
));
}
}
@@ -4,13 +4,12 @@ import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../utils/ticker_resolver.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
@@ -21,8 +20,12 @@ class AssetPageMobileLayout extends StatefulWidget {
final String? name;
final String? selectedTicker;
const AssetPageMobileLayout(
{super.key, required this.isin, this.selectedTicker, this.name});
const AssetPageMobileLayout({
super.key,
required this.isin,
this.selectedTicker,
this.name,
});
@override
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
@@ -36,7 +39,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
@override
void initState() {
super.initState();
//_selectedTicker = widget.selectedTicker;
_selectedTicker = widget.selectedTicker;
_tabController = TabController(length: 3, vsync: this);
}
@@ -48,11 +51,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
//_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
@@ -65,10 +65,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
forceRefresh: true, ticker: _selectedTicker));
// AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true
// for fundamentals, and the listener below will fetch the updated data with forceRefresh=false.
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
@@ -78,22 +76,33 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = widget.selectedTicker;
});
if (state is AssetFundamentalsLoaded && state.data != null) {
if (_selectedTicker == null ||
_selectedTicker!.trim().isEmpty ||
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
final bestTicker = TickerResolver.resolve(
isin: widget.isin,
candidateSymbol: widget.selectedTicker,
favoriteDetails: favList,
fundamentals: state.data,
);
if (bestTicker != null &&
bestTicker.isNotEmpty &&
bestTicker.toUpperCase() != widget.isin.toUpperCase()) {
setState(() {
_selectedTicker = bestTicker;
});
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: bestTicker,
forceRefresh: false,
));
}
}
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
}
},
child: SingleChildScrollView(
@@ -110,7 +119,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
onForceRefresh: _handleForceRefresh,
),
// 2. Full-Width Interactive Chart Section
// 2. Interactive Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -122,13 +131,13 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 330,
chartHeight: 320,
),
),
const SizedBox(height: 6),
const SizedBox(height: 8),
// 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades)
// 3. Tabbed Detailed Analysis
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -137,20 +146,28 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
unselectedLabelColor: theme.textSecondary,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 12),
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(text: 'FUNDAMENTALS'),
Tab(text: 'MUSTER & SIGNALE'),
Tab(text: 'TRADES'),
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER'),
Tab(
icon:
Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
@@ -3,14 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.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 '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../models/fundamental_data_model.dart';
import '../../utils/metric_explanations.dart';
import '../../widgets/fundamentals/analyst_price_target_card.dart';
import '../../widgets/fundamentals/fundamental_category_panels.dart';
import '../../widgets/fundamentals/company_profile_section.dart';
class FundamentalsTab extends StatefulWidget {
class FundamentalsTab extends StatelessWidget {
final String isin;
final String? symbol;
final bool isEmbedded;
@@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget {
required this.isin,
});
@override
State<FundamentalsTab> createState() => _FundamentalsTabState();
}
class _FundamentalsTabState extends State<FundamentalsTab> {
String _sym = '\$';
String _curCode = 'USD';
@override
void initState() {
super.initState();
String _getCurrencySymbol(String? ticker) {
if (ticker == null || ticker.isEmpty) return '';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
return '';
}
if (t.endsWith('.L')) return '£';
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
return '\$';
}
@override
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
super.didUpdateWidget(oldWidget);
String _getCurrencyCode(String? ticker) {
if (ticker == null || ticker.isEmpty) return 'EUR';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
return 'EUR';
}
if (t.endsWith('.L')) return 'GBP';
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CAD';
return 'USD';
}
@override
@@ -60,7 +64,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
@@ -70,89 +74,68 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
);
}
if (state is AssetFundamentalsLoaded) {
final data = state.data;
if (data != null) {
_sym = _getCurrencySymbol(data.ticker);
_curCode = _getCurrencyCode(data.ticker);
}
if (data == null) {
return _buildEmptyState();
}
if (state is AssetFundamentalsLoaded && state.data != null) {
final data = state.data!;
final sym = _getCurrencySymbol(data.ticker);
final curCode = _getCurrencyCode(data.ticker);
return SingleChildScrollView(
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Analyst Forecasts & Price Targets Header Card
_buildPriceTargetCard(data),
AnalystPriceTargetCard(data: data, currencySymbol: sym),
const SizedBox(height: 20),
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
_buildCategoryPanels(data),
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
const SizedBox(height: 20),
// 3. Company Description & Detailed Executive Board
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
const SizedBox(height: 12),
_buildProfileSection(data),
CompanyProfileSection(data: data, currencySymbol: sym),
],
),
);
}
return _buildEmptyState();
return _buildEmptyState(context);
},
);
}
Widget _buildPriceTargetCard(FundamentalDataModel data) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
Widget _buildEmptyState(BuildContext context) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.analytics_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Aktualisieren'),
),
],
),
),
);
}
@@ -188,16 +171,13 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
}
return SingleChildScrollView(
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Price Target Card Shimmer
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
const SizedBox(height: 20),
// 3 Category Panels Shimmer
if (isDesktop)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -234,9 +214,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
panelShimmer(),
],
),
const SizedBox(height: 20),
// Profile Section Shimmer
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
const SizedBox(height: 12),
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
@@ -244,454 +222,4 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
),
);
}
Widget _buildProfileSection(FundamentalDataModel data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Mitarbeiter: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text('Führungskräfte (Board)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14)),
const SizedBox(height: 8),
...data.executives.take(5).map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.person_outline, color: AppTheme.primaryEmerald, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
Text(e.title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
if (e.compensation != null && e.compensation! > 0)
Text(
_formatNumber(e.compensation),
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12),
),
],
),
),
)),
],
],
);
}
Widget _buildProfileBadge(String label, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.insert_chart_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text('Für dieses Asset wurden noch keine Bilanz- oder Bewertungskennzahlen erfasst.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.download),
label: const Text('Daten von Backend abrufen'),
),
],
),
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildCategoryPanels(FundamentalDataModel data) {
final valuationItems = [
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)),
_MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)),
_MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)),
_MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
];
final profitabilityItems = [
_MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)),
_MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)),
_MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)),
_MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_MetricRowItem('EBITDA', _formatNumber(data.ebitda)),
_MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)),
_MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)),
_MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)),
_MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)),
_MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)),
_MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)),
_MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)),
];
final dividendItems = [
_MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)),
_MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'),
_MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
];
final panel1 = _buildCategoryPanel(
title: 'Bewertungskennzahlen & Multiples',
icon: Icons.analytics_outlined,
items: valuationItems,
);
final panel2 = _buildCategoryPanel(
title: 'Rentabilität & Finanzen',
icon: Icons.account_balance_outlined,
items: profitabilityItems,
);
final panel3 = _buildCategoryPanel(
title: 'Dividenden & Termine',
icon: Icons.pie_chart_outline,
items: dividendItems,
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1050) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 14),
Expanded(child: panel2),
const SizedBox(width: 14),
Expanded(child: panel3),
],
);
} else if (constraints.maxWidth >= 680) {
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 12),
Expanded(child: panel2),
],
),
const SizedBox(height: 12),
panel3,
],
);
} else {
return Column(
children: [
panel1,
const SizedBox(height: 12),
panel2,
const SizedBox(height: 12),
panel3,
],
);
}
},
);
}
Widget _buildCategoryPanel({
required String title,
required IconData icon,
required List<_MetricRowItem> items,
}) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: AppTheme.primaryEmerald, size: 16),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 4),
...items.asMap().entries.map((entry) {
final idx = entry.key;
final item = entry.value;
final isEven = idx % 2 == 0;
return _buildMetricListRow(item.label, item.value, isEven: isEven, valueColor: item.valueColor);
}),
],
),
);
}
Widget _buildMetricListRow(String label, String value, {bool isEven = false, Color? valueColor}) {
return InkWell(
onTap: () => MetricExplanations.show(context, label),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(width: 4),
Icon(Icons.info_outline, size: 11, color: AppTheme.textMuted.withValues(alpha: 0.6)),
],
),
const SizedBox(width: 8),
Flexible(
child: Text(
value,
style: TextStyle(
color: valueColor ?? (value == 'N/A' ? AppTheme.textMuted : Colors.white),
fontWeight: FontWeight.w600,
fontSize: 12,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
String _fmtMultiple(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)}x' : 'N/A';
}
String _fmtDays(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(1)} Tage' : 'N/A';
}
String _fmtDebtToEquity(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null) return 'N/A';
// Yahoo liefert D/E als Prozentwert (z. B. 145.23 = 145.23% oder Faktor 1.45x)
if (n > 5) {
return '${(n / 100).toStringAsFixed(2)}x (${n.toStringAsFixed(1)} %)';
}
return '${n.toStringAsFixed(2)}x (${(n * 100).toStringAsFixed(1)} %)';
}
String _fmtPercent(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null) return 'N/A';
// Yahoo liefert Margen/Renditen als Dezimalzahl (z. B. 0.25 = 25%, 1.2 = 120%)
// Wenn |n| <= 2.5 ist, handelt es sich um eine Dezimalquote -> mit 100 multiplizieren
final p = n.abs() <= 2.5 ? n * 100 : n;
return '${p.toStringAsFixed(2)} %';
}
String _fmtCurrency(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null || n == 0) return 'N/A';
return '$_sym${n.toStringAsFixed(2)}';
}
String _fmtDate(dynamic val) {
if (val == null) return 'N/A';
final dt = DateTime.tryParse(val.toString());
return dt != null ? '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}' : val.toString();
}
String _formatNumber(dynamic val) {
if (val == null) return 'N/A';
final num? n = val is num ? val : num.tryParse(val.toString());
if (n == null) return val.toString();
final isNegative = n < 0;
final absVal = n.abs();
final prefix = isNegative ? '-$_sym' : _sym;
if (absVal >= 1e12) {
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bio.';
} else if (absVal >= 1e9) {
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
} else if (absVal >= 1e6) {
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
} else if (absVal >= 1e3) {
return '$prefix${(absVal / 1e3).toStringAsFixed(1)} Tsd.';
} else {
return '$prefix${absVal.toStringAsFixed(2)}';
}
}
/// Leitet das Währungssymbol vom Ticker-Suffix ab.
String _getCurrencySymbol(String? ticker) {
if (ticker == null || ticker.isEmpty) return '\$';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') ||
t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') ||
t.endsWith('.BE') || t.endsWith('.SG') ||
t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') ||
t.endsWith('.MC')) return '';
if (t.endsWith('.L')) return '£';
if (t.endsWith('.SW')) return 'CHF ';
if (t.endsWith('.TO')) return 'CA\$';
if (t.endsWith('.AX')) return 'A\$';
if (t.endsWith('.T')) return '¥';
if (t.endsWith('.HK')) return 'HK\$';
return '\$';
}
/// Leitet den Währungscode vom Ticker-Suffix ab.
String _getCurrencyCode(String? ticker) {
if (ticker == null || ticker.isEmpty) return 'USD';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') ||
t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') ||
t.endsWith('.BE') || t.endsWith('.SG') ||
t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') ||
t.endsWith('.MC')) return 'EUR';
if (t.endsWith('.L')) return 'GBP';
if (t.endsWith('.SW')) return 'CHF';
if (t.endsWith('.TO')) return 'CAD';
if (t.endsWith('.AX')) return 'AUD';
if (t.endsWith('.T')) return 'JPY';
if (t.endsWith('.HK')) return 'HKD';
return 'USD';
}
}
class _MetricRowItem {
final String label;
final String value;
final Color? valueColor;
const _MetricRowItem(this.label, this.value, {this.valueColor});
}
@@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../utils/metric_explanations.dart';
import '../../utils/pattern_explanations.dart';
import '../../widgets/chart/candlestick_chart.dart';
import '../../widgets/technical/pattern_card_item.dart';
import '../../widgets/technical/signal_card_item.dart';
import '../../widgets/technical/indicator_ribbon_bar.dart';
class TechnicalTab extends StatefulWidget {
import '../fullscreen_chart_screen.dart';
class TechnicalTab extends StatelessWidget {
final String isin;
final String? symbol;
final bool isDesktopLeftPanel;
@@ -30,31 +31,6 @@ class TechnicalTab extends StatefulWidget {
required this.isin,
});
@override
State<TechnicalTab> createState() => _TechnicalTabState();
}
class _TechnicalTabState extends State<TechnicalTab> {
bool _showSma50 = true;
bool _showSma200 = true;
bool _showEma = true;
bool _showPatterns = true;
bool _showSignals = true;
bool _showSupertrend = true;
// Set of disabled pattern indices for individual toggling
final Set<int> _disabledPatternIndices = {};
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(covariant TechnicalTab oldWidget) {
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
@@ -73,12 +49,14 @@ class _TechnicalTabState extends State<TechnicalTab> {
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text(
'Fehler beim Laden der Technischen Analyse: ${state.message}',
style: const TextStyle(color: Colors.white70)),
'Fehler beim Laden der Technischen Analyse: ${state.message}',
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetTechnicalBloc>().add(
LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)),
LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true),
),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
@@ -88,221 +66,91 @@ class _TechnicalTabState extends State<TechnicalTab> {
);
}
if (state is AssetTechnicalLoaded) {
final data = state.data;
List<CandleModel> candles = [];
List<ChartPatternModel> patterns = [];
List<StrategySignalModel> signals = [];
List<IndicatorModel> indicators = [];
if (state is AssetTechnicalLoaded && state.data != null) {
final data = state.data!;
final candles = data.candles;
final patterns = data.patterns;
final signals = data.signals;
final indicators = data.indicators;
if (data != null) {
candles = data.candles
.map((c) => CandleModel(
time: c.timestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume))
.toList();
patterns = data.patterns
.map((p) => ChartPatternModel(
type: p.type,
upperLine: p.upperLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
lowerLine: p.lowerLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
))
.toList();
signals = data.signals
.map((s) => StrategySignalModel(
type: 'strategy',
timestamp: s.date,
direction: s.type,
price: s.price,
description: s.title))
.toList();
indicators = data.indicators
.map((i) => IndicatorModel(
timestamp: i.timestamp,
ema20: i.ema20,
sma50: i.sma50,
sma200: i.sma200,
supertrendUpper: i.supertrendUpper,
supertrendLower: i.supertrendLower,
supertrendDirection: i.supertrendDirection))
.toList();
final activePatterns = <ChartPatternModel>[];
for (int i = 0; i < patterns.length; i++) {
if (!state.disabledPatternIndices.contains(i)) {
activePatterns.add(patterns[i]);
}
}
// Filter patterns according to individual checkbox states
final activePatterns = [
for (int i = 0; i < patterns.length; i++)
if (!_disabledPatternIndices.contains(i)) patterns[i]
];
final chartRibbon = GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildIndicatorChip(
'EMA (20)',
_showEma,
(v) => setState(() => _showEma = v),
Colors.blueAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'SMA (50)',
_showSma50,
(v) => setState(() => _showSma50 = v),
Colors.orangeAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'SMA (200)',
_showSma200,
(v) => setState(() => _showSma200 = v),
Colors.redAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'Supertrend',
_showSupertrend,
(v) => setState(() => _showSupertrend = v),
AppTheme.primaryEmerald),
const SizedBox(width: 6),
_buildIndicatorChip(
'Alle Muster',
_showPatterns,
(v) => setState(() => _showPatterns = v),
Colors.amberAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'Signale',
_showSignals,
(v) => setState(() => _showSignals = v),
AppTheme.accentCyan),
],
),
),
final chartWidget = CandlestickChart(
candles: candles,
indicators: indicators,
patterns: activePatterns,
signals: signals,
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
showSupertrend: state.showSupertrend,
height: chartHeight,
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
);
final chartWidget = SizedBox(
height: widget.chartHeight,
width: double.infinity,
child: CandlestickChart(
candles: candles,
patterns: activePatterns,
signals: signals,
indicators: indicators,
showPatterns: _showPatterns,
showEma: _showEma,
showSma50: _showSma50,
showSma200: _showSma200,
showSignals: _showSignals,
showSupertrend: _showSupertrend,
),
final chartRibbon = IndicatorRibbonBar(
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showSupertrend: state.showSupertrend,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
);
if (widget.showChartOnly) {
if (showChartOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
chartRibbon,
const SizedBox(height: 8),
const SizedBox(height: 10),
chartWidget,
],
);
}
final detailsSection = Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.architecture_outlined,
color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
const Text('Erkannte Chart-Muster & Signale',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white)),
],
),
if (patterns.isNotEmpty)
TextButton.icon(
onPressed: () {
setState(() {
if (_disabledPatternIndices.length ==
patterns.length) {
_disabledPatternIndices.clear();
} else {
_disabledPatternIndices.addAll(
List.generate(
patterns.length, (i) => i));
}
});
},
icon: Icon(
_disabledPatternIndices.isEmpty
? Icons.deselect
: Icons.select_all,
size: 16,
color: Colors.amberAccent),
label: Text(
_disabledPatternIndices.isEmpty
? 'Alle abwählen'
: 'Alle anwählen',
style: const TextStyle(
color: Colors.amberAccent, fontSize: 12)),
),
],
),
const SizedBox(height: 12),
if (patterns.isEmpty && signals.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text(
'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.',
style: TextStyle(
color: AppTheme.textMuted, fontSize: 12)),
),
)
else ...[
if (patterns.isNotEmpty) ...[
Text(
'Formationen & Trendlinien (Mit Checkbox im Chart schalten):',
style: TextStyle(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
fontSize: 13)),
const SizedBox(height: 6),
...List.generate(
patterns.length,
(index) =>
_buildPatternCard(patterns[index], index)),
const SizedBox(height: 12),
],
if (signals.isNotEmpty) ...[
Text('Strategie-Signale:',
style: TextStyle(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
fontSize: 13)),
const SizedBox(height: 6),
...signals.map((s) => _buildSignalCard(s)),
],
],
final detailsSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (patterns.isNotEmpty) ...[
const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
for (int i = 0; i < patterns.length; i++)
PatternCardItem(
pattern: patterns[i],
index: i,
isEnabled: !state.disabledPatternIndices.contains(i),
onToggle: (enabled) {
context.read<AssetTechnicalBloc>().add(
TogglePatternFilter(patternIndex: i, enabled: enabled),
);
},
),
const SizedBox(height: 16),
],
),
if (signals.isNotEmpty) ...[
const Text('Strategische Kauf- & Verkaufssignale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
for (final sig in signals) SignalCardItem(signal: sig),
],
],
);
if (widget.showDetailsOnly) {
if (showDetailsOnly) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: detailsSection,
@@ -325,228 +173,26 @@ class _TechnicalTabState extends State<TechnicalTab> {
}
return Center(
child: Text('Keine technisches Indikatoren verfügbar',
style: TextStyle(color: AppTheme.textMuted)),
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
);
},
);
}
Widget _buildPatternCard(ChartPatternModel pattern, int index) {
final isEnabled = !_disabledPatternIndices.contains(index);
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
DateTime? startDate;
DateTime? endDate;
if (allPoints.isNotEmpty) {
allPoints.sort((a, b) => a.time.compareTo(b.time));
startDate = allPoints.first.time;
endDate = allPoints.last.time;
}
final dateFormat = DateFormat('dd.MM.yy');
final dateStr = startDate != null && endDate != null
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
: 'Unbekannt';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// Checkbox for individual pattern toggling on the chart
Checkbox(
value: isEnabled,
activeColor: patternColor,
checkColor: Colors.black,
side:
BorderSide(color: patternColor.withValues(alpha: 0.6)),
onChanged: (bool? val) {
setState(() {
if (val == true) {
_disabledPatternIndices.remove(index);
} else {
_disabledPatternIndices.add(index);
}
});
},
),
Expanded(
child: InkWell(
onTap: () => PatternExplanations.showPatternDetails(
context, pattern.type),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled
? patternColor.withValues(alpha: 0.15)
: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.polyline_outlined,
color: isEnabled
? patternColor
: AppTheme.textMuted,
size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pattern.type,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isEnabled
? Colors.white
: AppTheme.textMuted,
fontSize: 14,
decoration: isEnabled
? null
: TextDecoration.lineThrough,
),
),
const SizedBox(width: 6),
Icon(Icons.info_outline,
size: 14, color: AppTheme.textMuted),
],
),
Text(
'Zeitraum: $dateStr\n'
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
style: TextStyle(
color: AppTheme.textMuted, fontSize: 11, height: 1.3),
),
],
),
),
StatusBadge(
label: isEnabled ? 'AKTIV' : 'AUS',
color:
isEnabled ? patternColor : AppTheme.textMuted,
),
],
),
),
),
),
],
),
),
);
}
Widget _buildSignalCard(StrategySignalModel signal) {
final isBuy = signal.type.toUpperCase() == 'BUY';
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(isBuy ? Icons.north_east : Icons.south_east,
color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(signal.type.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
fontSize: 14)),
const SizedBox(width: 8),
Text('@ €${signal.price.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13)),
],
),
const SizedBox(height: 4),
Text(
signal.description.isNotEmpty
? signal.description
: 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
style: TextStyle(
color: AppTheme.textSecondary, fontSize: 12)),
],
),
),
StatusBadge(label: 'SIGNAL', color: color),
],
),
),
);
}
Widget _buildIndicatorChip(String label, bool isSelected,
ValueChanged<bool> onChanged, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
selected: isSelected,
label: Text(label,
style: TextStyle(
color: isSelected ? Colors.black : color,
fontSize: 11,
fontWeight: FontWeight.bold)),
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
side: BorderSide(color: color.withValues(alpha: 0.4)),
showCheckmark: false,
onSelected: onChanged,
),
InkWell(
onTap: () => MetricExplanations.show(context, label),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child:
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
),
),
],
);
}
Widget _buildTechnicalShimmer(BuildContext context) {
if (widget.showChartOnly) {
if (showChartOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
const SizedBox(height: 8),
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
],
);
}
if (widget.showDetailsOnly) {
if (showDetailsOnly) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
@@ -570,7 +216,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
children: [
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
const SizedBox(height: 8),
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
const SizedBox(height: 16),
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
const SizedBox(height: 14),
File diff suppressed because it is too large Load Diff
@@ -1,124 +1,25 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../utils/pattern_explanations.dart';
import '../../models/technical_analysis_model.dart';
import 'candlestick_painter.dart';
class CandleModel {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
final double volume;
CandleModel({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
time: DateTime.tryParse(json['timestamp'] ?? json['time'] ?? '') ?? DateTime.now(),
open: (json['open'] ?? 0).toDouble(),
high: (json['high'] ?? 0).toDouble(),
low: (json['low'] ?? 0).toDouble(),
close: (json['close'] ?? 0).toDouble(),
volume: (json['volume'] ?? 0).toDouble(),
);
}
}
class IndicatorModel {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
ema20: json['ema20'] != null ? (json['ema20'] as num).toDouble() : null,
sma50: json['sma50'] != null ? (json['sma50'] as num).toDouble() : null,
sma200: json['sma200'] != null ? (json['sma200'] as num).toDouble() : null,
supertrendUpper: json['supertrendUpper'] != null ? (json['supertrendUpper'] as num).toDouble() : null,
supertrendLower: json['supertrendLower'] != null ? (json['supertrendLower'] as num).toDouble() : null,
supertrendDirection: json['supertrendDirection']?.toString(),
);
}
}
class PatternPoint {
final DateTime time;
final double price;
PatternPoint(this.time, this.price);
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
}
class ChartPatternModel {
final String type;
final List<PatternPoint> upperLine;
final List<PatternPoint> lowerLine;
ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
return ChartPatternModel(
type: json['type'] ?? '',
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
);
}
}
class StrategySignalModel {
final String type;
final DateTime timestamp;
final String direction;
final double price;
final String description;
StrategySignalModel({required this.type, required this.timestamp, required this.direction, required this.price, required this.description});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
return StrategySignalModel(
type: json['type'] ?? '',
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
direction: json['direction'] ?? '',
price: (json['price'] as num).toDouble(),
description: json['description'] ?? '',
);
}
}
export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel;
class CandlestickChart extends StatefulWidget {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showPatterns;
final bool showSignals;
final bool showSupertrend;
final double height;
final bool isFullscreen;
final VoidCallback? onToggleFullscreen;
const CandlestickChart({
super.key,
@@ -126,12 +27,15 @@ class CandlestickChart extends StatefulWidget {
this.patterns = const [],
this.signals = const [],
this.indicators = const [],
this.showPatterns = true,
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showPatterns = true,
this.showSignals = true,
this.showSupertrend = true,
this.height = 420,
this.isFullscreen = false,
this.onToggleFullscreen,
});
@override
@@ -141,81 +45,142 @@ class CandlestickChart extends StatefulWidget {
class _CandlestickChartState extends State<CandlestickChart> {
double _scale = 1.0;
double _panOffset = 0.0;
double _baseScale = 1.0;
double _basePanOffset = 0.0;
Offset _startFocalPoint = Offset.zero;
bool _isDragging = false;
Offset? _tapPosition;
CandleModel? _selectedCandle;
@override
Widget build(BuildContext context) {
if (widget.candles.isEmpty) {
return const Center(child: Text('No chart data'));
}
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitLatestCandles();
});
}
@override
void didUpdateWidget(covariant CandlestickChart oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.candles.length != widget.candles.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitLatestCandles();
});
}
}
void _fitLatestCandles() {
if (widget.candles.isEmpty || !mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
final width = (renderBox?.size.width ?? 600) - 60;
final double candleWidth = 10.0 * _scale;
final double totalCandleSpace = candleWidth + (5.0 * _scale);
final double futureSpace = totalCandleSpace * 10;
final double totalWidth = (widget.candles.length * totalCandleSpace) + futureSpace;
setState(() {
if (totalWidth > width) {
_panOffset = width - totalWidth;
} else {
_panOffset = 0.0;
}
});
}
void _applyZoom(double factor, [double? focalX]) {
if (widget.candles.isEmpty || !mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
final fx = focalX ?? (chartWidth / 2);
setState(() {
final oldScale = _scale;
_scale = (_scale * factor).clamp(0.1, 6.0);
_panOffset = fx - ((fx - _panOffset) * (_scale / oldScale));
_clampPanOffset(chartWidth);
});
}
void _clampPanOffset(double chartWidth) {
if (widget.candles.isEmpty) return;
final double totalCandleSpace = (10.0 + 5.0) * _scale;
final double totalWidth = (widget.candles.length * totalCandleSpace) + (totalCandleSpace * 10);
if (totalWidth <= chartWidth) {
_panOffset = 0.0;
} else {
final double minPan = chartWidth - totalWidth - 30;
const double maxPan = 30.0;
_panOffset = _panOffset.clamp(minPan, maxPan);
}
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return LayoutBuilder(
builder: (context, constraints) {
final double baseWidth = 10.0;
final double spacing = 5.0;
final double totalCandleSpace = (baseWidth + spacing) * _scale;
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
final double maxOffset = 100.0;
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
return Listener(
return Container(
height: widget.height,
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver.register(
pointerSignal,
(event) {
if (event is PointerScrollEvent) {
setState(() {
final double localX = event.localPosition.dx;
final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1;
final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0);
final double scaleRatio = newScale / _scale;
// Zoom centered on cursor
_panOffset = localX - (localX - _panOffset) * scaleRatio;
_scale = newScale;
final double updatedCandleSpace = (baseWidth + spacing) * _scale;
final double updatedContentWidth = (widget.candles.length + 15) * updatedCandleSpace;
final double newMinOffset = constraints.maxWidth - updatedContentWidth - 60.0;
_panOffset = _panOffset.clamp(newMinOffset < maxOffset ? newMinOffset : maxOffset, maxOffset);
});
}
},
);
if (pointerSignal.scrollDelta.dx != 0) {
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
setState(() {
_panOffset -= pointerSignal.scrollDelta.dx;
_clampPanOffset(chartWidth);
});
} else if (pointerSignal.scrollDelta.dy != 0) {
final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85;
_applyZoom(zoomFactor, pointerSignal.localPosition.dx);
}
}
},
child: GestureDetector(
onScaleUpdate: (details) {
setState(() {
_scale = (_scale * details.scale).clamp(0.2, 5.0);
_panOffset += details.focalPointDelta.dx;
_panOffset = _panOffset.clamp(minOffset, maxOffset);
if (_tapPosition != null) {
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
}
});
},
onScaleEnd: (_) => setState(() {
_tapPosition = null;
_selectedCandle = null;
}),
onTapDown: (details) {
_handleTap(details.localPosition, constraints.maxWidth);
},
child: Stack(
children: [
ClipRect(
child: CustomPaint(
child: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: (details) {
_baseScale = _scale;
_basePanOffset = _panOffset;
_startFocalPoint = details.focalPoint;
setState(() => _isDragging = true);
},
onScaleUpdate: (details) {
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
setState(() {
if (details.scale != 1.0) {
final oldScale = _scale;
_scale = (_baseScale * details.scale).clamp(0.1, 6.0);
final fx = details.localFocalPoint.dx;
_panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale));
} else {
_panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx);
}
_clampPanOffset(chartWidth);
});
},
onScaleEnd: (details) {
setState(() => _isDragging = false);
},
onTapDown: (details) {
_handleTap(details.localPosition);
},
child: Stack(
children: [
CustomPaint(
size: Size.infinite,
painter: _CandlePainter(
painter: CandlestickPainter(
candles: widget.candles,
patterns: widget.patterns,
signals: widget.signals,
@@ -232,74 +197,76 @@ class _CandlestickChartState extends State<CandlestickChart> {
tapPosition: _tapPosition,
),
),
),
if (_selectedCandle != null) _buildTooltip(theme),
// Floating Zoom & Pan Controls (Top-Left)
Positioned(
left: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 1.25).clamp(0.2, 5.0)),
tooltip: 'Zoom In',
),
IconButton(
icon: const Icon(Icons.zoom_out, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 0.8).clamp(0.2, 5.0)),
tooltip: 'Zoom Out',
),
IconButton(
icon: const Icon(Icons.center_focus_strong, size: 18),
color: theme.textMuted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() {
_scale = 1.0;
_panOffset = 0.0;
}),
tooltip: 'Reset Zoom & Pan',
),
],
),
),
),
],
if (_selectedCandle != null) _buildTooltip(theme),
_buildZoomControls(theme),
],
),
),
),
);
},
),
),
);
}
void _handleTap(Offset pos, double width) {
Widget _buildZoomControls(ThemePreset theme) {
return Positioned(
right: 10,
bottom: 28,
child: Container(
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.glassBorder),
),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildZoomButton(icon: Icons.chevron_left, tooltip: 'Nach links bewegen', onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
setState(() { _panOffset += 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
}),
_buildZoomButton(icon: Icons.chevron_right, tooltip: 'Nach rechts bewegen', onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
setState(() { _panOffset -= 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
}),
Container(width: 1, height: 16, color: theme.glassBorder),
_buildZoomButton(icon: Icons.add, tooltip: 'Vergrößern', onTap: () => _applyZoom(1.25)),
_buildZoomButton(icon: Icons.remove, tooltip: 'Verkleinern', onTap: () => _applyZoom(0.8)),
_buildZoomButton(icon: Icons.fit_screen_outlined, tooltip: 'Aktuelle Kerzen einpassen', onTap: _fitLatestCandles),
_buildZoomButton(icon: Icons.refresh, tooltip: 'Zoom 1:1 zurücksetzen', onTap: () { setState(() => _scale = 1.0); _fitLatestCandles(); }),
if (widget.onToggleFullscreen != null) ...[
Container(width: 1, height: 16, color: theme.glassBorder),
_buildZoomButton(
icon: widget.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
tooltip: widget.isFullscreen ? 'Vollbild beenden' : 'Vollbildmodus (Querformat)',
onTap: widget.onToggleFullscreen!,
),
],
],
),
),
);
}
Widget _buildZoomButton({required IconData icon, required String tooltip, required VoidCallback onTap}) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 16, color: Colors.white70),
),
),
);
}
void _handleTap(Offset pos) {
if (widget.candles.isEmpty) return;
// Right side is for axis, don't tap there
if (pos.dx > width - 60) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * _scale;
final double totalCandleSpace = candleWidth + (spacing * _scale);
// dx = (i * totalCandleSpace) + _panOffset;
// (dx - _panOffset) / totalCandleSpace = i;
final double candleWidth = 10.0 * _scale;
final double totalCandleSpace = candleWidth + (5.0 * _scale);
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
if (index >= 0 && index < widget.candles.length) {
@@ -311,9 +278,9 @@ class _CandlestickChartState extends State<CandlestickChart> {
}
Widget _buildTooltip(ThemePreset theme) {
final candle = _selectedCandle!;
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
final c = _selectedCandle!;
final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}";
return Positioned(
left: 10,
top: 10,
@@ -328,492 +295,12 @@ class _CandlestickChartState extends State<CandlestickChart> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('L: ${candle.low.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('C: ${candle.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${candle.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${c.open.toStringAsFixed(2)} | H: ${c.high.toStringAsFixed(2)} | L: ${c.low.toStringAsFixed(2)} | C: ${c.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)),
],
),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0; // Space for price axis
final double bottomPadding = 20.0; // Space for X-axis labels
_CandlePainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
// Find min/max in view
int firstVisibleIndex = -1;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > 0 && dx < chartWidth) {
if (firstVisibleIndex == -1) firstVisibleIndex = i;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice == 0) return;
// Add 10% padding to top/bottom
final range = maxPrice - minPrice;
maxPrice += range * 0.1;
minPrice -= range * 0.1;
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15; // Bottom 15% for volume
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
_drawGridAndAxis(canvas, size, chartWidth, candleAreaHeight, minPrice, maxPrice, paddedRange);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
// Map DateTime to X for patterns and signals
double getXForTime(DateTime t) {
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].time.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
// Clip to chart area so we don't draw over the axis
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue; // Culling
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
// Draw Wick
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
// Draw Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0); // minimum 1px height
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
// Draw Volume
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
// Indicators mapping by time
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.time) || ind.timestamp.difference(candle.time).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
_drawPatterns(canvas, getXForTime, getYForPrice);
_drawFutureProjectionZone(canvas, size, chartWidth, candleAreaHeight, getXForTime, getYForPrice);
}
if (showSignals) {
_drawSignals(canvas, getXForTime, getYForPrice);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
_drawCrosshair(canvas, size, chartWidth, chartHeight);
}
canvas.restore(); // Restore clip
}
void _drawFutureProjectionZone(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double Function(DateTime) getX, double Function(double) getY) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.time);
if (lastX < chartWidth) {
// 1. Shaded background for Future Zone (No divider line)
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = const Color(0xFF001F3F).withValues(alpha: 0.25)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
// Label for Future Zone
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (MUSTER-SCHÄTZUNG)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
// 2. Projected Ghost Candles & Target Line for active patterns
for (var pattern in patterns) {
if (pattern.lowerLine.isNotEmpty || pattern.upperLine.isNotEmpty) {
final targetPrice = pattern.lowerLine.isNotEmpty ? pattern.lowerLine.last.price : (pattern.upperLine.isNotEmpty ? pattern.upperLine.last.price : 0);
if (targetPrice > 0) {
final targetY = getY(targetPrice.toDouble());
final int numSteps = 10;
final double stepWidth = (chartWidth - lastX - 30) / numSteps;
if (stepWidth <= 0) continue;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? Colors.greenAccent : Colors.redAccent;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + k * stepWidth;
final waveNoise = sin(k * 0.8) * (priceDeltaPerStep.abs() * 0.3);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.2;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.2;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.6, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? Colors.greenAccent : Colors.redAccent;
// Draw Ghost Candle Wick
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.4)..strokeWidth = 1.0,
);
// Draw Ghost Candle Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.0)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
// Target Price Badge at final step
final targetX = currX;
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 2,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 2,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.9));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
}
void _drawGridAndAxis(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double minPrice, double maxPrice, double range) {
final gridPaint = Paint()
..color = theme.glassBorder
..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
// Y Axis
final int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
// X Axis
if (candles.isEmpty) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor(); // label every 80px
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].time;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2,'0')}-${t.day.toString().padLeft(2,'0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
void _drawPatterns(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
for (var pattern in patterns) {
final color = PatternExplanations.getColorForPattern(pattern.type);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
path.moveTo(getX(points[0].time), getY(points[0].price));
for (int i = 1; i < points.length; i++) {
path.lineTo(getX(points[i].time), getY(points[i].price));
}
canvas.drawPath(path, paint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
}
}
void _drawSignals(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
for (var signal in signals) {
final x = getX(signal.timestamp);
final y = getY(signal.price);
final isBuy = signal.direction.toUpperCase() == 'BUY';
final isSell = signal.direction.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
// Draw Pill Badge for Signal
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
// Pill Background
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
// Pointer Line to price point
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
// Text paint
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
void _drawCrosshair(Canvas canvas, Size size, double chartWidth, double chartHeight) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// Vertical
canvas.drawLine(Offset(tapPosition!.dx, 0), Offset(tapPosition!.dx, chartHeight), paint);
// Horizontal
if (tapPosition!.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition!.dy), Offset(chartWidth, tapPosition!.dy), paint);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,292 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import 'chart_overlay_renderer.dart';
class CandlestickPainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0;
final double bottomPadding = 20.0;
CandlestickPainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
int visibleCount = 0;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > -100 && dx < chartWidth + 100) {
visibleCount++;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (visibleCount == 0 || minPrice == double.infinity || maxPrice <= 0) {
for (var c in candles) {
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice <= 0) return;
final range = maxPrice - minPrice;
maxPrice += max(range * 0.1, 1.0);
minPrice -= max(range * 0.1, 1.0);
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15;
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
ChartOverlayRenderer.drawGridAndAxis(
canvas: canvas,
size: size,
chartWidth: chartWidth,
candleAreaHeight: candleAreaHeight,
minPrice: minPrice,
maxPrice: maxPrice,
range: paddedRange,
candles: candles,
scale: scale,
panOffset: panOffset,
bottomPadding: bottomPadding,
theme: theme,
);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
double getXForTime(DateTime t) {
if (candles.isEmpty) return 0.0;
final lastCandle = candles.last;
if (t.isAfter(lastCandle.timestamp) && candles.length > 1) {
final totalSpan = lastCandle.timestamp.difference(candles.first.timestamp).inSeconds;
final secPerCandle = totalSpan / (candles.length - 1);
if (secPerCandle > 0) {
final futureSecs = t.difference(lastCandle.timestamp).inSeconds;
final futureCandles = futureSecs / secPerCandle;
final lastDx = ((candles.length - 1) * totalCandleSpace) + panOffset + candleWidth / 2;
return lastDx + (futureCandles * totalCandleSpace);
}
}
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].timestamp.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue;
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0);
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.timestamp) || ind.timestamp.difference(candle.timestamp).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
ChartOverlayRenderer.drawPatterns(
canvas: canvas,
patterns: patterns,
getX: getXForTime,
getY: getYForPrice,
);
ChartOverlayRenderer.drawFutureProjectionZone(
canvas: canvas,
candles: candles,
patterns: patterns,
chartWidth: chartWidth,
candleAreaHeight: candleAreaHeight,
getX: getXForTime,
getY: getYForPrice,
theme: theme,
);
}
if (showSignals) {
ChartOverlayRenderer.drawSignals(
canvas: canvas,
signals: signals,
getX: getXForTime,
getY: getYForPrice,
theme: theme,
);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
ChartOverlayRenderer.drawCrosshair(
canvas: canvas,
tapPosition: tapPosition!,
chartWidth: chartWidth,
chartHeight: chartHeight,
theme: theme,
);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant CandlestickPainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,331 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import '../../utils/pattern_explanations.dart';
/// Helper for rendering chart overlays: grid, axes, patterns, future projections, and signals.
class ChartOverlayRenderer {
static void drawGridAndAxis({
required Canvas canvas,
required Size size,
required double chartWidth,
required double candleAreaHeight,
required double minPrice,
required double maxPrice,
required double range,
required List<CandleModel> candles,
required double scale,
required double panOffset,
required double bottomPadding,
required ThemePreset theme,
}) {
final gridPaint = Paint()..color = theme.glassBorder..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
const int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
if (candles.isEmpty) return;
const double baseWidth = 10.0;
const double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor();
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].timestamp;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
static void drawFutureProjectionZone({
required Canvas canvas,
required List<CandleModel> candles,
required List<ChartPatternModel> patterns,
required double chartWidth,
required double candleAreaHeight,
required double Function(DateTime) getX,
required double Function(double) getY,
required ThemePreset theme,
}) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.timestamp);
if (lastX < chartWidth - 10) {
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = theme.primaryColor.withValues(alpha: 0.05)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
final sepPaint = Paint()
..color = theme.primaryColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawLine(Offset(lastX, 0), Offset(lastX, candleAreaHeight), sepPaint);
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (KI & MUSTER)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
for (var pattern in patterns) {
double targetPrice = 0.0;
if (pattern.breakoutSignal != null && pattern.breakoutSignal!.targetPrice > 0) {
targetPrice = pattern.breakoutSignal!.targetPrice;
} else if (pattern.lowerLine.isNotEmpty && pattern.upperLine.isNotEmpty) {
final diff = (pattern.upperLine.last.price - pattern.lowerLine.last.price).abs();
targetPrice = lastCandle.close >= pattern.lowerLine.last.price
? lastCandle.close + (diff > 0 ? diff : lastCandle.close * 0.05)
: lastCandle.close - (diff > 0 ? diff : lastCandle.close * 0.05);
} else if (pattern.upperLine.isNotEmpty) {
targetPrice = pattern.upperLine.last.price;
} else if (pattern.lowerLine.isNotEmpty) {
targetPrice = pattern.lowerLine.last.price;
}
if (targetPrice > 0) {
final targetY = getY(targetPrice);
const int numSteps = 8;
final double availableWidth = max(chartWidth - lastX - 40, 60.0);
final double stepWidth = availableWidth / numSteps;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + (k * stepWidth);
final waveNoise = sin(k * 0.9) * (priceDeltaPerStep.abs() * 0.25);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.15;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.15;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.55, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.45)..strokeWidth = 1.0,
);
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.5)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
final targetX = currX;
final pct = ((targetPrice - lastCandle.close) / lastCandle.close) * 100;
final pctSign = pct >= 0 ? '+' : '';
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ($pctSign${pct.toStringAsFixed(1)}%) ',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 3,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 3,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.92));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
static void drawPatterns({
required Canvas canvas,
required List<ChartPatternModel> patterns,
required double Function(DateTime) getX,
required double Function(double) getY,
}) {
for (var pattern in patterns) {
final color = PatternExplanations.getColorForPattern(pattern.type);
final paint = Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 2.5;
final fillPaint = Paint()..color = color.withValues(alpha: 0.12)..style = PaintingStyle.fill;
Offset? firstPoint;
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
final startX = getX(points[0].time);
final startY = getY(points[0].price);
path.moveTo(startX, startY);
firstPoint ??= Offset(startX, startY);
for (int i = 1; i < points.length; i++) {
final px = getX(points[i].time);
final py = getY(points[i].price);
path.lineTo(px, py);
}
canvas.drawPath(path, paint);
for (var p in points) {
final px = getX(p.time);
final py = getY(p.price);
canvas.drawCircle(Offset(px, py), 4, Paint()..color = color);
canvas.drawCircle(Offset(px, py), 2, Paint()..color = Colors.white);
}
}
if (pattern.upperLine.length >= 2 && pattern.lowerLine.length >= 2) {
final polyPath = Path();
polyPath.moveTo(getX(pattern.upperLine[0].time), getY(pattern.upperLine[0].price));
for (int i = 1; i < pattern.upperLine.length; i++) {
polyPath.lineTo(getX(pattern.upperLine[i].time), getY(pattern.upperLine[i].price));
}
for (int i = pattern.lowerLine.length - 1; i >= 0; i--) {
polyPath.lineTo(getX(pattern.lowerLine[i].time), getY(pattern.lowerLine[i].price));
}
polyPath.close();
canvas.drawPath(polyPath, fillPaint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
if (firstPoint != null) {
final label = PatternExplanations.getGermanName(pattern.type);
final textPainter = TextPainter(
text: TextSpan(
text: ' $label ',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
)..layout();
final badgeX = firstPoint!.dx;
final badgeY = firstPoint!.dy - 18;
final badgeRect = RRect.fromLTRBR(
badgeX,
badgeY,
badgeX + textPainter.width + 4,
badgeY + textPainter.height + 4,
const Radius.circular(4),
);
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.85));
textPainter.paint(canvas, Offset(badgeX + 2, badgeY + 2));
}
}
}
static void drawSignals({
required Canvas canvas,
required List<StrategySignalModel> signals,
required double Function(DateTime) getX,
required double Function(double) getY,
required ThemePreset theme,
}) {
for (var signal in signals) {
final x = getX(signal.date);
final y = getY(signal.price);
final isBuy = signal.type.toUpperCase() == 'BUY';
final isSell = signal.type.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
final textPainter = TextPainter(
text: TextSpan(text: label, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
static void drawCrosshair({
required Canvas canvas,
required Offset tapPosition,
required double chartWidth,
required double chartHeight,
required ThemePreset theme,
}) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawLine(Offset(tapPosition.dx, 0), Offset(tapPosition.dx, chartHeight), paint);
if (tapPosition.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition.dy), Offset(chartWidth, tapPosition.dy), paint);
}
}
}
@@ -0,0 +1,73 @@
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/fundamental_data_model.dart';
class AnalystPriceTargetCard extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const AnalystPriceTargetCard({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text(
'Analysten-Konsens & Kursziele',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
class CompanyProfileSection extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const CompanyProfileSection({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCompensation(double? val) {
if (val == null || val <= 0) return '---';
if (val >= 1e6) return '$currencySymbol${(val / 1e6).toStringAsFixed(2)}M';
if (val >= 1e3) return '$currencySymbol${(val / 1e3).toStringAsFixed(0)}K';
return '$currencySymbol${val.toStringAsFixed(0)}';
}
String _formatExecutivePayment(CompanyExecutiveModel exec) {
if (exec.compensation != null && exec.compensation! > 0) {
return _fmtCompensation(exec.compensation);
}
if (exec.payment != null && exec.payment!.isNotEmpty) {
final p = exec.payment!.trim();
if (p.startsWith(currencySymbol) || p.startsWith('') || p.startsWith(r'$')) {
return p;
}
final numeric = double.tryParse(p);
if (numeric != null && numeric > 0) {
return _fmtCompensation(numeric);
}
return '$currencySymbol$p';
}
return '---';
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Vollzeitbeschäftigte: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Führungskräfte & Vorstand',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Column(
children: [
for (int i = 0; i < data.executives.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.executives[i].name,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 13),
),
const SizedBox(height: 2),
Text(
data.executives[i].title,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Builder(
builder: (context) {
final payStr = _formatExecutivePayment(data.executives[i]);
if (payStr == '---') return const SizedBox.shrink();
return Text(
payStr,
style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 12),
);
},
),
],
),
),
if (i < data.executives.length - 1) const Divider(color: Colors.white10, height: 1),
],
],
),
),
],
],
);
}
Widget _buildProfileBadge(String text, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(text, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500)),
],
),
);
}
}
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
import '../../utils/metric_explanations.dart';
class _MetricRowItem {
final String label;
final String value;
const _MetricRowItem(this.label, this.value);
}
class FundamentalCategoryPanels extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
final String currencyCode;
const FundamentalCategoryPanels({
super.key,
required this.data,
this.currencySymbol = '\$',
this.currencyCode = 'USD',
});
String _formatNumber(double? number) {
if (number == null) return 'N/A';
final abs = number.abs();
final sign = number < 0 ? '-' : '';
if (abs >= 1e12) return '$sign$currencySymbol${(abs / 1e12).toStringAsFixed(2)} Tsd. Mrd. $currencyCode';
if (abs >= 1e9) return '$sign$currencySymbol${(abs / 1e9).toStringAsFixed(2)} Mrd. $currencyCode';
if (abs >= 1e6) return '$sign$currencySymbol${(abs / 1e6).toStringAsFixed(2)} Mio. $currencyCode';
return '$sign$currencySymbol${NumberFormat("#,##0.00", "de_DE").format(abs)} $currencyCode';
}
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
String _fmtMultiple(double? val) {
if (val == null) return 'N/A';
return '${val.toStringAsFixed(2)}x';
}
String _fmtPercent(double? val) {
if (val == null) return 'N/A';
final p = (val.abs() <= 5.0 && val != 0.0) ? val * 100.0 : val;
return '${p.toStringAsFixed(2)}%';
}
String _fmtDebtToEquity(double? val) {
if (val == null) return 'N/A';
final p = val > 10.0 ? val : val * 100.0;
return '${p.toStringAsFixed(1)}%';
}
String _fmtDate(String? raw) {
if (raw == null || raw.isEmpty) return 'N/A';
final dt = DateTime.tryParse(raw);
if (dt == null) return raw;
return DateFormat('dd.MM.yyyy').format(dt);
}
@override
Widget build(BuildContext context) {
final valuationItems = [
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)),
_MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)),
_MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)),
_MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
];
final profitabilityItems = [
_MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)),
_MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)),
_MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)),
_MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_MetricRowItem('EBITDA', _formatNumber(data.ebitda)),
_MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)),
_MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)),
_MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)),
_MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)),
_MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)),
_MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)),
_MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)),
];
final dividendItems = [
_MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)),
_MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'),
_MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
];
final panel1 = _buildCategoryPanel(
context: context,
title: 'Bewertungskennzahlen & Multiples',
icon: Icons.analytics_outlined,
items: valuationItems,
);
final panel2 = _buildCategoryPanel(
context: context,
title: 'Rentabilität & Finanzen',
icon: Icons.account_balance_outlined,
items: profitabilityItems,
);
final panel3 = _buildCategoryPanel(
context: context,
title: 'Dividenden & Termine',
icon: Icons.pie_chart_outline,
items: dividendItems,
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1050) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 14),
Expanded(child: panel2),
const SizedBox(width: 14),
Expanded(child: panel3),
],
);
} else if (constraints.maxWidth >= 680) {
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 12),
Expanded(child: panel2),
],
),
const SizedBox(height: 12),
panel3,
],
);
} else {
return Column(
children: [
panel1,
const SizedBox(height: 12),
panel2,
const SizedBox(height: 12),
panel3,
],
);
}
},
);
}
Widget _buildCategoryPanel({
required BuildContext context,
required String title,
required IconData icon,
required List<_MetricRowItem> items,
}) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: AppTheme.primaryEmerald, size: 16),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 10),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 4),
for (int i = 0; i < items.length; i++) ...[
_buildMetricTile(context, items[i].label, items[i].value, isEven: i.isEven),
],
],
),
);
}
Widget _buildMetricTile(BuildContext context, String label, String value, {bool isEven = false}) {
final hasExplanation = MetricExplanations.hasExplanation(label);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
if (hasExplanation) ...[
const SizedBox(width: 4),
InkWell(
onTap: () => MetricExplanations.showModal(context, label),
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 12),
),
],
),
);
}
}

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