feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core

This commit is contained in:
2026-08-24 21:37:43 +02:00
parent 676496b77d
commit 0894c40f07
113 changed files with 12413 additions and 3613 deletions
@@ -0,0 +1,124 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_enums.dart';
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto` — one row
/// of `GET /api/v1/admin/evaluations`. Every score field is the real,
/// already-computed value the server persisted (including the honest 0/default
/// values recorded for [OutcomeReason.noTechnicalSetups]) — nothing here is
/// fabricated client-side (Rules.md §4).
class EvaluationHistoryEntryModel extends Equatable {
final String id;
final String isin;
final String symbol;
final double technicalScore;
final double sentimentScore;
final double fundamentalScore;
final double compositeOpportunityScore;
final double reliabilityBonus;
final bool passedEarningsLockout;
final int? daysToNextEarnings;
final bool passedDividendGate;
final int? daysToNextExDividend;
final UniverseSource? universeSource;
final DateTime? universeEnteredAtUtc;
final bool passedSimulationVeto;
final bool passedAiValidation;
final String aiThesisSummary;
final OutcomeReason outcomeReason;
final TriggerSource triggerSource;
final String? triggeredByUserId;
final String? proposalId;
final DateTime evaluatedAtUtc;
const EvaluationHistoryEntryModel({
required this.id,
required this.isin,
required this.symbol,
required this.technicalScore,
required this.sentimentScore,
required this.fundamentalScore,
required this.compositeOpportunityScore,
required this.reliabilityBonus,
required this.passedEarningsLockout,
this.daysToNextEarnings,
required this.passedDividendGate,
this.daysToNextExDividend,
this.universeSource,
this.universeEnteredAtUtc,
required this.passedSimulationVeto,
required this.passedAiValidation,
required this.aiThesisSummary,
required this.outcomeReason,
required this.triggerSource,
this.triggeredByUserId,
this.proposalId,
required this.evaluatedAtUtc,
});
/// True exactly when this evaluation resulted in a trade proposal.
bool get hasProposal => proposalId != null && proposalId!.isNotEmpty;
factory EvaluationHistoryEntryModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
DateTime parseDate(dynamic val) {
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
return DateTime.tryParse(val.toString())?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
}
return EvaluationHistoryEntryModel(
id: json['id']?.toString() ?? '',
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
technicalScore: parseDbl(json['technicalScore']),
sentimentScore: parseDbl(json['sentimentScore']),
fundamentalScore: parseDbl(json['fundamentalScore']),
compositeOpportunityScore: parseDbl(json['compositeOpportunityScore']),
reliabilityBonus: parseDbl(json['reliabilityBonus']),
passedEarningsLockout: json['passedEarningsLockout'] == true,
daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null,
passedDividendGate: json['passedDividendGate'] == true,
daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null,
universeSource: UniverseSource.fromJson(json['universeSource']?.toString()),
universeEnteredAtUtc: json['universeEnteredAtUtc'] == null ? null : parseDate(json['universeEnteredAtUtc']),
passedSimulationVeto: json['passedSimulationVeto'] == true,
passedAiValidation: json['passedAiValidation'] == true,
aiThesisSummary: json['aiThesisSummary']?.toString() ?? '',
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
triggerSource: TriggerSource.fromJson(json['triggerSource']?.toString()),
triggeredByUserId: json['triggeredByUserId']?.toString(),
proposalId: json['proposalId']?.toString(),
evaluatedAtUtc: parseDate(json['evaluatedAtUtc']),
);
}
@override
List<Object?> get props => [
id,
isin,
symbol,
technicalScore,
sentimentScore,
fundamentalScore,
compositeOpportunityScore,
reliabilityBonus,
passedEarningsLockout,
daysToNextEarnings,
passedDividendGate,
daysToNextExDividend,
universeSource,
universeEnteredAtUtc,
passedSimulationVeto,
passedAiValidation,
aiThesisSummary,
outcomeReason,
triggerSource,
triggeredByUserId,
proposalId,
evaluatedAtUtc,
];
}
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
/// Mirrors `FinlyticCore.Dtos.Trading.OutcomeReason` (`TradeEnums.cs`), which the
/// `/api/v1/admin/evaluations` endpoint serializes as a `JsonStringEnumConverter`
/// string using the exact C# member name (e.g. `"Approved"`, `"BelowScoreThreshold"`).
///
/// [unknown] is the fallback both for the server's own `Unknown = 0` default (an
/// honest "we don't know" rather than a fabricated reason, Rules.md §4) and for any
/// future server-side member this client doesn't recognize yet.
enum OutcomeReason {
unknown,
approved,
belowScoreThreshold,
earningsLockout,
simulationVeto,
aiRejected,
noTechnicalSetups,
duplicateActiveProposal,
dividendGate;
static OutcomeReason fromJson(String? raw) {
switch (raw) {
case 'Approved':
return OutcomeReason.approved;
case 'BelowScoreThreshold':
return OutcomeReason.belowScoreThreshold;
case 'EarningsLockout':
return OutcomeReason.earningsLockout;
case 'SimulationVeto':
return OutcomeReason.simulationVeto;
case 'AiRejected':
return OutcomeReason.aiRejected;
case 'NoTechnicalSetups':
return OutcomeReason.noTechnicalSetups;
case 'DuplicateActiveProposal':
return OutcomeReason.duplicateActiveProposal;
case 'DividendGate':
return OutcomeReason.dividendGate;
case 'Unknown':
default:
return OutcomeReason.unknown;
}
}
/// Exact server-side enum member name. `[FromQuery] OutcomeReason?` on
/// `AdminEvaluationHistoryController` model-binds a bare enum member name from
/// the query string (ASP.NET Core's default `Enum.TryParse`-based binder), not a
/// JSON string — so this is what must be sent back as the `outcome` filter value.
String toApiValue() {
switch (this) {
case OutcomeReason.approved:
return 'Approved';
case OutcomeReason.belowScoreThreshold:
return 'BelowScoreThreshold';
case OutcomeReason.earningsLockout:
return 'EarningsLockout';
case OutcomeReason.simulationVeto:
return 'SimulationVeto';
case OutcomeReason.aiRejected:
return 'AiRejected';
case OutcomeReason.noTechnicalSetups:
return 'NoTechnicalSetups';
case OutcomeReason.duplicateActiveProposal:
return 'DuplicateActiveProposal';
case OutcomeReason.dividendGate:
return 'DividendGate';
case OutcomeReason.unknown:
return 'Unknown';
}
}
String get label {
switch (this) {
case OutcomeReason.approved:
return 'Freigegeben';
case OutcomeReason.belowScoreThreshold:
return 'Score zu niedrig';
case OutcomeReason.earningsLockout:
return 'Earnings-Sperre';
case OutcomeReason.simulationVeto:
return 'Simulation-Veto';
case OutcomeReason.aiRejected:
return 'KI abgelehnt';
case OutcomeReason.noTechnicalSetups:
return 'Kein Setup';
case OutcomeReason.duplicateActiveProposal:
return 'Bereits aktiver Vorschlag';
case OutcomeReason.dividendGate:
return 'Dividend-Sperre';
case OutcomeReason.unknown:
return 'Unbekannt';
}
}
/// Color-coding for the history-list badge, reusing only colors already
/// established elsewhere in the app (`AppTheme.primaryEmerald`/`accentRed` plus
/// the `Colors.amber`/`Colors.purpleAccent` already used by
/// `EvaluationScoreBreakdownSheet`) rather than introducing a new palette.
Color get color {
switch (this) {
case OutcomeReason.approved:
return AppTheme.primaryEmerald;
case OutcomeReason.aiRejected:
case OutcomeReason.simulationVeto:
return AppTheme.accentRed;
case OutcomeReason.belowScoreThreshold:
case OutcomeReason.earningsLockout:
case OutcomeReason.duplicateActiveProposal:
case OutcomeReason.dividendGate:
return Colors.amber;
case OutcomeReason.noTechnicalSetups:
case OutcomeReason.unknown:
return AppTheme.textMuted;
}
}
}
/// Mirrors `FinlyticCore.Dtos.Trading.TriggerSource`.
enum TriggerSource {
unknown,
automatic,
manual;
static TriggerSource fromJson(String? raw) {
switch (raw) {
case 'Automatic':
return TriggerSource.automatic;
case 'Manual':
return TriggerSource.manual;
case 'Unknown':
default:
return TriggerSource.unknown;
}
}
String toApiValue() {
switch (this) {
case TriggerSource.automatic:
return 'Automatic';
case TriggerSource.manual:
return 'Manual';
case TriggerSource.unknown:
return 'Unknown';
}
}
String get label {
switch (this) {
case TriggerSource.automatic:
return 'Automatisch';
case TriggerSource.manual:
return 'Manuell';
case TriggerSource.unknown:
return 'Unbekannt';
}
}
Color get color {
switch (this) {
case TriggerSource.automatic:
return AppTheme.accentCyan;
case TriggerSource.manual:
return Colors.purpleAccent;
case TriggerSource.unknown:
return AppTheme.textMuted;
}
}
}
/// Mirrors `FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource` - which recurring
/// FinlyticTechnicals selection mechanism added the ISIN to the continuously
/// scanned universe before this evaluation ran. `null` on the Dart side (not
/// modeled as its own enum value here) means the evaluation happened outside
/// that universe entirely (e.g. a manual "Analyze now" call).
enum UniverseSource {
sentimentSpike,
userFavorite,
discovery;
static UniverseSource? fromJson(String? raw) {
switch (raw) {
case 'SentimentSpike':
return UniverseSource.sentimentSpike;
case 'UserFavorite':
return UniverseSource.userFavorite;
case 'Discovery':
return UniverseSource.discovery;
default:
return null;
}
}
String get label {
switch (this) {
case UniverseSource.sentimentSpike:
return 'Sentiment-Spike';
case UniverseSource.userFavorite:
return 'Nutzer-Favorit';
case UniverseSource.discovery:
return 'Discovery-Liste';
}
}
}
@@ -0,0 +1,44 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_entry_model.dart';
import 'evaluation_history_summary_model.dart';
/// Typed mirror of `FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse` — the
/// full response body of `GET /api/v1/admin/evaluations`.
class EvaluationHistoryResponseModel extends Equatable {
final int totalCount;
final List<EvaluationHistoryEntryModel> entries;
final EvaluationHistorySummaryModel summary;
const EvaluationHistoryResponseModel({
required this.totalCount,
required this.entries,
required this.summary,
});
factory EvaluationHistoryResponseModel.empty() => EvaluationHistoryResponseModel(
totalCount: 0,
entries: const [],
summary: EvaluationHistorySummaryModel.empty(),
);
factory EvaluationHistoryResponseModel.fromJson(Map<String, dynamic> json) {
final rawEntries = json['entries'];
final entries = rawEntries is List
? rawEntries.whereType<Map<String, dynamic>>().map(EvaluationHistoryEntryModel.fromJson).toList()
: <EvaluationHistoryEntryModel>[];
final rawSummary = json['summary'];
final summary = rawSummary is Map<String, dynamic>
? EvaluationHistorySummaryModel.fromJson(rawSummary)
: EvaluationHistorySummaryModel.empty();
return EvaluationHistoryResponseModel(
totalCount: json['totalCount'] is num ? (json['totalCount'] as num).toInt() : 0,
entries: entries,
summary: summary,
);
}
@override
List<Object?> get props => [totalCount, entries, summary];
}
@@ -0,0 +1,84 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_enums.dart';
/// Typed mirror of `FinlyticCore.Dtos.Trading.OutcomeReasonCountDto`.
class OutcomeReasonCountModel extends Equatable {
final OutcomeReason outcomeReason;
final int count;
const OutcomeReasonCountModel({required this.outcomeReason, required this.count});
factory OutcomeReasonCountModel.fromJson(Map<String, dynamic> json) {
return OutcomeReasonCountModel(
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
count: json['count'] is num ? (json['count'] as num).toInt() : 0,
);
}
@override
List<Object?> get props => [outcomeReason, count];
}
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistorySummaryDto` — the
/// pre-aggregated headline numbers for the admin evaluation-history tab. Every
/// field except [lastProposalCreatedAtUtc] is scoped to the same filters as the
/// paginated entry list it accompanies; [lastProposalCreatedAtUtc] deliberately
/// ignores the from/to filters (see the server-side DTO doc comment) so the admin
/// always sees "how long since the last real proposal" regardless of which
/// historical window is currently selected.
class EvaluationHistorySummaryModel extends Equatable {
final int totalEvaluations;
final List<OutcomeReasonCountModel> countsByOutcome;
final double averageCompositeScore;
final int proposalsCreated;
final DateTime? lastProposalCreatedAtUtc;
const EvaluationHistorySummaryModel({
required this.totalEvaluations,
required this.countsByOutcome,
required this.averageCompositeScore,
required this.proposalsCreated,
this.lastProposalCreatedAtUtc,
});
int countFor(OutcomeReason reason) {
for (final c in countsByOutcome) {
if (c.outcomeReason == reason) return c.count;
}
return 0;
}
factory EvaluationHistorySummaryModel.empty() => const EvaluationHistorySummaryModel(
totalEvaluations: 0,
countsByOutcome: [],
averageCompositeScore: 0,
proposalsCreated: 0,
lastProposalCreatedAtUtc: null,
);
factory EvaluationHistorySummaryModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
final rawCounts = json['countsByOutcome'];
final counts = rawCounts is List
? rawCounts.whereType<Map<String, dynamic>>().map(OutcomeReasonCountModel.fromJson).toList()
: <OutcomeReasonCountModel>[];
final rawLast = json['lastProposalCreatedAtUtc'];
return EvaluationHistorySummaryModel(
totalEvaluations: json['totalEvaluations'] is num ? (json['totalEvaluations'] as num).toInt() : 0,
countsByOutcome: counts,
averageCompositeScore: parseDbl(json['averageCompositeScore']),
proposalsCreated: json['proposalsCreated'] is num ? (json['proposalsCreated'] as num).toInt() : 0,
lastProposalCreatedAtUtc: rawLast != null ? DateTime.tryParse(rawLast.toString())?.toUtc() : null,
);
}
@override
List<Object?> get props => [totalEvaluations, countsByOutcome, averageCompositeScore, proposalsCreated, lastProposalCreatedAtUtc];
}
@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
/// Typed mirror of the fields the admin UI needs from
/// `FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto`, as returned by
/// `GET /api/v1/admin/evaluations/watchlist/{isin}/history` — the last N
/// technical-analysis setups computed for one ISIN, most recent first, so the
/// score trend (improving/worsening, and whether it ever cleared the engine's
/// top-pick bar) is visible even for setups too weak to ever reach the engine.
class RecentSetupModel extends Equatable {
final String strategyName;
final double qualityScore;
final bool isTopPick;
final String rating;
final DateTime createdAt;
const RecentSetupModel({
required this.strategyName,
required this.qualityScore,
required this.isTopPick,
required this.rating,
required this.createdAt,
});
factory RecentSetupModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return RecentSetupModel(
strategyName: json['strategyName']?.toString() ?? '',
qualityScore: parseDbl(json['qualityScore']),
isTopPick: json['isTopPick'] == true,
rating: json['rating']?.toString() ?? '',
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
);
}
@override
List<Object?> get props => [strategyName, qualityScore, isTopPick, rating, createdAt];
}
@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
import 'evaluation_history_enums.dart';
/// Typed mirror of `FinlyticCore.Dtos.TechnicalAnalysis.WatchlistEntryDto` — one
/// row of `GET /api/v1/admin/evaluations/watchlist`: an asset FinlyticTechnicals'
/// background scanner is actually evaluating every cycle, independent of
/// whether it has produced any evaluation the engine ever saw.
class WatchlistEntryModel extends Equatable {
final String isin;
final String? symbol;
final UniverseSource? source;
final int priority;
final DateTime addedAtUtc;
final DateTime? expiresAtUtc;
const WatchlistEntryModel({
required this.isin,
this.symbol,
this.source,
required this.priority,
required this.addedAtUtc,
this.expiresAtUtc,
});
factory WatchlistEntryModel.fromJson(Map<String, dynamic> json) {
DateTime parseDate(dynamic val) {
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
}
return WatchlistEntryModel(
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString(),
source: UniverseSource.fromJson(json['source']?.toString()),
priority: json['priority'] is num ? (json['priority'] as num).toInt() : 0,
addedAtUtc: parseDate(json['addedAtUtc']),
expiresAtUtc: json['expiresAtUtc'] == null ? null : parseDate(json['expiresAtUtc']),
);
}
@override
List<Object?> get props => [isin, symbol, source, priority, addedAtUtc, expiresAtUtc];
}