Compare commits
45 Commits
a3f9e55a7e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 567ddea46a | |||
| 2fe8cc0dca | |||
| d161efb370 | |||
| cb8a169043 | |||
| c5d7d359ba | |||
| 6a0f9af3f8 | |||
| 2b7d59f40d | |||
| 0894c40f07 | |||
| 676496b77d | |||
| 6974b2075b | |||
| 7060f0f7b1 | |||
| 5c95dd182c | |||
| a4959658a2 | |||
| f43ce2b7e9 | |||
| 12e7b57b16 | |||
| 600ccf299e | |||
| 8112598602 | |||
| 44b161d509 | |||
| 6ab84fe1de | |||
| 5497cc5de7 | |||
| 3972507cb0 | |||
| 75a2510b39 | |||
| aaef272f4e | |||
| d8ba28a810 | |||
| b5cc70c08c | |||
| 2ba54e8057 | |||
| b0f8d4b78b | |||
| f18f75c1ab | |||
| 1f9d66405a | |||
| 57554a9582 | |||
| 0d370d09e7 | |||
| 62e030e2cf | |||
| 1522c3480f | |||
| a94c36a878 | |||
| a1f2b888f6 | |||
| 3dbee36ca0 | |||
| 34fa774cbf | |||
| 882d24a316 | |||
| 7e18257e3e | |||
| 960a4bdbd6 | |||
| 067f1bfdd5 | |||
| 5a6a50a609 | |||
| 1ccb6b613f | |||
| 15f8f7896e | |||
| f08fecde23 |
+14
-1
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
Finlytic is a personal financial analysis/trading platform: a fleet of .NET 10 microservices communicating
|
||||
over MQTT, one HTTP/WebSocket gateway (`FinlyticBackend`), and a Flutter app (`FinlyticApp`) as the only
|
||||
client. There is no CI/multi-developer setup — this is a single-developer repo backed by one shared
|
||||
PostgreSQL instance ("OmniDB") and one MQTT broker, both external to this repo.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (.NET)
|
||||
|
||||
```
|
||||
dotnet build Finlytic.sln # build everything
|
||||
dotnet build FinlyticEngine # build a single project
|
||||
dotnet test FinlyticEngine.Tests # run one test project
|
||||
dotnet test FinlyticEngine.Tests --filter "FullyQualifiedName~TradeLifecycleServiceTests"
|
||||
```
|
||||
|
||||
- Solution-wide `dotnet test` will also try to run `FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs`,
|
||||
which connects to a real, throwaway local Postgres container on `localhost:55987` and has no `[Skip]`/trait
|
||||
to exclude it. It fails outside that one-off setup — run test projects individually, or filter it out with
|
||||
`--filter "FullyQualifiedName!~_Verify"`, rather than running the whole solution's tests.
|
||||
- `FinlyticBot.Tests` currently has no test files (scaffolding only).
|
||||
- EF Core migrations are created via the CLI only (never hand-written) from inside the owning service project,
|
||||
e.g.: `dotnet ef migrations add <Name> --project FinlyticAssets`.
|
||||
|
||||
### Frontend (Flutter, `FinlyticApp/`)
|
||||
|
||||
```
|
||||
flutter pub get
|
||||
flutter run # or -d chrome for the web dashboard
|
||||
flutter test
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### Docker Compose (`compose.yaml`)
|
||||
|
||||
The compose file only defines this repo's own services — Postgres and the MQTT broker are external
|
||||
infrastructure (see the comment block at the top of `compose.yaml` for required env vars: `DB_HOST`,
|
||||
`DB_PORT`, `DB_PASSWORD`, `MQTT_HOST`, `MQTT_PORT`, plus per-service secrets like `JWT_SECRET_KEY`,
|
||||
`ADMIN_DEFAULT_PASSWORD`, `ALPACA_KEY_ID`/`ALPACA_SECRET_KEY`). Set these in a local `.env` file.
|
||||
|
||||
`FinlyticNews` and `FinlyticFundamentals` build from a shared Playwright base image that must exist first:
|
||||
|
||||
```
|
||||
docker compose --profile build-base build finlytic-playwright-base
|
||||
docker compose build
|
||||
```
|
||||
|
||||
Keep the `PLAYWRIGHT_VERSION` build arg in `compose.yaml` in sync with the `Microsoft.Playwright` NuGet
|
||||
package version in `FinlyticCore/FinlyticCore.csproj`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Service topology
|
||||
|
||||
Nine .NET projects share one solution (`Finlytic.sln`): `FinlyticCore` (shared library, no entry point) plus
|
||||
eight deployable services — `FinlyticAssets`, `FinlyticNews`, `FinlyticFundamentals`, `FinlyticSentiment`,
|
||||
`FinlyticTechnicals`, `FinlyticEngine`, `FinlyticSimulation`, `FinlyticBot`, and `FinlyticBackend`. Each
|
||||
non-Core service is its own Docker image, has its own PostgreSQL database (`finlytic_assets`,
|
||||
`finlytic_news`, ... one per service — never shared tables across services), and runs its own EF Core
|
||||
migrations independently at startup via `DbContext.MigrateWithBootstrapAsync` (`FinlyticCore/Database/DatabaseBootstrapper.cs`).
|
||||
|
||||
- **`FinlyticBackend`** is the *only* service allowed to host Kestrel/HTTP/WebSocket endpoints. It is a thin
|
||||
aggregation gateway for `FinlyticApp`: its controllers and SignalR hubs (`Hubs/`) largely proxy to the
|
||||
backing microservices over MQTT RPC and rebroadcast their MQTT events to connected clients via SignalR
|
||||
(`BackendMqttBridge`). All other services are `Microsoft.NET.Sdk.Worker` background services with no HTTP
|
||||
surface at all — this is an enforced rule (`Rules.md` §5), not a stylistic default. See the
|
||||
`compose.yaml` comment block explaining why only `finlyticbackend` has a Docker `HEALTHCHECK`.
|
||||
- Every other service follows the same internal shape: `Database/` (DbContext + migrations),
|
||||
`Entities/` (EF entities), `Services/` (business logic + the service's MQTT client), `Util/`, and a
|
||||
`Program.cs` that wires DI, registers the service's MQTT client as a hosted service, and migrates the DB
|
||||
at startup.
|
||||
|
||||
### Inter-service communication (MQTT-only)
|
||||
|
||||
All cross-service calls go over MQTT — never direct HTTP between backend services. `FinlyticCore/Util/MqttTopics.cs`
|
||||
is the single source of truth for every topic and RPC channel name; add new channels there rather than
|
||||
inlining topic strings at call sites. Two patterns are used:
|
||||
|
||||
1. **RPC** (`services/request/{channel}/{correlationId}` → `services/response/{channel}/{correlationId}`),
|
||||
implemented by `FinlyticCore/Util/ManagedMqttClient.cs` (`SendRpcRequestAsync` / `SubscribeRpcAsync`).
|
||||
Used for synchronous-style query/command calls, e.g. `FinlyticBackend` asking `FinlyticAssets` to resolve
|
||||
an ISIN, or `FinlyticEngine` asking `FinlyticSentiment` for a sentiment summary.
|
||||
2. **Pub/sub event streams** (e.g. `finlytic/news/stream/{isin}`, `finlytic/engine/proposals/created`,
|
||||
`finlytic/bot/trades/stream`, `finlytic/logs/{service}`) for fire-and-forget notifications. `FinlyticBackend`'s
|
||||
`BackendMqttBridge` subscribes to the wildcard form of most of these (`*Wildcard` constants) purely to
|
||||
relay them onward to SignalR clients.
|
||||
|
||||
Every service also implements a shared `health_Ping` RPC channel and exposes runtime-configurable settings
|
||||
via a `{service}_settings_GetAll` / `{service}_settings_Update` channel pair (backed by `ISettingsService`
|
||||
and `IOptionsMonitor`-style dynamic config — see `FinlyticCore/Services/Settings`) — changing a setting does
|
||||
not require a service restart, and channel-level logging enable/disable follows the same mechanism (see
|
||||
`IFinlyticLogger<T>` / `FinlyticLogBroadcaster` in `FinlyticCore/Services/FinlyticLogger`).
|
||||
|
||||
### Domain flow (roughly upstream → downstream)
|
||||
|
||||
`FinlyticAssets` (asset/ISIN resolution, Trade Republic price feed) and `FinlyticNews`/`FinlyticFundamentals`
|
||||
(scraped/ingested data, using Playwright) feed `FinlyticSentiment` (FinBERT analysis) and `FinlyticTechnicals`
|
||||
(indicators/strategy setups), which feed `FinlyticEngine` (trade proposal generation, AI-assisted validation
|
||||
via an n8n webhook, trade lifecycle management) and `FinlyticSimulation` (backtesting/strategy reliability
|
||||
scoring). `FinlyticBot` executes accepted proposals as paper trades (via Alpaca or a synthetic ledger).
|
||||
`FinlyticBackend` sits above all of them as the gateway `FinlyticApp` talks to.
|
||||
|
||||
### Flutter app (`FinlyticApp/`)
|
||||
|
||||
Feature-folder structure under `lib/features/*` (auth, dashboard, discovery, trades, bot, simulation, news,
|
||||
calendar, favorites, search, asset_detail, proposals, admin), with `lib/core/` for cross-cutting network
|
||||
(Dio + interceptors), theme, and services, and `lib/shared/` for shared widgets. State management is
|
||||
`flutter_bloc`. Every outgoing request must carry the JWT via a central Dio interceptor, and a 401/403 must
|
||||
trigger an immediate client-side logout — this is an enforced rule (`Rules.md` §8), not optional handling.
|
||||
|
||||
## Project-wide rules (`Rules.md`)
|
||||
|
||||
`Rules.md` is a binding rules document for this repo, not a style guide — treat every rule as an
|
||||
architectural constraint to actively check against, not background reading. Full text is in `Rules.md`;
|
||||
key points, since they shape most non-trivial changes:
|
||||
|
||||
- Every service interface + implementation lives in one file, named after the implementation class.
|
||||
- Every method (any visibility) needs XML doc comments in English; implementations of interface/base
|
||||
methods use `/// <inheritdoc />` (custom helper methods still need their own explicit docs).
|
||||
- Strongly-typed data classes only for state passing, API payloads, and MQTT messages — no
|
||||
`Dictionary<string, object>`, `dynamic`, or raw `JObject`/`JsonDocument` in internal logic. Shared
|
||||
DTOs/entities/enums used by more than one service belong in `FinlyticCore`; service-specific models
|
||||
(e.g. raw third-party API shapes) stay in that service.
|
||||
- No mock/demo/fallback data anywhere, backend or frontend. Empty results are either a real empty set or an
|
||||
explicit exception — the Flutter UI must show an explicit empty state, never placeholder content.
|
||||
- Async all the way (no `.Result`/`.Wait()`/`.GetAwaiter().GetResult()`); async DB/MQTT/network methods take
|
||||
a trailing `CancellationToken` and pass it through.
|
||||
- Every `FinlyticBackend` HTTP/WebSocket endpoint requires `[Authorize]` unless explicitly `[AllowAnonymous]`
|
||||
(only `/api/v1/auth/login`-style endpoints and the `/health` check are exempt).
|
||||
- No hardcoded secrets; bind config via `IOptions<T>`/`IOptionsMonitor<T>`, and `FinlyticBackend` fails fast
|
||||
at startup if `JWT_SECRET_KEY`/`ADMIN_DEFAULT_PASSWORD` are missing or weak (see `Program.cs`).
|
||||
@@ -0,0 +1,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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
|
||||
COPY ["FinlyticAnalyzer/FinlyticAnalyzer.csproj", "FinlyticAnalyzer/"]
|
||||
RUN dotnet restore "FinlyticAnalyzer/FinlyticAnalyzer.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/FinlyticAnalyzer"
|
||||
RUN dotnet build "FinlyticAnalyzer.csproj" -c Release -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "FinlyticAnalyzer.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "FinlyticAnalyzer.dll"]
|
||||
@@ -1,60 +0,0 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted raw news, market context, AI prompt payload & response in PostgreSQL.
|
||||
/// </summary>
|
||||
[Table("analyses")]
|
||||
public class AnalysisEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string AnalysisId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string EventId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string Sector { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
public VixMarketRegime VixRegime { get; set; }
|
||||
public decimal VixValue { get; set; }
|
||||
|
||||
public double ImpactScore { get; set; }
|
||||
public double WinRate { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string RawDataJson { get; set; } = "{}";
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string AiOutputJson { get; set; } = "{}";
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string N8nResponseJson { get; set; } = "{}";
|
||||
|
||||
public double N8nEvalScore { get; set; }
|
||||
|
||||
[MaxLength(30)]
|
||||
public string N8nDecision { get; set; } = string.Empty;
|
||||
|
||||
public bool IsTradeProposed { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace FinlyticAnalyzer.Entities;
|
||||
|
||||
public class AnalyzerSettingsEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string ScanCronSchedule { get; set; } = "0 */1 * * *";
|
||||
public double MinSignalScore { get; set; } = 75.0;
|
||||
|
||||
public bool EnableLogMqttHealthPing { get; set; } = false;
|
||||
public bool EnableLogMqttGeneral { get; set; } = true;
|
||||
public bool EnableLogAnalyzerAuto { get; set; } = true;
|
||||
public bool EnableLogAnalyzerManual { get; set; } = true;
|
||||
public bool EnableLogDatabaseOps { get; set; } = true;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Assets;
|
||||
|
||||
namespace FinlyticAnalyzer.Entities;
|
||||
|
||||
[Table("trade_proposals")]
|
||||
public class TradeProposalEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string AnalysisId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string EventId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(30)]
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(150)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string Sector { get; set; } = "General";
|
||||
|
||||
public AssetType Type { get; set; } = AssetType.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// KI-Entscheidung ("BUY", "SELL", "HOLD", "REJECTED")
|
||||
/// </summary>
|
||||
[MaxLength(20)]
|
||||
public string ProposedAction { get; set; } = "BUY";
|
||||
|
||||
public double ConfidenceScore { get; set; }
|
||||
|
||||
// --- KI Execution Plan (Vorgeschlagene Preismarken) ---
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal EntryPrice { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal StopLoss { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal TakeProfit { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? EntryZoneMin { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? EntryZoneMax { get; set; }
|
||||
|
||||
public string? TakeProfitTargets { get; set; } // Comma-separated or JSON
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? RiskRewardRatio { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal? MaxLeverage { get; set; }
|
||||
|
||||
// --- Kontext aus Request & KI ---
|
||||
public string ReasonSummary { get; set; } = string.Empty;
|
||||
public string TechnicalRationale { get; set; } = string.Empty;
|
||||
public string FundamentalRationale { get; set; } = string.Empty;
|
||||
public string RiskWarning { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(30)]
|
||||
public string RiskTolerance { get; set; } = "Balanced";
|
||||
|
||||
[MaxLength(20)]
|
||||
public string Timeframe { get; set; } = "1-7 Tage";
|
||||
|
||||
[MaxLength(30)]
|
||||
public string InstrumentType { get; set; } = "KnockOut";
|
||||
|
||||
public VixMarketRegime VixRegime { get; set; }
|
||||
|
||||
[Column(TypeName = "decimal(18,4)")]
|
||||
public decimal VixValue { get; set; }
|
||||
|
||||
public double WinRate { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(3);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,136 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260801073402_Init")]
|
||||
partial class Init
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Init : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "analyses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AnalysisId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
EventId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Sector = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
Isin = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
VixRegime = table.Column<int>(type: "integer", nullable: false),
|
||||
VixValue = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
ImpactScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
WinRate = table.Column<double>(type: "double precision", nullable: false),
|
||||
RawDataJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
AiOutputJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
N8nResponseJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
N8nEvalScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
N8nDecision = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
IsTradeProposed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_analyses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ScanCronSchedule = table.Column<string>(type: "text", nullable: false),
|
||||
MinSignalScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Settings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_AnalysisId",
|
||||
table: "analyses",
|
||||
column: "AnalysisId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_CreatedAt",
|
||||
table: "analyses",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_EventId",
|
||||
table: "analyses",
|
||||
column: "EventId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_Isin",
|
||||
table: "analyses",
|
||||
column: "Isin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_analyses_Sector",
|
||||
table: "analyses",
|
||||
column: "Sector");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "analyses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260803185020_AddLogFilterSettings")]
|
||||
partial class AddLogFilterSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLogFilterSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogAnalyzerAuto",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogAnalyzerManual",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogDatabaseOps",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogMqttGeneral",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableLogMqttHealthPing",
|
||||
table: "Settings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogAnalyzerAuto",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogAnalyzerManual",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogDatabaseOps",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogMqttGeneral",
|
||||
table: "Settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableLogMqttHealthPing",
|
||||
table: "Settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260804184350_CheckPendingMigrations")]
|
||||
partial class CheckPendingMigrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CheckPendingMigrations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260805184638_AddTradeProposals")]
|
||||
partial class AddTradeProposals
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("ConfidenceScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProposedAction")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ReasonSummary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.ToTable("TradeProposals");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTradeProposals : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TradeProposals",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Isin = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Type = table.Column<int>(type: "integer", nullable: false),
|
||||
ProposedAction = table.Column<string>(type: "text", nullable: false),
|
||||
ConfidenceScore = table.Column<double>(type: "double precision", nullable: false),
|
||||
ReasonSummary = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TradeProposals", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TradeProposals_ExpiresAt",
|
||||
table: "TradeProposals",
|
||||
column: "ExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TradeProposals_Isin",
|
||||
table: "TradeProposals",
|
||||
column: "Isin");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TradeProposals");
|
||||
}
|
||||
}
|
||||
}
|
||||
-277
@@ -1,277 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
[DbContext(typeof(AnalyzerDbContext))]
|
||||
[Migration("20260813202556_CheckPendingAnalyzer")]
|
||||
partial class CheckPendingAnalyzer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AiOutputJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ImpactScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsTradeProposed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("N8nDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double>("N8nEvalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("N8nResponseJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("RawDataJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnalysisId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.HasIndex("Sector");
|
||||
|
||||
b.ToTable("analyses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerAuto")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogAnalyzerManual")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogDatabaseOps")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttGeneral")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableLogMqttHealthPing")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double>("MinSignalScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ScanCronSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AnalysisId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<double>("ConfidenceScore")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("EntryPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("EntryZoneMax")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("EntryZoneMin")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("EventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FundamentalRationale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InstrumentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<decimal?>("MaxLeverage")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<string>("ProposedAction")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("ReasonSummary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("RiskRewardRatio")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("RiskTolerance")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("RiskWarning")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Sector")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<decimal>("StopLoss")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<decimal>("TakeProfit")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("TakeProfitTargets")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TechnicalRationale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Timeframe")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("VixRegime")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("VixValue")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<double>("WinRate")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("Isin");
|
||||
|
||||
b.ToTable("trade_proposals");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticAnalyzer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CheckPendingAnalyzer : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_TradeProposals",
|
||||
table: "TradeProposals");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "TradeProposals",
|
||||
newName: "trade_proposals");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_TradeProposals_Isin",
|
||||
table: "trade_proposals",
|
||||
newName: "IX_trade_proposals_Isin");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_TradeProposals_ExpiresAt",
|
||||
table: "trade_proposals",
|
||||
newName: "IX_trade_proposals_ExpiresAt");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ProposedAction",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(150)",
|
||||
maxLength: 150,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Isin",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AnalysisId",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EntryPrice",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EntryZoneMax",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EntryZoneMin",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EventId",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FundamentalRationale",
|
||||
table: "trade_proposals",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InstrumentType",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MaxLeverage",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "RiskRewardRatio",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RiskTolerance",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RiskWarning",
|
||||
table: "trade_proposals",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Sector",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "StopLoss",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Symbol",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TakeProfit",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TakeProfitTargets",
|
||||
table: "trade_proposals",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TechnicalRationale",
|
||||
table: "trade_proposals",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Timeframe",
|
||||
table: "trade_proposals",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VixRegime",
|
||||
table: "trade_proposals",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "VixValue",
|
||||
table: "trade_proposals",
|
||||
type: "numeric(18,4)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "WinRate",
|
||||
table: "trade_proposals",
|
||||
type: "double precision",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_trade_proposals",
|
||||
table: "trade_proposals",
|
||||
column: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_trade_proposals",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AnalysisId",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EntryPrice",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EntryZoneMax",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EntryZoneMin",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EventId",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FundamentalRationale",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InstrumentType",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxLeverage",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RiskRewardRatio",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RiskTolerance",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RiskWarning",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Sector",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StopLoss",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Symbol",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TakeProfit",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TakeProfitTargets",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TechnicalRationale",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Timeframe",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VixRegime",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VixValue",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WinRate",
|
||||
table: "trade_proposals");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "trade_proposals",
|
||||
newName: "TradeProposals");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_trade_proposals_Isin",
|
||||
table: "TradeProposals",
|
||||
newName: "IX_TradeProposals_Isin");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_trade_proposals_ExpiresAt",
|
||||
table: "TradeProposals",
|
||||
newName: "IX_TradeProposals_ExpiresAt");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ProposedAction",
|
||||
table: "TradeProposals",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(20)",
|
||||
oldMaxLength: 20);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "TradeProposals",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(150)",
|
||||
oldMaxLength: 150);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Isin",
|
||||
table: "TradeProposals",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(30)",
|
||||
oldMaxLength: 30);
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_TradeProposals",
|
||||
table: "TradeProposals",
|
||||
column: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=finlytic_analyzer;Username=admin;Password=admin"
|
||||
},
|
||||
"MQTT": {
|
||||
"Host": "localhost",
|
||||
"Port": "1883",
|
||||
"ClientId": "finlytic_analyzer"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,86 +0,0 @@
|
||||
# FinlyticApp — Architecture Guidelines & Engineering Standards
|
||||
|
||||
Dieses Dokument definiert die verbindlichen Architektur- und Entwicklungsstandards für die **FinlyticApp** (Dart / Flutter Cross-Platform Client für Android, iOS und Web).
|
||||
|
||||
---
|
||||
|
||||
## 1. Grundprinzipien & Dateistruktur
|
||||
|
||||
Das Projekt folgt einer strengen **Feature-Driven Clean Architecture**. Dadurch wird eine vollständige Entkopplung von Benutzeroberfläche, Geschäftslogik und Datenquellen gewährleistet.
|
||||
|
||||
### 📏 Dateigröße & Struktur-Limits
|
||||
* **Maximal 150 bis 200 Zeilen pro Datei:** Wenn eine Datei diese Grenze überschreitet, muss sie in kleinere, fokussierte Einheiten refactored werden (*Single Responsibility Principle*).
|
||||
* **Eine Hauptklasse pro Datei:** Helper-Klassen gehören in eigene Dateien, es sei denn, sie sind `private` und ausschließlich lokal relevant.
|
||||
* **Kompakte `build()`-Methoden:** Die `build()`-Methode dient nur der Anordnung von Sub-Widgets und darf selten länger als 30–40 Zeilen sein.
|
||||
|
||||
### 📁 Verzeichnisstruktur (`lib/`)
|
||||
|
||||
```text
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── network/ # ApiClient, MQTT-Service, WebSockets
|
||||
│ ├── theme/ # Finlytic Dark Glassmorphism, Typography, Colors
|
||||
│ ├── utils/ # Formatierer (Währungen, Prozentangaben, Datum)
|
||||
│ └── widgets/ # App-weit genutzte UI-Komponenten (Buttons, Modals)
|
||||
├── features/
|
||||
│ ├── dashboard/ # Dashboard-Overview, Analytics-Cards
|
||||
│ ├── news/ # Feed, Pagination, Sentiment-Analysen
|
||||
│ ├── favorites/ # Favoriten-Grid, Watchlist
|
||||
│ ├── calendar/ # Corporate Calendar, Earnings, Dividenden
|
||||
│ ├── trades/ # Trade-Signale, Automatische Trades
|
||||
│ ├── admin/ # User-Verwaltung, System-Settings (Admin-Only)
|
||||
│ └── asset_detail/ # Fundamentaldaten, TA-Chart, Finance-Metrics
|
||||
└── main.dart # Entry Point & Service Locator Initialization
|
||||
|
||||
2. Clean Architecture Layering
|
||||
Jedes Feature im features/-Ordner wird intern strikt in drei Layer unterteilt:LayerVerantwortlichkeitErlaubte Abhängigkeiten1. PresentationUI-Komponenten, Screen-Layouts, Consumer von States.Greift nur auf Logic (BLoC/Notifier) zu. Keine direkten API/DB-Calls!2. Domain / LogicBusiness-Logik, State Management, UseCases, Entities.Absolut frei von flutter/material.dart! Nutzt Repositories als Abstraktion.3. DataAPI-Clients, DTOs, Local Caching, MQTT-Stream Handlers.Implementiert Repository-Interfaces aus dem Domain Layer.
|
||||
|
||||
3. Widget-Architektur & Sub-Widget Auslagerung
|
||||
❌ VERBOTEN: Helper-Methoden für Widgets (_buildX())Unter keinen Umständen dürfen Methoden innerhalb von Widget-Klassen definiert werden, die ein Widget zurückgeben:Dart// ❌ FALSCH: Baut keinen eigenen BuildContext/Lifecycle auf und erfordert Rebuilding des gesamten Mutter-Widgets!
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text('Dashboard'),
|
||||
);
|
||||
}
|
||||
✅ PFLICHT: Auslagerung in eigene StatelessWidget KlassenJedes logische Teilsegment der Benutzeroberfläche muss als eigene Klasse ausgegliedert werden:Dart// ✅ KORREKT: Saubere Performance, eigener BuildContext, optimierter Element-Tree
|
||||
class DashboardHeader extends StatelessWidget {
|
||||
const DashboardHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text('Dashboard'),
|
||||
);
|
||||
}
|
||||
}
|
||||
⚡ Performance-Regeln für Widgets:const Konstruktoren: Jedes Sub-Widget muss wenn möglich einen const Konstruktor haben, um unnötige Re-Renders im Widget-Tree zu verhindern.Keine Business-Logik im UI-Widget: Widgets reagieren ausschließlich auf übergebene Daten oder States und leiten Nutzerinteraktionen über Callbacks / BLoC-Events weiter.
|
||||
|
||||
4. Data Classes & Code-Generierung
|
||||
Immutability: Alle Models, DTOs und States müssen unbeeinflussbar (immutable) sein.Freezed & JSON Serializable: Das manuelle Schreiben von fromJson, toJson oder copyWith ist untersagt. Es wird freezed zusammen mit build_runner eingesetzt.Dartimport 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'asset_model.freezed.dart';
|
||||
part 'asset_model.g.dart';
|
||||
|
||||
@freezed
|
||||
class Asset with _$Asset {
|
||||
const factory Asset({
|
||||
required String isin,
|
||||
required String name,
|
||||
required double currentPrice,
|
||||
required String currency,
|
||||
}) = _Asset;
|
||||
|
||||
factory Asset.fromJson(Map<String, dynamic> json) => _$AssetFromJson(json);
|
||||
}
|
||||
5. Responsive & Adaptive Navigation LayoutFinlyticApp wird plattformübergreifend betrieben und muss sich dem Screen-Format anpassen:Mobile Breakpoint (< 800px): Anforderung von Material NavigationBar (Android) / Cupertino Tab Bar (iOS) am unteren Bildschirmrand.Web / Desktop Breakpoint (>= 800px): Automatische Skalierung auf ein linkes Tab-Menü (NavigationRail / Sidebar).Keine starren Dimensionen: Nutzung von LayoutBuilder, Flexible und Expanded, um Überläufe (Pixel Overflow) auf schmalen Displays zu verhindern.6. Linter Standard (analysis_options.yaml)Alle Entwickler müssen die folgenden Linter-Regeln in der analysis_options.yaml einhalten:YAMLlinter:
|
||||
rules:
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_final_fields
|
||||
- prefer_final_locals
|
||||
- avoid_unnecessary_containers
|
||||
- sizedbox_for_whitespace
|
||||
- use_build_context_synchronously
|
||||
- always_declare_return_types
|
||||
@@ -1,71 +0,0 @@
|
||||
# FinlyticApp (Flutter Application)
|
||||
|
||||
FinlyticApp is the cross-platform mobile and desktop application for the Finlytic Enterprise Financial Intelligence Platform, built with Flutter, BLoC State Management, and Clean Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Clean Architecture Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/
|
||||
│ ├── network/ # ApiClient (Dio), SignalRService
|
||||
│ ├── services/ # SecureStorageService
|
||||
│ ├── theme/ # AppTheme (Glassmorphism, Dark Emerald Theme)
|
||||
│ └── widgets/ # GlassContainer, StatusBadge, AssetLogoWidget
|
||||
└── features/
|
||||
├── trades/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── asset_detail/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── favorites/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── auth/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── admin/ # models/, repositories/, bloc/, views/, widgets/
|
||||
├── calendar/ # models/, repositories/, bloc/, views/, widgets/
|
||||
└── search/ # models/, repositories/, bloc/, views/, widgets/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Features & Modules
|
||||
|
||||
### 1. Trades Module
|
||||
- **`TradeModel`**, **`TradeRepository`**, **`TradeBloc`**: Manages real-time trade signals, entry/exit prices, stop-loss/take-profit, and position closure.
|
||||
- **`TradesFeedScreen`**: Interactive trade cards with live profit/loss indicators.
|
||||
|
||||
### 2. Assets & Technical Analysis Module
|
||||
- **`AssetModel`**, **`FundamentalDataModel`**, **`TechnicalAnalysisModel`**, **`AssetRepository`**, **`AssetDetailBloc`**.
|
||||
- **`AssetDetailScreen`** & **`TechnicalAnalysisTabView`**: Interactive chart indicators (EMA 20, SMA 50, SMA 200, Supertrend, RSI, MACD).
|
||||
|
||||
### 3. Favorites Module
|
||||
- **`FavoriteAssetModel`**, **`FavoritesRepository`**, **`FavoritesBloc`**.
|
||||
- **`FavoritesScreen`** & **`WatchlistCard`**: Real-time price tracking and watchlist management.
|
||||
|
||||
### 4. Auth Module
|
||||
- **`UserModel`** (with `Equatable`), **`AuthRepository`**, **`AuthBloc`**.
|
||||
- **`LoginScreen`**, **`RegisterScreen`**, **`ForgotPasswordDialog`**: Secure JWT authentication and persistent local session storage.
|
||||
|
||||
### 5. Admin Module
|
||||
- **`AdminUserModel`**, **`AdminRepository`**, **`AdminBloc`**.
|
||||
- **`AdminUsersScreen`**: User role management and AI pipeline cutoff settings.
|
||||
|
||||
### 6. Calendar Module
|
||||
- **`CorporateEventModel`**, **`CalendarRepository`**, **`CalendarBloc`**.
|
||||
- **`CorporateCalendarScreen`**: Monthly calendar grid, date filter, and category chips (Earnings, ExDividend, Payout).
|
||||
|
||||
### 7. Search Module
|
||||
- **`SearchResultModel`**, **`SearchRepository`**, **`SearchBloc`** (with Debounce).
|
||||
- **`AssetSearchDialog`**: Reactive omnibox asset search with ISIN lookup and instant logo resolution.
|
||||
|
||||
---
|
||||
|
||||
## Feature Status
|
||||
|
||||
### Implemented Features
|
||||
- [x] Full Clean Architecture migration across all 7 feature modules (`models/`, `repositories/`, `bloc/`).
|
||||
- [x] Strongly typed data models extending `Equatable` for zero redundant widget rebuilds.
|
||||
- [x] Complete removal of all mock/demo fallbacks in repositories and screens.
|
||||
- [x] SignalR WebSocket integration (`NewsHub`, `TradeHub`).
|
||||
- [x] Premium glassmorphism dark mode design system (`AppTheme`).
|
||||
|
||||
### Planned Features
|
||||
- [ ] Push Notifications integration via Firebase Cloud Messaging for instant trade alerts.
|
||||
- [ ] Biometric Authentication (FaceID / TouchID / Fingerprint) unlock.
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package: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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+115
-107
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+161
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+263
@@ -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
Reference in New Issue
Block a user