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 |
@@ -23,3 +23,16 @@
|
|||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
|
## Exported Docker image archives — never needed inside a build context (~2.7 GB)
|
||||||
|
Docker/
|
||||||
|
**/*.tar
|
||||||
|
|
||||||
|
## Flutter client — not referenced by any Dockerfile (~450 MB).
|
||||||
|
## The compiled web bundle ships via FinlyticBackend/wwwroot instead.
|
||||||
|
FinlyticApp/
|
||||||
|
**/.dart_tool/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
## dotnet publish output on the host (final stage copies from the publish stage)
|
||||||
|
**/publish/
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
## Build outputs
|
## Build outputs
|
||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
|
**/publish/
|
||||||
|
TestResults/
|
||||||
|
|
||||||
## Rider / JetBrains / VS Code / Visual Studio
|
## Rider / JetBrains / VS Code / Visual Studio
|
||||||
.idea/
|
.idea/
|
||||||
@@ -22,6 +24,8 @@ assets/
|
|||||||
|
|
||||||
## Secrets & local environment files
|
## Secrets & local environment files
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.bak*
|
||||||
*.env.local
|
*.env.local
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
|
||||||
@@ -36,4 +40,5 @@ appsettings.Development.json
|
|||||||
## Temporary data & scratch
|
## Temporary data & scratch
|
||||||
Yahoo finance data/
|
Yahoo finance data/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
*.tar
|
||||||
FinlyticApp/lib/tickers_grep.json
|
FinlyticApp/lib/tickers_grep.json
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Finlytic is a personal financial analysis/trading platform: a fleet of .NET 10 microservices communicating
|
||||||
|
over MQTT, one HTTP/WebSocket gateway (`FinlyticBackend`), and a Flutter app (`FinlyticApp`) as the only
|
||||||
|
client. There is no CI/multi-developer setup — this is a single-developer repo backed by one shared
|
||||||
|
PostgreSQL instance ("OmniDB") and one MQTT broker, both external to this repo.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (.NET)
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build Finlytic.sln # build everything
|
||||||
|
dotnet build FinlyticEngine # build a single project
|
||||||
|
dotnet test FinlyticEngine.Tests # run one test project
|
||||||
|
dotnet test FinlyticEngine.Tests --filter "FullyQualifiedName~TradeLifecycleServiceTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Solution-wide `dotnet test` will also try to run `FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs`,
|
||||||
|
which connects to a real, throwaway local Postgres container on `localhost:55987` and has no `[Skip]`/trait
|
||||||
|
to exclude it. It fails outside that one-off setup — run test projects individually, or filter it out with
|
||||||
|
`--filter "FullyQualifiedName!~_Verify"`, rather than running the whole solution's tests.
|
||||||
|
- `FinlyticBot.Tests` currently has no test files (scaffolding only).
|
||||||
|
- EF Core migrations are created via the CLI only (never hand-written) from inside the owning service project,
|
||||||
|
e.g.: `dotnet ef migrations add <Name> --project FinlyticAssets`.
|
||||||
|
|
||||||
|
### Frontend (Flutter, `FinlyticApp/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter pub get
|
||||||
|
flutter run # or -d chrome for the web dashboard
|
||||||
|
flutter test
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose (`compose.yaml`)
|
||||||
|
|
||||||
|
The compose file only defines this repo's own services — Postgres and the MQTT broker are external
|
||||||
|
infrastructure (see the comment block at the top of `compose.yaml` for required env vars: `DB_HOST`,
|
||||||
|
`DB_PORT`, `DB_PASSWORD`, `MQTT_HOST`, `MQTT_PORT`, plus per-service secrets like `JWT_SECRET_KEY`,
|
||||||
|
`ADMIN_DEFAULT_PASSWORD`, `ALPACA_KEY_ID`/`ALPACA_SECRET_KEY`). Set these in a local `.env` file.
|
||||||
|
|
||||||
|
`FinlyticNews` and `FinlyticFundamentals` build from a shared Playwright base image that must exist first:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile build-base build finlytic-playwright-base
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the `PLAYWRIGHT_VERSION` build arg in `compose.yaml` in sync with the `Microsoft.Playwright` NuGet
|
||||||
|
package version in `FinlyticCore/FinlyticCore.csproj`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Service topology
|
||||||
|
|
||||||
|
Nine .NET projects share one solution (`Finlytic.sln`): `FinlyticCore` (shared library, no entry point) plus
|
||||||
|
eight deployable services — `FinlyticAssets`, `FinlyticNews`, `FinlyticFundamentals`, `FinlyticSentiment`,
|
||||||
|
`FinlyticTechnicals`, `FinlyticEngine`, `FinlyticSimulation`, `FinlyticBot`, and `FinlyticBackend`. Each
|
||||||
|
non-Core service is its own Docker image, has its own PostgreSQL database (`finlytic_assets`,
|
||||||
|
`finlytic_news`, ... one per service — never shared tables across services), and runs its own EF Core
|
||||||
|
migrations independently at startup via `DbContext.MigrateWithBootstrapAsync` (`FinlyticCore/Database/DatabaseBootstrapper.cs`).
|
||||||
|
|
||||||
|
- **`FinlyticBackend`** is the *only* service allowed to host Kestrel/HTTP/WebSocket endpoints. It is a thin
|
||||||
|
aggregation gateway for `FinlyticApp`: its controllers and SignalR hubs (`Hubs/`) largely proxy to the
|
||||||
|
backing microservices over MQTT RPC and rebroadcast their MQTT events to connected clients via SignalR
|
||||||
|
(`BackendMqttBridge`). All other services are `Microsoft.NET.Sdk.Worker` background services with no HTTP
|
||||||
|
surface at all — this is an enforced rule (`Rules.md` §5), not a stylistic default. See the
|
||||||
|
`compose.yaml` comment block explaining why only `finlyticbackend` has a Docker `HEALTHCHECK`.
|
||||||
|
- Every other service follows the same internal shape: `Database/` (DbContext + migrations),
|
||||||
|
`Entities/` (EF entities), `Services/` (business logic + the service's MQTT client), `Util/`, and a
|
||||||
|
`Program.cs` that wires DI, registers the service's MQTT client as a hosted service, and migrates the DB
|
||||||
|
at startup.
|
||||||
|
|
||||||
|
### Inter-service communication (MQTT-only)
|
||||||
|
|
||||||
|
All cross-service calls go over MQTT — never direct HTTP between backend services. `FinlyticCore/Util/MqttTopics.cs`
|
||||||
|
is the single source of truth for every topic and RPC channel name; add new channels there rather than
|
||||||
|
inlining topic strings at call sites. Two patterns are used:
|
||||||
|
|
||||||
|
1. **RPC** (`services/request/{channel}/{correlationId}` → `services/response/{channel}/{correlationId}`),
|
||||||
|
implemented by `FinlyticCore/Util/ManagedMqttClient.cs` (`SendRpcRequestAsync` / `SubscribeRpcAsync`).
|
||||||
|
Used for synchronous-style query/command calls, e.g. `FinlyticBackend` asking `FinlyticAssets` to resolve
|
||||||
|
an ISIN, or `FinlyticEngine` asking `FinlyticSentiment` for a sentiment summary.
|
||||||
|
2. **Pub/sub event streams** (e.g. `finlytic/news/stream/{isin}`, `finlytic/engine/proposals/created`,
|
||||||
|
`finlytic/bot/trades/stream`, `finlytic/logs/{service}`) for fire-and-forget notifications. `FinlyticBackend`'s
|
||||||
|
`BackendMqttBridge` subscribes to the wildcard form of most of these (`*Wildcard` constants) purely to
|
||||||
|
relay them onward to SignalR clients.
|
||||||
|
|
||||||
|
Every service also implements a shared `health_Ping` RPC channel and exposes runtime-configurable settings
|
||||||
|
via a `{service}_settings_GetAll` / `{service}_settings_Update` channel pair (backed by `ISettingsService`
|
||||||
|
and `IOptionsMonitor`-style dynamic config — see `FinlyticCore/Services/Settings`) — changing a setting does
|
||||||
|
not require a service restart, and channel-level logging enable/disable follows the same mechanism (see
|
||||||
|
`IFinlyticLogger<T>` / `FinlyticLogBroadcaster` in `FinlyticCore/Services/FinlyticLogger`).
|
||||||
|
|
||||||
|
### Domain flow (roughly upstream → downstream)
|
||||||
|
|
||||||
|
`FinlyticAssets` (asset/ISIN resolution, Trade Republic price feed) and `FinlyticNews`/`FinlyticFundamentals`
|
||||||
|
(scraped/ingested data, using Playwright) feed `FinlyticSentiment` (FinBERT analysis) and `FinlyticTechnicals`
|
||||||
|
(indicators/strategy setups), which feed `FinlyticEngine` (trade proposal generation, AI-assisted validation
|
||||||
|
via an n8n webhook, trade lifecycle management) and `FinlyticSimulation` (backtesting/strategy reliability
|
||||||
|
scoring). `FinlyticBot` executes accepted proposals as paper trades (via Alpaca or a synthetic ledger).
|
||||||
|
`FinlyticBackend` sits above all of them as the gateway `FinlyticApp` talks to.
|
||||||
|
|
||||||
|
### Flutter app (`FinlyticApp/`)
|
||||||
|
|
||||||
|
Feature-folder structure under `lib/features/*` (auth, dashboard, discovery, trades, bot, simulation, news,
|
||||||
|
calendar, favorites, search, asset_detail, proposals, admin), with `lib/core/` for cross-cutting network
|
||||||
|
(Dio + interceptors), theme, and services, and `lib/shared/` for shared widgets. State management is
|
||||||
|
`flutter_bloc`. Every outgoing request must carry the JWT via a central Dio interceptor, and a 401/403 must
|
||||||
|
trigger an immediate client-side logout — this is an enforced rule (`Rules.md` §8), not optional handling.
|
||||||
|
|
||||||
|
## Project-wide rules (`Rules.md`)
|
||||||
|
|
||||||
|
`Rules.md` is a binding rules document for this repo, not a style guide — treat every rule as an
|
||||||
|
architectural constraint to actively check against, not background reading. Full text is in `Rules.md`;
|
||||||
|
key points, since they shape most non-trivial changes:
|
||||||
|
|
||||||
|
- Every service interface + implementation lives in one file, named after the implementation class.
|
||||||
|
- Every method (any visibility) needs XML doc comments in English; implementations of interface/base
|
||||||
|
methods use `/// <inheritdoc />` (custom helper methods still need their own explicit docs).
|
||||||
|
- Strongly-typed data classes only for state passing, API payloads, and MQTT messages — no
|
||||||
|
`Dictionary<string, object>`, `dynamic`, or raw `JObject`/`JsonDocument` in internal logic. Shared
|
||||||
|
DTOs/entities/enums used by more than one service belong in `FinlyticCore`; service-specific models
|
||||||
|
(e.g. raw third-party API shapes) stay in that service.
|
||||||
|
- No mock/demo/fallback data anywhere, backend or frontend. Empty results are either a real empty set or an
|
||||||
|
explicit exception — the Flutter UI must show an explicit empty state, never placeholder content.
|
||||||
|
- Async all the way (no `.Result`/`.Wait()`/`.GetAwaiter().GetResult()`); async DB/MQTT/network methods take
|
||||||
|
a trailing `CancellationToken` and pass it through.
|
||||||
|
- Every `FinlyticBackend` HTTP/WebSocket endpoint requires `[Authorize]` unless explicitly `[AllowAnonymous]`
|
||||||
|
(only `/api/v1/auth/login`-style endpoints and the `/health` check are exempt).
|
||||||
|
- No hardcoded secrets; bind config via `IOptions<T>`/`IOptionsMonitor<T>`, and `FinlyticBackend` fails fast
|
||||||
|
at startup if `JWT_SECRET_KEY`/`ADMIN_DEFAULT_PASSWORD` are missing or weak (see `Program.cs`).
|
||||||
@@ -0,0 +1,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
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -15,14 +15,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticFundamentals", "Fin
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSentiment", "FinlyticSentiment\FinlyticSentiment.csproj", "{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSentiment", "FinlyticSentiment\FinlyticSentiment.csproj", "{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicalAnalysis", "FinlyticTechnicalAnalysis\FinlyticTechnicalAnalysis.csproj", "{A1C82F63-4482-4E99-9231-1184FA2E001F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTechnicals", "FinlyticTechnicals\FinlyticTechnicals.csproj", "{A1C82F63-4482-4E99-9231-1184FA2E001F}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAnalyzer", "FinlyticAnalyzer\FinlyticAnalyzer.csproj", "{E9F7C091-62C4-417A-B981-8977DF82A1B0}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticTrades", "FinlyticTrades\FinlyticTrades.csproj", "{57D84C2E-73E1-4231-A91B-6B620FCE5289}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBackend", "FinlyticBackend\FinlyticBackend.csproj", "{C1A924B8-904E-436D-B07E-4E621F51C1AA}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine", "FinlyticEngine\FinlyticEngine.csproj", "{8112DE84-695D-489B-9568-C531B34C63F8}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticSimulation", "FinlyticSimulation\FinlyticSimulation.csproj", "{1407B23D-3B7F-4673-9548-AA2AFF2D8011}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot", "FinlyticBot\FinlyticBot.csproj", "{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine.Tests", "FinlyticEngine.Tests\FinlyticEngine.Tests.csproj", "{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
|
||||||
|
EndProject
|
||||||
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.ActiveCfg = Release|Any CPU
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.Build.0 = Release|Any CPU
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{999DB199-69C5-46D2-BDAD-59C84F5769F1}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{D458A1B3-16CF-45E8-859D-87542A4A83A7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -107,18 +105,104 @@ Global
|
|||||||
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x86.Build.0 = Release|Any CPU
|
{9C3BB705-86AD-4A89-AA0F-A52C87A4950B}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{E9F7C091-62C4-417A-B981-8977DF82A1B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{57D84C2E-73E1-4231-A91B-6B620FCE5289}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A1C82F63-4482-4E99-9231-1184FA2E001F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C1A924B8-904E-436D-B07E-4E621F51C1AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8112DE84-695D-489B-9568-C531B34C63F8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{1407B23D-3B7F-4673-9548-AA2AFF2D8011}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{E871DD92-5450-43D8-A730-D2CA1F0B6CE3}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{20ADD67C-EC26-4195-9DD0-0B661BE9A4AE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
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:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../services/secure_storage_service.dart';
|
import '../services/secure_storage_service.dart';
|
||||||
|
|
||||||
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
|
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
|
||||||
@@ -7,7 +8,15 @@ class ApiClient {
|
|||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
Function()? onUnauthorized;
|
Function()? onUnauthorized;
|
||||||
|
|
||||||
static const String baseUrl = 'http://localhost:5000';
|
static String get baseUrl {
|
||||||
|
if (kIsWeb) {//todo on release
|
||||||
|
final origin = Uri.base.origin;
|
||||||
|
if (origin.isNotEmpty && !origin.contains('null') && !origin.startsWith('file:')) {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const String.fromEnvironment('BACKEND_URL', defaultValue: 'http://localhost:5000');
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient(this._storageService) {
|
ApiClient(this._storageService) {
|
||||||
_dio = Dio(
|
_dio = Dio(
|
||||||
@@ -29,7 +38,7 @@ class ApiClient {
|
|||||||
return handler.next(options);
|
return handler.next(options);
|
||||||
},
|
},
|
||||||
onError: (DioException error, handler) async {
|
onError: (DioException error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401 || error.response?.statusCode == 403) {
|
||||||
await _storageService.clearAll();
|
await _storageService.clearAll();
|
||||||
onUnauthorized?.call();
|
onUnauthorized?.call();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:finlytic_app/core/network/api_client.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:signalr_core/signalr_core.dart';
|
import 'package:signalr_core/signalr_core.dart';
|
||||||
import '../services/secure_storage_service.dart';
|
import '../services/secure_storage_service.dart';
|
||||||
|
|
||||||
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
|
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
|
||||||
/// Connects persistently to `/hubs/health` and `/hubs/favorites-prices` WebSockets.
|
/// Connects persistently to `/hubs/health`, `/hubs/favorites-prices`, `/hubs/trade-stream`, `/hubs/logs`, and `/hubs/news`.
|
||||||
class SignalRService extends ChangeNotifier {
|
class SignalRService extends ChangeNotifier {
|
||||||
final SecureStorageService storageService;
|
final SecureStorageService storageService;
|
||||||
|
|
||||||
HubConnection? _healthConnection;
|
HubConnection? _healthConnection;
|
||||||
HubConnection? _favoritesConnection;
|
HubConnection? _favoritesConnection;
|
||||||
|
HubConnection? _tradeStreamConnection;
|
||||||
|
HubConnection? _logsConnection;
|
||||||
|
HubConnection? _newsConnection;
|
||||||
|
|
||||||
bool _isConnected = false;
|
bool _isConnected = false;
|
||||||
final _statusController = StreamController<bool>.broadcast();
|
final _statusController = StreamController<bool>.broadcast();
|
||||||
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
|
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
|
||||||
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
|
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _tradeProposalController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _tradeUpdateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _botPositionController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _portfolioSummaryController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _logMessageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _newsArticleController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
|
||||||
bool get isConnected => _isConnected;
|
bool get isConnected => _isConnected;
|
||||||
Stream<bool> get connectionStream => _statusController.stream;
|
Stream<bool> get connectionStream => _statusController.stream;
|
||||||
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
|
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
|
||||||
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
|
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get tradeProposalStream => _tradeProposalController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get tradeUpdateStream => _tradeUpdateController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get botPositionStream => _botPositionController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get portfolioSummaryStream => _portfolioSummaryController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get logMessageStream => _logMessageController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get newsArticleStream => _newsArticleController.stream;
|
||||||
|
|
||||||
static const String baseUrl = 'http://localhost:5000';
|
static String get baseUrl => ApiClient.baseUrl;
|
||||||
|
|
||||||
SignalRService(this.storageService);
|
SignalRService(this.storageService);
|
||||||
|
|
||||||
@@ -29,14 +45,18 @@ class SignalRService extends ChangeNotifier {
|
|||||||
if (_isConnected) return;
|
if (_isConnected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await storageService.getToken();
|
// Reads the token fresh from secure storage on every connection attempt
|
||||||
|
// (initial connect AND every automatic reconnect), so a token refreshed
|
||||||
|
// mid-session is always picked up instead of being pinned to the value
|
||||||
|
// read at initSignalR() time.
|
||||||
|
Future<String?> tokenFactory() => storageService.getToken();
|
||||||
|
|
||||||
// 1. Connect SystemHealthHub over WebSockets
|
// 1. Connect SystemHealthHub over WebSockets
|
||||||
_healthConnection = HubConnectionBuilder()
|
_healthConnection = HubConnectionBuilder()
|
||||||
.withUrl(
|
.withUrl(
|
||||||
'$baseUrl/hubs/health',
|
'$baseUrl/hubs/health',
|
||||||
HttpConnectionOptions(
|
HttpConnectionOptions(
|
||||||
accessTokenFactory: () async => token,
|
accessTokenFactory: tokenFactory,
|
||||||
transport: HttpTransportType.webSockets,
|
transport: HttpTransportType.webSockets,
|
||||||
logging: (level, message) {
|
logging: (level, message) {
|
||||||
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
|
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
|
||||||
@@ -58,19 +78,12 @@ class SignalRService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_healthConnection!.onclose((error) {
|
|
||||||
if (kDebugMode) debugPrint('[SignalR Health WS] Closed: $error');
|
|
||||||
});
|
|
||||||
|
|
||||||
await _healthConnection!.start();
|
|
||||||
if (kDebugMode) debugPrint('[SignalR Health WS] Connected via WebSocket to /hubs/health.');
|
|
||||||
|
|
||||||
// 2. Connect FavoritesPriceHub over WebSockets
|
// 2. Connect FavoritesPriceHub over WebSockets
|
||||||
_favoritesConnection = HubConnectionBuilder()
|
_favoritesConnection = HubConnectionBuilder()
|
||||||
.withUrl(
|
.withUrl(
|
||||||
'$baseUrl/hubs/favorites-prices',
|
'$baseUrl/hubs/favorites-prices',
|
||||||
HttpConnectionOptions(
|
HttpConnectionOptions(
|
||||||
accessTokenFactory: () async => token,
|
accessTokenFactory: tokenFactory,
|
||||||
transport: HttpTransportType.webSockets,
|
transport: HttpTransportType.webSockets,
|
||||||
logging: (level, message) {
|
logging: (level, message) {
|
||||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
|
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
|
||||||
@@ -91,8 +104,126 @@ class SignalRService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await _favoritesConnection!.start();
|
// 3. Connect TradeStreamHub over WebSockets (Engine & Bot Real-Time Streams)
|
||||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] Connected via WebSocket to /hubs/favorites-prices.');
|
_tradeStreamConnection = HubConnectionBuilder()
|
||||||
|
.withUrl(
|
||||||
|
'$baseUrl/hubs/trade-stream',
|
||||||
|
HttpConnectionOptions(
|
||||||
|
accessTokenFactory: tokenFactory,
|
||||||
|
transport: HttpTransportType.webSockets,
|
||||||
|
logging: (level, message) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR TradeStream WS] $message');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceiveTradeProposal', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_tradeProposalController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR Proposal Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceiveTradeUpdate', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_tradeUpdateController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR TradeUpdate Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceiveBotPositionUpdate', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_botPositionController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR BotPosition Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tradeStreamConnection!.on('ReceivePortfolioSummary', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_portfolioSummaryController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR PortfolioSummary Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Connect LogStreamHub over WebSockets
|
||||||
|
_logsConnection = HubConnectionBuilder()
|
||||||
|
.withUrl(
|
||||||
|
'$baseUrl/hubs/logs',
|
||||||
|
HttpConnectionOptions(
|
||||||
|
accessTokenFactory: tokenFactory,
|
||||||
|
transport: HttpTransportType.webSockets,
|
||||||
|
logging: (level, message) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR Logs WS] $message');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_logsConnection!.on('ReceiveLogMessage', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_logMessageController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR Log Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Connect NewsHub over WebSockets (Live News Feed)
|
||||||
|
_newsConnection = HubConnectionBuilder()
|
||||||
|
.withUrl(
|
||||||
|
'$baseUrl/hubs/news',
|
||||||
|
HttpConnectionOptions(
|
||||||
|
accessTokenFactory: tokenFactory,
|
||||||
|
transport: HttpTransportType.webSockets,
|
||||||
|
logging: (level, message) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR News WS] $message');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
_newsConnection!.on('ReceiveNewArticle', (arguments) {
|
||||||
|
if (arguments != null && arguments.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> map = Map<String, dynamic>.from(arguments.first as Map);
|
||||||
|
_newsArticleController.add(map);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[SignalR News Parsing Error] $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
_healthConnection!.start() ?? Future.value(),
|
||||||
|
_favoritesConnection!.start() ?? Future.value(),
|
||||||
|
_tradeStreamConnection!.start() ?? Future.value(),
|
||||||
|
_logsConnection!.start() ?? Future.value(),
|
||||||
|
_newsConnection!.start() ?? Future.value(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (kDebugMode) debugPrint('[SignalR WS] All 5 Real-Time WebSockets successfully connected.');
|
||||||
|
|
||||||
_isConnected = true;
|
_isConnected = true;
|
||||||
_statusController.add(true);
|
_statusController.add(true);
|
||||||
@@ -105,20 +236,13 @@ class SignalRService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void broadcastHealthUpdate(List<Map<String, dynamic>> healthData) {
|
|
||||||
_healthController.add(healthData);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void broadcastFavoritePrices(Map<String, dynamic> priceMap) {
|
|
||||||
_favoritePricesController.add(priceMap);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void disconnect() async {
|
void disconnect() async {
|
||||||
try {
|
try {
|
||||||
await _healthConnection?.stop();
|
await _healthConnection?.stop();
|
||||||
await _favoritesConnection?.stop();
|
await _favoritesConnection?.stop();
|
||||||
|
await _tradeStreamConnection?.stop();
|
||||||
|
await _logsConnection?.stop();
|
||||||
|
await _newsConnection?.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
_statusController.add(false);
|
_statusController.add(false);
|
||||||
@@ -131,6 +255,12 @@ class SignalRService extends ChangeNotifier {
|
|||||||
_statusController.close();
|
_statusController.close();
|
||||||
_healthController.close();
|
_healthController.close();
|
||||||
_favoritePricesController.close();
|
_favoritePricesController.close();
|
||||||
|
_tradeProposalController.close();
|
||||||
|
_tradeUpdateController.close();
|
||||||
|
_botPositionController.close();
|
||||||
|
_portfolioSummaryController.close();
|
||||||
|
_logMessageController.close();
|
||||||
|
_newsArticleController.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
@@ -24,7 +25,13 @@ class AssetLogoWidget extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
|
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
|
||||||
String? resolveUrl(String? url) {
|
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;
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
|
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
|
||||||
}
|
}
|
||||||
@@ -65,12 +72,13 @@ class AssetLogoWidget extends StatelessWidget {
|
|||||||
return _buildFallback(initial, colors);
|
return _buildFallback(initial, colors);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: Image.network(
|
: CachedNetworkImage(
|
||||||
image,
|
imageUrl: image,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
placeholder: (context, url) => _buildFallback(initial, colors),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
_failedUrls.add(image);
|
_failedUrls.add(image);
|
||||||
return _buildFallback(initial, colors);
|
return _buildFallback(initial, colors);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,15 +15,26 @@ class StatusBadge extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory StatusBadge.sentiment(String status, {double? score}) {
|
factory StatusBadge.sentiment(String status, {double? score}) {
|
||||||
Color bg = AppTheme.textMuted;
|
final sUpper = status.trim().toUpperCase();
|
||||||
if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) {
|
Color bg;
|
||||||
|
if (sUpper.contains('POS')) {
|
||||||
bg = AppTheme.primaryEmerald;
|
bg = AppTheme.primaryEmerald;
|
||||||
} else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) {
|
} else if (sUpper.contains('NEG')) {
|
||||||
bg = AppTheme.accentRed;
|
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;
|
bg = AppTheme.accentCyan;
|
||||||
}
|
}
|
||||||
return StatusBadge(label: status.toUpperCase(), color: bg);
|
} else {
|
||||||
|
bg = AppTheme.textMuted;
|
||||||
|
}
|
||||||
|
return StatusBadge(label: sUpper.isNotEmpty ? sUpper : 'NEUTRAL', color: bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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/core/network/api_client.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
|
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
|
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
|
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/evaluation_history_enums.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/evaluation_history_response_model.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/recent_setup_model.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/watchlist_entry_model.dart';
|
||||||
|
|
||||||
class AdminRepository {
|
class AdminRepository {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
AdminRepository({required this.apiClient});
|
const AdminRepository({required this.apiClient});
|
||||||
|
|
||||||
Future<List<AdminUserModel>> fetchUsers() async {
|
Future<List<AdminUserModel>> fetchUsers() async {
|
||||||
try {
|
try {
|
||||||
@@ -17,8 +23,7 @@ class AdminRepository {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error fetching admin users: $e');
|
throw Exception('Nutzer konnten nicht geladen werden: $e');
|
||||||
throw Exception('Nutzer konnten nicht geladen werden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,4 +40,111 @@ class AdminRepository {
|
|||||||
throw Exception('Aktualisieren fehlgeschlagen');
|
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/network/signalr_service.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/status_badge.dart';
|
|
||||||
import '../models/admin_update_user_request_dto.dart';
|
import '../models/admin_update_user_request_dto.dart';
|
||||||
import '../models/admin_user_model.dart';
|
import '../models/admin_user_model.dart';
|
||||||
import '../bloc/admin_bloc.dart';
|
import '../bloc/admin_bloc.dart';
|
||||||
import '../repositories/admin_repository.dart';
|
import '../repositories/admin_repository.dart';
|
||||||
import '../widgets/admin_kpi_header.dart';
|
import '../widgets/admin_kpi_header.dart';
|
||||||
|
import '../widgets/admin_user_card_item.dart';
|
||||||
import '../widgets/create_user_dialog.dart';
|
import '../widgets/create_user_dialog.dart';
|
||||||
import '../widgets/edit_user_dialog.dart';
|
import '../widgets/edit_user_dialog.dart';
|
||||||
|
|
||||||
import '../widgets/system_diagnostics_widget.dart';
|
import '../widgets/system_diagnostics_widget.dart';
|
||||||
|
|
||||||
/// Role-Restricted Admin Panel Screen managing users, service settings, and system health.
|
|
||||||
class AdminUsersScreen extends StatelessWidget {
|
class AdminUsersScreen extends StatelessWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
final SignalRService? signalRService;
|
final SignalRService? signalRService;
|
||||||
@@ -102,61 +100,52 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Top Header Ribbon
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Administration & System',
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Finlytic Admin-Dashboard • Microservices & Benutzer',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
IconButton(
|
||||||
padding: const EdgeInsets.all(8),
|
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
|
||||||
decoration: BoxDecoration(
|
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
tooltip: 'Neu laden',
|
||||||
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 SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Zentrales Management für Nutzer, Mikrodienste & MQTT System-Bus',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _openCreateUser(context),
|
onPressed: () => _openCreateUser(context),
|
||||||
icon: const Icon(Icons.person_add_outlined, size: 18),
|
icon: const Icon(Icons.person_add_alt_1_rounded, size: 18),
|
||||||
label: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
|
label: const Text('Neuer Benutzer'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// KPI Header Metrics
|
|
||||||
AdminKpiHeader(
|
AdminKpiHeader(
|
||||||
users: users,
|
users: users,
|
||||||
signalRService: widget.signalRService,
|
signalRService: widget.signalRService,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Tab Selector Ribbon
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.glassSurface,
|
color: AppTheme.glassSurface,
|
||||||
@@ -182,19 +171,12 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Tab Content View
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
// Tab 1: User Management with Filter & Actions
|
|
||||||
_buildUserManagementTab(context, state, users),
|
_buildUserManagementTab(context, state, users),
|
||||||
|
SystemDiagnosticsWidget(signalRService: widget.signalRService),
|
||||||
// Tab 2: System Diagnostics & Microservices Health
|
|
||||||
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 filteredUsers = allUsers.where((u) {
|
||||||
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||||
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
|
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||||
@@ -243,7 +224,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Filter Bar (Search Field & Role Filter Chips)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -292,8 +272,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
// User Cards List
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: filteredUsers.isEmpty
|
child: filteredUsers.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
@@ -302,10 +280,7 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
|
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text('Keine passenden Benutzer gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 14)),
|
||||||
'Keine passenden Benutzer gefunden.',
|
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -313,95 +288,10 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
itemCount: filteredUsers.length,
|
itemCount: filteredUsers.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final u = filteredUsers[index];
|
final u = filteredUsers[index];
|
||||||
final String role = u.role;
|
return AdminUserCardItem(
|
||||||
final bool isActive = u.isActive;
|
user: u,
|
||||||
|
onToggleActive: (val) => _toggleUserActiveStatus(context, u, val),
|
||||||
final Color roleColor = role == 'Admin'
|
onEdit: () => _openEditUser(context, u),
|
||||||
? 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/status_badge.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 {
|
class ServiceDetailScreen extends StatefulWidget {
|
||||||
final String serviceName;
|
final String serviceName;
|
||||||
final ApiClient apiClient;
|
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
|
@override
|
||||||
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
|
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||||
|
late final AdminRepository _repository;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
String _error = '';
|
String _error = '';
|
||||||
List<dynamic> _settings = [];
|
List<ServiceSettingDto> _settings = [];
|
||||||
final Map<String, TextEditingController> _controllers = {};
|
final Map<String, TextEditingController> _controllers = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient);
|
||||||
_fetchServiceDetails();
|
_fetchServiceDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,16 +49,14 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
|
|
||||||
Future<void> _fetchServiceDetails() async {
|
Future<void> _fetchServiceDetails() async {
|
||||||
try {
|
try {
|
||||||
final res = await widget.apiClient.get('/api/v1/admin/settings');
|
final groupedSettings = await _repository.fetchSettings();
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
|
||||||
final groupedSettings = res.data as Map<String, dynamic>;
|
|
||||||
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
|
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_settings = serviceSettings;
|
_settings = serviceSettings;
|
||||||
for (var s in _settings) {
|
for (var s in _settings) {
|
||||||
final key = s['key']?.toString() ?? '';
|
final key = s.key;
|
||||||
final val = s['value']?.toString() ?? '';
|
final val = s.value;
|
||||||
if (!_controllers.containsKey(key)) {
|
if (!_controllers.containsKey(key)) {
|
||||||
_controllers[key] = TextEditingController(text: val);
|
_controllers[key] = TextEditingController(text: val);
|
||||||
} else {
|
} else {
|
||||||
@@ -56,9 +65,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
}
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
throw Exception('Failed to load settings');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
@@ -70,17 +76,24 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
final payload = <String, String>{};
|
final payload = <String, dynamic>{};
|
||||||
_controllers.forEach((k, v) {
|
_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(
|
await _repository.updateServiceSettings(widget.serviceName, payload);
|
||||||
'/api/v1/admin/settings/${widget.serviceName}',
|
|
||||||
data: payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.statusCode == 200 || res.statusCode == 204) {
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -102,9 +115,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw Exception('Server returned status code ${res.statusCode}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -121,7 +131,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _formatLabel(String key) {
|
String _formatLabel(String key) {
|
||||||
return key
|
// Strip the "Logging.Channel." prefix for display - the section header already says "Logging-Kanäle",
|
||||||
|
// repeating it on every single chip label added visual noise without any extra information.
|
||||||
|
final withoutChannelPrefix = key.startsWith('Logging.Channel.') ? key.substring('Logging.Channel.'.length) : key;
|
||||||
|
|
||||||
|
return withoutChannelPrefix
|
||||||
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
||||||
.replaceAll('Minutes', '(Minuten)')
|
.replaceAll('Minutes', '(Minuten)')
|
||||||
.replaceAll('Seconds', '(Sekunden)')
|
.replaceAll('Seconds', '(Sekunden)')
|
||||||
@@ -133,6 +147,187 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
.replaceAll('Multiplier', 'Multiplikator');
|
.replaceAll('Multiplier', 'Multiplikator');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Groups settings by kind so the settings card reads as organized sections instead of one long,
|
||||||
|
/// unstructured list mixing logging toggles, feature switches, numeric thresholds, and free text together.
|
||||||
|
/// Order is fixed (not alphabetical) so the most-scanned category (logging channels, usually the most
|
||||||
|
/// numerous) sits first.
|
||||||
|
static const List<String> _categoryOrder = ['Logging-Kanäle', 'Umschalter', 'Zahlenwerte', 'Text'];
|
||||||
|
|
||||||
|
String _categoryFor(ServiceSettingDto s) {
|
||||||
|
if (s.key.startsWith('Logging.Channel.')) return 'Logging-Kanäle';
|
||||||
|
|
||||||
|
final type = s.type.toLowerCase();
|
||||||
|
final looksBoolean = type == 'bool' || s.value.toLowerCase() == 'true' || s.value.toLowerCase() == 'false';
|
||||||
|
if (looksBoolean) return 'Umschalter';
|
||||||
|
|
||||||
|
final looksNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||||
|
if (looksNumeric) return 'Zahlenwerte';
|
||||||
|
|
||||||
|
return 'Text';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<ServiceSettingDto>> get _groupedSettings {
|
||||||
|
final groups = <String, List<ServiceSettingDto>>{};
|
||||||
|
for (final s in _settings) {
|
||||||
|
groups.putIfAbsent(_categoryFor(s), () => []).add(s);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title, IconData icon) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 15, color: AppTheme.textMuted),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textMuted, letterSpacing: 0.4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact toggle "chip" for a single boolean setting - used for logging channels, which can easily number
|
||||||
|
/// a dozen+ per service, so a full-width `SwitchListTile` per entry (the previous, only, layout for every
|
||||||
|
/// setting regardless of category or count) made the card feel "gequetscht"/cramped and pushed the actually
|
||||||
|
/// important numeric settings far down the page.
|
||||||
|
Widget _buildToggleChip(ServiceSettingDto s, TextEditingController controller) {
|
||||||
|
final boolVal = controller.text.toLowerCase() == 'true';
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: s.description.isNotEmpty ? s.description : _formatLabel(s.key),
|
||||||
|
triggerMode: TooltipTriggerMode.tap,
|
||||||
|
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () => setState(() => controller.text = (!boolVal).toString()),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : AppTheme.glassSurface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.5) : AppTheme.glassBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
boolVal ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: boolVal ? AppTheme.primaryEmerald : AppTheme.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_formatLabel(s.key),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: boolVal ? Colors.white : AppTheme.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSwitchSetting(ServiceSettingDto s, TextEditingController controller) {
|
||||||
|
final boolVal = controller.text.toLowerCase() == 'true';
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.glassSurface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : AppTheme.glassBorder),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(_formatLabel(s.key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
|
subtitle: s.description.isNotEmpty ? Text(s.description, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||||
|
value: boolVal,
|
||||||
|
activeThumbColor: AppTheme.primaryEmerald,
|
||||||
|
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||||
|
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextFieldSetting(ServiceSettingDto s, TextEditingController controller, {required bool isNumeric}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _formatLabel(s.key),
|
||||||
|
helperText: s.description.isNotEmpty ? s.description : null,
|
||||||
|
helperMaxLines: 2,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.primaryEmerald,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildGroupedSettingsSections() {
|
||||||
|
final grouped = _groupedSettings;
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
for (final category in _categoryOrder) {
|
||||||
|
final items = grouped[category];
|
||||||
|
if (items == null || items.isEmpty) continue;
|
||||||
|
|
||||||
|
widgets.add(_buildSectionHeader(
|
||||||
|
'$category (${items.length})',
|
||||||
|
switch (category) {
|
||||||
|
'Logging-Kanäle' => Icons.terminal_rounded,
|
||||||
|
'Umschalter' => Icons.toggle_on_outlined,
|
||||||
|
'Zahlenwerte' => Icons.numbers_outlined,
|
||||||
|
_ => Icons.tune_outlined,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
if (category == 'Logging-Kanäle') {
|
||||||
|
final chips = <Widget>[];
|
||||||
|
for (final s in items) {
|
||||||
|
final controller = _controllers[s.key];
|
||||||
|
if (controller != null) chips.add(_buildToggleChip(s, controller));
|
||||||
|
}
|
||||||
|
widgets.add(Wrap(spacing: 8, runSpacing: 8, children: chips));
|
||||||
|
} else if (category == 'Umschalter') {
|
||||||
|
for (final s in items) {
|
||||||
|
final controller = _controllers[s.key];
|
||||||
|
if (controller != null) widgets.add(_buildSwitchSetting(s, controller));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (final s in items) {
|
||||||
|
final controller = _controllers[s.key];
|
||||||
|
if (controller != null) {
|
||||||
|
widgets.add(_buildTextFieldSetting(s, controller, isNumeric: category == 'Zahlenwerte'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.add(const SizedBox(height: 14));
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -169,49 +364,7 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
if (_settings.isEmpty)
|
if (_settings.isEmpty)
|
||||||
const Text('Keine spezifischen Einstellungen gefunden.')
|
const Text('Keine spezifischen Einstellungen gefunden.')
|
||||||
else
|
else
|
||||||
..._settings.map((s) {
|
..._buildGroupedSettingsSections(),
|
||||||
final key = s['key']?.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(),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (_settings.isNotEmpty)
|
if (_settings.isNotEmpty)
|
||||||
@@ -238,16 +391,9 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
GlassContainer(
|
LiveLogConsole(
|
||||||
padding: const EdgeInsets.all(24),
|
serviceName: widget.serviceName,
|
||||||
child: Column(
|
apiClient: widget.apiClient,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('Statistiken', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Live-Statistiken werden noch implementiert...', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.white54)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,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/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../repositories/admin_repository.dart';
|
||||||
import '../../../core/widgets/status_badge.dart';
|
import 'service_settings_form.dart';
|
||||||
|
|
||||||
/// Service Metadata Info used for Admin Config Navigation
|
|
||||||
class ServiceConfigMeta {
|
class ServiceConfigMeta {
|
||||||
final String key;
|
final String key;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
@@ -21,17 +21,18 @@ class ServiceConfigMeta {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Centralized Service Configuration Management Widget for Admin Panel.
|
|
||||||
class PipelineSettingsWidget extends StatefulWidget {
|
class PipelineSettingsWidget extends StatefulWidget {
|
||||||
final ApiClient? apiClient;
|
final ApiClient? apiClient;
|
||||||
|
final AdminRepository? repository;
|
||||||
|
|
||||||
const PipelineSettingsWidget({super.key, this.apiClient});
|
const PipelineSettingsWidget({super.key, this.apiClient, this.repository});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
|
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||||
|
late final AdminRepository _repository;
|
||||||
String _selectedServiceKey = 'FinlyticAssets';
|
String _selectedServiceKey = 'FinlyticAssets';
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
@@ -66,16 +67,9 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
accentColor: Color(0xFFEC4899),
|
accentColor: Color(0xFFEC4899),
|
||||||
),
|
),
|
||||||
ServiceConfigMeta(
|
ServiceConfigMeta(
|
||||||
key: 'FinlyticAnalyzer',
|
key: 'FinlyticEngine',
|
||||||
displayName: 'Analyzer Signal Engine',
|
displayName: 'Trading Engine',
|
||||||
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
|
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
|
||||||
icon: Icons.analytics_outlined,
|
|
||||||
accentColor: Color(0xFFF59E0B),
|
|
||||||
),
|
|
||||||
ServiceConfigMeta(
|
|
||||||
key: 'FinlyticTrades',
|
|
||||||
displayName: 'Trade Manager',
|
|
||||||
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
|
|
||||||
icon: Icons.candlestick_chart_outlined,
|
icon: Icons.candlestick_chart_outlined,
|
||||||
accentColor: Color(0xFF10B981),
|
accentColor: Color(0xFF10B981),
|
||||||
),
|
),
|
||||||
@@ -86,6 +80,13 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
icon: Icons.corporate_fare_outlined,
|
icon: Icons.corporate_fare_outlined,
|
||||||
accentColor: Color(0xFF06B6D4),
|
accentColor: Color(0xFF06B6D4),
|
||||||
),
|
),
|
||||||
|
ServiceConfigMeta(
|
||||||
|
key: 'FinlyticBot',
|
||||||
|
displayName: 'FinlyticBot (Paper)',
|
||||||
|
description: 'Alpaca Paper Trading, Risikomanagement & Sizing Engine',
|
||||||
|
icon: Icons.smart_toy_outlined,
|
||||||
|
accentColor: Color(0xFF10B981),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final Map<String, Map<String, TextEditingController>> _controllers = {
|
final Map<String, Map<String, TextEditingController>> _controllers = {
|
||||||
@@ -112,59 +113,65 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
'MinConfidenceScore': TextEditingController(text: '0.70'),
|
'MinConfidenceScore': TextEditingController(text: '0.70'),
|
||||||
'MaxBatchSize': TextEditingController(text: '50'),
|
'MaxBatchSize': TextEditingController(text: '50'),
|
||||||
},
|
},
|
||||||
'FinlyticAnalyzer': {
|
'FinlyticEngine': {
|
||||||
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
|
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
|
||||||
'MinSignalScore': TextEditingController(text: '75'),
|
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
|
||||||
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
|
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
|
||||||
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
|
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
|
||||||
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
|
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
|
||||||
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
|
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
|
||||||
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
|
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
|
||||||
},
|
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
|
||||||
'FinlyticTrades': {
|
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
|
||||||
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
|
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
|
||||||
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
|
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
|
||||||
'MaxOpenPositions': TextEditingController(text: '5'),
|
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
|
||||||
},
|
},
|
||||||
'FinlyticFundamentals': {
|
'FinlyticFundamentals': {
|
||||||
'CacheTtlHours': TextEditingController(text: '24'),
|
'CacheTtlHours': TextEditingController(text: '24'),
|
||||||
'EnableYahooFallback': TextEditingController(text: 'true'),
|
'EnableYahooFallback': TextEditingController(text: 'true'),
|
||||||
},
|
},
|
||||||
|
'FinlyticBot': {
|
||||||
|
'Alpaca.KeyId': TextEditingController(text: ''),
|
||||||
|
'Alpaca.SecretKey': TextEditingController(text: ''),
|
||||||
|
'Alpaca.IsPaper': TextEditingController(text: 'true'),
|
||||||
|
'Bot.EnableAutoExecution': TextEditingController(text: 'true'),
|
||||||
|
'Bot.RiskPerTradePercent': TextEditingController(text: '1.0'),
|
||||||
|
'Bot.MaxPositionAllocationPercent': TextEditingController(text: '20.0'),
|
||||||
|
'Bot.MaxConcurrentPositions': TextEditingController(text: '5'),
|
||||||
|
'Bot.DailyLossLimitPercent': TextEditingController(text: '3.0'),
|
||||||
|
'Bot.MonitoringIntervalSeconds': TextEditingController(text: '15'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void didChangeDependencies() {
|
||||||
super.initState();
|
super.didChangeDependencies();
|
||||||
|
if (!_initialized) {
|
||||||
|
_initialized = true;
|
||||||
|
final client = widget.apiClient ?? context.read<ApiClient>();
|
||||||
|
_repository = widget.repository ?? AdminRepository(apiClient: client);
|
||||||
_fetchSettings();
|
_fetchSettings();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _fetchSettings() async {
|
Future<void> _fetchSettings() async {
|
||||||
if (widget.apiClient == null) return;
|
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final res = await widget.apiClient!.get('/api/v1/admin/settings');
|
final settings = await _repository.fetchSettings();
|
||||||
if (res.statusCode == 200 && res.data is Map) {
|
settings.forEach((svc, items) {
|
||||||
final Map<String, dynamic> data = Map<String, dynamic>.from(res.data);
|
|
||||||
data.forEach((svc, items) {
|
|
||||||
if (items is List) {
|
|
||||||
_controllers.putIfAbsent(svc, () => {});
|
_controllers.putIfAbsent(svc, () => {});
|
||||||
for (var item in items) {
|
for (final s in items) {
|
||||||
final key = item['key']?.toString();
|
if (_controllers[svc]!.containsKey(s.key)) {
|
||||||
final val = item['value']?.toString();
|
_controllers[svc]![s.key]!.text = s.value;
|
||||||
if (key != null && val != null) {
|
|
||||||
if (_controllers[svc]!.containsKey(key)) {
|
|
||||||
_controllers[svc]![key]!.text = val;
|
|
||||||
} else {
|
} else {
|
||||||
_controllers[svc]![key] = TextEditingController(text: val);
|
_controllers[svc]![s.key] = TextEditingController(text: s.value);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Retain standard default in-memory values
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -179,9 +186,7 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
payload[k] = v.text;
|
payload[k] = v.text;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (widget.apiClient != null) {
|
await _repository.updateServiceSettings(_selectedServiceKey, payload);
|
||||||
await widget.apiClient!.put('/api/v1/admin/settings/$_selectedServiceKey', data: payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -192,7 +197,7 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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),
|
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -219,7 +224,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
|
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
|
||||||
@@ -228,7 +232,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Service Selection Ribbon
|
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -250,15 +253,16 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
svc.displayName,
|
svc.displayName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
color: isSelected ? Colors.white : AppTheme.textMuted,
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -269,148 +273,41 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Service Details & Config Panel
|
|
||||||
GlassContainer(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(activeService.displayName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
activeService.displayName,
|
const SizedBox(height: 2),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
Text(activeService.description, style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
|
||||||
),
|
|
||||||
Text(
|
|
||||||
activeService.description,
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
ElevatedButton.icon(
|
||||||
),
|
|
||||||
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: 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,
|
onPressed: _isSaving ? null : _saveSettings,
|
||||||
icon: _isSaving
|
icon: _isSaving
|
||||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||||
: const Icon(Icons.save_outlined),
|
: const Icon(Icons.save_outlined, size: 16),
|
||||||
label: Text(
|
label: Text(_isSaving ? 'Speichere...' : 'Speichern'),
|
||||||
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen für ${activeService.displayName} Speichern',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
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();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData _getServiceIcon(String name) {
|
||||||
|
switch (name) {
|
||||||
|
case 'FinlyticBackend':
|
||||||
|
return Icons.hub_outlined;
|
||||||
|
case 'FinlyticAssets':
|
||||||
|
return Icons.inventory_2_outlined;
|
||||||
|
case 'FinlyticNews':
|
||||||
|
return Icons.newspaper_outlined;
|
||||||
|
case 'FinlyticTechnicals':
|
||||||
|
case 'FinlyticTechnicalAnalysis':
|
||||||
|
return Icons.show_chart_outlined;
|
||||||
|
case 'FinlyticSentiment':
|
||||||
|
return Icons.psychology_outlined;
|
||||||
|
case 'FinlyticEngine':
|
||||||
|
case 'FinlyticAnalyzer':
|
||||||
|
case 'FinlyticTrades':
|
||||||
|
return Icons.candlestick_chart_outlined;
|
||||||
|
case 'FinlyticFundamentals':
|
||||||
|
return Icons.corporate_fare_outlined;
|
||||||
|
case 'FinlyticBot':
|
||||||
|
return Icons.smart_toy_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.dns_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final int totalCount = _serviceStatuses.length;
|
final int totalCount = _serviceStatuses.length;
|
||||||
@@ -191,7 +217,7 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
|
_getServiceIcon(name),
|
||||||
size: 18,
|
size: 18,
|
||||||
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
import '../models/recent_setup_model.dart';
|
||||||
|
import '../models/watchlist_entry_model.dart';
|
||||||
|
import '../repositories/admin_repository.dart';
|
||||||
|
|
||||||
|
/// Card sitting next to "AUFSCHLÜSSELUNG NACH GRUND" showing how many assets
|
||||||
|
/// FinlyticTechnicals' background scanner is currently watching. Tapping opens
|
||||||
|
/// a dialog listing every entry — this directly answers "is anything even
|
||||||
|
/// being checked in the background right now", independent of whether any of
|
||||||
|
/// those checks have (yet) produced a proposal-worthy evaluation the engine
|
||||||
|
/// history tab above would show.
|
||||||
|
class WatchlistCard extends StatefulWidget {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const WatchlistCard({super.key, required this.apiClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WatchlistCard> createState() => _WatchlistCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistCardState extends State<WatchlistCard> {
|
||||||
|
late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient);
|
||||||
|
List<WatchlistEntryModel>? _entries;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final entries = await _repository.fetchWatchlist();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_entries = entries;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _WatchlistDialog(repository: _repository, initialEntries: _entries ?? const []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final count = _entries?.length;
|
||||||
|
final value = _error != null ? '—' : (count?.toString() ?? '…');
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
onTap: _showDialog,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('WATCHLIST', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.list_alt_rounded, size: 16, color: AppTheme.accentCyan),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(value, style: TextStyle(fontSize: 22, color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text('überwachte Assets', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 11)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistDialog extends StatefulWidget {
|
||||||
|
final AdminRepository repository;
|
||||||
|
final List<WatchlistEntryModel> initialEntries;
|
||||||
|
|
||||||
|
const _WatchlistDialog({required this.repository, required this.initialEntries});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_WatchlistDialog> createState() => _WatchlistDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistDialogState extends State<_WatchlistDialog> {
|
||||||
|
late List<WatchlistEntryModel> _entries = widget.initialEntries;
|
||||||
|
bool _refreshing = false;
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
setState(() => _refreshing = true);
|
||||||
|
try {
|
||||||
|
final fresh = await widget.repository.fetchWatchlist();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_entries = fresh;
|
||||||
|
_refreshing = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _refreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime utc) {
|
||||||
|
final local = utc.toLocal();
|
||||||
|
final d = local.day.toString().padLeft(2, '0');
|
||||||
|
final m = local.month.toString().padLeft(2, '0');
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '$d.$m.${local.year} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final activeTheme = AppTheme.activePreset;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: activeTheme.cardSurface,
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
|
||||||
|
child: GlassContainer(
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.list_alt_rounded, color: AppTheme.accentCyan, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text('Watchlist (${_entries.length})', style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _refreshing ? null : _refresh,
|
||||||
|
icon: _refreshing
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||||
|
tooltip: 'Neu laden',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Assets, die FinlyticTechnicals derzeit im Hintergrund fortlaufend überprüft. Eintrag antippen für die letzten Bewertungen.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_entries.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(
|
||||||
|
child: Text('Die Watchlist ist derzeit leer.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: _entries.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white12),
|
||||||
|
itemBuilder: (context, index) => _WatchlistEntryTile(
|
||||||
|
entry: _entries[index],
|
||||||
|
repository: widget.repository,
|
||||||
|
formatTimestamp: _formatTimestamp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistEntryTile extends StatefulWidget {
|
||||||
|
final WatchlistEntryModel entry;
|
||||||
|
final AdminRepository repository;
|
||||||
|
final String Function(DateTime) formatTimestamp;
|
||||||
|
|
||||||
|
const _WatchlistEntryTile({required this.entry, required this.repository, required this.formatTimestamp});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_WatchlistEntryTile> createState() => _WatchlistEntryTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WatchlistEntryTileState extends State<_WatchlistEntryTile> {
|
||||||
|
List<RecentSetupModel>? _history;
|
||||||
|
bool _loading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
if (_history != null || _loading) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final history = await widget.repository.fetchWatchlistEntryHistory(widget.entry.isin);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_history = history;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entry = widget.entry;
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
|
child: ExpansionTile(
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
|
if (expanded) _loadHistory();
|
||||||
|
},
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.symbol?.isNotEmpty == true ? entry.symbol! : entry.isin,
|
||||||
|
style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (entry.source != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentCyan.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Text(entry.source!.label, style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Seit ${widget.formatTimestamp(entry.addedAtUtc)}'
|
||||||
|
'${entry.expiresAtUtc != null ? ' · Läuft ab ${widget.formatTimestamp(entry.expiresAtUtc!)}' : ''}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _buildHistoryBody(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryBody() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12));
|
||||||
|
}
|
||||||
|
final history = _history ?? const [];
|
||||||
|
if (history.isEmpty) {
|
||||||
|
return Text(
|
||||||
|
'Noch keine technische Bewertung für dieses Asset erfasst.',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('LETZTE BEWERTUNGEN', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...history.map((setup) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 90,
|
||||||
|
child: Text(widget.formatTimestamp(setup.createdAt), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(setup.strategyName, style: TextStyle(color: AppTheme.textPrimary, fontSize: 11), overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber).withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
setup.qualityScore.toStringAsFixed(1),
|
||||||
|
style: TextStyle(
|
||||||
|
color: setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,32 @@ class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState>
|
|||||||
emit(AssetTechnicalError(e.toString()));
|
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 {}
|
abstract class AssetTechnicalEvent {}
|
||||||
|
|
||||||
class LoadAssetTechnical extends AssetTechnicalEvent {
|
class LoadAssetTechnical extends AssetTechnicalEvent {
|
||||||
final String isin;
|
final String isin;
|
||||||
final bool forceRefresh;
|
final bool forceRefresh;
|
||||||
final String? ticker;
|
final String? ticker;
|
||||||
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.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';
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
|
||||||
abstract class AssetTechnicalState {}
|
abstract class AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalInitial extends AssetTechnicalState {}
|
class AssetTechnicalInitial extends AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalLoading extends AssetTechnicalState {}
|
class AssetTechnicalLoading extends AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalLoaded extends AssetTechnicalState {
|
class AssetTechnicalLoaded extends AssetTechnicalState {
|
||||||
final TechnicalAnalysisModel? data;
|
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 {
|
class AssetTechnicalError extends AssetTechnicalState {
|
||||||
final String message;
|
final String message;
|
||||||
AssetTechnicalError(this.message);
|
AssetTechnicalError(this.message);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../trades/models/trade_model.dart';
|
|
||||||
import 'asset_trades_event.dart';
|
import 'asset_trades_event.dart';
|
||||||
import 'asset_trades_state.dart';
|
import 'asset_trades_state.dart';
|
||||||
import '../../repositories/asset_repository.dart';
|
import '../../repositories/asset_repository.dart';
|
||||||
@@ -20,28 +19,23 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
|||||||
on<TriggerManualAnalysis>((event, emit) async {
|
on<TriggerManualAnalysis>((event, emit) async {
|
||||||
emit(AssetTradesLoading());
|
emit(AssetTradesLoading());
|
||||||
try {
|
try {
|
||||||
final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
// Server contract: always 200 -> AssetEvaluationResultDto, whether the
|
||||||
|
// pipeline produced a proposal or rejected the opportunity. The trade
|
||||||
|
// list itself is unaffected until the user actually accepts a
|
||||||
|
// proposal, so it is simply reloaded as-is; the analysis result is
|
||||||
|
// surfaced separately for the UI to react to exactly once.
|
||||||
|
final result = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||||
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
||||||
|
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
|
||||||
final list = List<TradeModel>.from(existingTrades);
|
|
||||||
final newProposal = analysisRes?.proposal;
|
|
||||||
if (newProposal != null) {
|
|
||||||
final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId));
|
|
||||||
if (!isDuplicate) {
|
|
||||||
list.insert(0, newProposal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit(AssetTradesLoaded(list));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
on<RejectTradeEvent>((event, emit) async {
|
on<DismissTradeEvent>((event, emit) {
|
||||||
try {
|
// Purely local: no server call, see DismissTradeEvent doc comment.
|
||||||
await repository.rejectTrade(event.tradeId);
|
final current = state;
|
||||||
add(LoadAssetTrades(event.isin));
|
if (current is AssetTradesLoaded) {
|
||||||
} catch (e) {
|
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
|
||||||
emit(AssetTradesError("Failed to reject trade: $e"));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
on<AcceptTradeEvent>((event, emit) async {
|
on<AcceptTradeEvent>((event, emit) async {
|
||||||
@@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
|||||||
emit(AssetTradesError("Failed to accept trade: $e"));
|
emit(AssetTradesError("Failed to accept trade: $e"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
on<AddTradeFillEvent>((event, emit) async {
|
||||||
|
try {
|
||||||
|
await repository.addTradeFill(event.tradeId, executedPrice: event.executedPrice, quantity: event.quantity);
|
||||||
|
add(LoadAssetTrades(event.isin));
|
||||||
|
} catch (e) {
|
||||||
|
emit(AssetTradesError("Failed to update trade execution: $e"));
|
||||||
|
}
|
||||||
|
});
|
||||||
on<CloseTradeEvent>((event, emit) async {
|
on<CloseTradeEvent>((event, emit) async {
|
||||||
try {
|
try {
|
||||||
await repository.closeTrade(event.tradeId, event.exitPrice);
|
await repository.closeTrade(event.tradeId, event.exitPrice);
|
||||||
|
|||||||
@@ -13,16 +13,34 @@ class TriggerManualAnalysis extends AssetTradesEvent {
|
|||||||
final ManualAnalysisRequestDto? payload;
|
final ManualAnalysisRequestDto? payload;
|
||||||
TriggerManualAnalysis(this.isin, {this.payload});
|
TriggerManualAnalysis(this.isin, {this.payload});
|
||||||
}
|
}
|
||||||
class RejectTradeEvent extends AssetTradesEvent {
|
/// Dismisses a trade proposal from the locally displayed list only.
|
||||||
|
///
|
||||||
|
/// There is no server-side "reject" anymore: a proposal is a system-wide
|
||||||
|
/// opportunity that any user may accept independently, so rejecting it has
|
||||||
|
/// no server-side meaning. This purely removes the card from the current
|
||||||
|
/// in-memory list; the proposal keeps existing server-side until its 24h
|
||||||
|
/// TTL expires, so it can reappear after the next reload (Rules.md §4 —
|
||||||
|
/// no fabricated "permanently rejected" state is invented).
|
||||||
|
class DismissTradeEvent extends AssetTradesEvent {
|
||||||
final String tradeId;
|
final String tradeId;
|
||||||
final String isin;
|
DismissTradeEvent(this.tradeId);
|
||||||
RejectTradeEvent(this.tradeId, this.isin);
|
|
||||||
}
|
}
|
||||||
class AcceptTradeEvent extends AssetTradesEvent {
|
class AcceptTradeEvent extends AssetTradesEvent {
|
||||||
final TradeAcceptanceDto tradeAcceptanceDto;
|
final TradeAcceptanceDto tradeAcceptanceDto;
|
||||||
final String isin;
|
final String isin;
|
||||||
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
|
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records a corrective/additional fill against an already-active trade
|
||||||
|
/// (review-execution path). Distinct from [AcceptTradeEvent], which targets
|
||||||
|
/// a proposal, not an existing trade — see `AssetRepository.addTradeFill`.
|
||||||
|
class AddTradeFillEvent extends AssetTradesEvent {
|
||||||
|
final String tradeId;
|
||||||
|
final String isin;
|
||||||
|
final double executedPrice;
|
||||||
|
final double quantity;
|
||||||
|
AddTradeFillEvent(this.tradeId, this.isin, this.executedPrice, this.quantity);
|
||||||
|
}
|
||||||
class CloseTradeEvent extends AssetTradesEvent {
|
class CloseTradeEvent extends AssetTradesEvent {
|
||||||
final String tradeId;
|
final String tradeId;
|
||||||
final String isin;
|
final String isin;
|
||||||
|
|||||||
@@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {}
|
|||||||
class AssetTradesLoading extends AssetTradesState {}
|
class AssetTradesLoading extends AssetTradesState {}
|
||||||
class AssetTradesLoaded extends AssetTradesState {
|
class AssetTradesLoaded extends AssetTradesState {
|
||||||
final List<TradeModel> data;
|
final List<TradeModel> data;
|
||||||
AssetTradesLoaded(this.data);
|
|
||||||
|
/// Transient result of a just-triggered manual analysis. Only set on the
|
||||||
|
/// state instance emitted directly by `TriggerManualAnalysis` — a plain
|
||||||
|
/// reload/dismiss/accept emits a fresh `AssetTradesLoaded` without it, so a
|
||||||
|
/// `BlocConsumer` listener naturally reacts to it exactly once instead of
|
||||||
|
/// on every rebuild.
|
||||||
|
///
|
||||||
|
/// Always fully populated when set: the server contract no longer has a
|
||||||
|
/// silent "204, no proposal" outcome, so unlike the old
|
||||||
|
/// `manualAnalysisProposal`/`manualAnalysisEmpty` pair, a single non-null
|
||||||
|
/// value here already tells the caller everything — check
|
||||||
|
/// `manualAnalysisResult!.hasProposal` to distinguish an accepted
|
||||||
|
/// opportunity from a rejected one with real scores/AI reasoning attached
|
||||||
|
/// (Rules.md §4).
|
||||||
|
final AssetEvaluationResultModel? manualAnalysisResult;
|
||||||
|
|
||||||
|
AssetTradesLoaded(this.data, {this.manualAnalysisResult});
|
||||||
}
|
}
|
||||||
class AssetTradesError extends AssetTradesState {
|
class AssetTradesError extends AssetTradesState {
|
||||||
final String message;
|
final String message;
|
||||||
|
|||||||
@@ -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 '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 {
|
class FundamentalDataModel extends Equatable {
|
||||||
final String isin;
|
final String isin;
|
||||||
@@ -16,10 +25,10 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final double currentPrice;
|
final double currentPrice;
|
||||||
final double dayChangeAbsolute;
|
final double dayChangeAbsolute;
|
||||||
final double dayChangePercent;
|
final double dayChangePercent;
|
||||||
final double fiftyTwoWeekHigh;
|
final double? fiftyTwoWeekHigh;
|
||||||
final double fiftyTwoWeekLow;
|
final double? fiftyTwoWeekLow;
|
||||||
final double marketCapitalization;
|
final double? marketCapitalization;
|
||||||
final double enterpriseValue;
|
final double? enterpriseValue;
|
||||||
|
|
||||||
final double? peRatioTrailing;
|
final double? peRatioTrailing;
|
||||||
final double? peRatioForward;
|
final double? peRatioForward;
|
||||||
@@ -85,10 +94,10 @@ class FundamentalDataModel extends Equatable {
|
|||||||
required this.currentPrice,
|
required this.currentPrice,
|
||||||
required this.dayChangeAbsolute,
|
required this.dayChangeAbsolute,
|
||||||
required this.dayChangePercent,
|
required this.dayChangePercent,
|
||||||
required this.fiftyTwoWeekHigh,
|
this.fiftyTwoWeekHigh,
|
||||||
required this.fiftyTwoWeekLow,
|
this.fiftyTwoWeekLow,
|
||||||
required this.marketCapitalization,
|
this.marketCapitalization,
|
||||||
required this.enterpriseValue,
|
this.enterpriseValue,
|
||||||
this.peRatioTrailing,
|
this.peRatioTrailing,
|
||||||
this.peRatioForward,
|
this.peRatioForward,
|
||||||
this.pegRatio,
|
this.pegRatio,
|
||||||
@@ -135,12 +144,6 @@ class FundamentalDataModel extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
|
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) {
|
double? parseNullableDouble(dynamic val) {
|
||||||
if (val == null) return null;
|
if (val == null) return null;
|
||||||
if (val is num) return val.toDouble();
|
if (val is num) return val.toDouble();
|
||||||
@@ -171,8 +174,8 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
||||||
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
||||||
: primaryTickerVal;
|
: primaryTickerVal;
|
||||||
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal;
|
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal;
|
||||||
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString();
|
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString();
|
||||||
|
|
||||||
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
||||||
extractExchangeStr(assetMap?['primaryTicker']) ??
|
extractExchangeStr(assetMap?['primaryTicker']) ??
|
||||||
@@ -181,76 +184,74 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
||||||
List<TickerModel> availableTickersList = [];
|
List<TickerModel> availableTickersList = [];
|
||||||
if (rawTickers is List) {
|
if (rawTickers is List) {
|
||||||
availableTickersList = rawTickers.map((t) {
|
availableTickersList = rawTickers
|
||||||
if (t is Map<String, dynamic>) {
|
.whereType<Map<String, dynamic>>()
|
||||||
return TickerModel.fromJson(t);
|
.map((t) => TickerModel.fromJson(t))
|
||||||
} else {
|
.toList();
|
||||||
return TickerModel(ticker: t.toString());
|
|
||||||
}
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revenue & Margins Derivation
|
|
||||||
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
||||||
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
final rawGrossProfit = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
||||||
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']);
|
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargins'] ?? fundMap?['grossMargin'] ?? json['grossMargin']);
|
||||||
if (grossMarginVal == null && grossProf != null) {
|
double? grossProfVal = rawGrossProfit;
|
||||||
if (grossProf <= 1.0 && grossProf >= 0.0) {
|
if (rawGrossProfit != null) {
|
||||||
grossMarginVal = grossProf;
|
if (rawGrossProfit <= 1.0 && rawGrossProfit >= 0.0) {
|
||||||
|
grossMarginVal ??= rawGrossProfit;
|
||||||
|
if (totalRev != null && totalRev > 0) {
|
||||||
|
grossProfVal = rawGrossProfit * totalRev;
|
||||||
|
}
|
||||||
} else if (totalRev != null && totalRev > 0) {
|
} else if (totalRev != null && totalRev > 0) {
|
||||||
grossMarginVal = grossProf / totalRev;
|
grossMarginVal ??= rawGrossProfit / totalRev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enterprise Value to Revenue
|
|
||||||
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
|
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
|
||||||
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
||||||
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
||||||
evToRevVal = evVal / totalRev;
|
evToRevVal = evVal / totalRev;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event Dates (Ex-Dividend & Next Earnings)
|
String? exDivDateStr = fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString();
|
||||||
String? exDividendDateVal = json['exDividendDate']?.toString() ?? fundMap?['exDividendDate']?.toString();
|
String? nextEarningsDateStr = fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString();
|
||||||
String? nextEarningsDateVal = json['nextEarningsDate']?.toString() ?? fundMap?['nextEarningsDate']?.toString();
|
|
||||||
|
|
||||||
final rawEvents = json['events'];
|
final rawEvents = json['events'];
|
||||||
if (rawEvents is List && rawEvents.isNotEmpty) {
|
if (rawEvents is List) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final parsedEvents = <Map<String, dynamic>>[];
|
|
||||||
for (final ev in rawEvents) {
|
final divEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
|
||||||
if (ev is Map<String, dynamic>) {
|
final t = e['type']?.toString().toUpperCase() ?? '';
|
||||||
final dtStr = ev['date']?.toString();
|
return t == 'DIVIDEND' || t == 'EX_DIVIDEND';
|
||||||
final dt = dtStr != null ? DateTime.tryParse(dtStr) : null;
|
}).toList();
|
||||||
if (dt != null) {
|
|
||||||
parsedEvents.add({
|
if (exDivDateStr == null && divEvents.isNotEmpty) {
|
||||||
'type': ev['type']?.toString().toUpperCase() ?? '',
|
divEvents.sort((a, b) {
|
||||||
'date': dt,
|
final da = DateTime.tryParse(a['date']?.toString() ?? '') ?? DateTime(1970);
|
||||||
'dateStr': dtStr,
|
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 earningsEvents = rawEvents.whereType<Map<String, dynamic>>().where((e) {
|
||||||
final dividendEvents = parsedEvents.where((e) => e['type'] == 'DIVIDEND').toList()
|
final t = e['type']?.toString().toUpperCase() ?? '';
|
||||||
..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
|
return t.contains('EARNINGS');
|
||||||
final futureDividends = dividendEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList();
|
}).toList();
|
||||||
if (futureDividends.isNotEmpty) {
|
|
||||||
exDividendDateVal = futureDividends.first['dateStr'] as String;
|
|
||||||
} else if (dividendEvents.isNotEmpty) {
|
|
||||||
exDividendDateVal = dividendEvents.last['dateStr'] as String;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextEarningsDateVal == null) {
|
if (nextEarningsDateStr == null && earningsEvents.isNotEmpty) {
|
||||||
final earningsEvents = parsedEvents.where((e) => e['type'] == 'EARNINGS_RELEASE' || e['type'] == 'EARNINGS_CALL').toList()
|
earningsEvents.sort((a, b) {
|
||||||
..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
|
final da = DateTime.tryParse(a['date']?.toString() ?? '') ?? DateTime(1970);
|
||||||
final futureEarnings = earningsEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList();
|
final db = DateTime.tryParse(b['date']?.toString() ?? '') ?? DateTime(1970);
|
||||||
if (futureEarnings.isNotEmpty) {
|
return da.compareTo(db);
|
||||||
nextEarningsDateVal = futureEarnings.first['dateStr'] as String;
|
});
|
||||||
} else if (earningsEvents.isNotEmpty) {
|
final upcoming = earningsEvents.firstWhere((e) {
|
||||||
nextEarningsDateVal = earningsEvents.last['dateStr'] as String;
|
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,
|
ticker: tickerVal,
|
||||||
companyName: companyNameVal,
|
companyName: companyNameVal,
|
||||||
exchange: exchangeVal,
|
exchange: exchangeVal,
|
||||||
tradingCurrency: json['tradingCurrency']?.toString(),
|
tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(),
|
||||||
businessSummary: businessSummaryVal,
|
businessSummary: businessSummaryVal,
|
||||||
sector: json['sector']?.toString(),
|
sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(),
|
||||||
industry: json['industry']?.toString(),
|
industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(),
|
||||||
country: json['country']?.toString(),
|
country: assetMap?['country']?.toString() ?? json['country']?.toString(),
|
||||||
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
|
employees: (assetMap?['employees'] ?? json['employees']) is int
|
||||||
currentPrice: parseDouble(json['currentPrice']),
|
? (assetMap?['employees'] ?? json['employees']) as int
|
||||||
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
|
: int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''),
|
||||||
dayChangePercent: parseDouble(json['dayChangePercent']),
|
currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0,
|
||||||
fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0,
|
||||||
fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0,
|
||||||
marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']),
|
fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
||||||
enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']),
|
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
||||||
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']),
|
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
|
||||||
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']),
|
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']),
|
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
|
||||||
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? json['pbRatio']),
|
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
|
||||||
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']),
|
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
|
||||||
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']),
|
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? fundMap?['enterpriseToEbitda'] ?? json['evToEbitda']),
|
||||||
evToRevenue: evToRevVal,
|
evToRevenue: evToRevVal,
|
||||||
totalRevenue: totalRev,
|
totalRevenue: totalRev,
|
||||||
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
|
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? fundMap?['revenueGrowth'] ?? json['revenueGrowthYoY']),
|
||||||
grossProfit: grossProf,
|
grossProfit: grossProfVal,
|
||||||
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
|
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']),
|
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
|
||||||
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
||||||
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
|
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? fundMap?['operatingCashflow'] ?? json['operatingCashFlow']),
|
||||||
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
|
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? fundMap?['freeCashflow'] ?? json['freeCashFlow']),
|
||||||
grossMargin: grossMarginVal,
|
grossMargin: grossMarginVal,
|
||||||
operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']),
|
operatingMargin: parseNullableDouble(fundMap?['operatingIncome'] ?? fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
|
||||||
netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']),
|
netProfitMargin: parseNullableDouble(fundMap?['netIncome'] ?? fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
|
||||||
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
||||||
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
||||||
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
||||||
@@ -299,424 +302,51 @@ class FundamentalDataModel extends Equatable {
|
|||||||
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
||||||
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
||||||
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
|
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']),
|
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
|
||||||
exDividendDate: exDividendDateVal,
|
exDividendDate: exDivDateStr,
|
||||||
nextEarningsDate: nextEarningsDateVal,
|
nextEarningsDate: nextEarningsDateStr,
|
||||||
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
||||||
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
||||||
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
||||||
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
|
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']),
|
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
|
||||||
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
||||||
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
||||||
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
||||||
executives: (json['executives'] as List?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
financialStatements: (json['financialStatements'] as List?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
estimates: (json['estimates'] as List?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
availableTickers: availableTickersList,
|
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
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
isin,
|
isin, primaryTicker, ticker, companyName, exchange, tradingCurrency,
|
||||||
primaryTicker,
|
businessSummary, sector, industry, country, employees, currentPrice,
|
||||||
ticker,
|
dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow,
|
||||||
companyName,
|
marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward,
|
||||||
exchange,
|
pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin,
|
||||||
tradingCurrency,
|
operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets,
|
||||||
businessSummary,
|
returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio,
|
||||||
sector,
|
dividendYield, payoutRatio, exDividendDate, nextEarningsDate,
|
||||||
industry,
|
percentHeldByInstitutions, percentHeldByInsiders, shortRatio,
|
||||||
country,
|
shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh,
|
||||||
employees,
|
priceTargetMedian, priceTargetMean, executives, financialStatements,
|
||||||
currentPrice,
|
estimates, availableTickers,
|
||||||
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 {
|
class TechnicalAnalysisModel extends Equatable {
|
||||||
final String symbol;
|
final String symbol;
|
||||||
final String currency;
|
final String currency;
|
||||||
|
final double? currentPrice;
|
||||||
final String trend;
|
final String trend;
|
||||||
final String rsi;
|
final String rsi;
|
||||||
final String macd;
|
final String macd;
|
||||||
final String overallSignal;
|
final String overallSignal;
|
||||||
final String sma50;
|
final String sma50;
|
||||||
final String sma200;
|
final String sma200;
|
||||||
final double vix;
|
final double? vix;
|
||||||
final String sp500Trend;
|
final String? sp500Trend;
|
||||||
final double dxy;
|
final double? dxy;
|
||||||
final double? stopLossAtr;
|
final double? stopLossAtr;
|
||||||
final List<CandleModel> candles;
|
final List<CandleModel> candles;
|
||||||
final List<IndicatorModel> indicators;
|
final List<IndicatorModel> indicators;
|
||||||
@@ -216,15 +217,16 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
const TechnicalAnalysisModel({
|
const TechnicalAnalysisModel({
|
||||||
required this.symbol,
|
required this.symbol,
|
||||||
this.currency = 'EUR',
|
this.currency = 'EUR',
|
||||||
|
this.currentPrice,
|
||||||
required this.trend,
|
required this.trend,
|
||||||
required this.rsi,
|
required this.rsi,
|
||||||
required this.macd,
|
required this.macd,
|
||||||
required this.overallSignal,
|
required this.overallSignal,
|
||||||
required this.sma50,
|
required this.sma50,
|
||||||
required this.sma200,
|
required this.sma200,
|
||||||
this.vix = 16.5,
|
this.vix,
|
||||||
this.sp500Trend = 'Bullish',
|
this.sp500Trend,
|
||||||
this.dxy = 104.2,
|
this.dxy,
|
||||||
this.stopLossAtr,
|
this.stopLossAtr,
|
||||||
this.candles = const [],
|
this.candles = const [],
|
||||||
this.indicators = const [],
|
this.indicators = const [],
|
||||||
@@ -245,7 +247,6 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
|
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
|
||||||
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
|
||||||
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
||||||
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
@@ -259,17 +260,18 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return TechnicalAnalysisModel(
|
return TechnicalAnalysisModel(
|
||||||
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
currency: json['currency']?.toString() ?? 'EUR',
|
currency: json['currency']?.toString() ?? 'EUR',
|
||||||
|
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
|
||||||
trend: parsedTrend,
|
trend: parsedTrend,
|
||||||
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
||||||
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
||||||
overallSignal: parsedSignal,
|
overallSignal: parsedSignal,
|
||||||
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
||||||
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
||||||
vix: (regime?['vixValue'] as num?)?.toDouble() ?? 16.5,
|
vix: (regime?['vixValue'] as num?)?.toDouble(),
|
||||||
sp500Trend: regime?['marketTrend']?.toString() ?? 'Bullish',
|
sp500Trend: regime?['marketTrend']?.toString(),
|
||||||
dxy: (regime?['dxyValue'] as num?)?.toDouble() ?? 104.2,
|
dxy: (regime?['dxyValue'] as num?)?.toDouble(),
|
||||||
stopLossAtr: lastInd?.recommendedStopLoss,
|
stopLossAtr: lastInd?.recommendedStopLoss,
|
||||||
candles: candlesList,
|
candles: candlesList,
|
||||||
indicators: indicatorsList,
|
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/core/network/api_client.dart';
|
||||||
|
|
||||||
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
|
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
|
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart';
|
|
||||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/trades/repositories/trade_repository.dart';
|
||||||
|
|
||||||
class AssetRepository {
|
class AssetRepository {
|
||||||
final ApiClient apiClient;
|
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 {
|
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 {
|
try {
|
||||||
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
||||||
if (ticker != null && ticker.isNotEmpty) {
|
if (ticker != null && ticker.isNotEmpty) {
|
||||||
url += '&ticker=$ticker';
|
url += '&ticker=$ticker';
|
||||||
}
|
}
|
||||||
final res = await apiClient.get(url);
|
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);
|
return FundamentalDataModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
print('Error fetching fundamentals for $isin: $e');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
|
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 {
|
try {
|
||||||
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
||||||
if (ticker != null && ticker.isNotEmpty) {
|
if (ticker != null && ticker.isNotEmpty) {
|
||||||
url += '&ticker=$ticker';
|
url += '&ticker=$ticker';
|
||||||
}
|
}
|
||||||
final res = await apiClient.get(url);
|
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);
|
return TechnicalAnalysisModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
print('Error fetching TA for $isin: $e');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<double?> getLivePrice(String isin) async {
|
||||||
|
try {
|
||||||
|
final res = await apiClient.get('/api/v1/assets/$isin/live');
|
||||||
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
|
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
|
||||||
|
if (val is num) return val.toDouble();
|
||||||
|
if (val != null) return double.tryParse(val.toString());
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||||
try {
|
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
||||||
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 {
|
/// Triggers an on-demand manual analysis for [isin] via `POST /api/v1/analyze/manual`.
|
||||||
try {
|
///
|
||||||
|
/// Server contract: always `200 OK` with a full `AssetEvaluationResultDto`
|
||||||
|
/// body — even when the analysis ran but did not clear the bar for a trade
|
||||||
|
/// proposal (`AssetEvaluationResultModel.proposal == null`), the response
|
||||||
|
/// still carries the real, already-computed scores and AI reasoning, so
|
||||||
|
/// there is no more silent `204 No Content` outcome to handle here
|
||||||
|
/// (Rules.md §4). A non-2xx status (missing ISIN, engine unreachable, no
|
||||||
|
/// RPC response, unexpected error) surfaces as a `DioException` that
|
||||||
|
/// propagates to the caller instead of being swallowed into `null`.
|
||||||
|
Future<AssetEvaluationResultModel> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||||
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
if (res.data != null && res.data is Map<String, dynamic>) {
|
||||||
return ManualAnalysisResponseDto.fromJson(res.data);
|
return AssetEvaluationResultModel.fromJson(res.data);
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error triggering manual analysis for $isin: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> rejectTrade(String tradeId) async {
|
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
||||||
try {
|
|
||||||
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
|
|
||||||
} catch (e) {
|
|
||||||
print('Error rejecting trade $tradeId: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
|
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
||||||
try {
|
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
||||||
final payload = tradeAcceptanceDto.toJson();
|
|
||||||
await apiClient.post('/api/v1/user/trades/accept', data: payload);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error accepting trade: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> closeTrade(String tradeId, double exitPrice) async {
|
Future<TradeModel> addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async =>
|
||||||
try {
|
_tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity);
|
||||||
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error closing trade $tradeId: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
static void show(BuildContext context, String key) {
|
||||||
final info = data[key];
|
final info = data[key];
|
||||||
if (info == null) return;
|
if (info == null) return;
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ class PatternExplanations {
|
|||||||
return colors[patternType.hashCode.abs() % colors.length];
|
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) {
|
static void showPatternDetails(BuildContext context, String rawPatternType) {
|
||||||
final key = dictionary.keys.firstWhere(
|
final key = dictionary.keys.firstWhere(
|
||||||
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
|
(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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../bloc/header/asset_header_bloc.dart';
|
import '../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../bloc/header/asset_header_event.dart';
|
|
||||||
import '../bloc/technical/asset_technical_bloc.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_bloc.dart';
|
||||||
import '../bloc/trades/asset_trades_event.dart';
|
import '../bloc/trades/asset_trades_event.dart';
|
||||||
import '../repositories/asset_repository.dart';
|
import '../repositories/asset_repository.dart';
|
||||||
|
import '../utils/ticker_resolver.dart';
|
||||||
import 'layouts/asset_page_desktop_layout.dart';
|
import 'layouts/asset_page_desktop_layout.dart';
|
||||||
import 'layouts/asset_page_mobile_layout.dart';
|
import 'layouts/asset_page_mobile_layout.dart';
|
||||||
|
|
||||||
@@ -29,17 +31,23 @@ class AssetDetailScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repository = AssetRepository(apiClient: apiClient);
|
final repository = AssetRepository(apiClient: apiClient);
|
||||||
|
|
||||||
|
return BlocBuilder<FavoritesCubit, FavoritesState>(
|
||||||
|
builder: (context, favState) {
|
||||||
|
final initialTicker = TickerResolver.resolve(
|
||||||
|
isin: isin,
|
||||||
|
candidateSymbol: symbol,
|
||||||
|
favoriteDetails: favState.favoriteDetails,
|
||||||
|
);
|
||||||
|
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetHeaderBloc(repository: repository)
|
create: (context) => AssetFundamentalsBloc(repository: repository)
|
||||||
..add(LoadAssetHeader(isin, ticker: symbol)),
|
..add(LoadAssetFundamentals(isin, ticker: initialTicker)),
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetFundamentalsBloc(repository: repository),
|
create: (context) => AssetTechnicalBloc(repository: repository)
|
||||||
),
|
..add(LoadAssetTechnical(isin, ticker: initialTicker)),
|
||||||
BlocProvider(
|
|
||||||
create: (context) => AssetTechnicalBloc(repository: repository),
|
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetTradesBloc(repository: repository)
|
create: (context) => AssetTradesBloc(repository: repository)
|
||||||
@@ -53,17 +61,20 @@ class AssetDetailScreen extends StatelessWidget {
|
|||||||
return AssetPageDesktopLayout(
|
return AssetPageDesktopLayout(
|
||||||
isin: isin,
|
isin: isin,
|
||||||
name: name,
|
name: name,
|
||||||
selectedTicker: symbol,
|
selectedTicker: initialTicker ?? symbol,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return AssetPageMobileLayout(
|
return AssetPageMobileLayout(
|
||||||
isin: isin,
|
isin: isin,
|
||||||
name: name,
|
name: name,
|
||||||
selectedTicker: symbol,
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-26
@@ -4,13 +4,12 @@ import '../../../../core/theme/app_theme.dart';
|
|||||||
import '../../../favorites/cubit/favorites_cubit.dart';
|
import '../../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../../bloc/header/asset_header_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../bloc/header/asset_header_event.dart';
|
|
||||||
import '../../bloc/header/asset_header_state.dart';
|
|
||||||
import '../../bloc/technical/asset_technical_bloc.dart';
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
import '../../bloc/trades/asset_trades_event.dart';
|
import '../../bloc/trades/asset_trades_event.dart';
|
||||||
|
import '../../utils/ticker_resolver.dart';
|
||||||
import '../../widgets/header/asset_hero_header.dart';
|
import '../../widgets/header/asset_hero_header.dart';
|
||||||
import '../tabs/fundamentals_tab.dart';
|
import '../tabs/fundamentals_tab.dart';
|
||||||
import '../tabs/technical_tab.dart';
|
import '../tabs/technical_tab.dart';
|
||||||
@@ -21,8 +20,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? selectedTicker;
|
final String? selectedTicker;
|
||||||
|
|
||||||
const AssetPageDesktopLayout(
|
const AssetPageDesktopLayout({
|
||||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.selectedTicker,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
||||||
@@ -31,12 +34,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
|
|||||||
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
String? _selectedExchange;
|
|
||||||
String? _selectedTicker;
|
String? _selectedTicker;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_selectedTicker = widget.selectedTicker;
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +51,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
|
|
||||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedExchange = newExchange;
|
|
||||||
_selectedTicker = newTicker;
|
_selectedTicker = newTicker;
|
||||||
});
|
});
|
||||||
context.read<AssetHeaderBloc>().add(
|
|
||||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
ticker: newTicker, forceRefresh: false));
|
ticker: newTicker, forceRefresh: false));
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
@@ -65,10 +65,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceRefresh() {
|
void _handleForceRefresh() {
|
||||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
forceRefresh: true,
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
exchange: _selectedExchange,
|
|
||||||
ticker: _selectedTicker));
|
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
ticker: _selectedTicker, forceRefresh: true));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||||
@@ -78,22 +76,33 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
|
return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is AssetHeaderLoaded && state.data != null) {
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
if (_selectedTicker == 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(() {
|
setState(() {
|
||||||
_selectedTicker = widget.selectedTicker;
|
_selectedTicker = bestTicker;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
|
||||||
widget.isin,
|
|
||||||
ticker: _selectedTicker,
|
|
||||||
forceRefresh: false));
|
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
||||||
widget.isin,
|
widget.isin,
|
||||||
ticker: _selectedTicker,
|
ticker: bestTicker,
|
||||||
forceRefresh: false));
|
forceRefresh: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -191,7 +200,6 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import '../../../../core/theme/app_theme.dart';
|
|||||||
import '../../../favorites/cubit/favorites_cubit.dart';
|
import '../../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../../bloc/header/asset_header_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../bloc/header/asset_header_event.dart';
|
|
||||||
import '../../bloc/header/asset_header_state.dart';
|
|
||||||
import '../../bloc/technical/asset_technical_bloc.dart';
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
import '../../bloc/trades/asset_trades_event.dart';
|
import '../../bloc/trades/asset_trades_event.dart';
|
||||||
|
import '../../utils/ticker_resolver.dart';
|
||||||
import '../../widgets/header/asset_hero_header.dart';
|
import '../../widgets/header/asset_hero_header.dart';
|
||||||
import '../tabs/fundamentals_tab.dart';
|
import '../tabs/fundamentals_tab.dart';
|
||||||
import '../tabs/technical_tab.dart';
|
import '../tabs/technical_tab.dart';
|
||||||
@@ -21,8 +20,12 @@ class AssetPageMobileLayout extends StatefulWidget {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? selectedTicker;
|
final String? selectedTicker;
|
||||||
|
|
||||||
const AssetPageMobileLayout(
|
const AssetPageMobileLayout({
|
||||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.selectedTicker,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
||||||
@@ -36,7 +39,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
//_selectedTicker = widget.selectedTicker;
|
_selectedTicker = widget.selectedTicker;
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +51,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
|
|
||||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||||
setState(() {
|
setState(() {
|
||||||
//_selectedExchange = newExchange;
|
|
||||||
_selectedTicker = newTicker;
|
_selectedTicker = newTicker;
|
||||||
});
|
});
|
||||||
context.read<AssetHeaderBloc>().add(
|
|
||||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
ticker: newTicker, forceRefresh: false));
|
ticker: newTicker, forceRefresh: false));
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
@@ -65,10 +65,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceRefresh() {
|
void _handleForceRefresh() {
|
||||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
forceRefresh: true, ticker: _selectedTicker));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
// 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<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
ticker: _selectedTicker, forceRefresh: true));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||||
@@ -78,22 +76,33 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
|
return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is AssetHeaderLoaded && state.data != null) {
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
if (_selectedTicker == 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(() {
|
setState(() {
|
||||||
_selectedTicker = widget.selectedTicker;
|
_selectedTicker = bestTicker;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
|
||||||
widget.isin,
|
|
||||||
ticker: _selectedTicker,
|
|
||||||
forceRefresh: false));
|
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
||||||
widget.isin,
|
widget.isin,
|
||||||
ticker: _selectedTicker,
|
ticker: bestTicker,
|
||||||
forceRefresh: false));
|
forceRefresh: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -110,7 +119,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
onForceRefresh: _handleForceRefresh,
|
onForceRefresh: _handleForceRefresh,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. Full-Width Interactive Chart Section
|
// 2. Interactive Chart
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -122,13 +131,13 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
symbol: _selectedTicker,
|
symbol: _selectedTicker,
|
||||||
showChartOnly: true,
|
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(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -137,20 +146,28 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
border: Border.all(color: theme.glassBorder),
|
border: Border.all(color: theme.glassBorder),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
|
isScrollable: true,
|
||||||
|
tabAlignment: TabAlignment.start,
|
||||||
labelColor: theme.primaryColor,
|
labelColor: theme.primaryColor,
|
||||||
unselectedLabelColor: theme.textMuted,
|
unselectedLabelColor: theme.textSecondary,
|
||||||
indicatorColor: theme.primaryColor,
|
indicatorColor: theme.primaryColor,
|
||||||
dividerColor: theme.glassBorder,
|
dividerColor: theme.glassBorder,
|
||||||
labelStyle: const TextStyle(
|
labelStyle: const TextStyle(
|
||||||
fontWeight: FontWeight.bold, fontSize: 12),
|
fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'FUNDAMENTALS'),
|
Tab(
|
||||||
Tab(text: 'MUSTER & SIGNALE'),
|
icon: Icon(Icons.analytics_outlined, size: 18),
|
||||||
Tab(text: 'TRADES'),
|
text: 'ÜBERSICHT'),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.architecture_outlined, size: 18),
|
||||||
|
text: 'MUSTER'),
|
||||||
|
Tab(
|
||||||
|
icon:
|
||||||
|
Icon(Icons.candlestick_chart_outlined, size: 18),
|
||||||
|
text: 'TRADES'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.dart';
|
import '../../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../models/fundamental_data_model.dart';
|
import '../../widgets/fundamentals/analyst_price_target_card.dart';
|
||||||
import '../../utils/metric_explanations.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 isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isEmbedded;
|
final bool isEmbedded;
|
||||||
@@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget {
|
|||||||
required this.isin,
|
required this.isin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
String _getCurrencySymbol(String? ticker) {
|
||||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
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')) {
|
||||||
class _FundamentalsTabState extends State<FundamentalsTab> {
|
return '€';
|
||||||
String _sym = '\$';
|
}
|
||||||
String _curCode = 'USD';
|
if (t.endsWith('.L')) return '£';
|
||||||
|
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
|
||||||
@override
|
return '\$';
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
String _getCurrencyCode(String? ticker) {
|
||||||
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
|
if (ticker == null || ticker.isEmpty) return 'EUR';
|
||||||
super.didUpdateWidget(oldWidget);
|
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
|
@override
|
||||||
@@ -60,7 +64,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
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),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
),
|
),
|
||||||
@@ -70,89 +74,68 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetFundamentalsLoaded) {
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
final data = state.data;
|
final data = state.data!;
|
||||||
if (data != null) {
|
final sym = _getCurrencySymbol(data.ticker);
|
||||||
_sym = _getCurrencySymbol(data.ticker);
|
final curCode = _getCurrencyCode(data.ticker);
|
||||||
_curCode = _getCurrencyCode(data.ticker);
|
|
||||||
}
|
|
||||||
if (data == null) {
|
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 1. Analyst Forecasts & Price Targets Header Card
|
// 1. Analyst Forecasts & Price Targets Header Card
|
||||||
_buildPriceTargetCard(data),
|
AnalystPriceTargetCard(data: data, currencySymbol: sym),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
||||||
_buildCategoryPanels(data),
|
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 3. Company Description & Detailed Executive Board
|
// 3. Company Description & Detailed Executive Board
|
||||||
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildProfileSection(data),
|
CompanyProfileSection(data: data, currencySymbol: sym),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildEmptyState();
|
return _buildEmptyState(context);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPriceTargetCard(FundamentalDataModel data) {
|
Widget _buildSectionHeader(String title, IconData icon) {
|
||||||
final rating = data.consensusRating ?? 'N/A';
|
return Row(
|
||||||
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: [
|
children: [
|
||||||
Row(
|
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
Text(title, style: const 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) {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
return Column(
|
return Center(
|
||||||
|
child: GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
Icon(Icons.analytics_outlined, color: AppTheme.textMuted, size: 48),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 12),
|
||||||
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
|
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(
|
return SingleChildScrollView(
|
||||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Price Target Card Shimmer
|
|
||||||
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 3 Category Panels Shimmer
|
|
||||||
if (isDesktop)
|
if (isDesktop)
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -234,9 +214,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
panelShimmer(),
|
panelShimmer(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// Profile Section Shimmer
|
|
||||||
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
|
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:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.dart';
|
import '../../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
|
||||||
import '../../bloc/technical/asset_technical_bloc.dart';
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/technical/asset_technical_state.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/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 isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isDesktopLeftPanel;
|
final bool isDesktopLeftPanel;
|
||||||
@@ -30,31 +31,6 @@ class TechnicalTab extends StatefulWidget {
|
|||||||
required this.isin,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
@@ -74,11 +50,13 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Fehler beim Laden der Technischen Analyse: ${state.message}',
|
'Fehler beim Laden der Technischen Analyse: ${state.message}',
|
||||||
style: const TextStyle(color: Colors.white70)),
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
||||||
LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
),
|
),
|
||||||
@@ -88,221 +66,91 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetTechnicalLoaded) {
|
if (state is AssetTechnicalLoaded && state.data != null) {
|
||||||
final data = state.data;
|
final data = state.data!;
|
||||||
List<CandleModel> candles = [];
|
final candles = data.candles;
|
||||||
List<ChartPatternModel> patterns = [];
|
final patterns = data.patterns;
|
||||||
List<StrategySignalModel> signals = [];
|
final signals = data.signals;
|
||||||
List<IndicatorModel> indicators = [];
|
final indicators = data.indicators;
|
||||||
|
|
||||||
if (data != null) {
|
final activePatterns = <ChartPatternModel>[];
|
||||||
candles = data.candles
|
for (int i = 0; i < patterns.length; i++) {
|
||||||
.map((c) => CandleModel(
|
if (!state.disabledPatternIndices.contains(i)) {
|
||||||
time: c.timestamp,
|
activePatterns.add(patterns[i]);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter patterns according to individual checkbox states
|
final chartWidget = CandlestickChart(
|
||||||
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 = SizedBox(
|
|
||||||
height: widget.chartHeight,
|
|
||||||
width: double.infinity,
|
|
||||||
child: CandlestickChart(
|
|
||||||
candles: candles,
|
candles: candles,
|
||||||
|
indicators: indicators,
|
||||||
patterns: activePatterns,
|
patterns: activePatterns,
|
||||||
signals: signals,
|
signals: signals,
|
||||||
indicators: indicators,
|
showSma50: state.showSma50,
|
||||||
showPatterns: _showPatterns,
|
showSma200: state.showSma200,
|
||||||
showEma: _showEma,
|
showEma: state.showEma,
|
||||||
showSma50: _showSma50,
|
showPatterns: state.showPatterns,
|
||||||
showSma200: _showSma200,
|
showSignals: state.showSignals,
|
||||||
showSignals: _showSignals,
|
showSupertrend: state.showSupertrend,
|
||||||
showSupertrend: _showSupertrend,
|
height: chartHeight,
|
||||||
),
|
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.showChartOnly) {
|
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 (showChartOnly) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
chartRibbon,
|
chartRibbon,
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
chartWidget,
|
chartWidget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final detailsSection = Padding(
|
final detailsSection = Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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) ...[
|
if (patterns.isNotEmpty) ...[
|
||||||
Text(
|
const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
'Formationen & Trendlinien (Mit Checkbox im Chart schalten):',
|
const SizedBox(height: 8),
|
||||||
style: TextStyle(
|
for (int i = 0; i < patterns.length; i++)
|
||||||
color: AppTheme.textSecondary,
|
PatternCardItem(
|
||||||
fontWeight: FontWeight.w600,
|
pattern: patterns[i],
|
||||||
fontSize: 13)),
|
index: i,
|
||||||
const SizedBox(height: 6),
|
isEnabled: !state.disabledPatternIndices.contains(i),
|
||||||
...List.generate(
|
onToggle: (enabled) {
|
||||||
patterns.length,
|
context.read<AssetTechnicalBloc>().add(
|
||||||
(index) =>
|
TogglePatternFilter(patternIndex: i, enabled: enabled),
|
||||||
_buildPatternCard(patterns[index], index)),
|
);
|
||||||
const SizedBox(height: 12),
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (signals.isNotEmpty) ...[
|
if (signals.isNotEmpty) ...[
|
||||||
Text('Strategie-Signale:',
|
const Text('Strategische Kauf- & Verkaufssignale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
style: TextStyle(
|
const SizedBox(height: 8),
|
||||||
color: AppTheme.textSecondary,
|
for (final sig in signals) SignalCardItem(signal: sig),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
...signals.map((s) => _buildSignalCard(s)),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.showDetailsOnly) {
|
if (showDetailsOnly) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: detailsSection,
|
child: detailsSection,
|
||||||
@@ -325,228 +173,26 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Keine technisches Indikatoren verfügbar',
|
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
|
||||||
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) {
|
Widget _buildTechnicalShimmer(BuildContext context) {
|
||||||
if (widget.showChartOnly) {
|
if (showChartOnly) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
const SizedBox(height: 8),
|
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(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -570,7 +216,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
children: [
|
children: [
|
||||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
const SizedBox(height: 8),
|
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 SizedBox(height: 16),
|
||||||
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||||
const SizedBox(height: 14),
|
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/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../utils/pattern_explanations.dart';
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import 'candlestick_painter.dart';
|
||||||
|
|
||||||
class CandleModel {
|
export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel;
|
||||||
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'] ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CandlestickChart extends StatefulWidget {
|
class CandlestickChart extends StatefulWidget {
|
||||||
final List<CandleModel> candles;
|
final List<CandleModel> candles;
|
||||||
final List<ChartPatternModel> patterns;
|
final List<ChartPatternModel> patterns;
|
||||||
final List<StrategySignalModel> signals;
|
final List<StrategySignalModel> signals;
|
||||||
final List<IndicatorModel> indicators;
|
final List<IndicatorModel> indicators;
|
||||||
final bool showPatterns;
|
|
||||||
final bool showSma50;
|
final bool showSma50;
|
||||||
final bool showSma200;
|
final bool showSma200;
|
||||||
final bool showEma;
|
final bool showEma;
|
||||||
|
final bool showPatterns;
|
||||||
final bool showSignals;
|
final bool showSignals;
|
||||||
final bool showSupertrend;
|
final bool showSupertrend;
|
||||||
|
final double height;
|
||||||
|
final bool isFullscreen;
|
||||||
|
final VoidCallback? onToggleFullscreen;
|
||||||
|
|
||||||
const CandlestickChart({
|
const CandlestickChart({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -126,12 +27,15 @@ class CandlestickChart extends StatefulWidget {
|
|||||||
this.patterns = const [],
|
this.patterns = const [],
|
||||||
this.signals = const [],
|
this.signals = const [],
|
||||||
this.indicators = const [],
|
this.indicators = const [],
|
||||||
this.showPatterns = true,
|
|
||||||
this.showSma50 = true,
|
this.showSma50 = true,
|
||||||
this.showSma200 = true,
|
this.showSma200 = true,
|
||||||
this.showEma = true,
|
this.showEma = true,
|
||||||
|
this.showPatterns = true,
|
||||||
this.showSignals = true,
|
this.showSignals = true,
|
||||||
this.showSupertrend = true,
|
this.showSupertrend = true,
|
||||||
|
this.height = 420,
|
||||||
|
this.isFullscreen = false,
|
||||||
|
this.onToggleFullscreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -141,81 +45,142 @@ class CandlestickChart extends StatefulWidget {
|
|||||||
class _CandlestickChartState extends State<CandlestickChart> {
|
class _CandlestickChartState extends State<CandlestickChart> {
|
||||||
double _scale = 1.0;
|
double _scale = 1.0;
|
||||||
double _panOffset = 0.0;
|
double _panOffset = 0.0;
|
||||||
|
double _baseScale = 1.0;
|
||||||
|
double _basePanOffset = 0.0;
|
||||||
|
Offset _startFocalPoint = Offset.zero;
|
||||||
|
bool _isDragging = false;
|
||||||
Offset? _tapPosition;
|
Offset? _tapPosition;
|
||||||
CandleModel? _selectedCandle;
|
CandleModel? _selectedCandle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void initState() {
|
||||||
if (widget.candles.isEmpty) {
|
super.initState();
|
||||||
return const Center(child: Text('No chart data'));
|
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;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return Container(
|
||||||
builder: (context, constraints) {
|
height: widget.height,
|
||||||
final double baseWidth = 10.0;
|
decoration: BoxDecoration(
|
||||||
final double spacing = 5.0;
|
color: theme.cardSurface,
|
||||||
final double totalCandleSpace = (baseWidth + spacing) * _scale;
|
borderRadius: BorderRadius.circular(16),
|
||||||
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
|
border: Border.all(color: theme.glassBorder),
|
||||||
|
),
|
||||||
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
|
child: ClipRRect(
|
||||||
final double maxOffset = 100.0;
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Listener(
|
||||||
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
|
|
||||||
|
|
||||||
return Listener(
|
|
||||||
onPointerSignal: (pointerSignal) {
|
onPointerSignal: (pointerSignal) {
|
||||||
if (pointerSignal is PointerScrollEvent) {
|
if (pointerSignal is PointerScrollEvent) {
|
||||||
GestureBinding.instance.pointerSignalResolver.register(
|
if (pointerSignal.scrollDelta.dx != 0) {
|
||||||
pointerSignal,
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
(event) {
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
if (event is PointerScrollEvent) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final double localX = event.localPosition.dx;
|
_panOffset -= pointerSignal.scrollDelta.dx;
|
||||||
final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1;
|
_clampPanOffset(chartWidth);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
} else if (pointerSignal.scrollDelta.dy != 0) {
|
||||||
|
final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85;
|
||||||
|
_applyZoom(zoomFactor, pointerSignal.localPosition.dx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
child: MouseRegion(
|
||||||
}
|
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
|
||||||
},
|
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onScaleStart: (details) {
|
||||||
|
_baseScale = _scale;
|
||||||
|
_basePanOffset = _panOffset;
|
||||||
|
_startFocalPoint = details.focalPoint;
|
||||||
|
setState(() => _isDragging = true);
|
||||||
|
},
|
||||||
onScaleUpdate: (details) {
|
onScaleUpdate: (details) {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
setState(() {
|
setState(() {
|
||||||
_scale = (_scale * details.scale).clamp(0.2, 5.0);
|
if (details.scale != 1.0) {
|
||||||
_panOffset += details.focalPointDelta.dx;
|
final oldScale = _scale;
|
||||||
_panOffset = _panOffset.clamp(minOffset, maxOffset);
|
_scale = (_baseScale * details.scale).clamp(0.1, 6.0);
|
||||||
if (_tapPosition != null) {
|
final fx = details.localFocalPoint.dx;
|
||||||
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
|
_panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale));
|
||||||
|
} else {
|
||||||
|
_panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx);
|
||||||
}
|
}
|
||||||
|
_clampPanOffset(chartWidth);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onScaleEnd: (_) => setState(() {
|
onScaleEnd: (details) {
|
||||||
_tapPosition = null;
|
setState(() => _isDragging = false);
|
||||||
_selectedCandle = null;
|
},
|
||||||
}),
|
|
||||||
onTapDown: (details) {
|
onTapDown: (details) {
|
||||||
_handleTap(details.localPosition, constraints.maxWidth);
|
_handleTap(details.localPosition);
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRect(
|
CustomPaint(
|
||||||
child: CustomPaint(
|
|
||||||
size: Size.infinite,
|
size: Size.infinite,
|
||||||
painter: _CandlePainter(
|
painter: CandlestickPainter(
|
||||||
candles: widget.candles,
|
candles: widget.candles,
|
||||||
patterns: widget.patterns,
|
patterns: widget.patterns,
|
||||||
signals: widget.signals,
|
signals: widget.signals,
|
||||||
@@ -232,74 +197,76 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
tapPosition: _tapPosition,
|
tapPosition: _tapPosition,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (_selectedCandle != null) _buildTooltip(theme),
|
if (_selectedCandle != null) _buildTooltip(theme),
|
||||||
// Floating Zoom & Pan Controls (Top-Left)
|
_buildZoomControls(theme),
|
||||||
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',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
if (widget.candles.isEmpty) return;
|
||||||
|
final double candleWidth = 10.0 * _scale;
|
||||||
// Right side is for axis, don't tap there
|
final double totalCandleSpace = candleWidth + (5.0 * _scale);
|
||||||
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 int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
|
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
|
||||||
|
|
||||||
if (index >= 0 && index < widget.candles.length) {
|
if (index >= 0 && index < widget.candles.length) {
|
||||||
@@ -311,8 +278,8 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTooltip(ThemePreset theme) {
|
Widget _buildTooltip(ThemePreset theme) {
|
||||||
final candle = _selectedCandle!;
|
final c = _selectedCandle!;
|
||||||
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
|
final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}";
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 10,
|
left: 10,
|
||||||
@@ -328,492 +295,12 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
|
Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
|
||||||
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, 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('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)),
|
||||||
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)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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