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
@@ -1,5 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../trades/models/trade_model.dart';
import 'asset_trades_event.dart';
import 'asset_trades_state.dart';
import '../../repositories/asset_repository.dart';
@@ -20,28 +19,23 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
on<TriggerManualAnalysis>((event, emit) async {
emit(AssetTradesLoading());
try {
final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
// Server contract: always 200 -> AssetEvaluationResultDto, whether the
// pipeline produced a proposal or rejected the opportunity. The trade
// list itself is unaffected until the user actually accepts a
// proposal, so it is simply reloaded as-is; the analysis result is
// surfaced separately for the UI to react to exactly once.
final result = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
final existingTrades = await repository.getAssetTrades(event.isin, null);
final list = List<TradeModel>.from(existingTrades);
final newProposal = analysisRes?.proposal;
if (newProposal != null) {
final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId));
if (!isDuplicate) {
list.insert(0, newProposal);
}
}
emit(AssetTradesLoaded(list));
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
} catch (e) {
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
}
});
on<RejectTradeEvent>((event, emit) async {
try {
await repository.rejectTrade(event.tradeId);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to reject trade: $e"));
on<DismissTradeEvent>((event, emit) {
// Purely local: no server call, see DismissTradeEvent doc comment.
final current = state;
if (current is AssetTradesLoaded) {
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
}
});
on<AcceptTradeEvent>((event, emit) async {
@@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
emit(AssetTradesError("Failed to accept trade: $e"));
}
});
on<AddTradeFillEvent>((event, emit) async {
try {
await repository.addTradeFill(event.tradeId, executedPrice: event.executedPrice, quantity: event.quantity);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to update trade execution: $e"));
}
});
on<CloseTradeEvent>((event, emit) async {
try {
await repository.closeTrade(event.tradeId, event.exitPrice);
@@ -13,16 +13,34 @@ class TriggerManualAnalysis extends AssetTradesEvent {
final ManualAnalysisRequestDto? payload;
TriggerManualAnalysis(this.isin, {this.payload});
}
class RejectTradeEvent extends AssetTradesEvent {
/// Dismisses a trade proposal from the locally displayed list only.
///
/// There is no server-side "reject" anymore: a proposal is a system-wide
/// opportunity that any user may accept independently, so rejecting it has
/// no server-side meaning. This purely removes the card from the current
/// in-memory list; the proposal keeps existing server-side until its 24h
/// TTL expires, so it can reappear after the next reload (Rules.md §4 —
/// no fabricated "permanently rejected" state is invented).
class DismissTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
RejectTradeEvent(this.tradeId, this.isin);
DismissTradeEvent(this.tradeId);
}
class AcceptTradeEvent extends AssetTradesEvent {
final TradeAcceptanceDto tradeAcceptanceDto;
final String isin;
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
}
/// Records a corrective/additional fill against an already-active trade
/// (review-execution path). Distinct from [AcceptTradeEvent], which targets
/// a proposal, not an existing trade — see `AssetRepository.addTradeFill`.
class AddTradeFillEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
final double executedPrice;
final double quantity;
AddTradeFillEvent(this.tradeId, this.isin, this.executedPrice, this.quantity);
}
class CloseTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
@@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {}
class AssetTradesLoading extends AssetTradesState {}
class AssetTradesLoaded extends AssetTradesState {
final List<TradeModel> data;
AssetTradesLoaded(this.data);
/// Transient result of a just-triggered manual analysis. Only set on the
/// state instance emitted directly by `TriggerManualAnalysis` — a plain
/// reload/dismiss/accept emits a fresh `AssetTradesLoaded` without it, so a
/// `BlocConsumer` listener naturally reacts to it exactly once instead of
/// on every rebuild.
///
/// Always fully populated when set: the server contract no longer has a
/// silent "204, no proposal" outcome, so unlike the old
/// `manualAnalysisProposal`/`manualAnalysisEmpty` pair, a single non-null
/// value here already tells the caller everything — check
/// `manualAnalysisResult!.hasProposal` to distinguish an accepted
/// opportunity from a rejected one with real scores/AI reasoning attached
/// (Rules.md §4).
final AssetEvaluationResultModel? manualAnalysisResult;
AssetTradesLoaded(this.data, {this.manualAnalysisResult});
}
class AssetTradesError extends AssetTradesState {
final String message;
@@ -1,180 +0,0 @@
import 'package:equatable/equatable.dart';
import '../../trades/models/trade_model.dart';
class ExecutionPlanModel extends Equatable {
final double stopLoss;
final List<double> takeProfitTargets;
final double riskRewardRatio;
final double maxLeverage;
const ExecutionPlanModel({
this.stopLoss = 0.0,
this.takeProfitTargets = const [],
this.riskRewardRatio = 0.0,
this.maxLeverage = 1.0,
});
factory ExecutionPlanModel.fromJson(Map<String, dynamic> json) {
double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0;
return ExecutionPlanModel(
stopLoss: parseDbl(json['stopLoss']),
takeProfitTargets: (json['takeProfitTargets'] as List<dynamic>? ?? []).map((e) => parseDbl(e)).toList(),
riskRewardRatio: parseDbl(json['riskRewardRatio']),
maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']),
);
}
@override
List<Object?> get props => [stopLoss, takeProfitTargets, riskRewardRatio, maxLeverage];
}
class DetailedAnalysisModel extends Equatable {
final String technicalRationale;
final String fundamentalRationale;
final String riskWarning;
const DetailedAnalysisModel({
this.technicalRationale = '',
this.fundamentalRationale = '',
this.riskWarning = '',
});
factory DetailedAnalysisModel.fromJson(Map<String, dynamic> json) {
return DetailedAnalysisModel(
technicalRationale: json['technicalRationale']?.toString() ?? '',
fundamentalRationale: json['fundamentalRationale']?.toString() ?? '',
riskWarning: json['riskWarning']?.toString() ?? '',
);
}
@override
List<Object?> get props => [technicalRationale, fundamentalRationale, riskWarning];
}
class N8nAnalysisResponseDto extends Equatable {
final String aiDecision; // "Proceed", "Rejected", "Hold"
final String aiReasoning;
final int evalScore;
final String suggestedDirection; // "Long", "Short"
final String suggestedRisk;
final String suggestedTimeframe;
final ExecutionPlanModel? executionPlan;
final DetailedAnalysisModel? detailedAnalysis;
const N8nAnalysisResponseDto({
this.aiDecision = 'Rejected',
this.aiReasoning = '',
this.evalScore = 0,
this.suggestedDirection = 'Long',
this.suggestedRisk = 'Moderate',
this.suggestedTimeframe = '1D',
this.executionPlan,
this.detailedAnalysis,
});
factory N8nAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
ExecutionPlanModel? execPlan;
if (json['executionPlan'] != null && json['executionPlan'] is Map<String, dynamic>) {
execPlan = ExecutionPlanModel.fromJson(json['executionPlan']);
}
DetailedAnalysisModel? detailAnalysis;
if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map<String, dynamic>) {
detailAnalysis = DetailedAnalysisModel.fromJson(json['detailedAnalysis']);
}
return N8nAnalysisResponseDto(
aiDecision: json['aiDecision']?.toString() ?? 'Rejected',
aiReasoning: json['aiReasoning']?.toString() ?? '',
evalScore: (json['evalScore'] as num?)?.toInt() ?? 0,
suggestedDirection: json['suggestedDirection']?.toString() ?? 'Long',
suggestedRisk: json['suggestedRisk']?.toString() ?? 'Moderate',
suggestedTimeframe: json['suggestedTimeframe']?.toString() ?? '1D',
executionPlan: execPlan,
detailedAnalysis: detailAnalysis,
);
}
@override
List<Object?> get props => [
aiDecision,
aiReasoning,
evalScore,
suggestedDirection,
suggestedRisk,
suggestedTimeframe,
executionPlan,
detailedAnalysis,
];
}
class ManualAnalysisResponseDto extends Equatable {
final String analysisId;
final bool isTradeProposed;
final String status;
final String recommendation; // "RECOMMENDED", "NOT_RECOMMENDED"
final N8nAnalysisResponseDto? n8nResponse;
final TradeModel? proposal;
final String message;
const ManualAnalysisResponseDto({
required this.analysisId,
this.isTradeProposed = false,
this.status = 'Success',
this.recommendation = 'NOT_RECOMMENDED',
this.n8nResponse,
this.proposal,
this.message = '',
});
factory ManualAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
N8nAnalysisResponseDto? n8n;
if (json['n8nResponse'] != null && json['n8nResponse'] is Map<String, dynamic>) {
n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']);
}
TradeModel? prop;
if (json['proposal'] != null && json['proposal'] is Map<String, dynamic>) {
prop = TradeModel.fromJson(json['proposal']);
} else if (n8n != null) {
final exec = n8n.executionPlan;
final det = n8n.detailedAnalysis;
final isProceed = n8n.aiDecision.toLowerCase() == 'proceed';
final analysisIdStr = (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '';
final tradeIdStr = 'PROP-${analysisIdStr.length > 10 ? analysisIdStr.substring(0, 10).toUpperCase() : 'MANUAL'}';
prop = TradeModel(
id: tradeIdStr,
analysisId: analysisIdStr,
symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? '',
isin: (json['isin'] ?? json['Isin'])?.toString() ?? '',
status: isProceed ? 'Proposed' : 'Rejected',
signalType: n8n.suggestedDirection.toUpperCase() == 'SHORT' ? 'SELL' : 'BUY',
entryPrice: 0.0,
stopLoss: exec?.stopLoss ?? 0.0,
takeProfit: (exec?.takeProfitTargets.isNotEmpty ?? false) ? exec!.takeProfitTargets.first : 0.0,
reasoning: n8n.aiReasoning,
technicalRationale: det?.technicalRationale ?? '',
fundamentalRationale: det?.fundamentalRationale ?? '',
riskWarning: det?.riskWarning ?? '',
takeProfitTargets: exec?.takeProfitTargets ?? const [],
maxLeverage: exec?.maxLeverage ?? 1.0,
riskTolerance: n8n.suggestedRisk,
timeframe: n8n.suggestedTimeframe,
);
}
return ManualAnalysisResponseDto(
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
isTradeProposed: json['isTradeProposed'] == true || json['IsTradeProposed'] == true,
status: (json['status'] ?? json['Status'])?.toString() ?? 'Success',
recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'NOT_RECOMMENDED',
n8nResponse: n8n,
proposal: prop,
message: (json['message'] ?? json['Message'])?.toString() ?? '',
);
}
@override
List<Object?> get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message];
}
@@ -2,7 +2,6 @@ import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
@@ -102,23 +101,47 @@ class AssetRepository {
return null;
}
Future<double?> getLivePrice(String isin) async {
try {
final res = await apiClient.get('/api/v1/assets/$isin/live');
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
if (val is num) return val.toDouble();
if (val != null) return double.tryParse(val.toString());
}
} catch (_) {}
return null;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
return _tradeRepository.fetchTrades(isin: isin, status: status);
}
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
/// Triggers an on-demand manual analysis for [isin] via `POST /api/v1/analyze/manual`.
///
/// Server contract: always `200 OK` with a full `AssetEvaluationResultDto`
/// body — even when the analysis ran but did not clear the bar for a trade
/// proposal (`AssetEvaluationResultModel.proposal == null`), the response
/// still carries the real, already-computed scores and AI reasoning, so
/// there is no more silent `204 No Content` outcome to handle here
/// (Rules.md §4). A non-2xx status (missing ISIN, engine unreachable, no
/// RPC response, unexpected error) surfaces as a `DioException` that
/// propagates to the caller instead of being swallowed into `null`.
Future<AssetEvaluationResultModel> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
final body = payload != null ? payload.toJson() : {'isin': isin};
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return ManualAnalysisResponseDto.fromJson(res.data);
if (res.data != null && res.data is Map<String, dynamic>) {
return AssetEvaluationResultModel.fromJson(res.data);
}
return null;
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
}
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
Future<void> closeTrade(String tradeId, double exitPrice) async =>
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
Future<TradeModel> addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async =>
_tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity);
}
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
import '../../../bot/repositories/bot_repository.dart';
import '../../../proposals/views/proposal_decision_screen.dart';
import '../../../trades/models/trade_model.dart';
import '../../../trades/widgets/trade_execution_cockpit.dart';
import '../../../trades/widgets/trade_closing_cockpit.dart';
@@ -22,11 +26,12 @@ class TradesTab extends StatefulWidget {
}
class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false;
late final BotRepository _botRepository;
@override
void initState() {
super.initState();
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@@ -39,8 +44,23 @@ class _TradesTabState extends State<TradesTab> {
defaultSymbol: widget.symbol,
isActive: isActive,
onAccept: (dto) {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
final tId = trade.id;
// `TradeExecutionCockpit._buildDto()` already picks the right identifier
// (trade.id for isActive, trade.proposalId otherwise) and always fills
// actualEntryPrice/quantity from the two fields the dialog actually
// collects — but the two identifiers target different server-side
// operations: accepting a *proposal* vs. recording a fill against an
// already-*existing* trade (`UserTradesController.AcceptTrade` looks
// `dto.tradeId` up as a proposal id, which fails for an active trade's
// own id). Route accordingly instead of always calling AcceptTradeEvent.
if (isActive) {
final price = dto.actualEntryPrice ?? dto.entryPrice;
final qty = dto.quantity ?? dto.positionSize;
if (price == null || qty == null) return;
tradesBloc.add(AddTradeFillEvent(tId, widget.symbol, price, qty));
} else {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
@@ -50,10 +70,15 @@ class _TradesTabState extends State<TradesTab> {
);
},
onReject: (tId) {
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
// Purely local dismissal — there is no server-side rejection (a
// proposal is a system-wide opportunity anyone may still accept).
// Wording must not claim a permanence the backend doesn't provide.
tradesBloc.add(DismissTradeEvent(tId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tId abgelehnt.'),
content: const Text(
'Vorschlag ausgeblendet er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
),
backgroundColor: AppTheme.textSecondary,
behavior: SnackBarBehavior.floating,
),
@@ -62,20 +87,101 @@ class _TradesTabState extends State<TradesTab> {
);
}
Future<void> _executeProposalViaBot(BuildContext context, TradeProposalModel proposal) async {
Navigator.of(context).pop();
try {
await _botRepository.executeProposal(proposal.proposalId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler bei der Bot-Übergabe: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
}
void _showProposalDecision(BuildContext context, TradeProposalModel proposal) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProposalDecisionScreen(
proposal: proposal,
onExecuteBot: () => _executeProposalViaBot(context, proposal),
onManualTrade: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
behavior: SnackBarBehavior.floating,
),
);
},
),
),
);
}
/// Shows the real, already-computed score breakdown and AI reasoning for a
/// manual analysis that ran but did not produce a trade proposal
/// ([AssetEvaluationResultModel.proposal] is `null`). Replaces the old bare
/// "kein Vorschlag" snackbar: the user gets to see *why* the opportunity
/// was rejected, not just *that* it was (Rules.md §4). Every value shown
/// here comes straight from the server response — nothing is invented, and
/// [AssetEvaluationResultModel.daysToNextEarnings] is only rendered when
/// the server actually sent a value.
void _showEvaluationRejectedSheet(BuildContext context, AssetEvaluationResultModel result) {
EvaluationScoreBreakdownSheet.show(
context,
title: 'Analyse abgeschlossen kein Vorschlag',
subtitle:
'Für ${widget.symbol} wurde keine aktive Trade-Empfehlung erzeugt. Die berechneten Werte und die KI-Begründung stehen unten.',
headerIcon: result.aiApproved ? Icons.psychology_outlined : Icons.block_outlined,
headerColor: result.aiApproved ? AppTheme.primaryEmerald : AppTheme.accentRed,
compositeScore: result.compositeScore,
technicalScore: result.technicalScore,
sentimentScore: result.sentimentScore,
fundamentalScore: result.fundamentalScore,
passedEarningsLockout: result.passedEarningsLockout,
daysToNextEarnings: result.daysToNextEarnings,
passedDividendGate: result.passedDividendGate,
daysToNextExDividend: result.daysToNextExDividend,
reasoningLabel: result.aiApproved ? 'KI-These' : 'Ablehnungsgrund',
reasoningText: result.aiThesisSummary,
identifiedRisks: result.aiIdentifiedRisks,
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
listener: (context, state) {
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
final List<TradeModel> tradesList = state.data;
if (tradesList.isNotEmpty) {
_justTriggeredAnalysis = false;
final latestTrade = tradesList.first;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showEditTradeExecutionDialog(context, latestTrade);
});
if (state is! AssetTradesLoaded) return;
final result = state.manualAnalysisResult;
if (result == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (result.hasProposal) {
_showProposalDecision(context, result.proposal!);
} else {
// Rejected (or no technical setup at all) - show the real, already
// computed scores and AI reasoning instead of a bare "no proposal"
// snackbar, so the user understands *why*, not just *that*
// (Rules.md §4).
_showEvaluationRejectedSheet(context, result);
}
}
});
},
builder: (context, state) {
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
@@ -117,11 +223,10 @@ class _TradesTabState extends State<TradesTab> {
symbol: widget.symbol,
initialRiskScore: 50.0,
onTrigger: (payload) {
setState(() => _justTriggeredAnalysis = true);
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'),
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
@@ -153,18 +258,12 @@ class _TradesTabState extends State<TradesTab> {
else if (state is AssetTradesLoaded) ...[
_buildTradeList(
'Aktive Trade-Signale & Positionen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
}).toList(),
tradesList.where((t) => t.isActive || t.isProposed).toList(),
),
const SizedBox(height: 20),
_buildTradeList(
'Historische Trades & KI-Bewertungen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
}).toList(),
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
),
],
],
@@ -208,8 +307,7 @@ class _TradesTabState extends State<TradesTab> {
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
final s = trade.status.toUpperCase();
final isActive = s == 'ACTIVE';
final isActive = trade.isActive;
return AssetTradeItemCard(
trade: trade,
@@ -223,7 +321,7 @@ class _TradesTabState extends State<TradesTab> {
trade: trade,
defaultSymbol: widget.symbol,
onClose: (dto) {
final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -4,6 +4,15 @@ import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../trades/models/trade_model.dart';
/// Trade summary card for the asset-detail "Trades" tab.
///
/// Migrated onto `ActiveTradeDto` (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
/// A number of fields this card used to show no longer exist server-side at
/// all (reasoning/technicalRationale/fundamentalRationale/riskWarning,
/// hasPendingExitAlert/pendingExitReason, entryZoneMin/Max, maxLeverage,
/// timeframe/riskTolerance/companyName, closeReason) — those sections were
/// removed rather than kept alive showing an empty/zero placeholder
/// (Rules.md §4).
class AssetTradeItemCard extends StatelessWidget {
final TradeModel trade;
final String defaultSymbol;
@@ -20,43 +29,42 @@ class AssetTradeItemCard extends StatelessWidget {
this.onClose,
});
String _fmt(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? n.toStringAsFixed(2) : val.toString();
}
String _fmt(double val) => val.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol;
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
final status = trade.status.toUpperCase();
final isBuy = side == 'BUY' || side == 'LONG';
final isActive = status == 'ACTIVE';
final isin = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : defaultSymbol;
final isBuy = trade.direction.isLong;
final isActive = trade.isActive;
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
final takeProfit = trade.takeProfit;
final takeProfitTargets = trade.takeProfitTargets;
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0)
? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2)
: null;
final maxLeverage = trade.maxLeverage;
// Entry price: the real fill-weighted average the engine already
// computed, not a planned/target zone (that concept no longer exists
// server-side).
final entryPrice = trade.averageBuyIn;
final actualEntry = trade.actualEntryPrice;
final posSize = trade.positionSize;
final levUsed = trade.leverageUsed;
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
final entryFee = trade.entryFee;
final exitFee = trade.exitFee;
// Live protective stop: `currentStopLoss` (not `initialStopLoss`) is
// used here because this card shows the trade's live state — the
// current stop already reflects any break-even/trailing adjustment the
// engine has made. `initialStopLoss` (the original plan value) is only
// relevant historically and is shown in the trade detail view instead.
final stopLoss = trade.currentStopLoss;
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
final tpStages = trade.exitPlan.takeProfitStages;
// Server-computed reward:risk multiple for the first take-profit stage —
// used instead of a client-side recomputation from raw prices.
final primaryRMultiple = tpStages.isNotEmpty ? tpStages.first.rMultiple : null;
final investedCapital = entryPrice > 0 && trade.totalQuantity > 0 ? entryPrice * trade.totalQuantity : null;
// Never recomputed from raw prices client-side — always the server's
// own figure (realized once resolved, otherwise its live unrealized
// value; see `TradeModel.pnlEur`).
final pnlEur = trade.pnlEur;
final pnlPercent = trade.unrealizedPnlPercent;
final isPnlWin = pnlEur >= 0;
final pnlColor = isPnlWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
final showPnl = isActive || trade.isClosed;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
@@ -71,25 +79,26 @@ class AssetTradeItemCard extends StatelessWidget {
children: [
Row(
children: [
StatusBadge(label: side, color: sideColor),
StatusBadge(label: trade.direction.label, color: sideColor),
const SizedBox(width: 8),
StatusBadge(
label: status,
color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted),
label: trade.status.label,
color: isActive ? AppTheme.primaryEmerald : (trade.isProposed ? AppTheme.accentCyan : AppTheme.textMuted),
),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(
trade.derivativeIsin.isNotEmpty ? '${trade.instrumentType} (${trade.derivativeIsin})' : trade.instrumentType,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(
trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty
? '${trade.instrumentType.label} (${trade.derivativeIsin})'
: trade.instrumentType.label,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
Row(
@@ -120,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
] else if (trade.isProposed) ...[
if (onAccept != null)
ElevatedButton.icon(
onPressed: onAccept,
@@ -146,55 +155,14 @@ class AssetTradeItemCard extends StatelessWidget {
_buildDriftRadarBar(trade),
],
// Pending Exit Alert Banner
if (isActive && trade.hasPendingExitAlert) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('KI-Guardian Ratschlag: Position schließen!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12)),
if (trade.pendingExitReason.isNotEmpty)
Text(trade.pendingExitReason, style: const TextStyle(color: Colors.white70, fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis),
],
),
),
if (onClose != null)
ElevatedButton(
onPressed: onClose,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
),
],
const SizedBox(height: 12),
Text(
'${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}',
'${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol} ($isin)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(height: 12),
// Target Price Metrics Grid
// Price Metrics Grid
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -207,22 +175,26 @@ class AssetTradeItemCard extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Einstiegskurs', '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${_fmt(stopLoss)}',
AppTheme.accentRed,
),
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
_buildTradeStat(
'Take-Profit',
tpStages.isNotEmpty ? tpStages.map((s) => '${_fmt(s.targetPrice)}').join(' / ') : 'Kein Fixziel (Trailing-Exit)',
AppTheme.primaryEmerald,
),
],
),
if (crv != null || maxLeverage > 0) ...[
const Divider(color: Colors.white12, height: 16),
if (primaryRMultiple != null) ...[
const Divider(color: Colors.white10, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
_buildTradeStat('Chance-Risiko (TP1, R-Multiple)', '${_fmt(primaryRMultiple)}R', AppTheme.accentCyan),
if (investedCapital != null) _buildTradeStat('Eingesetztes Kapital', '${_fmt(investedCapital)}', Colors.white70),
],
),
],
@@ -230,8 +202,8 @@ class AssetTradeItemCard extends StatelessWidget {
),
),
// Execution Details if active
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
// Position size / quantity
if (trade.totalQuantity > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
@@ -240,84 +212,62 @@ class AssetTradeItemCard extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Expanded(
child: Text(
'Stückzahl: ${_fmt(trade.totalQuantity)}${trade.isDerivative ? ' (Derivat)' : ''}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white),
),
),
],
),
),
],
// PnL (server-computed, never recalculated client-side)
if (showPnl) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pnlColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
Icon(isPnlWin ? Icons.trending_up : Icons.trending_down, size: 16, color: pnlColor),
const SizedBox(width: 6),
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
Text(
trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL (unrealisiert):',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Tatsächl. Einstieg', '${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
_buildTradeStat('Investition', posSize > 0 ? '${_fmt(posSize)}' : 'N/A', Colors.white),
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
_buildTradeStat('Aktueller Kurs', '${_fmt(trade.currentPrice)}', Colors.white),
_buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor),
_buildTradeStat('PnL (%)', '${pnlPercent >= 0 ? "+" : ""}${_fmt(pnlPercent)}%', pnlColor),
],
),
if (entryFee > 0 || exitFee > 0) ...[
const SizedBox(height: 6),
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
],
),
),
],
// Realized PnL if closed
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
const SizedBox(height: 12),
Builder(
builder: (context) {
final pnlVal = trade.calculatedPnlAbs;
final pnlPctVal = trade.calculatedPnlPct;
final isWin = pnlVal >= 0;
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color),
const SizedBox(width: 6),
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white),
_buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color),
_buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed),
],
),
if (trade.closeReason.isNotEmpty) ...[
const SizedBox(height: 6),
Text('Grund: ${trade.closeReason}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
],
),
);
},
),
],
// KI Timeline Expansion
if (trade.hourlyUpdates.isNotEmpty) ...[
// Execution history (replaces the removed AI-Guardian hourly
// check-in timeline, which no backend DTO produces anymore —
// this is the trade's real fill history instead).
if (trade.fills.isNotEmpty) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
@@ -325,10 +275,10 @@ class AssetTradeItemCard extends StatelessWidget {
dense: true,
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
title: Text(
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
'Ausführungshistorie (${trade.fills.length} Fills)',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
),
children: trade.hourlyUpdates.reversed.take(4).map((u) {
children: trade.fills.reversed.map((f) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(8),
@@ -339,62 +289,27 @@ class AssetTradeItemCard extends StatelessWidget {
child: Row(
children: [
Text(
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
'${f.executedAtUtc.day.toString().padLeft(2, '0')}.${f.executedAtUtc.month.toString().padLeft(2, '0')} '
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (u.recommendation.toLowerCase().contains('close')
? AppTheme.accentRed
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(u.recommendation, style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold)),
),
const SizedBox(width: 8),
Expanded(
child: Text(
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' (Gebühr €${_fmt(f.fee)})' : ''}',
style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (f.note != null && f.note!.isNotEmpty)
Text(f.note!, style: TextStyle(color: AppTheme.textMuted, fontSize: 10, fontStyle: FontStyle.italic)),
],
),
);
}).toList(),
),
],
// AI Analysis Expansion
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 12),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 8),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 8),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 8),
],
if (riskWarning.isNotEmpty) _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
),
@@ -407,11 +322,6 @@ class AssetTradeItemCard extends StatelessWidget {
IconData icon;
switch (t.driftStatus) {
case DriftStatus.exitAlert:
col = AppTheme.accentRed;
label = 'Drift-Radar: Ausstieg empfohlen';
icon = Icons.warning_rounded;
break;
case DriftStatus.trailingActive:
col = AppTheme.accentCyan;
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
@@ -424,7 +334,7 @@ class AssetTradeItemCard extends StatelessWidget {
break;
case DriftStatus.onTrack:
col = AppTheme.primaryEmerald;
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
label = 'Drift-Radar: Prognose intakt';
icon = Icons.radar;
break;
}
@@ -458,16 +368,4 @@ class AssetTradeItemCard extends StatelessWidget {
],
);
}
Widget _buildRationaleBlock(String title, String text, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
const SizedBox(height: 2),
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
],
);
}
}
@@ -10,7 +10,7 @@ class CloseTradeDialog {
required String defaultSymbol,
required void Function(CloseTradeRequestDto) onClose,
}) {
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
final entry = trade.averageBuyIn;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
@@ -37,14 +37,20 @@ class CloseTradeDialog {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
Text(
(trade.derivativeIsin?.isNotEmpty ?? false)
? 'Trade-ID: ${trade.id} | Derivat: ${trade.derivativeIsin} (${trade.instrumentType.label}) | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}'
: 'Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
const SizedBox(height: 16),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Z.B. 105.50',
decoration: InputDecoration(
labelText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Derivat-Verkaufskurs (€)' : 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Gekauft zu €${entry.toStringAsFixed(2)}',
helperText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Gib den Verkaufskurs des Derivats/Zertifikats ein' : null,
),
),
],