feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user