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
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,35 @@ class TradeRepository {
}
}
/// Fetches system-wide, currently active trade proposals
/// (`GET /api/v1/user/trades?status=Proposed`).
///
/// Unlike [fetchTrades], the server does **not** return `ActiveTradeDto`
/// (`TradeModel`) for this query: `UserTradesController.GetUserTrades`
/// branches on `status == "Proposed"` and instead calls `engine_GetProposals`,
/// which returns `List<TradeProposalDto>` — a structurally different shape
/// (`proposalId` instead of `id`, no `userId`/PnL fields, since proposals are
/// system-wide opportunities not owned by any user). Parsing that response
/// as `TradeModel` would silently produce garbage/empty fields, so this is a
/// dedicated method that parses `TradeProposalModel` instead of overloading
/// [fetchTrades] for two incompatible server-side contracts.
Future<List<TradeProposalModel>> fetchProposals() async {
try {
final response = await apiClient.get('/api/v1/user/trades', queryParameters: {
'status': 'Proposed',
'_t': DateTime.now().millisecondsSinceEpoch,
});
if (response.statusCode == 200 && response.data != null) {
final List<dynamic> data = response.data;
return data.map((json) => TradeProposalModel.fromJson(Map<String, dynamic>.from(json))).toList();
}
return [];
} catch (e) {
throw Exception('Vorschläge konnten nicht geladen werden: $e');
}
}
Future<List<DerivativeItemModel>> fetchDerivatives(
String isin, {
String optionType = 'long',
@@ -73,12 +102,13 @@ class TradeRepository {
}
}
Future<void> rejectTrade(String tradeId) async {
final response = await apiClient.post('/api/v1/user/trades/$tradeId/reject');
if (response.statusCode != 200) {
throw Exception('Trade konnte nicht abgelehnt werden');
}
}
// NOTE: there is intentionally no `rejectTrade` here anymore. A trade
// proposal is a system-wide opportunity that many users may accept
// independently; "rejecting" it server-side would have no meaning and
// the corresponding endpoint (`POST /api/v1/user/trades/{id}/reject`) has
// been removed. Dismissing a proposal is now a purely local UI action
// (see `AssetTradesBloc`'s `DismissTradeEvent`) — the proposal keeps
// existing server-side until it naturally expires (24h TTL).
Future<void> closeTrade(String id, {CloseTradeRequestDto? dto}) async {
final response = await apiClient.post('/api/v1/user/trades/$id/close', data: dto?.toJson());
@@ -86,5 +116,34 @@ class TradeRepository {
throw Exception('Trade konnte nicht geschlossen werden');
}
}
/// Records an additional/corrective fill against an already-active trade
/// (`EngineController.AddTradeFill` -> `engine_AddFill`). This is the
/// correct server-side counterpart for the "review/edit execution
/// numbers" path on an active trade — unlike `acceptTrade`, which targets
/// a proposal, not an existing trade. `userId`/`tradeId` are always
/// overwritten server-side from the JWT claim/route, never trusted from
/// this payload.
Future<TradeModel> addTradeFill(
String tradeId, {
required double executedPrice,
required double quantity,
double fee = 0,
String? note,
}) async {
final response = await apiClient.post(
'/api/v1/engine/trades/$tradeId/fills',
data: {
'executedPrice': executedPrice,
'quantity': quantity,
'fee': fee,
if (note != null) 'note': note,
},
);
if (response.statusCode == 200 && response.data != null) {
return TradeModel.fromJson(response.data);
}
throw Exception('Ausführung konnte nicht gespeichert werden');
}
}
@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../bot/repositories/bot_repository.dart';
import '../../proposals/views/proposal_decision_screen.dart';
import '../bloc/trade_bloc.dart';
import '../bloc/trade_event.dart';
import '../bloc/trade_state.dart';
@@ -31,13 +34,22 @@ class TradesFeedScreen extends StatelessWidget {
create: (context) => TradeBloc(
repository: TradeRepository(apiClient: apiClient),
)..add(const FetchTrades()),
child: const _TradesFeedScreenContent(),
child: _TradesFeedScreenContent(
apiClient: apiClient,
signalRService: signalRService,
),
);
}
}
class _TradesFeedScreenContent extends StatefulWidget {
const _TradesFeedScreenContent();
final ApiClient apiClient;
final SignalRService signalRService;
const _TradesFeedScreenContent({
required this.apiClient,
required this.signalRService,
});
@override
State<_TradesFeedScreenContent> createState() => _TradesFeedScreenContentState();
@@ -47,10 +59,129 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
String _selectedFilter = 'Offen';
String _searchQuery = '';
final TextEditingController _searchCtrl = TextEditingController();
StreamSubscription<Map<String, dynamic>>? _proposalSubscription;
late final BotRepository _botRepository;
late final TradeRepository _tradeRepository;
// Persistent, REST-backed list of currently active, system-wide trade
// proposals (`GET /api/v1/user/trades?status=Proposed`). This is
// deliberately independent of `TradeBloc`/`allTrades`: proposals are not
// owned by any user, so they never show up in `engine_GetTrades`
// (`ActiveTradeDto`/`TradeModel`), which is what `TradeBloc` fetches. Before
// this list existed, the only way a proposal ever reached the UI was the
// live SignalR push below — a user who wasn't online with the app
// connected at the exact moment a proposal was created would never see it,
// even though it stays valid for up to 24h server-side.
List<TradeProposalModel> _proposals = [];
bool _proposalsLoading = true;
String? _proposalsError;
@override
void initState() {
super.initState();
_botRepository = BotRepository(apiClient: widget.apiClient);
_tradeRepository = TradeRepository(apiClient: widget.apiClient);
_loadProposals();
// Live proposals pushed by the strategy engine (`/hubs/trade-stream`,
// event `ReceiveTradeProposal`) are surfaced immediately as a decision
// screen instead of only appearing once persisted in the trades list.
_proposalSubscription = widget.signalRService.tradeProposalStream.listen((json) {
if (!mounted) return;
try {
final proposal = TradeProposalModel.fromJson(json);
_mergeLiveProposal(proposal);
_showProposalDecision(proposal);
} catch (_) {
// Malformed live payload: ignore rather than show a broken screen.
}
});
}
Future<void> _loadProposals() async {
setState(() {
_proposalsLoading = true;
_proposalsError = null;
});
try {
final proposals = await _tradeRepository.fetchProposals();
if (!mounted) return;
setState(() {
_proposals = proposals;
_proposalsLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_proposalsError = 'Vorschläge konnten nicht geladen werden.';
_proposalsLoading = false;
});
}
}
/// Inserts/updates a proposal received live over SignalR into the
/// persistent list without waiting for a full refetch, so the list stays
/// consistent if it happens to be visible when the push arrives.
void _mergeLiveProposal(TradeProposalModel proposal) {
setState(() {
final idx = _proposals.indexWhere((p) => p.proposalId == proposal.proposalId);
if (idx >= 0) {
_proposals[idx] = proposal;
} else {
_proposals = [proposal, ..._proposals];
}
});
}
void _showProposalDecision(TradeProposalModel proposal) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProposalDecisionScreen(
proposal: proposal,
onExecuteBot: () => _executeProposalViaBot(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,
),
);
},
),
),
);
}
Future<void> _executeProposalViaBot(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,
),
);
}
}
@override
void dispose() {
_searchCtrl.dispose();
_proposalSubscription?.cancel();
super.dispose();
}
@@ -60,7 +191,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
TradeExecutionCockpit.show(
context,
trade: trade,
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
isActive: isActive,
onAccept: (dto) {
tradeBloc.add(AcceptTradeProposalEvent(dto));
@@ -81,7 +212,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
TradeClosingCockpit.show(
context,
trade: trade,
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
onClose: (CloseTradeRequestDto dto) {
tradeBloc.add(CloseTrade(trade.id, dto: dto));
ScaffoldMessenger.of(context).showSnackBar(
@@ -103,179 +234,341 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
child: RefreshIndicator(
onRefresh: () async {
context.read<TradeBloc>().add(const FetchTrades());
await _loadProposals();
},
color: AppTheme.primaryEmerald,
backgroundColor: AppTheme.cardSurface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Live Portfolio & Trades',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 2),
Text(
'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
IconButton(
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Trades Aktualisieren',
),
],
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<TradeBloc, TradeState>(
builder: (context, state) {
if (state is TradeLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
child: BlocBuilder<TradeBloc, TradeState>(
builder: (context, state) {
if (state is TradeLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is TradeError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
if (state is TradeError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: AppTheme.accentRed),
const SizedBox(height: 12),
Text(state.message, style: TextStyle(color: AppTheme.textMuted)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
child: const Text('Erneut Versuchen'),
),
],
),
);
}
if (state is TradeLoaded) {
// `allTrades` comes from `GET /api/v1/user/trades`, which only ever
// returns trades belonging to the signed-in user (own proposals,
// own open positions, own history) — never other users' or
// system-wide data. `bookedTrades` excludes still-open proposals so
// the "Trades" chip's count matches what it actually displays,
// instead of silently disagreeing with the "Vorschläge" chip.
final allTrades = state.trades;
final proposals = allTrades.where((t) => t.isProposed).toList();
final bookedTrades = allTrades.where((t) => !t.isProposed).toList();
final activeTrades = allTrades.where((t) => t.isActive).toList();
final closedTrades = allTrades.where((t) => t.isClosed).toList();
final rejectedTrades = allTrades.where((t) => t.isRejected).toList();
// The "Vorschläge" chip does NOT render `proposals` (derived
// from `allTrades`/`TradeModel`): proposals are system-wide
// opportunities not owned by any user, so `engine_GetTrades`
// (what `TradeBloc`/`allTrades` fetches) never returns them —
// that list is structurally always empty. The real,
// persistent set of active proposals is `_proposals`, fetched
// separately via `GET /api/v1/user/trades?status=Proposed`
// and rendered by `_buildProposalsSliver` below.
final showingProposalsTab = _selectedFilter == 'Vorschläge';
List<TradeModel> filteredList = allTrades;
if (_selectedFilter == 'Trades') {
filteredList = bookedTrades;
} else if (_selectedFilter == 'Offen') {
filteredList = activeTrades;
} else if (_selectedFilter == 'Vorschläge') {
filteredList = const [];
} else if (_selectedFilter == 'Geschlossen') {
filteredList = closedTrades;
} else if (_selectedFilter == 'Abgelehnt') {
filteredList = rejectedTrades;
}
List<TradeProposalModel> filteredProposals = _proposals;
if (_searchQuery.trim().isNotEmpty) {
final q = _searchQuery.toLowerCase().trim();
filteredList = filteredList.where((t) =>
t.symbol.toLowerCase().contains(q) ||
t.underlyingIsin.toLowerCase().contains(q)).toList();
filteredProposals = filteredProposals.where((p) =>
p.symbol.toLowerCase().contains(q) ||
p.underlyingIsin.toLowerCase().contains(q)).toList();
}
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
sliver: SliverList(
delegate: SliverChildListDelegate([
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.error_outline, size: 48, color: AppTheme.accentRed),
const SizedBox(height: 12),
Text(state.message, style: TextStyle(color: AppTheme.textMuted)),
const SizedBox(height: 16),
ElevatedButton(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Live Portfolio & Trades',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 2),
Text(
'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
IconButton(
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
child: const Text('Erneut Versuchen'),
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Trades Aktualisieren',
),
],
),
);
}
if (state is TradeLoaded) {
final allTrades = state.trades;
final proposals = allTrades.where((t) => t.isProposed).toList();
final activeTrades = allTrades.where((t) => t.isActive).toList();
final closedTrades = allTrades.where((t) => t.isClosed).toList();
final rejectedTrades = allTrades.where((t) => t.isRejected).toList();
List<TradeModel> filteredList = allTrades;
if (_selectedFilter == 'Alle') {
filteredList = allTrades.where((t) => !t.isProposed).toList();
} else if (_selectedFilter == 'Offen') {
filteredList = activeTrades;
} else if (_selectedFilter == 'Vorschläge') {
filteredList = proposals;
} else if (_selectedFilter == 'Geschlossen') {
filteredList = closedTrades;
} else if (_selectedFilter == 'Abgelehnt') {
filteredList = rejectedTrades;
}
if (_searchQuery.trim().isNotEmpty) {
final q = _searchQuery.toLowerCase().trim();
filteredList = filteredList.where((t) =>
t.symbol.toLowerCase().contains(q) ||
t.isin.toLowerCase().contains(q) ||
t.companyName.toLowerCase().contains(q)).toList();
}
return ListView(
children: [
TradePerformanceBar(
activeTrades: activeTrades,
allTrades: allTrades,
proposals: proposals,
),
ProposedAutoTradesCard(
proposals: proposals,
onAcceptProposal: (trade) => _handleAcceptProposal(context, trade),
),
Row(
children: [
Expanded(
child: TextField(
controller: _searchCtrl,
onChanged: (val) => setState(() => _searchQuery = val),
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
hintText: 'Suche nach Symbol, ISIN oder Name...',
hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 13),
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.white54),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))),
),
const SizedBox(height: 16),
TradePerformanceBar(
activeTrades: activeTrades,
allTrades: allTrades,
proposals: proposals,
),
ProposedAutoTradesCard(
proposals: proposals,
onAcceptProposal: (trade) => _handleAcceptProposal(context, trade),
),
Row(
children: [
Expanded(
child: TextField(
controller: _searchCtrl,
onChanged: (val) => setState(() => _searchQuery = val),
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
hintText: 'Suche nach Symbol, ISIN oder Name...',
hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 13),
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.white54),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))),
),
),
),
],
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip('Offen', activeTrades.length),
_filterChip('Vorschläge', _proposals.length),
_filterChip('Geschlossen', closedTrades.length),
_filterChip('Abgelehnt', rejectedTrades.length),
_filterChip('Trades', bookedTrades.length),
],
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip('Offen', activeTrades.length),
_filterChip('Vorschläge', proposals.length),
_filterChip('Geschlossen', closedTrades.length),
_filterChip('Abgelehnt', rejectedTrades.length),
_filterChip('Alle', allTrades.length),
],
),
),
const SizedBox(height: 16),
]),
),
),
if (showingProposalsTab)
..._buildProposalsSlivers(filteredProposals)
else if (filteredList.isEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 40),
sliver: SliverToBoxAdapter(
child: Center(
child: Column(
children: [
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
const SizedBox(height: 16),
if (filteredList.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(
children: [
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: filteredList.length,
itemBuilder: (context, index) {
final trade = filteredList[index];
return TradeCard(
trade: trade,
onAccept: () => _handleAcceptProposal(context, trade),
onSettings: () => _handleAcceptProposal(context, trade, isActive: true),
onClose: () => _handleCloseTrade(context, trade),
);
},
),
],
);
}
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: filteredList.length,
itemBuilder: (context, index) {
final trade = filteredList[index];
return TradeCard(
trade: trade,
onAccept: () => _handleAcceptProposal(context, trade),
onSettings: () => _handleAcceptProposal(context, trade, isActive: true),
onClose: () => _handleCloseTrade(context, trade),
);
},
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
);
}
return const SizedBox.shrink();
},
return const SizedBox.shrink();
},
),
),
),
);
}
/// Builds the sliver(s) for the "Vorschläge" tab: a persistent, REST-backed
/// list of every currently active proposal (see `_proposals`/`_loadProposals`),
/// not just whatever happened to arrive live over SignalR while this screen
/// was open. Loading/error/empty are all explicit states (Rules.md §4) —
/// there is no silent "nothing shown" case.
List<Widget> _buildProposalsSlivers(List<TradeProposalModel> proposals) {
if (_proposalsLoading) {
return [
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 40),
sliver: SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
),
),
];
}
if (_proposalsError != null) {
return [
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 40),
sliver: SliverToBoxAdapter(
child: Center(
child: Column(
children: [
Icon(Icons.error_outline, size: 40, color: AppTheme.accentRed),
const SizedBox(height: 8),
Text(_proposalsError!, style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _loadProposals,
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
child: const Text('Erneut Versuchen'),
),
),
],
],
),
),
),
),
];
}
if (proposals.isEmpty) {
return [
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 40),
sliver: SliverToBoxAdapter(
child: Center(
child: Column(
children: [
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine aktiven Vorschläge.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
),
),
),
];
}
return [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: proposals.length,
itemBuilder: (context, index) => _buildProposalListCard(proposals[index]),
),
),
];
}
Widget _buildProposalListCard(TradeProposalModel proposal) {
final isLong = proposal.isLong;
final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
final symbol = proposal.symbol.isNotEmpty ? proposal.symbol : proposal.underlyingIsin;
final expiresAtUtc = proposal.expiresAtUtc;
final remaining = expiresAtUtc?.difference(DateTime.now().toUtc());
final expiryLabel = remaining == null
? 'Ablauf unbekannt'
: remaining.isNegative
? 'Abgelaufen'
: remaining.inHours >= 1
? 'Läuft in ${remaining.inHours}h ab'
: 'Läuft in ${remaining.inMinutes}min ab';
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => _showProposalDecision(proposal),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: signalColor.withValues(alpha: 0.15),
border: Border.all(color: signalColor.withValues(alpha: 0.5)),
),
child: Text(
isLong ? 'LONG' : 'SHORT',
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(symbol, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
const SizedBox(height: 2),
Text(
'Einstieg €${proposal.entryPrice.toStringAsFixed(2)} · Score ${proposal.compositeScore.toStringAsFixed(0)} · $expiryLabel',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: AppTheme.textMuted, size: 20),
],
),
),
),
);
}
@@ -324,4 +617,3 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
);
}
}
@@ -63,7 +63,7 @@ class ProposedAutoTradesCard extends StatelessWidget {
// Top proposal
final topProposal = proposals.first;
final isBuy = topProposal.signalType.toUpperCase() == 'BUY' || topProposal.signalType.toUpperCase() == 'LONG';
final isBuy = topProposal.direction.isLong;
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
@@ -147,7 +147,7 @@ class ProposedAutoTradesCard extends StatelessWidget {
const Icon(Icons.auto_awesome, size: 14, color: Colors.amber),
const SizedBox(width: 6),
Text(
'Score: ${topProposal.winRate.toStringAsFixed(0)}%',
topProposal.instrumentType.label,
style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 12),
),
],
@@ -170,7 +170,7 @@ class ProposedAutoTradesCard extends StatelessWidget {
border: Border.all(color: signalColor.withValues(alpha: 0.5)),
),
child: Text(
topProposal.signalType.toUpperCase(),
topProposal.direction.label,
style: TextStyle(color: signalColor, fontWeight: FontWeight.w900, fontSize: 14, letterSpacing: 1),
),
),
@@ -180,50 +180,46 @@ class ProposedAutoTradesCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
topProposal.symbol.isNotEmpty && topProposal.symbol != 'UNKNOWN'
? topProposal.symbol
: (topProposal.companyName.isNotEmpty && topProposal.companyName != 'UNKNOWN' ? topProposal.companyName : (topProposal.isin.isNotEmpty ? topProposal.isin : 'Aktie')),
topProposal.symbol.isNotEmpty ? topProposal.symbol : topProposal.underlyingIsin,
style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 24, color: Colors.white, height: 1.1),
),
if (topProposal.companyName.isNotEmpty && topProposal.companyName != topProposal.symbol && topProposal.companyName != 'UNKNOWN')
Text(
topProposal.companyName,
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
topProposal.underlyingIsin,
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
if (topProposal.reasoning.isNotEmpty) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lightbulb_outline, size: 18, color: AppTheme.accentCyan.withValues(alpha: 0.8)),
const SizedBox(width: 12),
Expanded(
child: Text(
topProposal.reasoning,
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
],
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lightbulb_outline, size: 18, color: AppTheme.accentCyan.withValues(alpha: 0.8)),
const SizedBox(width: 12),
Expanded(
child: Text(
'Einstieg €${topProposal.averageBuyIn.toStringAsFixed(2)} • Stop-Loss €${topProposal.currentStopLoss.toStringAsFixed(2)}'
'${topProposal.primaryTakeProfit != null ? ' • Take-Profit €${topProposal.primaryTakeProfit!.toStringAsFixed(2)}' : ''}',
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 24),
@@ -35,16 +35,15 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
@override
void initState() {
super.initState();
_entryPriceCtrl = TextEditingController(text: widget.trade.entryPrice.toStringAsFixed(2));
_entryPriceCtrl = TextEditingController(text: widget.trade.averageBuyIn.toStringAsFixed(2));
_positionSizeCtrl = TextEditingController(text: '1000');
final lev = widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1.0;
_leverageCtrl = TextEditingController(text: lev == lev.roundToDouble() ? lev.toInt().toString() : lev.toStringAsFixed(2));
_stopLossCtrl = TextEditingController(text: widget.trade.stopLoss.toStringAsFixed(2));
_takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit.toStringAsFixed(2));
_leverageCtrl = TextEditingController(text: '1');
_stopLossCtrl = TextEditingController(text: widget.trade.currentStopLoss.toStringAsFixed(2));
_takeProfitCtrl = TextEditingController(text: (widget.trade.primaryTakeProfit ?? 0.0).toStringAsFixed(2));
_notesCtrl = TextEditingController();
_entryFeeCtrl = TextEditingController(text: '0.00');
_exitFeeCtrl = TextEditingController(text: '0.00');
double price = widget.trade.entryPrice;
double price = widget.trade.averageBuyIn;
_quantityCtrl = TextEditingController(text: (1000 / (price > 0 ? price : 1)).toStringAsFixed(4));
}
@@ -176,8 +175,7 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
final dto = TradeAcceptanceDto(
userId: widget.userId,
tradeId: widget.trade.id,
analysisId: widget.trade.analysisId,
isin: widget.trade.isin,
isin: widget.trade.underlyingIsin,
symbol: widget.trade.symbol,
actualEntryPrice: _parseNum(_entryPriceCtrl.text),
positionSize: _parseNum(_positionSizeCtrl.text),
@@ -187,13 +185,11 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
quantity: _parseNum(_quantityCtrl.text),
isRecurring: _isRecurring,
executionTimestamp: DateTime.now().toUtc(),
signalType: widget.trade.signalType,
entryPrice: widget.trade.entryPrice,
stopLoss: widget.trade.stopLoss,
takeProfit: widget.trade.takeProfit,
instrumentType: widget.trade.instrumentType,
timeframe: widget.trade.timeframe,
reasoning: widget.trade.reasoning,
signalType: widget.trade.direction.label,
entryPrice: widget.trade.averageBuyIn,
stopLoss: widget.trade.currentStopLoss,
takeProfit: widget.trade.primaryTakeProfit,
instrumentType: widget.trade.instrumentType.label,
);
Navigator.of(context).pop(dto);
},
@@ -14,62 +14,25 @@ class TradeCalculationCard extends StatelessWidget {
this.isCollapsible = false,
});
String _formatLeverage(double lev) {
if (lev <= 0) return '1x';
if (lev == lev.roundToDouble()) {
return '${lev.toInt()}x';
}
var s = lev.toStringAsFixed(2);
if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return '${s.replaceAll('.', ',')}x';
}
@override
Widget build(BuildContext context) {
final entry = trade.actualEntryPrice > 0
? trade.actualEntryPrice
: (trade.entryPrice > 0 ? trade.entryPrice : 1.0);
final posSize = trade.positionSize > 0 ? trade.positionSize : 1000.0;
final lev = trade.leverageUsed > 0 ? trade.leverageUsed : 1.0;
final isShort = trade.signalType.toUpperCase() == 'SELL' ||
trade.signalType.toUpperCase() == 'SHORT';
final totalFees = trade.entryFee + (trade.exitFee > 0 ? trade.exitFee : 1.0);
final entry = trade.averageBuyIn;
final quantity = trade.totalQuantity;
final posSize = entry * quantity;
final isShort = !trade.direction.isLong;
final quantity = entry > 0 ? (posSize / entry) : 0.0;
// Distance-to-stop risk, computed only from real DTO fields (averageBuyIn,
// currentStopLoss, totalQuantity) — no fee/leverage assumptions are
// invented since ActiveTradeDto no longer carries either (Rules.md §4).
final sl = trade.currentStopLoss;
final riskAmountAbs = (isShort ? (sl - entry) : (entry - sl)).abs() * quantity;
// SL Risk
final sl = trade.stopLoss;
final movePctSL = entry > 0 && sl > 0
? (isShort ? ((sl - entry) / entry) : ((entry - sl) / entry))
: 0.0;
final rawLoss = (movePctSL * posSize * lev).abs();
final isDerivative = trade.instrumentType.toLowerCase().contains('knock') ||
trade.instrumentType.toLowerCase().contains('option') ||
trade.instrumentType.toLowerCase().contains('factor') ||
trade.instrumentType.toLowerCase().contains('turbo');
final cappedLoss = isDerivative ? rawLoss.clamp(0.0, posSize) : rawLoss;
final riskAmountAbs = cappedLoss + totalFees;
// Reward-to-target(s), same principle.
final stages = trade.exitPlan.takeProfitStages;
final primaryTp = trade.primaryTakeProfit;
final rewardAmountAbs = primaryTp != null ? (isShort ? (entry - primaryTp) : (primaryTp - entry)).abs() * quantity : null;
// TP Reward
final tp = trade.takeProfit;
final movePctTP = entry > 0 && tp > 0
? (isShort ? ((entry - tp) / entry) : ((tp - entry) / entry))
: 0.0;
final rawProfit = (movePctTP * posSize * lev);
final profitAfterFees = rawProfit - totalFees;
final rewardAmountAbs = profitAfterFees > 0 ? profitAfterFees : 0.0;
// CRV
final crv = (riskAmountAbs > 0 && rewardAmountAbs > 0)
? (rewardAmountAbs / riskAmountAbs)
: 0.0;
// Multi-Targets
final targets = trade.takeProfitTargets.isNotEmpty
? trade.takeProfitTargets
: (trade.takeProfit > 0 ? [trade.takeProfit] : <double>[]);
final crv = (rewardAmountAbs != null && riskAmountAbs > 0) ? (rewardAmountAbs / riskAmountAbs) : null;
final content = Container(
padding: const EdgeInsets.all(14),
@@ -85,47 +48,33 @@ class TradeCalculationCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_statTile(
'Stückzahl (Basiswert)',
'Stückzahl',
'${quantity.toStringAsFixed(2)} Stk.',
Colors.white,
),
_statTile(
'Max. Verlust (SL)',
'Risiko bis Stop-Loss',
'-€${riskAmountAbs.toStringAsFixed(2)}',
AppTheme.accentRed,
),
_statTile(
'Gewinn-Potenzial (TP)',
'+€${rewardAmountAbs.toStringAsFixed(2)}',
'Potenzial bis 1. Ziel',
rewardAmountAbs != null ? '+€${rewardAmountAbs.toStringAsFixed(2)}' : 'Trailing-Exit',
AppTheme.primaryEmerald,
),
_statTile(
'Chance-Risiko (CRV)',
crv > 0 ? '1 : ${crv.toStringAsFixed(2)}' : '-',
crv != null ? '1 : ${crv.toStringAsFixed(2)}' : '-',
AppTheme.accentCyan,
),
],
),
const Divider(color: Colors.white10, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Pauschalgebühren: €${totalFees.toStringAsFixed(2)} (€${trade.entryFee.toStringAsFixed(2)} Kauf + €${(trade.exitFee > 0 ? trade.exitFee : 1.0).toStringAsFixed(2)} Verkauf)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
if (lev > 1.0)
Text(
'Effektiver Hebel: ${_formatLeverage(lev)}',
style: TextStyle(
color: AppTheme.accentCyan,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
Text(
'Investiertes Kapital: €${posSize.toStringAsFixed(2)} • Exit-Strategie: ${trade.exitPlan.strategyType.label}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
if (targets.length > 1) ...[
if (stages.length > 1) ...[
const Divider(color: Colors.white10, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -140,30 +89,24 @@ class TradeCalculationCard extends StatelessWidget {
),
),
Text(
'${targets.length} Ziele',
'${stages.length} Ziele',
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
),
],
),
const SizedBox(height: 8),
...targets.asMap().entries.map((entryItem) {
final idx = entryItem.key;
final targetPrice = entryItem.value;
final isCurrent = (tp - targetPrice).abs() < 0.001;
...stages.map((stage) {
final targetPrice = stage.targetPrice;
final isCurrent = primaryTp != null && (primaryTp - targetPrice).abs() < 0.001;
final targetMovePct = entry > 0
? (isShort
? ((entry - targetPrice) / entry)
: ((targetPrice - entry) / entry))
: 0.0;
final rawTargetProfit = targetMovePct * posSize * lev;
final netTargetProfit = rawTargetProfit - totalFees;
final targetMove = (isShort ? (entry - targetPrice) : (targetPrice - entry));
final netTargetProfit = targetMove * quantity;
final cappedNet = netTargetProfit > 0 ? netTargetProfit : 0.0;
final retPct = posSize > 0 ? (cappedNet / posSize * 100) : 0.0;
final targetCrv = (riskAmountAbs > 0 && cappedNet > 0)
? (cappedNet / riskAmountAbs)
: 0.0;
final baseMove = (targetMovePct * 100).abs();
final baseMove = entry > 0 ? (targetMove / entry * 100).abs() : 0.0;
return Container(
margin: const EdgeInsets.only(bottom: 6),
@@ -195,7 +138,7 @@ class TradeCalculationCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
),
child: Text(
'TP${idx + 1}',
'TP${stage.stageNumber}',
style: TextStyle(
color: isCurrent ? Colors.black : Colors.white,
fontSize: 10,
@@ -1,14 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../../favorites/cubit/favorites_cubit.dart';
import '../../favorites/models/favorite_asset_model.dart';
import '../models/trade_model.dart';
import 'trade_calculation_card.dart';
import 'trade_detail_modal.dart';
/// Migrated onto `ActiveTradeDto`. `trade.currentPrice` is the engine's own
/// tracked live price, so this card no longer needs to cross-reference the
/// favorites feed for a "live" quote — doing so would just be a second,
/// possibly-stale source of truth for a number the trade payload already
/// carries. The old "is this actually a derivative quote or the underlying's"
/// heuristic (comparing `entryPrice`/`actualEntryPrice` magnitudes) is gone
/// too: there is only one entry price now (`averageBuyIn`, the real
/// fill-weighted average), so there is nothing left to disambiguate.
class TradeCard extends StatelessWidget {
final TradeModel trade;
final VoidCallback? onAccept;
@@ -23,400 +28,314 @@ class TradeCard extends StatelessWidget {
this.onSettings,
});
String _fmt(double val) => val.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
final isBuy = trade.signalType == 'BUY' || trade.signalType == 'LONG';
final isBuy = trade.direction.isLong;
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
final isProposed = trade.isProposed;
final isActive = trade.isActive;
final isClosed = trade.isClosed;
return BlocBuilder<FavoritesCubit, FavoritesState>(
builder: (context, favState) {
double livePrice = 0.0;
final keyUpper = (trade.isin.isNotEmpty ? trade.isin : trade.symbol).toUpperCase();
final match = favState.favoriteDetails.firstWhere(
(f) => f.isin.toUpperCase() == keyUpper || f.symbol.toUpperCase() == keyUpper,
orElse: () => const FavoriteAssetModel(isin: '', symbol: '', name: '', currentPrice: 0.0, change24h: 0.0),
);
if (match.currentPrice > 0) {
livePrice = match.currentPrice;
}
// Server-computed, never recalculated client-side (Rules.md: don't
// re-derive P&L — `pnlEur` picks realized vs. unrealized, `
// unrealizedPnlPercent` is populated by the engine for both open and
// closed trades since `CurrentPrice` is pinned to the close price once
// a trade is closed).
final pnlAbs = trade.pnlEur;
final pnlPct = trade.unrealizedPnlPercent;
final isPnlPos = pnlAbs >= 0;
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
final currPrice = trade.currentPrice;
final tpStages = trade.exitPlan.takeProfitStages;
final primaryTp = trade.primaryTakeProfit;
final pnlAbs = livePrice > 0 ? trade.calculateLivePnlAbs(livePrice) : trade.calculatedPnlAbs;
final pnlPct = livePrice > 0 ? trade.calculateLivePnlPct(livePrice) : trade.calculatedPnlPct;
final isPnlPos = pnlAbs >= 0;
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice;
return GlassContainer(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return GlassContainer(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row: Signal, Symbol, Drift-Radar & Live PnL
Row(
children: [
// Header Row: Signal, Symbol, Drift-Radar & Live PnL
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: signalColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: signalColor.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor),
const SizedBox(width: 4),
Text(trade.signalType, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
? trade.companyName
: (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Position')),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
if (trade.instrumentType.isNotEmpty) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
),
],
],
),
const SizedBox(height: 2),
Text(
'${trade.symbol.isNotEmpty ? trade.symbol : ""} ${trade.isin.isNotEmpty ? "" + trade.isin : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
// Status / PnL / Drift-Radar Badge
if (isActive || isClosed) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pnlColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isPnlPos ? '+' : ''}${pnlAbs.abs().toStringAsFixed(2)}',
style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14),
),
Text(
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%',
style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
] else if (trade.isRejected) ...[
StatusBadge(label: 'ABGELEHNT', color: AppTheme.accentRed),
] else ...[
StatusBadge(label: 'VORSCHLAG', color: Colors.amber),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: signalColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: signalColor.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor),
const SizedBox(width: 4),
Text(trade.direction.label, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
],
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
trade.symbol.isNotEmpty ? trade.symbol : (trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : 'Position'),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
child: Text(trade.instrumentType.label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: 2),
Text(
'${trade.symbol.isNotEmpty ? trade.symbol : ""}${trade.underlyingIsin.isNotEmpty ? "${trade.underlyingIsin}" : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
// Active Drift Radar / Trailing Alert Indicator
if (isActive) ...[
const SizedBox(height: 10),
_buildDriftRadarBar(trade),
],
// PENDING EXIT ALERT BANNER (Zero Auto-Close notification)
if (isActive && trade.hasPendingExitAlert) ...[
const SizedBox(height: 10),
// Status / PnL / Drift-Radar Badge
if (isActive || isClosed) ...[
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.15),
color: pnlColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
border: Border.all(color: pnlColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isPnlPos ? '+' : ''}${pnlAbs.abs().toStringAsFixed(2)}',
style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14),
),
Text(
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%',
style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
] else if (trade.isRejected) ...[
StatusBadge(label: trade.status.label, color: AppTheme.accentRed),
] else ...[
StatusBadge(label: trade.status.label, color: Colors.amber),
],
],
),
// Active Drift Radar / Trailing Alert Indicator
if (isActive) ...[
const SizedBox(height: 10),
_buildDriftRadarBar(trade),
],
const SizedBox(height: 12),
// Price Metrics Grid with Live Kurs & Trailing SL
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_priceItem('Einstieg', trade.averageBuyIn > 0 ? '${_fmt(trade.averageBuyIn)}' : '', Colors.white),
_priceItem('Live-Kurs', currPrice > 0 ? '${_fmt(currPrice)}' : '', AppTheme.accentCyan),
_priceItem(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${_fmt(trade.currentStopLoss)}',
AppTheme.accentRed,
),
_priceItem(
tpStages.length > 1 ? 'TP (1. Stufe)' : 'Take-Profit',
primaryTp != null ? '${_fmt(primaryTp)}' : 'Trailing-Exit',
AppTheme.primaryEmerald,
),
],
),
),
if (tpStages.length > 1) ...[
const SizedBox(height: 8),
Row(
children: [
Text(
'Ziele: ',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: tpStages.map((stage) {
final isCurrent = primaryTp != null && (primaryTp - stage.targetPrice).abs() < 0.01;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: isCurrent
? AppTheme.primaryEmerald.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isCurrent
? AppTheme.primaryEmerald
: Colors.white.withValues(alpha: 0.15),
),
),
child: Text(
'TP${stage.stageNumber}: €${_fmt(stage.targetPrice)}',
style: TextStyle(
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
fontSize: 10.5,
fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold,
),
),
);
}).toList(),
),
),
],
),
],
// 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,
childrenPadding: const EdgeInsets.only(bottom: 6),
dense: true,
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
title: Text(
'Ausführungshistorie (${trade.fills.length} Fills)',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
),
children: trade.fills.reversed.take(4).map((f) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
const SizedBox(width: 10),
Text(
'${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),
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,
),
],
child: Text(
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
maxLines: 1,
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)),
),
],
),
),
],
);
}).toList(),
),
],
const SizedBox(height: 12),
const SizedBox(height: 8),
// Price Metrics Grid with Live Kurs & Trailing SL
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_priceItem(
isActive || isClosed ? 'Einstieg' : 'Ziel-Einstieg',
trade.actualEntryPrice > 0
? '${trade.actualEntryPrice.toStringAsFixed(2)}'
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'),
Colors.white,
),
_priceItem('Live-Kurs', '${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
_priceItem(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${trade.stopLoss.toStringAsFixed(2)}',
AppTheme.accentRed,
),
_priceItem(
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
'${trade.takeProfit.toStringAsFixed(2)}',
AppTheme.primaryEmerald,
),
],
),
// Collapsible Live-Kalkulation & TP-Multi-Target Card
TradeCalculationCard(trade: trade, isCollapsible: true, initiallyExpanded: false),
const SizedBox(height: 12),
// Footer Action Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
trade.exitPlan.strategyType.label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
if (trade.takeProfitTargets.length > 1) ...[
const SizedBox(height: 8),
Row(
children: [
Text(
'Ziele: ',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
Row(
children: [
IconButton(
onPressed: () => TradeDetailModal.show(
context,
trade: trade,
onAccept: onAccept,
onClose: onClose,
),
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: trade.takeProfitTargets.asMap().entries.map((entry) {
final idx = entry.key;
final tpVal = entry.value;
final isCurrent = (trade.takeProfit - tpVal).abs() < 0.01;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: isCurrent
? AppTheme.primaryEmerald.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isCurrent
? AppTheme.primaryEmerald
: Colors.white.withValues(alpha: 0.15),
),
),
child: Text(
'TP${idx + 1}: €${tpVal.toStringAsFixed(2)}',
style: TextStyle(
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
fontSize: 10.5,
fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold,
),
),
);
}).toList(),
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
tooltip: 'Details',
),
if (isProposed && onAccept != null) ...[
const SizedBox(width: 6),
ElevatedButton.icon(
onPressed: onAccept,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('Trade Übernehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
),
],
if (trade.reasoning.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
trade.reasoning,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
// KI-Timeline Expansion if updates exist
if (trade.hourlyUpdates.isNotEmpty) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 6),
dense: true,
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
title: Text(
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
),
children: trade.hourlyUpdates.reversed.take(4).map((u) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(6),
if (isActive && onClose != null) ...[
const SizedBox(width: 6),
OutlinedButton.icon(
onPressed: onClose,
icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed),
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Row(
children: [
Text(
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.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),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
if (isActive && onSettings != null) ...[
const SizedBox(width: 6),
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
tooltip: 'Einstellungen anpassen',
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
),
);
}).toList(),
),
],
const SizedBox(height: 8),
// Collapsible Live-Kalkulation & TP-Multi-Target Card
TradeCalculationCard(trade: trade, isCollapsible: true, initiallyExpanded: false),
const SizedBox(height: 12),
// Footer Action Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? "${trade.leverageUsed.toStringAsFixed(1)}x Hebel" : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
Row(
children: [
IconButton(
onPressed: () => TradeDetailModal.show(
context,
trade: trade,
onAccept: onAccept,
onClose: onClose,
),
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
tooltip: 'KI-Begründung & Details',
),
if (isProposed && onAccept != null) ...[
const SizedBox(width: 6),
ElevatedButton.icon(
onPressed: onAccept,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('Trade Übernehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
if (isActive && onClose != null) ...[
const SizedBox(width: 6),
OutlinedButton.icon(
onPressed: onClose,
icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed),
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
if (isActive && onSettings != null) ...[
const SizedBox(width: 6),
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
tooltip: 'Einstellungen anpassen',
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
),
),
],
],
),
),
],
],
),
],
),
);
},
],
),
);
}
@@ -426,11 +345,6 @@ class TradeCard 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';
@@ -443,7 +357,7 @@ class TradeCard 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;
}
@@ -467,12 +381,16 @@ class TradeCard extends StatelessWidget {
);
}
Widget _priceItem(String label, String val, Color valColor) {
Widget _priceItem(String label, String val, Color valColor, {String? subtitle}) {
return Column(
children: [
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: valColor, fontWeight: FontWeight.bold, fontSize: 12)),
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 1),
Text(subtitle, style: TextStyle(color: AppTheme.accentCyan, fontSize: 9, fontWeight: FontWeight.w600)),
],
],
);
}
@@ -47,9 +47,7 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
@override
void initState() {
super.initState();
final defaultPrice = widget.trade.currentPrice > 0
? widget.trade.currentPrice
: (widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : widget.trade.entryPrice);
final defaultPrice = widget.trade.currentPrice > 0 ? widget.trade.currentPrice : widget.trade.averageBuyIn;
_exitPriceCtrl = TextEditingController(text: defaultPrice.toStringAsFixed(2));
_exitFeeCtrl = TextEditingController(text: '1.00');
@@ -72,27 +70,29 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
return double.tryParse(clean) ?? fallback;
}
double get _exitPrice => _parse(_exitPriceCtrl, widget.trade.entryPrice);
double get _exitPrice => _parse(_exitPriceCtrl, widget.trade.averageBuyIn);
double get _exitFee => _parse(_exitFeeCtrl, 1.0);
double get _entryPrice => widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : (widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 1.0);
double get _posSize => widget.trade.positionSize > 0 ? widget.trade.positionSize : 1000.0;
double get _quantity => widget.trade.quantity > 0 ? widget.trade.quantity : (_posSize / _entryPrice);
double get _entryPrice => widget.trade.averageBuyIn;
double get _quantity => widget.trade.totalQuantity;
double get _posSize => _entryPrice * _quantity;
double get _calculatedProceeds {
if (_exitPrice <= 0 || _quantity <= 0) return 0.0;
return _quantity * _exitPrice;
}
/// Forward preview of the realized P&L this closing input would produce —
/// computed from real trade fields only (averageBuyIn, totalQuantity,
/// direction) plus the fee the user is entering right now. This is NOT a
/// re-derivation of the server's `realizedPnlEur`: the trade isn't closed
/// yet, so the server has no such value to show (Rules.md §4 — a genuine
/// "what happens if I close now" preview, not an invented duplicate).
double get _calculatedPnlAbs {
if (_entryPrice <= 0 || _exitPrice <= 0) return 0.0;
final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT';
final movePct = isShort ? ((_entryPrice - _exitPrice) / _entryPrice) : ((_exitPrice - _entryPrice) / _entryPrice);
final lev = (widget.trade.instrumentType.toLowerCase().contains('knock') || widget.trade.instrumentType.toLowerCase().contains('option'))
? 1.0
: (widget.trade.leverageUsed > 0 ? widget.trade.leverageUsed : 1.0);
final totalFees = (widget.trade.entryFee > 0 ? widget.trade.entryFee : 1.0) + _exitFee;
return (movePct * _posSize * lev) - totalFees;
final isShort = !widget.trade.direction.isLong;
final rawMove = isShort ? (_entryPrice - _exitPrice) : (_exitPrice - _entryPrice);
return (rawMove * _quantity) - _exitFee;
}
double get _calculatedPnlPct {
@@ -188,7 +188,7 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
Text(
'Trade #${widget.trade.id}${widget.trade.companyName.isNotEmpty ? widget.trade.companyName : widget.defaultSymbol}',
'Trade #${widget.trade.id}${widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
@@ -224,7 +224,7 @@ class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
_summaryCol('Einstiegskurs', '${_entryPrice.toStringAsFixed(2)}'),
_summaryCol('Investition', '${_posSize.toStringAsFixed(0)}'),
_summaryCol('Stückzahl', '${_quantity.toStringAsFixed(2)} Stk.'),
_summaryCol('Instrument', widget.trade.instrumentType),
_summaryCol('Instrument', widget.trade.instrumentType.label),
],
),
),
@@ -1,20 +1,32 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../core/theme/app_theme.dart';
import '../models/trade_model.dart';
import 'trade_calculation_card.dart';
/// Migrated onto `ActiveTradeDto`. The old "KI-Analysen, Bewertungen &
/// Begründungen" expansion (reasoning/technicalRationale/
/// fundamentalRationale/riskWarning, plus the hourly AI-Guardian check-in
/// timeline) has no backend equivalent anymore — none of those fields exist
/// on `ActiveTradeDto`, so the section was removed rather than shown empty
/// (Rules.md §4). This does leave the detail view noticeably thinner than
/// before: today it can only show the mechanical trade state (prices, exit
/// plan, fills), not any narrative "why" behind the trade.
class TradeDetailContent extends StatelessWidget {
final TradeModel trade;
const TradeDetailContent({super.key, required this.trade});
String _fmt(double val) => val.toStringAsFixed(2);
@override
Widget build(BuildContext context) {
final pnlAbs = trade.calculatedPnlAbs;
final pnlPct = trade.calculatedPnlPct;
final pnlAbs = trade.pnlEur;
final pnlPct = trade.unrealizedPnlPercent;
final isPnlPos = pnlAbs >= 0;
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
final currPrice = trade.effectiveCurrentPrice;
final currPrice = trade.currentPrice;
final tpStages = trade.exitPlan.takeProfitStages;
final primaryTp = trade.primaryTakeProfit;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -25,38 +37,6 @@ class TradeDetailContent extends StatelessWidget {
const SizedBox(height: 14),
],
// Exit Alert if pending
if (trade.isActive && trade.hasPendingExitAlert) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ausstiegs-Empfehlung der KI!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 2),
Text(
trade.pendingExitReason.isNotEmpty ? trade.pendingExitReason : 'Die Indikatoren raten zum Verlassen der Position zur Gewinnsicherung / Risikominimierung.',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
],
),
),
const SizedBox(height: 14),
],
// Metrics Grid
Container(
padding: const EdgeInsets.all(16),
@@ -69,27 +49,25 @@ class TradeDetailContent extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_metricItem(
trade.isActive || trade.isClosed ? 'Ausführung' : 'Ziel-Einstieg',
trade.actualEntryPrice > 0
? '${trade.actualEntryPrice.toStringAsFixed(2)}'
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'),
trade.isActive || trade.isClosed ? 'Einstiegskurs' : 'Ziel-Einstieg',
trade.averageBuyIn > 0 ? '${_fmt(trade.averageBuyIn)}' : '',
Colors.white,
),
_metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
_metricItem('Live-Kurs', currPrice > 0 ? '${_fmt(currPrice)}' : '', AppTheme.accentCyan),
_metricItem(
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
'${trade.stopLoss.toStringAsFixed(2)}',
'${_fmt(trade.currentStopLoss)}',
AppTheme.accentRed,
),
_metricItem(
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
'${trade.takeProfit.toStringAsFixed(2)}',
tpStages.length > 1 ? 'TP (1. Stufe)' : 'Take-Profit',
primaryTp != null ? '${_fmt(primaryTp)}' : 'Trailing-Exit',
AppTheme.primaryEmerald,
),
],
),
),
if (trade.takeProfitTargets.length > 1) ...[
if (tpStages.length > 1) ...[
const SizedBox(height: 10),
Row(
children: [
@@ -101,10 +79,8 @@ class TradeDetailContent extends StatelessWidget {
child: Wrap(
spacing: 6,
runSpacing: 4,
children: trade.takeProfitTargets.asMap().entries.map((entry) {
final idx = entry.key;
final tpVal = entry.value;
final isCurrent = (trade.takeProfit - tpVal).abs() < 0.01;
children: tpStages.map((stage) {
final isCurrent = primaryTp != null && (primaryTp - stage.targetPrice).abs() < 0.01;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
@@ -120,7 +96,7 @@ class TradeDetailContent extends StatelessWidget {
),
),
child: Text(
'TP${idx + 1}: €${tpVal.toStringAsFixed(2)}',
'TP${stage.stageNumber}: €${_fmt(stage.targetPrice)}',
style: TextStyle(
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
fontSize: 11,
@@ -146,7 +122,7 @@ class TradeDetailContent extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)),
Text(trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL:', style: const TextStyle(color: Colors.white70, fontSize: 13)),
Text(
'${isPnlPos ? '+' : ''}${pnlAbs.abs().toStringAsFixed(2)} (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)',
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15),
@@ -157,7 +133,7 @@ class TradeDetailContent extends StatelessWidget {
],
const SizedBox(height: 20),
// 3. LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
// LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
TradeCalculationCard(trade: trade),
const SizedBox(height: 18),
@@ -173,197 +149,82 @@ class TradeDetailContent extends StatelessWidget {
),
child: Column(
children: [
_paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'),
if (trade.derivativeIsin.isNotEmpty) _paramRow('Derivat / Hebel ISIN:', trade.derivativeIsin),
_paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'),
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(1)}x'),
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)}'),
_paramRow('Instrument Typ:', trade.instrumentType.label),
if (trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty) _paramRow('Derivat ISIN:', trade.derivativeIsin!),
_paramRow('Ausführungsart:', trade.executionMode.label),
_paramRow('Exit-Strategie:', trade.exitPlan.strategyType.label),
_paramRow('Eröffnet am:', _formatDate(trade.openedAtUtc)),
if (trade.closedAtUtc != null) _paramRow('Geschlossen am:', _formatDate(trade.closedAtUtc!)),
],
),
),
const SizedBox(height: 18),
// AUFKLAPPBARE KARTE: KI-Analysen, Bewertungen & Begründungen
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Theme(
data: ThemeData(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: false,
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
iconColor: AppTheme.accentCyan,
collapsedIconColor: Colors.white70,
leading: Icon(Icons.auto_awesome, color: AppTheme.primaryEmerald, size: 20),
title: const Text(
'KI-Analysen, Bewertungen & Begründungen',
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
'Technische & fundamentale Begründung, Risikowarnung & Guardian-Protokoll',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
children: [
const Divider(color: Colors.white10, height: 16),
// Hourly Updates Timeline
if (trade.hourlyUpdates.isNotEmpty) ...[
_sectionTitle(Icons.history_toggle_off, 'KI-Guardian Überwachungsprotokoll (${trade.hourlyUpdates.length} Checks)', AppTheme.accentCyan),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Column(
children: trade.hourlyUpdates.reversed.map((u) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${u.timestamp.day.toString().padLeft(2, '0')}.${u.timestamp.month.toString().padLeft(2, '0')} ${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
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.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(u.recommendation, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
u.reasoning.isNotEmpty ? u.reasoning : 'Stündliche Überprüfung durchgeführt.',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
Text(
'Kurs: €${u.currentPrice.toStringAsFixed(2)}${u.suggestedStopLoss != null ? " • Neuer SL: €${u.suggestedStopLoss!.toStringAsFixed(2)}" : ""} • VIX: ${u.vixValue.toStringAsFixed(1)}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
],
),
);
}).toList(),
),
// 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: 18),
_sectionTitle(Icons.history_toggle_off, 'Ausführungshistorie (${trade.fills.length} Fills)', AppTheme.accentCyan),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Column(
children: trade.fills.reversed.map((f) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatDate(f.executedAtUtc),
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' • Gebühr €${_fmt(f.fee)}' : ''}${f.note != null && f.note!.isNotEmpty ? '${f.note}' : ''}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
const SizedBox(height: 14),
],
if (trade.reasoning.isNotEmpty) ...[
_sectionTitle(Icons.auto_awesome, 'KI-Gesamteinschätzung & Begründung', AppTheme.primaryEmerald),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.2)),
),
child: Text(trade.reasoning, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
),
const SizedBox(height: 14),
],
if (trade.technicalRationale.isNotEmpty) ...[
_sectionTitle(Icons.show_chart, 'Technische Analyse & Indikatoren', AppTheme.accentCyan),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 12.5, height: 1.4)),
),
const SizedBox(height: 14),
],
if (trade.fundamentalRationale.isNotEmpty) ...[
_sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 12.5, height: 1.4)),
),
const SizedBox(height: 14),
],
if (trade.riskWarning.isNotEmpty) ...[
_sectionTitle(Icons.warning_amber_rounded, 'Risikohinweis & Marktumfeld', AppTheme.accentRed),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)),
),
child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)),
),
],
],
);
}).toList(),
),
),
),
],
],
);
}
String _formatDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
Widget _buildDriftRadarCard(TradeModel t) {
Color col;
String title;
String desc;
switch (t.driftStatus) {
case DriftStatus.exitAlert:
col = AppTheme.accentRed;
title = 'Ausstiegssignal aktiv';
desc = 'Die Marktbedingungen oder Stop-Limits deuten auf einen Ausstieg hin.';
break;
case DriftStatus.trailingActive:
col = AppTheme.accentCyan;
title = 'Trailing Stop aktiv nachgezogen';
desc = 'Die KI hat den Stop-Loss zur Absicherung von Gewinnen nachgezogen.';
desc = 'Der aktuelle Stop-Loss wurde zur Absicherung von Gewinnen nachgezogen.';
break;
case DriftStatus.driftWarning:
col = Colors.orangeAccent;
title = 'Leichte Drift / Kursabweichung';
desc = 'Der Kurs bewegt sich leicht entgegen der primären Prognose.';
desc = 'Der unrealisierte Verlust hat die Warnschwelle überschritten.';
break;
case DriftStatus.onTrack:
col = AppTheme.primaryEmerald;
title = 'Auf Kurs • Prognose intakt';
desc = 'Die Entwicklung entspricht der statistischen KI-Prognose.';
title = 'Auf Kurs';
desc = 'Keine besonderen Abweichungen erkannt.';
break;
}
@@ -425,4 +286,3 @@ class TradeDetailContent extends StatelessWidget {
);
}
}
@@ -35,7 +35,7 @@ class TradeDetailModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isBuy = trade.signalType == 'BUY';
final isBuy = trade.direction.isLong;
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
@@ -78,7 +78,7 @@ class TradeDetailModal extends StatelessWidget {
border: Border.all(color: signalColor.withValues(alpha: 0.5)),
),
child: Text(
trade.signalType,
trade.direction.label,
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 14),
),
),
@@ -88,21 +88,13 @@ class TradeDetailModal extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN'
? trade.symbol
: (trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN' ? trade.companyName : (trade.isin.isNotEmpty ? trade.isin : 'Aktie')),
trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.white),
),
if (trade.companyName.isNotEmpty && trade.companyName != trade.symbol && trade.companyName != 'UNKNOWN')
Text(
trade.companyName,
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
if (trade.isin.isNotEmpty || trade.sector.isNotEmpty)
Text(
'${trade.isin.isNotEmpty ? trade.isin : ""}${trade.isin.isNotEmpty && trade.sector.isNotEmpty ? "" : ""}${trade.sector.isNotEmpty ? trade.sector : ""}',
style: TextStyle(color: AppTheme.textMuted.withValues(alpha: 0.7), fontSize: 11),
),
Text(
trade.underlyingIsin,
style: TextStyle(color: AppTheme.textMuted.withValues(alpha: 0.7), fontSize: 11),
),
],
),
),
@@ -118,7 +110,7 @@ class TradeDetailModal extends StatelessWidget {
const Icon(Icons.star, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'${trade.winRate.toStringAsFixed(0)}% Win-Rate',
trade.status.label,
style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 11),
),
],
@@ -28,39 +28,17 @@ class TradeExecutionAiPlanCard extends StatelessWidget {
);
}
static Widget _buildRationaleBlock(String title, String content, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(content, style: const TextStyle(color: Colors.white70, fontSize: 12, height: 1.4)),
],
),
);
}
@override
Widget build(BuildContext context) {
final signal = trade.signalType.toUpperCase();
final isLong = signal == 'BUY' || signal == 'LONG';
final isLong = trade.direction.isLong;
final signalColor = isLong ? 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 - entryPrice) / (entryPrice - stopLoss).abs();
final maxLeverage = trade.maxLeverage;
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
final entryPrice = trade.averageBuyIn;
final stopLoss = trade.currentStopLoss;
final takeProfitStages = trade.exitPlan.takeProfitStages;
final primaryTp = trade.primaryTakeProfit;
final riskDistance = (entryPrice - stopLoss).abs();
final crv = (primaryTp != null && riskDistance > 0) ? (primaryTp - entryPrice).abs() / riskDistance : null;
return Container(
padding: const EdgeInsets.all(12),
@@ -79,79 +57,47 @@ class TradeExecutionAiPlanCard extends StatelessWidget {
children: [
StatusBadge(label: isLong ? 'LONG / KAUFEN' : 'SHORT / VERKAUFEN', color: signalColor),
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.instrumentType, style: const TextStyle(color: Colors.white, 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.instrumentType.label, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
Row(
children: [
Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan),
Text(trade.status.label, style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
],
),
if (trade.winRate > 0)
Row(
children: [
Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan),
Text('Win-Rate: ${trade.winRate}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
],
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Haltedauer: ${trade.timeframe.isNotEmpty ? trade.timeframe : '1-14 Tage'}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
Text('Risiko: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
if (trade.vixValue > 0)
Text('VIX: ${_fmt(trade.vixValue)} (${trade.vixRegime})', style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold)),
],
),
Text('Exit-Strategie: ${trade.exitPlan.strategyType.label}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss Target', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat('Take-Profit Target', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
_buildTradeStat('Einstieg', '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat(
takeProfitStages.length > 1 ? 'Take-Profit Stufen' : 'Take-Profit',
takeProfitStages.isNotEmpty ? takeProfitStages.map((s) => '${_fmt(s.targetPrice)}').join(' / ') : 'Trailing-Exit (kein Fixziel)',
AppTheme.primaryEmerald,
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv > 0) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Empf. Max Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
_buildTradeStat('Signal Typ', isLong ? 'LONG / BULLISH' : 'SHORT / BEARISH', signalColor),
],
),
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 10),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('Ausführliche KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 6),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 6),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 6),
],
if (riskWarning.isNotEmpty)
_buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
);
File diff suppressed because it is too large Load Diff
@@ -1,429 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/network/api_client.dart';
import '../../asset_detail/repositories/asset_repository.dart';
import '../models/trade_model.dart';
import '../models/trade_acceptance_dto.dart';
import 'trade_execution_ai_plan_card.dart';
class TradeExecutionDialog {
static const double _defaultPositionSize = 1000.0;
static const double _defaultLeverage = 1.0;
static String _normalizeInstrumentType(String raw) {
final clean = raw.toLowerCase().trim();
if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) {
return 'KnockOut';
}
if (clean.contains('option')) {
return 'Option';
}
if (clean.contains('cfd')) {
return 'CFD';
}
if (clean.contains('crypto') || clean.contains('krypto')) {
return 'Crypto';
}
if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) {
return 'Stock';
}
return 'KnockOut';
}
static void show(
BuildContext context, {
required TradeModel trade,
required String defaultSymbol,
bool isActive = false,
required Function(TradeAcceptanceDto dto) onAccept,
Function(String tradeId)? onReject,
}) {
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0;
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
final actualEntryController = TextEditingController(text: initEntry.toStringAsFixed(2));
final positionSizeController = TextEditingController(text: initPos.toStringAsFixed(2));
final leverageController = TextEditingController(text: initLev.toStringAsFixed(1));
final quantityController = TextEditingController(text: initQty.toStringAsFixed(4));
final entryFeeController = TextEditingController(text: trade.entryFee.toStringAsFixed(2));
final exitFeeController = TextEditingController(text: trade.exitFee.toStringAsFixed(2));
final slController = TextEditingController(text: trade.stopLoss.toString());
final tpController = TextEditingController(text: trade.takeProfit.toString());
final derivativeIsinController = TextEditingController(text: trade.derivativeIsin);
String selectedInstrumentType = _normalizeInstrumentType(
trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut',
);
bool isFetchingDerivativePrice = false;
void recalculateQuantity() {
final entryStr = actualEntryController.text.replaceAll(',', '.').trim();
final posStr = positionSizeController.text.replaceAll(',', '.').trim();
final entry = double.tryParse(entryStr) ?? 0.0;
final posSize = double.tryParse(posStr) ?? 0.0;
if (entry > 0 && posSize > 0) {
final q = posSize / entry;
quantityController.text = q.toStringAsFixed(4);
}
}
TradeAcceptanceDto buildDto() {
final isinVal = trade.isin.isNotEmpty ? trade.isin : (trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol);
final symbolVal = trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol;
double parseNum(String text, double fallback) {
final clean = text.replaceAll(',', '.').trim();
return double.tryParse(clean) ?? fallback;
}
return TradeAcceptanceDto(
userId: trade.userId,
tradeId: trade.id,
analysisId: trade.analysisId,
isin: isinVal,
symbol: symbolVal,
actualEntryPrice: parseNum(actualEntryController.text, trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice),
positionSize: parseNum(positionSizeController.text, trade.positionSize > 0 ? trade.positionSize : 1000.0),
leverageUsed: parseNum(leverageController.text, trade.leverageUsed > 0 ? trade.leverageUsed : 1.0),
entryFee: parseNum(entryFeeController.text, trade.entryFee),
exitFee: parseNum(exitFeeController.text, trade.exitFee),
quantity: parseNum(quantityController.text, trade.quantity),
executionTimestamp: DateTime.now().toUtc(),
signalType: trade.signalType,
entryPrice: trade.entryPrice,
stopLoss: parseNum(slController.text, trade.stopLoss),
takeProfit: parseNum(tpController.text, trade.takeProfit),
instrumentType: selectedInstrumentType,
derivativeIsin: derivativeIsinController.text.trim(),
timeframe: trade.timeframe,
reasoning: trade.reasoning,
);
}
Future<void> fetchDerivativePrice(StateSetter setModalState, String inputIsin) async {
final cleanIsin = inputIsin.trim().toUpperCase();
if (cleanIsin.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
);
return;
}
setModalState(() => isFetchingDerivativePrice = true);
try {
final apiClient = context.read<ApiClient>();
final assetRepo = AssetRepository(apiClient: apiClient);
final technicals = await assetRepo.getAssetTechnical(cleanIsin, true);
double? fetchedPrice;
if (technicals != null) {
if (technicals.candles.isNotEmpty) {
fetchedPrice = technicals.candles.last.close;
} else if (technicals.currentPrice != null && technicals.currentPrice! > 0) {
fetchedPrice = technicals.currentPrice;
}
}
if (fetchedPrice != null && fetchedPrice > 0) {
actualEntryController.text = fetchedPrice.toStringAsFixed(2);
recalculateQuantity();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Live-Kurs für Derivat $cleanIsin abgerufen: €${fetchedPrice.toStringAsFixed(2)}'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kein Kurs für Derivat ISIN $cleanIsin gefunden.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Abrufen des Kurses für $cleanIsin: $e'), backgroundColor: AppTheme.accentRed, behavior: SnackBarBehavior.floating),
);
} finally {
setModalState(() => isFetchingDerivativePrice = false);
}
}
actualEntryController.addListener(recalculateQuantity);
positionSizeController.addListener(recalculateQuantity);
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (stfContext, setModalState) {
final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') ||
selectedInstrumentType.toLowerCase().contains('option') ||
selectedInstrumentType.toLowerCase().contains('factor') ||
selectedInstrumentType.toLowerCase().contains('derivat');
final assetType = trade.assetType.toLowerCase();
final categories = trade.derivativeProductCategories;
final hasCfd = trade.hasCfd;
final availableOptions = <MapEntry<String, String>>[];
if (assetType == 'crypto') {
availableOptions.add(const MapEntry('Crypto', 'Krypto'));
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'Krypto CFD'));
} else if (assetType == 'etf') {
availableOptions.add(const MapEntry('Stock', 'ETF (Direktinvestment)'));
if (categories.isEmpty || categories.contains('knockOutProduct')) {
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
}
if (categories.contains('vanillaWarrant')) {
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
}
if (categories.contains('factorCertificate')) {
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
}
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
} else {
availableOptions.add(const MapEntry('Stock', 'Aktie (Direktinvestment)'));
if (categories.isEmpty || categories.contains('knockOutProduct')) {
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
}
if (categories.contains('vanillaWarrant')) {
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
}
if (categories.contains('factorCertificate')) {
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
}
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
}
final safeInstrumentValue = availableOptions.any((o) => o.key == selectedInstrumentType)
? selectedInstrumentType
: (availableOptions.isNotEmpty ? availableOptions.first.key : 'Stock');
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)),
title: Row(
children: [
Icon(Icons.flash_on, color: AppTheme.primaryEmerald),
const SizedBox(width: 8),
Text(isActive ? 'Aktiven Trade anpassen' : 'Trade-Vorschlag ausführen', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
],
),
content: SizedBox(
width: 580,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 12),
TradeExecutionAiPlanCard(trade: trade),
const SizedBox(height: 16),
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: safeInstrumentValue,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: availableOptions.map((opt) {
return DropdownMenuItem(value: opt.key, child: Text(opt.value, style: const TextStyle(color: Colors.white, fontSize: 13)));
}).toList(),
onChanged: (val) {
if (val != null) setModalState(() => selectedInstrumentType = val);
},
),
const SizedBox(height: 10),
if (isKnockout) ...[
Row(
children: [
Expanded(
child: TextField(
controller: derivativeIsinController,
decoration: const InputDecoration(labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)', hintText: 'ISIN des Hebels eingeben...', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: isFetchingDerivativePrice ? null : () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
icon: isFetchingDerivativePrice
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.bolt, size: 16),
label: const Text('tr_GetPrice'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)),
),
],
),
const SizedBox(height: 10),
],
Row(
children: [
Expanded(
child: TextField(
controller: actualEntryController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Tatsächlicher Einstiegskurs (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: positionSizeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Investitionsvolumen (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: leverageController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Genutzter Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Stückzahl (Invest. / Einstieg)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: entryFeeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Einstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: exitFeeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Ausstiegsgebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: slController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Stop-Loss (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: tpController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Take-Profit (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
],
),
if (trade.takeProfitTargets.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 4,
children: trade.takeProfitTargets.asMap().entries.map((entry) {
final idx = entry.key;
final targetPrice = entry.value;
return ActionChip(
label: Text('TP${idx + 1}: €${targetPrice.toStringAsFixed(2)}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
onPressed: () {
tpController.text = targetPrice.toStringAsFixed(2);
},
backgroundColor: Colors.white10,
side: BorderSide(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
);
}).toList(),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.withValues(alpha: 0.25)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.gavel_outlined, size: 14, color: Colors.amber.withValues(alpha: 0.85)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Rechtlicher Hinweis: Keine Anlageberatung. Sämtliche Angaben dienen ausschließlich Informationszwecken. Hebelprodukte bergen ein hohes Verlustrisiko bis hin zum Totalverlust.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 10, height: 1.3),
),
),
],
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
if (!isActive && onReject != null)
OutlinedButton.icon(
onPressed: () {
onReject(trade.id);
Navigator.pop(dialogContext);
},
icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16),
label: Text('Trade Ablehnen', style: TextStyle(color: AppTheme.accentRed)),
style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed)),
),
if (!isActive && onReject != null) const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () {
final dto = buildDto();
onAccept(dto);
Navigator.of(dialogContext).pop();
},
icon: Icon(isActive ? Icons.save : Icons.check_circle, size: 16),
label: Text(isActive ? 'Einstellungen Speichern' : 'Trade Annehmen & Ausführen'),
style: ElevatedButton.styleFrom(
backgroundColor: isActive ? AppTheme.accentCyan : AppTheme.primaryEmerald,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
}
@@ -27,10 +27,11 @@ class TradePerformanceBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final totalOpenPnlAbs = activeTrades.fold<double>(0, (sum, t) => sum + t.calculatedPnlAbs);
final totalOpenPnlAbs = activeTrades.fold<double>(0, (sum, t) => sum + t.pnlEur);
final isPnlPos = totalOpenPnlAbs >= 0;
final winRatePct = allTrades.isNotEmpty
? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100)
final closedTrades = allTrades.where((t) => t.isClosed).toList();
final winRatePct = closedTrades.isNotEmpty
? (closedTrades.where((t) => t.pnlEur >= 0).length / closedTrades.length * 100)
: 0.0;
return GlassContainer(