From 34fa774cbfcbcb8fdd4e8b751a5d67bc16a50889 Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sat, 15 Aug 2026 19:30:25 +0200 Subject: [PATCH] feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings --- .../Controllers/ManualAnalysisController.cs | 23 +- .../Services/IWinRateCalculator.cs | 14 + .../Services/WinRateCalculator.cs | 106 +- FinlyticAnalyzer/Util/AnalyzerMqttClient.cs | 44 +- .../views/asset_detail_screen.dart | 78 +- .../asset_detail/views/tabs/trades_tab.dart | 92 +- .../widgets/trades/asset_trade_item_card.dart | 178 ++- .../trades/live_trade_settings_dialog.dart | 4 +- .../trades/manual_analysis_dialog.dart | 591 +++++++--- .../lib/features/trades/bloc/trade_bloc.dart | 2 +- .../lib/features/trades/bloc/trade_event.dart | 7 +- .../models/close_trade_request_dto.dart | 14 +- .../features/trades/models/trade_model.dart | 112 ++ .../trades/repositories/trade_repository.dart | 38 + .../trades/views/trades_feed_screen.dart | 102 +- .../widgets/trade_acceptance_dialog.dart | 3 +- .../widgets/trade_calculation_card.dart | 319 ++++++ .../features/trades/widgets/trade_card.dart | 602 ++++++---- .../trades/widgets/trade_closing_cockpit.dart | 473 ++++++++ .../trades/widgets/trade_detail_content.dart | 383 +++++-- .../widgets/trade_execution_cockpit.dart | 1020 +++++++++++++++++ .../widgets/trade_execution_dialog.dart | 104 +- FinlyticApp/lib/main.dart | 2 + FinlyticBackend/Util/WebMqttClient.cs | 3 + FinlyticTrades/Database/TradesDbContext.cs | 16 + FinlyticTrades/Entities/TradeEntity.cs | 7 + ...ndDerivativeCategoriesToTrades.Designer.cs | 357 ++++++ ...ssetTypeAndDerivativeCategoriesToTrades.cs | 76 ++ .../TradesDbContextModelSnapshot.cs | 42 + .../Services/TradeLifecycleService.cs | 32 +- FinlyticTrades/Util/TradesMqttClient.cs | 21 +- 31 files changed, 4235 insertions(+), 630 deletions(-) create mode 100644 FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart create mode 100644 FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart create mode 100644 FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart create mode 100644 FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.Designer.cs create mode 100644 FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.cs diff --git a/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs b/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs index 90ffc5f..68062f6 100644 --- a/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs +++ b/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs @@ -115,12 +115,19 @@ public class ManualAnalysisController : ControllerBase var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); bool shouldProceed = n8nResponse != null && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase); + double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate( + request.Sector, + request.Symbol, + regime, + n8nEvalScore: n8nResponse?.EvalScore, + signalType: n8nResponse?.SuggestedDirection ?? "BUY"); + TradeProposalDto? proposal = null; if (shouldProceed && n8nResponse != null) { proposal = new TradeProposalDto { - TradeId = "PROP-" + Guid.NewGuid().ToString("N"), + TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(), AnalysisId = analysisId, EventId = analysisId, Sector = request.Sector, @@ -132,11 +139,21 @@ public class ManualAnalysisController : ControllerBase RiskTolerance = n8nResponse.SuggestedRisk, Timeframe = timeframeFormatted, InstrumentType = request.InstrumentType, - WinRate = winRate, + WinRate = dynamicWinRate, VixRegime = regime, VixValue = currentVix, TtlMinutes = 60, Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}", + StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0, + TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0, + EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min, + EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max, + TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets, + RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio, + MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage, + TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty, + FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty, + RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty, CreatedAt = DateTime.UtcNow }; } @@ -151,7 +168,7 @@ public class ManualAnalysisController : ControllerBase VixRegime = regime, VixValue = currentVix, ImpactScore = 1.0, - WinRate = winRate, + WinRate = dynamicWinRate, RawDataJson = JsonSerializer.Serialize(request), AiOutputJson = proposal != null ? JsonSerializer.Serialize(proposal) : "{}", N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", diff --git a/FinlyticAnalyzer/Services/IWinRateCalculator.cs b/FinlyticAnalyzer/Services/IWinRateCalculator.cs index 3499f70..ea83ccb 100644 --- a/FinlyticAnalyzer/Services/IWinRateCalculator.cs +++ b/FinlyticAnalyzer/Services/IWinRateCalculator.cs @@ -8,4 +8,18 @@ public interface IWinRateCalculator /// Calculates the win rate for a given sector and symbol under the specified market regime. /// double CalculateWinRate(string sector, string symbol, VixMarketRegime regime); + + /// + /// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime. + /// + double CalculateDynamicWinRate( + string sector, + string symbol, + VixMarketRegime regime, + double? n8nEvalScore = null, + double? technicalScore = null, + double? sentimentScore = null, + double? fundamentalScore = null, + string signalType = "BUY"); } + diff --git a/FinlyticAnalyzer/Services/WinRateCalculator.cs b/FinlyticAnalyzer/Services/WinRateCalculator.cs index 173002a..9dc5dcf 100644 --- a/FinlyticAnalyzer/Services/WinRateCalculator.cs +++ b/FinlyticAnalyzer/Services/WinRateCalculator.cs @@ -34,31 +34,105 @@ public class WinRateCalculator : IWinRateCalculator /// Uses cached feedback records (3-minute TTL) to prevent disk I/O bottlenecks. /// public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime) + { + return CalculateDynamicWinRate(sector, symbol, regime); + } + + /// + /// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime. + /// + public double CalculateDynamicWinRate( + string sector, + string symbol, + VixMarketRegime regime, + double? n8nEvalScore = null, + double? technicalScore = null, + double? sentimentScore = null, + double? fundamentalScore = null, + string signalType = "BUY") { try { - var records = GetCachedOrLoadRecords(); - if (records.Count == 0) return 65.0; - - var matching = records.Where(r => - string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) && - r.VixRegime == regime).ToList(); - - if (matching.Count > 0) + // 1. N8n AI Confidence Score (Weight: 40%) + double n8nComponent = 62.0; + if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0) { - int winningTrades = matching.Count(r => r.IsWin); - double calculatedWinRate = (double)winningTrades / matching.Count * 100.0; - _logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})", - "AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, matching.Count); - return Math.Round(calculatedWinRate, 1); + n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value; } + + // 2. Technical Score (Weight: 30%) + double taComponent = 60.0; + if (technicalScore.HasValue && technicalScore.Value > 0) + { + taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value; + } + + // 3. Sentiment Score (Weight: 15%) + double sentComponent = 58.0; + if (sentimentScore.HasValue) + { + if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0) + { + // Map sentiment from -1.0..+1.0 into 35.0..85.0 + sentComponent = 50.0 + (sentimentScore.Value * 25.0); + } + else + { + sentComponent = sentimentScore.Value; + } + } + + // 4. Fundamental Score (Weight: 15%) + double fundComponent = 60.0; + if (fundamentalScore.HasValue && fundamentalScore.Value > 0) + { + fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value; + } + + // Multi-factor weighted composite + double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15); + + // 5. Market Regime & Volatility Adjustment + double vixAdjustment = regime switch + { + VixMarketRegime.LowVol => +4.0, // Calm trending market + VixMarketRegime.Normal => +1.5, // Normal conditions + VixMarketRegime.HighVol => -3.5, // Increased whipsaws + VixMarketRegime.Panic => -8.0, // High panic / uncertainty + _ => 0.0 + }; + + composite += vixAdjustment; + + // 6. Historical track record calibration (if available in feedback records) + var records = GetCachedOrLoadRecords(); + if (records.Count > 0) + { + var matching = records.Where(r => + string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) && + r.VixRegime == regime).ToList(); + + if (matching.Count >= 5) + { + int winningTrades = matching.Count(r => r.IsWin); + double historicalWinRate = (double)winningTrades / matching.Count * 100.0; + composite = (composite * 0.75) + (historicalWinRate * 0.25); + } + } + + // Clamp between realistic financial statistical bounds (45.0% to 92.0%) + double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0); + + _logger.LogInformation("[{Channel}] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]", + "AnalyzerChannel", symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime); + + return finalWinRate; } catch (Exception ex) { - _logger.LogWarning(ex, "[{Channel}] Error reading feedback files for win-rate calculation. Falling back to default.", "AnalyzerChannel"); + _logger.LogWarning(ex, "[{Channel}] Error calculating dynamic win-rate for {Symbol}. Fallback applied.", "AnalyzerChannel", symbol); + return 65.0; } - - return 65.0; // Default baseline win-rate } private List GetCachedOrLoadRecords() diff --git a/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs b/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs index 5ef10d1..5a8762a 100644 --- a/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs +++ b/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs @@ -329,11 +329,19 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService var settings = await settingsService.GetSettingsAsync(); double minSignalScore = settings.MinSignalScore; - double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75; + double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate( + manualReq.Sector, + manualReq.Symbol, + regime, + n8nEvalScore: n8nResponse?.EvalScore, + sentimentScore: manualReq.SentimentData?.CurrentSummary?.CompoundScore, + signalType: n8nResponse?.SuggestedDirection ?? "BUY"); + + double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : (dynamicWinRate / 100.0); bool shouldProceed = n8nResponse != null && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) && (confidenceScore * 100.0) >= minSignalScore && - winRate >= minSignalScore; + dynamicWinRate >= minSignalScore; TradeProposalDto? proposalDto = null; if (n8nResponse != null) @@ -353,7 +361,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService RiskTolerance = n8nResponse.SuggestedRisk, Timeframe = timeframeFormatted, InstrumentType = manualReq.InstrumentType, - WinRate = winRate, + WinRate = dynamicWinRate, VixRegime = regime, VixValue = currentVix, TtlMinutes = 60, @@ -384,7 +392,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService VixRegime = regime, VixValue = currentVix, ImpactScore = 1.0, - WinRate = winRate, + WinRate = dynamicWinRate, RawDataJson = JsonSerializer.Serialize(manualReq), AiOutputJson = proposalDto != null ? JsonSerializer.Serialize(proposalDto) : "{}", N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", @@ -527,6 +535,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? taResp = null; FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? fundResp = null; FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto? livePriceResp = null; + FinlyticCore.Dtos.Sentiment.IsinSentimentSummaryDto? sentResp = null; try { @@ -549,7 +558,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService livePriceResp = livePriceTask.Result; taResp = taTask.Result; fundResp = fundTask.Result; - var sentResp = sentTask.Result; + sentResp = sentTask.Result; if (taResp?.Indicators != null) { @@ -735,10 +744,31 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService ActionRequired = isHighConviction ? "PROMPT_USER_FOR_MANUAL_TRADE" : "NO_ACTION" }; + double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate( + filterResult.Sector, + finalSymbol, + regime, + n8nEvalScore: n8nResponse?.EvalScore, + sentimentScore: sentResp?.CurrentSummary?.CompoundScore, + signalType: n8nResponse?.SuggestedDirection ?? "BUY"); + using (var scope = _scopeFactory.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); + bool hasRecentProposal = await dbContext.Analyses.AnyAsync(a => + a.Isin == filterResult.Isin && + a.IsTradeProposed && + a.CreatedAt >= DateTime.UtcNow.AddHours(-4), + cancellationToken); + + if (hasRecentProposal && isHighConviction) + { + _logger.LogInformation("[{Channel}] [AutoScreener] Asset {Symbol} ({Isin}) already has an active trade proposal in the last 4 hours. Skipping duplicate trade proposal generation.", + "AnalyzerChannel", finalSymbol, filterResult.Isin); + isHighConviction = false; + } + var analysisEntity = new AnalysisEntity { AnalysisId = analysisId, @@ -749,7 +779,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService VixRegime = regime, VixValue = currentVix, ImpactScore = filterResult.ImpactScore, - WinRate = winRate, + WinRate = dynamicWinRate, RawDataJson = payloadStr, AiOutputJson = JsonSerializer.Serialize(recommendation), N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", @@ -780,7 +810,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService RiskTolerance = n8nResponse.SuggestedRisk ?? "Balanced", Timeframe = $"{minTf}-{maxTf} Tage", InstrumentType = "KnockOut", - WinRate = winRate, + WinRate = dynamicWinRate, VixRegime = regime, VixValue = currentVix, TtlMinutes = 180, diff --git a/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart b/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart index 98b92f7..ee8b9f6 100644 --- a/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart +++ b/FinlyticApp/lib/features/asset_detail/views/asset_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/network/api_client.dart'; +import '../../favorites/cubit/favorites_cubit.dart'; import '../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../bloc/fundamentals/asset_fundamentals_event.dart'; import '../bloc/technical/asset_technical_bloc.dart'; @@ -8,6 +9,7 @@ import '../bloc/technical/asset_technical_event.dart'; import '../bloc/trades/asset_trades_bloc.dart'; import '../bloc/trades/asset_trades_event.dart'; import '../repositories/asset_repository.dart'; +import '../utils/ticker_resolver.dart'; import 'layouts/asset_page_desktop_layout.dart'; import 'layouts/asset_page_mobile_layout.dart'; @@ -29,39 +31,49 @@ class AssetDetailScreen extends StatelessWidget { Widget build(BuildContext context) { final repository = AssetRepository(apiClient: apiClient); - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => AssetFundamentalsBloc(repository: repository) - ..add(LoadAssetFundamentals(isin, ticker: symbol)), - ), - BlocProvider( - create: (context) => AssetTechnicalBloc(repository: repository) - ..add(LoadAssetTechnical(isin, ticker: symbol)), - ), - BlocProvider( - create: (context) => AssetTradesBloc(repository: repository) - ..add(LoadAssetTrades(isin)), - ), - ], - child: Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= 900) { - return AssetPageDesktopLayout( - isin: isin, - name: name, - selectedTicker: symbol, - ); - } - return AssetPageMobileLayout( - isin: isin, - name: name, - selectedTicker: symbol, - ); - }, - ), - ), + return BlocBuilder( + builder: (context, favState) { + final initialTicker = TickerResolver.resolve( + isin: isin, + candidateSymbol: symbol, + favoriteDetails: favState.favoriteDetails, + ); + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => AssetFundamentalsBloc(repository: repository) + ..add(LoadAssetFundamentals(isin, ticker: initialTicker)), + ), + BlocProvider( + create: (context) => AssetTechnicalBloc(repository: repository) + ..add(LoadAssetTechnical(isin, ticker: initialTicker)), + ), + BlocProvider( + create: (context) => AssetTradesBloc(repository: repository) + ..add(LoadAssetTrades(isin)), + ), + ], + child: Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 900) { + return AssetPageDesktopLayout( + isin: isin, + name: name, + selectedTicker: initialTicker ?? symbol, + ); + } + return AssetPageMobileLayout( + isin: isin, + name: name, + selectedTicker: initialTicker ?? symbol, + ); + }, + ), + ), + ); + }, ); } } diff --git a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart index 2318111..74b71a5 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart @@ -5,13 +5,12 @@ import '../../../../core/widgets/glass_container.dart'; import '../../../../core/widgets/shimmer_loading.dart'; import '../../../../core/widgets/status_badge.dart'; import '../../../trades/models/trade_model.dart'; -import '../../../trades/widgets/trade_execution_dialog.dart'; +import '../../../trades/widgets/trade_execution_cockpit.dart'; +import '../../../trades/widgets/trade_closing_cockpit.dart'; import '../../bloc/trades/asset_trades_bloc.dart'; import '../../bloc/trades/asset_trades_event.dart'; import '../../bloc/trades/asset_trades_state.dart'; -import '../../widgets/trades/live_trade_settings_dialog.dart'; import '../../widgets/trades/manual_analysis_dialog.dart'; -import '../../widgets/trades/close_trade_dialog.dart'; import '../../widgets/trades/asset_trade_item_card.dart'; class TradesTab extends StatefulWidget { @@ -24,13 +23,6 @@ class TradesTab extends StatefulWidget { class _TradesTabState extends State { bool _justTriggeredAnalysis = false; - LiveTradeSettings _settings = const LiveTradeSettings( - defaultPositionSize: 2500.0, - defaultLeverage: 5.0, - defaultRiskScore: 50.0, - defaultOrderFee: 1.0, - autoAcceptSignals: false, - ); @override void initState() { @@ -41,7 +33,7 @@ class _TradesTabState extends State { void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) { final tradesBloc = context.read(); - TradeExecutionDialog.show( + TradeExecutionCockpit.show( context, trade: trade, defaultSymbol: widget.symbol, @@ -116,55 +108,36 @@ class _TradesTabState extends State { ], ), const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () { - ManualAnalysisDialog.show( - context, - symbol: widget.symbol, - initialRiskScore: _settings.defaultRiskScore, - onTrigger: (payload) { - setState(() => _justTriggeredAnalysis = true); - context.read().add(TriggerManualAnalysis(widget.symbol, payload: payload)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'), - backgroundColor: AppTheme.accentCyan, - behavior: SnackBarBehavior.floating, - ), - ); - }, + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + ManualAnalysisDialog.show( + context, + symbol: widget.symbol, + initialRiskScore: 50.0, + onTrigger: (payload) { + setState(() => _justTriggeredAnalysis = true); + context.read().add(TriggerManualAnalysis(widget.symbol, payload: payload)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'), + backgroundColor: AppTheme.accentCyan, + behavior: SnackBarBehavior.floating, + ), ); }, - icon: const Icon(Icons.auto_awesome, size: 18), - label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), + ); + }, + icon: const Icon(Icons.auto_awesome, size: 18), + label: const Text('KI-Analyse Starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentCyan, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - const SizedBox(width: 10), - IconButton.filledTonal( - onPressed: () { - LiveTradeSettingsDialog.show( - context, - currentSettings: _settings, - onSave: (newSettings) => setState(() => _settings = newSettings), - ); - }, - icon: const Icon(Icons.settings, color: Colors.white), - tooltip: 'Live Trade Einstellungen', - style: IconButton.styleFrom( - backgroundColor: AppTheme.glassSurface, - padding: const EdgeInsets.all(14), - ), - ), - ], + ), ), ], ), @@ -245,7 +218,7 @@ class _TradesTabState extends State { onSettings: () => _showEditTradeExecutionDialog(context, trade, isActive: true), onClose: isActive ? () { - CloseTradeDialog.show( + TradeClosingCockpit.show( context, trade: trade, defaultSymbol: widget.symbol, @@ -254,7 +227,7 @@ class _TradesTabState extends State { context.read().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Trade ${trade.id} geschlossen! Ausstiegskurs: €${dto.userExitPrice.toStringAsFixed(2)}'), + content: Text('Trade ${trade.id} geschlossen! Realisierter Ausstiegskurs: €${dto.userExitPrice.toStringAsFixed(2)}'), backgroundColor: AppTheme.primaryEmerald, behavior: SnackBarBehavior.floating, ), @@ -270,3 +243,4 @@ class _TradesTabState extends State { ); } } + diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart index a887724..90062bd 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart @@ -65,6 +65,7 @@ class AssetTradeItemCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header Row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -84,7 +85,10 @@ class AssetTradeItemCard extends StatelessWidget { color: AppTheme.glassSurface, borderRadius: BorderRadius.circular(6), ), - child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)), + child: Text( + trade.derivativeIsin.isNotEmpty ? '${trade.instrumentType} (${trade.derivativeIsin})' : trade.instrumentType, + style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold), + ), ), ], ), @@ -99,7 +103,7 @@ class AssetTradeItemCard extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.accentRed, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -125,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -135,12 +139,62 @@ class AssetTradeItemCard extends StatelessWidget { ), ], ), + + // Active Drift Radar Bar + if (isActive) ...[ + const SizedBox(height: 10), + _buildDriftRadarBar(trade), + ], + + // Pending Exit Alert Banner + if (isActive && trade.hasPendingExitAlert) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.accentRed.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('KI-Guardian Ratschlag: Position schließen!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12)), + if (trade.pendingExitReason.isNotEmpty) + Text(trade.pendingExitReason, style: const TextStyle(color: Colors.white70, fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis), + ], + ), + ), + if (onClose != null) + ElevatedButton( + onPressed: onClose, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentRed, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ], + const SizedBox(height: 12), Text( '${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), const SizedBox(height: 12), + + // Target Price Metrics Grid Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -154,7 +208,11 @@ class AssetTradeItemCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white), - _buildTradeStat('Stop-Loss', '€${_fmt(stopLoss)}', AppTheme.accentRed), + _buildTradeStat( + trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss', + '€${_fmt(stopLoss)}', + AppTheme.accentRed, + ), _buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '€${_fmt(t)}').join(' / ') : '€${_fmt(takeProfit)}', AppTheme.primaryEmerald), ], ), @@ -171,6 +229,8 @@ class AssetTradeItemCard extends StatelessWidget { ], ), ), + + // Execution Details if active if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[ const SizedBox(height: 12), Container( @@ -208,6 +268,8 @@ class AssetTradeItemCard extends StatelessWidget { ), ), ], + + // Realized PnL if closed if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[ const SizedBox(height: 12), Builder( @@ -243,12 +305,72 @@ class AssetTradeItemCard extends StatelessWidget { _buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed), ], ), + if (trade.closeReason.isNotEmpty) ...[ + const SizedBox(height: 6), + Text('Grund: ${trade.closeReason}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + ], ], ), ); }, ), ], + + // KI Timeline Expansion + if (trade.hourlyUpdates.isNotEmpty) ...[ + 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), + ), + 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, + ), + ), + ], + ), + ); + }).toList(), + ), + ], + + // AI Analysis Expansion if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[ const SizedBox(height: 12), ExpansionTile( @@ -279,6 +401,53 @@ class AssetTradeItemCard extends StatelessWidget { ); } + Widget _buildDriftRadarBar(TradeModel t) { + Color col; + String label; + 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'; + icon = Icons.security; + break; + case DriftStatus.driftWarning: + col = Colors.orangeAccent; + label = 'Drift-Radar: Leichte Abweichung von Prognose'; + icon = Icons.tune; + break; + case DriftStatus.onTrack: + col = AppTheme.primaryEmerald; + label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich'; + icon = Icons.radar; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: col.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: col.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Icon(icon, color: col, size: 14), + const SizedBox(width: 6), + Expanded( + child: Text(label, style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + Widget _buildTradeStat(String title, String val, Color col) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -301,3 +470,4 @@ class AssetTradeItemCard extends StatelessWidget { ); } } + diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart index 46e4bb6..d08f1d4 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/live_trade_settings_dialog.dart @@ -29,8 +29,8 @@ class LiveTradeSettingsDialog { double tempFee = currentSettings.defaultOrderFee; bool tempAuto = currentSettings.autoAcceptSignals; - final posController = TextEditingController(text: tempPos.toStringAsFixed(0)); - final levController = TextEditingController(text: tempLev.toStringAsFixed(1)); + final posController = TextEditingController(text: tempPos == tempPos.roundToDouble() ? tempPos.toInt().toString() : tempPos.toStringAsFixed(2)); + final levController = TextEditingController(text: tempLev == tempLev.roundToDouble() ? tempLev.toInt().toString() : tempLev.toStringAsFixed(2)); final feeController = TextEditingController(text: tempFee.toStringAsFixed(2)); showDialog( diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart index b2e1fe1..d13f298 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/manual_analysis_dialog.dart @@ -1,174 +1,469 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import '../../../../core/theme/app_theme.dart'; import '../../models/manual_analysis_request_dto.dart'; -class ManualAnalysisDialog { +class ManualAnalysisDialog extends StatefulWidget { + final String symbol; + final double initialRiskScore; + final void Function(ManualAnalysisRequestDto) onTrigger; + + const ManualAnalysisDialog({ + super.key, + required this.symbol, + required this.initialRiskScore, + required this.onTrigger, + }); + static void show( BuildContext context, { required String symbol, required double initialRiskScore, required void Function(ManualAnalysisRequestDto) onTrigger, }) { - double riskScore = initialRiskScore; - final minTimeframeController = TextEditingController(text: '1'); - final maxTimeframeController = TextEditingController(text: '14'); - String timeframeUnit = 'Tage'; - String instrumentType = 'Knock-Out Zertifikat (Turbo)'; - final notesController = TextEditingController(); - showDialog( context: context, - builder: (dialogContext) { - return StatefulBuilder( - builder: (builderContext, setModalState) { - return AlertDialog( - backgroundColor: AppTheme.cardSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppTheme.glassBorder), - ), - title: Row( + barrierDismissible: false, + builder: (dialogContext) => ManualAnalysisDialog( + symbol: symbol, + initialRiskScore: initialRiskScore, + onTrigger: onTrigger, + ), + ); + } + + @override + State createState() => _ManualAnalysisDialogState(); +} + +class _ManualAnalysisDialogState extends State with SingleTickerProviderStateMixin { + late double _riskScore; + String _selectedTimeframePreset = 'swing'; // intraday, swing, position, custom + final _minTimeframeCtrl = TextEditingController(text: '1'); + final _maxTimeframeCtrl = TextEditingController(text: '14'); + String _timeframeUnit = 'Tage'; + String _instrumentType = 'Knock-Out Zertifikat (Turbo)'; + final _notesCtrl = TextEditingController(); + + bool _isAnalyzing = false; + int _analysisStage = 0; // 0: Idle, 1: Marktdaten, 2: TA/FA Indikatoren, 3: KI Setup + Timer? _stageTimer; + + @override + void initState() { + super.initState(); + _riskScore = widget.initialRiskScore; + } + + @override + void dispose() { + _stageTimer?.cancel(); + _minTimeframeCtrl.dispose(); + _maxTimeframeCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + void _selectTimeframePreset(String key, int min, int max, String unit) { + setState(() { + _selectedTimeframePreset = key; + _minTimeframeCtrl.text = min.toString(); + _maxTimeframeCtrl.text = max.toString(); + _timeframeUnit = unit; + }); + } + + void _selectRiskPreset(double score) { + setState(() { + _riskScore = score; + }); + } + + void _startAnalysis() { + setState(() { + _isAnalyzing = true; + _analysisStage = 1; + }); + + _stageTimer = Timer.periodic(const Duration(milliseconds: 700), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_analysisStage < 3) { + setState(() { + _analysisStage++; + }); + } else { + timer.cancel(); + final payload = ManualAnalysisRequestDto( + isin: widget.symbol, + symbol: widget.symbol, + riskScore: _riskScore.toInt(), + minTimeframeValue: int.tryParse(_minTimeframeCtrl.text) ?? 1, + maxTimeframeValue: int.tryParse(_maxTimeframeCtrl.text) ?? 14, + timeframeUnit: _timeframeUnit, + instrumentType: _instrumentType, + userNotes: _notesCtrl.text, + headline: 'Manuelle KI-Analyse für ${widget.symbol}', + ); + + Navigator.of(context).pop(); + widget.onTrigger(payload); + } + }); + } + + String get _stageText { + switch (_analysisStage) { + case 1: + return 'Lade Live-Marktdaten & Orderbuch...'; + case 2: + return 'Berechne Technische Indikatoren & Muster...'; + case 3: + return 'KI generiert optimales Trade-Setup...'; + default: + return 'Analyse Jetzt Ausführen'; + } + } + + @override + Widget build(BuildContext context) { + final riskColor = _riskScore < 35 + ? AppTheme.primaryEmerald + : (_riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed); + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Container( + width: 560, + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppTheme.glassBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.6), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 14), + child: Row( children: [ - Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22), - const SizedBox(width: 8), - Expanded( - child: Text('KI-Analyse für $symbol', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.accentCyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22), ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'KI-Trade Setup Generator', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + Text( + 'Asset: ${widget.symbol}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + ], + ), + ), + if (!_isAnalyzing) + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white54), + ), ], ), - content: SizedBox( - width: 440, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Wählen Sie Ihre Zielparameter für die Trade-Evaluierung:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), - const SizedBox(height: 16), - const Text('Zeithorizont (Timeframe):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: TextField( - controller: minTimeframeController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), + ), + const Divider(color: Colors.white12, height: 1), + + // Content + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. TIMEFRAME PRESETS + const Text('1. ZEITHORIZONT (TIMEFRAME)', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 8), + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _presetChip('⚡ Intraday (1–4 Std.)', 'intraday', () => _selectTimeframePreset('intraday', 1, 4, 'Stunden')), + _presetChip('🌊 Swing-Trade (1–14 Tage)', 'swing', () => _selectTimeframePreset('swing', 1, 14, 'Tage')), + _presetChip('📈 Positions-Trade (2–8 Wo.)', 'position', () => _selectTimeframePreset('position', 2, 8, 'Wochen')), + _presetChip('⚙ Benutzerdefiniert', 'custom', () => setState(() => _selectedTimeframePreset = 'custom')), + ], + ), + ), + + if (_selectedTimeframePreset == 'custom') ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _minTimeframeCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: maxTimeframeController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: DropdownButtonFormField( - initialValue: timeframeUnit, - dropdownColor: AppTheme.cardSurface, - decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - items: const [ - DropdownMenuItem(value: 'Stunden', child: Text('Stunden')), - DropdownMenuItem(value: 'Tage', child: Text('Tage')), - DropdownMenuItem(value: 'Wochen', child: Text('Wochen')), - DropdownMenuItem(value: 'Monate', child: Text('Monate')), - ], - onChanged: (val) { - if (val != null) setModalState(() => timeframeUnit = val); - }, - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - Text( - '${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})', - style: TextStyle( - color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ], - ), - Slider( - value: riskScore, - min: 0, - max: 100, - divisions: 100, - activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), - inactiveColor: AppTheme.glassSurface, - onChanged: (val) => setModalState(() => riskScore = val), - ), - const SizedBox(height: 12), - const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 6), - DropdownButtonFormField( - initialValue: instrumentType, - dropdownColor: AppTheme.cardSurface, - decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)), - items: const [ - DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')), - DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')), - DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')), - DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')), - DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')), - ], - onChanged: (val) { - if (val != null) setModalState(() => instrumentType = val); - }, - ), - const SizedBox(height: 16), - const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 6), - TextField( - controller: notesController, - maxLines: 3, - decoration: const InputDecoration( - hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...', ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _maxTimeframeCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + initialValue: _timeframeUnit, + dropdownColor: AppTheme.cardSurface, + decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), + items: const [ + DropdownMenuItem(value: 'Stunden', child: Text('Stunden')), + DropdownMenuItem(value: 'Tage', child: Text('Tage')), + DropdownMenuItem(value: 'Wochen', child: Text('Wochen')), + DropdownMenuItem(value: 'Monate', child: Text('Monate')), + ], + onChanged: (val) { + if (val != null) setState(() => _timeframeUnit = val); + }, + ), + ), + ], + ), + ], + + const SizedBox(height: 18), + + // 2. RISIKO-PROFIL + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('2. RISIKOBEREITSCHAFT', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + Text( + '${_riskScore.toInt()}/100 (${_riskScore < 35 ? "Konservativ" : (_riskScore < 70 ? "Ausgewogen" : "Spekulativ")})', + style: TextStyle(color: riskColor, fontWeight: FontWeight.bold, fontSize: 12), ), ], ), + const SizedBox(height: 8), + + Row( + children: [ + _riskProfileChip('🟢 Konservativ', 25, AppTheme.primaryEmerald), + _riskProfileChip('🟡 Ausgewogen', 50, Colors.orangeAccent), + _riskProfileChip('🔴 Spekulativ', 85, AppTheme.accentRed), + ], + ), + Slider( + value: _riskScore, + min: 0, + max: 100, + divisions: 100, + activeColor: riskColor, + inactiveColor: AppTheme.glassSurface, + onChanged: _isAnalyzing ? null : (val) => setState(() => _riskScore = val), + ), + + const SizedBox(height: 12), + + // 3. INSTRUMENT + const Text('3. FINANZINSTRUMENT', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 8), + + DropdownButtonFormField( + initialValue: _instrumentType, + dropdownColor: AppTheme.cardSurface, + decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)), + items: const [ + DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo) - Hebel')), + DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')), + DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')), + DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')), + DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')), + ], + onChanged: _isAnalyzing ? null : (val) { + if (val != null) setState(() => _instrumentType = val); + }, + ), + + const SizedBox(height: 14), + + // 4. NOTIZEN & QUICK TAGS + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('4. ANWEISUNG FÜR DIE KI (OPTIONAL)', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + Wrap( + spacing: 4, + children: [ + _hintTag('Enge Stops'), + _hintTag('Hoher Hebel'), + _hintTag('Earnings Play'), + ], + ), + ], + ), + const SizedBox(height: 6), + TextField( + controller: _notesCtrl, + maxLines: 2, + enabled: !_isAnalyzing, + style: const TextStyle(color: Colors.white, fontSize: 12), + decoration: const InputDecoration( + hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...', + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ], + ), + ), + + // Animated Footer Button + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isAnalyzing ? null : _startAnalysis, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentCyan, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: _isAnalyzing ? 0 : 4, + ), + child: _isAnalyzing + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.black), + ), + const SizedBox(width: 12), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Text( + _stageText, + key: ValueKey(_analysisStage), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + ], + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.flash_on, size: 20), + SizedBox(width: 8), + Text('Analyse Jetzt Ausführen', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 15)), + ], + ), ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), - ), - ElevatedButton.icon( - onPressed: () { - final payload = ManualAnalysisRequestDto( - isin: symbol, - symbol: symbol, - riskScore: riskScore.toInt(), - minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1, - maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14, - timeframeUnit: timeframeUnit, - instrumentType: instrumentType, - userNotes: notesController.text, - headline: 'Manuelle KI-Analyse für $symbol', - ); - Navigator.pop(dialogContext); - onTrigger(payload); - }, - icon: const Icon(Icons.flash_on), - label: const Text('Analyse Jetzt Ausführen'), - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black), - ), - ], - ); - }, - ); - }, + ), + ], + ), + ), + ); + } + + Widget _presetChip(String label, String key, VoidCallback onTap) { + final isSelected = _selectedTimeframePreset == key; + return GestureDetector( + onTap: _isAnalyzing ? null : onTap, + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppTheme.accentCyan.withValues(alpha: 0.2) : AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isSelected ? AppTheme.accentCyan : AppTheme.glassBorder), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? AppTheme.accentCyan : Colors.white70, + fontSize: 11, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + Widget _riskProfileChip(String label, double score, Color color) { + final isSelected = (_riskScore - score).abs() < 15; + return Expanded( + child: GestureDetector( + onTap: _isAnalyzing ? null : () => _selectRiskPreset(score), + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(vertical: 7), + decoration: BoxDecoration( + color: isSelected ? color.withValues(alpha: 0.2) : AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isSelected ? color : AppTheme.glassBorder), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected ? color : Colors.white70, + fontSize: 11, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ), + ); + } + + Widget _hintTag(String text) { + return GestureDetector( + onTap: _isAnalyzing + ? null + : () { + if (!_notesCtrl.text.contains(text)) { + _notesCtrl.text = _notesCtrl.text.isEmpty ? text : '${_notesCtrl.text}, $text'; + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '+ $text', + style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), ); } } + diff --git a/FinlyticApp/lib/features/trades/bloc/trade_bloc.dart b/FinlyticApp/lib/features/trades/bloc/trade_bloc.dart index 4d56a89..c3b184e 100644 --- a/FinlyticApp/lib/features/trades/bloc/trade_bloc.dart +++ b/FinlyticApp/lib/features/trades/bloc/trade_bloc.dart @@ -26,7 +26,7 @@ class TradeBloc extends Bloc { Future _onCloseTrade(CloseTrade event, Emitter emit) async { emit(TradeLoading()); try { - await repository.closeTrade(event.tradeId); + await repository.closeTrade(event.tradeId, dto: event.dto); final trades = await repository.fetchTrades(); emit(TradeLoaded(trades)); } catch (e) { diff --git a/FinlyticApp/lib/features/trades/bloc/trade_event.dart b/FinlyticApp/lib/features/trades/bloc/trade_event.dart index bfe9dc5..0790df4 100644 --- a/FinlyticApp/lib/features/trades/bloc/trade_event.dart +++ b/FinlyticApp/lib/features/trades/bloc/trade_event.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import '../models/trade_acceptance_dto.dart'; +import '../models/close_trade_request_dto.dart'; abstract class TradeEvent extends Equatable { const TradeEvent(); @@ -20,11 +21,12 @@ class FetchTrades extends TradeEvent { class CloseTrade extends TradeEvent { final String tradeId; + final CloseTradeRequestDto? dto; - const CloseTrade(this.tradeId); + const CloseTrade(this.tradeId, {this.dto}); @override - List get props => [tradeId]; + List get props => [tradeId, dto]; } class AcceptTradeProposalEvent extends TradeEvent { @@ -35,3 +37,4 @@ class AcceptTradeProposalEvent extends TradeEvent { @override List get props => [dto]; } + diff --git a/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart b/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart index c82b3f0..f58adc9 100644 --- a/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart +++ b/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart @@ -1,12 +1,24 @@ /// Typed DTO for requesting a trade exit/close. class CloseTradeRequestDto { final double userExitPrice; + final DateTime? userExitTimestamp; + final double exitFee; + final String closeReason; - const CloseTradeRequestDto({required this.userExitPrice}); + const CloseTradeRequestDto({ + required this.userExitPrice, + this.userExitTimestamp, + this.exitFee = 1.0, + this.closeReason = 'ManualClosure', + }); Map toJson() { return { 'userExitPrice': userExitPrice, + if (userExitTimestamp != null) 'userExitTimestamp': userExitTimestamp!.toUtc().toIso8601String(), + 'exitFee': exitFee, + 'closeReason': closeReason, }; } } + diff --git a/FinlyticApp/lib/features/trades/models/trade_model.dart b/FinlyticApp/lib/features/trades/models/trade_model.dart index b6d9782..88eea38 100644 --- a/FinlyticApp/lib/features/trades/models/trade_model.dart +++ b/FinlyticApp/lib/features/trades/models/trade_model.dart @@ -1,5 +1,59 @@ import 'package:equatable/equatable.dart'; +enum DriftStatus { + onTrack, + trailingActive, + driftWarning, + exitAlert, +} + +class TradeHourlyUpdateModel extends Equatable { + final String recommendation; + final double currentPrice; + final double? suggestedStopLoss; + final double? suggestedTakeProfit; + final double vixValue; + final String reasoning; + final DateTime timestamp; + + const TradeHourlyUpdateModel({ + required this.recommendation, + required this.currentPrice, + this.suggestedStopLoss, + this.suggestedTakeProfit, + this.vixValue = 0.0, + required this.reasoning, + required this.timestamp, + }); + + factory TradeHourlyUpdateModel.fromJson(Map json) { + double parseDbl(dynamic val) { + if (val == null) return 0.0; + if (val is num) return val.toDouble(); + return double.tryParse(val.toString()) ?? 0.0; + } + + DateTime ts = DateTime.now(); + final tsStr = (json['timestamp'] ?? json['Timestamp'])?.toString(); + if (tsStr != null && tsStr.isNotEmpty) { + ts = DateTime.tryParse(tsStr) ?? DateTime.now(); + } + + return TradeHourlyUpdateModel( + recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'Hold', + currentPrice: parseDbl(json['currentPrice'] ?? json['CurrentPrice']), + suggestedStopLoss: json['suggestedStopLoss'] != null ? parseDbl(json['suggestedStopLoss'] ?? json['SuggestedStopLoss']) : null, + suggestedTakeProfit: json['suggestedTakeProfit'] != null ? parseDbl(json['suggestedTakeProfit'] ?? json['SuggestedTakeProfit']) : null, + vixValue: parseDbl(json['vixValue'] ?? json['VixValue']), + reasoning: (json['reasoning'] ?? json['Reasoning'])?.toString() ?? '', + timestamp: ts, + ); + } + + @override + List get props => [recommendation, currentPrice, suggestedStopLoss, suggestedTakeProfit, reasoning, timestamp]; +} + class TradeModel extends Equatable { final String id; final String analysisId; @@ -27,6 +81,9 @@ class TradeModel extends Equatable { final double winRate; final String timeframe; final String instrumentType; + final String assetType; + final bool hasCfd; + final List derivativeProductCategories; final String derivativeIsin; final DateTime? createdAt; @@ -41,6 +98,12 @@ class TradeModel extends Equatable { final double exitFee; final double quantity; + final String closeReason; + final DateTime? userExitTimestamp; + final bool hasPendingExitAlert; + final String pendingExitReason; + final List hourlyUpdates; + const TradeModel({ required this.id, this.analysisId = '', @@ -68,6 +131,9 @@ class TradeModel extends Equatable { this.winRate = 50.0, this.timeframe = '1D', this.instrumentType = 'Stock', + this.assetType = 'stock', + this.hasCfd = false, + this.derivativeProductCategories = const [], this.derivativeIsin = '', this.createdAt, this.riskTolerance = 'Moderate', @@ -80,6 +146,11 @@ class TradeModel extends Equatable { this.entryFee = 0.0, this.exitFee = 0.0, this.quantity = 0.0, + this.closeReason = '', + this.userExitTimestamp, + this.hasPendingExitAlert = false, + this.pendingExitReason = '', + this.hourlyUpdates = const [], }); bool get isActive => status.toLowerCase() == 'active'; @@ -87,6 +158,15 @@ class TradeModel extends Equatable { bool get isRejected => status.toLowerCase() == 'rejected'; bool get isProposed => (status.toLowerCase() == 'proposed' || isGlobalProposal) && !isRejected && !isActive && !isClosed; + DriftStatus get driftStatus { + if (hasPendingExitAlert) return DriftStatus.exitAlert; + if (hourlyUpdates.any((u) => u.recommendation.toLowerCase().contains('adjustsl') || u.recommendation.toLowerCase().contains('trailing'))) { + return DriftStatus.trailingActive; + } + if (calculatedPnlPct < -3.5) return DriftStatus.driftWarning; + return DriftStatus.onTrack; + } + double get effectiveCurrentPrice { if (currentPrice > 0) return currentPrice; if (actualEntryPrice > 0) return actualEntryPrice; @@ -162,6 +242,18 @@ class TradeModel extends Equatable { dt = DateTime.tryParse(createdStr); } + DateTime? exitDt; + final exitStr = (json['userExitTimestamp'] ?? json['UserExitTimestamp'])?.toString(); + if (exitStr != null && exitStr.isNotEmpty) { + exitDt = DateTime.tryParse(exitStr); + } + + List updates = []; + final rawUpdates = json['hourlyUpdates'] ?? json['HourlyUpdates']; + if (rawUpdates is List) { + updates = rawUpdates.map((u) => TradeHourlyUpdateModel.fromJson(Map.from(u))).toList(); + } + return TradeModel( id: idVal, analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '', @@ -189,6 +281,11 @@ class TradeModel extends Equatable { winRate: parseDbl(json['winRate'] ?? json['WinRate']), timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D', instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock', + assetType: (json['assetType'] ?? json['AssetType'])?.toString() ?? 'stock', + hasCfd: json['hasCfd'] == true || json['HasCfd'] == true, + derivativeProductCategories: (json['derivativeProductCategories'] ?? json['DerivativeProductCategories']) is List + ? ((json['derivativeProductCategories'] ?? json['DerivativeProductCategories']) as List).map((e) => e.toString()).toList() + : const [], derivativeIsin: (json['derivativeIsin'] ?? json['DerivativeIsin'] ?? json['knockoutIsin'] ?? json['KnockoutIsin'])?.toString() ?? '', createdAt: dt, riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate', @@ -203,6 +300,11 @@ class TradeModel extends Equatable { entryFee: parseDbl(json['entryFee'] ?? json['EntryFee']), exitFee: parseDbl(json['exitFee'] ?? json['ExitFee']), quantity: parseDbl(json['quantity'] ?? json['Quantity']), + closeReason: (json['closeReason'] ?? json['CloseReason'])?.toString() ?? '', + userExitTimestamp: exitDt, + hasPendingExitAlert: json['hasPendingExitAlert'] == true || json['HasPendingExitAlert'] == true, + pendingExitReason: (json['pendingExitReason'] ?? json['PendingExitReason'])?.toString() ?? '', + hourlyUpdates: updates, ); } @@ -234,6 +336,9 @@ class TradeModel extends Equatable { 'winRate': winRate, 'timeframe': timeframe, 'instrumentType': instrumentType, + 'assetType': assetType, + 'hasCfd': hasCfd, + 'derivativeProductCategories': derivativeProductCategories, 'derivativeIsin': derivativeIsin, 'createdAt': createdAt?.toIso8601String(), 'riskTolerance': riskTolerance, @@ -246,6 +351,10 @@ class TradeModel extends Equatable { 'entryFee': entryFee, 'exitFee': exitFee, 'quantity': quantity, + 'closeReason': closeReason, + 'userExitTimestamp': userExitTimestamp?.toIso8601String(), + 'hasPendingExitAlert': hasPendingExitAlert, + 'pendingExitReason': pendingExitReason, }; } @@ -263,5 +372,8 @@ class TradeModel extends Equatable { currentPrice, pnlAbsolute, pnlPercent, + hasPendingExitAlert, + hourlyUpdates, ]; } + diff --git a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart index 02ccea3..f7def57 100644 --- a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart +++ b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart @@ -3,6 +3,7 @@ import 'package:finlytic_app/core/network/api_client.dart'; import 'package:finlytic_app/features/trades/models/trade_model.dart'; import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart'; import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart'; +import 'package:finlytic_app/features/trades/models/derivative_item_model.dart'; class TradeRepository { final ApiClient apiClient; @@ -29,6 +30,42 @@ class TradeRepository { } } + Future> fetchDerivatives( + String isin, { + String optionType = 'long', + double? targetLeverage, + double? minLeverage, + double? maxLeverage, + String? search, + String? after, + int? page, + bool forceRefresh = false, + }) async { + try { + final queryParams = { + 'optionType': optionType, + if (targetLeverage != null && targetLeverage > 0) 'targetLeverage': targetLeverage, + if (minLeverage != null) 'minLeverage': minLeverage, + if (maxLeverage != null) 'maxLeverage': maxLeverage, + if (search != null && search.isNotEmpty) 'search': search, + if (after != null && after.isNotEmpty) 'after': after, + if (page != null) 'page': page, + if (forceRefresh) 'forceRefresh': 'true', + '_t': DateTime.now().millisecondsSinceEpoch, + }; + + final response = await apiClient.get('/api/v1/assets/$isin/derivatives', queryParameters: queryParams); + + if (response.statusCode == 200 && response.data != null) { + final List data = response.data; + return data.map((json) => DerivativeItemModel.fromJson(json)).toList(); + } + return []; + } catch (e) { + throw Exception('Derivate konnten nicht geladen werden: $e'); + } + } + Future acceptTrade(TradeAcceptanceDto dto) async { final response = await apiClient.post('/api/v1/user/trades/accept', data: dto.toJson()); if (response.statusCode != 200) { @@ -50,3 +87,4 @@ class TradeRepository { } } } + diff --git a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart index 3138909..f8a7751 100644 --- a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart +++ b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart @@ -3,17 +3,16 @@ 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 '../../auth/bloc/auth_bloc.dart'; import '../bloc/trade_bloc.dart'; import '../bloc/trade_event.dart'; import '../bloc/trade_state.dart'; import '../models/trade_model.dart'; -import '../models/trade_acceptance_dto.dart'; +import '../models/close_trade_request_dto.dart'; import '../repositories/trade_repository.dart'; import '../widgets/trade_card.dart'; import '../widgets/proposed_auto_trades_card.dart'; -import '../widgets/trade_acceptance_dialog.dart'; -import '../widgets/trade_execution_dialog.dart'; +import '../widgets/trade_execution_cockpit.dart'; +import '../widgets/trade_closing_cockpit.dart'; import '../widgets/trade_performance_bar.dart'; class TradesFeedScreen extends StatelessWidget { @@ -55,28 +54,45 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { super.dispose(); } - Future _handleAcceptProposal(BuildContext context, TradeModel trade) async { - final authState = context.read().state; - final currentUserId = (authState is Authenticated) ? authState.user.userId : 'default_user'; + void _handleAcceptProposal(BuildContext context, TradeModel trade, {bool isActive = false}) { + final tradeBloc = context.read(); - final result = await showDialog( - context: context, - builder: (ctx) => TradeAcceptanceDialog( - trade: trade, - theme: AppTheme.darkClassic, - userId: currentUserId, - ), + TradeExecutionCockpit.show( + context, + trade: trade, + defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin, + isActive: isActive, + onAccept: (dto) { + tradeBloc.add(AcceptTradeProposalEvent(dto)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isActive ? 'Einstellungen für ${trade.symbol} gespeichert!' : 'Trade für ${trade.symbol} eröffnet!'), + backgroundColor: AppTheme.primaryEmerald, + behavior: SnackBarBehavior.floating, + ), + ); + }, ); + } - if (result != null && mounted) { - context.read().add(AcceptTradeProposalEvent(result)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Trade wird in dein Portfolio übernommen...'), - backgroundColor: AppTheme.primaryEmerald, - ), - ); - } + void _handleCloseTrade(BuildContext context, TradeModel trade) { + final tradeBloc = context.read(); + + TradeClosingCockpit.show( + context, + trade: trade, + defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin, + onClose: (CloseTradeRequestDto dto) { + tradeBloc.add(CloseTrade(trade.id, dto: dto)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Position ${trade.symbol} geschlossen! Realisierter Verkaufskurs: €${dto.userExitPrice.toStringAsFixed(2)}'), + backgroundColor: AppTheme.primaryEmerald, + behavior: SnackBarBehavior.floating, + ), + ); + }, + ); } @override @@ -102,12 +118,12 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Live Portfolio & Trading', + 'Live Portfolio & Trades', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), ), const SizedBox(height: 2), Text( - 'KI-Erkennungen, Vorschläge & Aktive Positionen', + 'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), ], @@ -211,11 +227,11 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { scrollDirection: Axis.horizontal, child: Row( children: [ - _filterChip('Alle', allTrades.length), _filterChip('Offen', activeTrades.length), _filterChip('Vorschläge', proposals.length), - _filterChip('Abgelehnt', rejectedTrades.length), _filterChip('Geschlossen', closedTrades.length), + _filterChip('Abgelehnt', rejectedTrades.length), + _filterChip('Alle', allTrades.length), ], ), ), @@ -228,7 +244,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { children: [ Icon(Icons.inbox, size: 40, color: AppTheme.textMuted), const SizedBox(height: 8), - Text('Keine Trades in der Kategorie "$_selectedFilter" gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), + Text('Keine Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), ], ), ), @@ -243,13 +259,8 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { return TradeCard( trade: trade, onAccept: () => _handleAcceptProposal(context, trade), - onSettings: () => _showTradeSettingsDialog(context, trade), - onClose: () { - context.read().add(CloseTrade(trade.id)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Position wird geschlossen...')), - ); - }, + onSettings: () => _handleAcceptProposal(context, trade, isActive: true), + onClose: () => _handleCloseTrade(context, trade), ); }, ), @@ -312,24 +323,5 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { ), ); } - - void _showTradeSettingsDialog(BuildContext context, TradeModel trade) { - final tradeBloc = context.read(); - - TradeExecutionDialog.show( - context, - trade: trade, - defaultSymbol: trade.symbol, - isActive: true, - onAccept: (dto) { - tradeBloc.add(AcceptTradeProposalEvent(dto)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Einstellungen für ${trade.symbol} gespeichert.'), - backgroundColor: AppTheme.primaryEmerald, - ), - ); - }, - ); - } } + diff --git a/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart b/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart index 7203eb6..531f31d 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart @@ -37,7 +37,8 @@ class _TradeAcceptanceDialogState extends State { super.initState(); _entryPriceCtrl = TextEditingController(text: widget.trade.entryPrice.toStringAsFixed(2)); _positionSizeCtrl = TextEditingController(text: '1000'); - _leverageCtrl = TextEditingController(text: (widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1).toStringAsFixed(0)); + 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)); _notesCtrl = TextEditingController(); diff --git a/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart b/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart new file mode 100644 index 0000000..6e1cf5b --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../models/trade_model.dart'; + +class TradeCalculationCard extends StatelessWidget { + final TradeModel trade; + final bool initiallyExpanded; + final bool isCollapsible; + + const TradeCalculationCard({ + super.key, + required this.trade, + this.initiallyExpanded = true, + 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 quantity = entry > 0 ? (posSize / entry) : 0.0; + + // 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; + + // 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] : []); + + final content = Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _statTile( + 'Stückzahl (Basiswert)', + '${quantity.toStringAsFixed(2)} Stk.', + Colors.white, + ), + _statTile( + 'Max. Verlust (SL)', + '-€${riskAmountAbs.toStringAsFixed(2)}', + AppTheme.accentRed, + ), + _statTile( + 'Gewinn-Potenzial (TP)', + '+€${rewardAmountAbs.toStringAsFixed(2)}', + AppTheme.primaryEmerald, + ), + _statTile( + 'Chance-Risiko (CRV)', + crv > 0 ? '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, + ), + ), + ], + ), + if (targets.length > 1) ...[ + const Divider(color: Colors.white10, height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '🎯 MEHRSTUFIGE GEWINN-KALKULATION', + style: TextStyle( + color: AppTheme.primaryEmerald, + fontSize: 11, + fontWeight: FontWeight.w900, + letterSpacing: 0.5, + ), + ), + Text( + '${targets.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; + + final targetMovePct = entry > 0 + ? (isShort + ? ((entry - targetPrice) / entry) + : ((targetPrice - entry) / entry)) + : 0.0; + final rawTargetProfit = targetMovePct * posSize * lev; + final netTargetProfit = rawTargetProfit - totalFees; + 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(); + + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isCurrent + ? AppTheme.primaryEmerald.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCurrent + ? AppTheme.primaryEmerald.withValues(alpha: 0.7) + : Colors.white.withValues(alpha: 0.07), + width: isCurrent ? 1.5 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isCurrent + ? AppTheme.primaryEmerald + : Colors.white12, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'TP${idx + 1}', + style: TextStyle( + color: isCurrent ? Colors.black : Colors.white, + fontSize: 10, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 8), + Text( + '€${targetPrice.toStringAsFixed(2)}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + Text( + '(+${baseMove.toStringAsFixed(1)}% Basiswert)', + style: TextStyle( + color: AppTheme.textMuted, fontSize: 10.5), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '+€${cappedNet.toStringAsFixed(2)} (+${retPct.toStringAsFixed(1)}%)', + style: TextStyle( + color: isCurrent + ? AppTheme.primaryEmerald + : Colors.white, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + if (targetCrv > 0) + Text( + 'CRV 1 : ${targetCrv.toStringAsFixed(2)}', + style: TextStyle( + color: isCurrent + ? AppTheme.primaryEmerald + : AppTheme.accentCyan, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + }), + ], + ], + ), + ); + + if (!isCollapsible) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '3. LIVE-KALKULATION (AUTOMATISCH)', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w900, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + content, + ], + ); + } + + return Theme( + data: ThemeData(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: initiallyExpanded, + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + dense: true, + iconColor: AppTheme.primaryEmerald, + collapsedIconColor: Colors.white70, + title: Row( + children: [ + Icon(Icons.calculate_outlined, color: AppTheme.primaryEmerald, size: 16), + const SizedBox(width: 8), + const Text( + 'Live-Kalkulation & Gewinn-Potenzial', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + children: [ + content, + ], + ), + ); + } + + Widget _statTile(String label, String value, Color col) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), + const SizedBox(height: 2), + Text(value, + style: TextStyle( + color: col, fontWeight: FontWeight.bold, fontSize: 12.5)), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/trades/widgets/trade_card.dart b/FinlyticApp/lib/features/trades/widgets/trade_card.dart index 16eadb1..66755d4 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_card.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_card.dart @@ -2,9 +2,11 @@ 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'; class TradeCard extends StatelessWidget { @@ -23,7 +25,7 @@ class TradeCard extends StatelessWidget { @override Widget build(BuildContext context) { - final isBuy = trade.signalType == 'BUY'; + final isBuy = trade.signalType == 'BUY' || trade.signalType == 'LONG'; final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; final isProposed = trade.isProposed; final isActive = trade.isActive; @@ -47,224 +49,422 @@ class TradeCard extends StatelessWidget { final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed; final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice; - return GestureDetector( - onTap: () => TradeDetailModal.show( - context, - trade: trade, - onAccept: onAccept, - onClose: onClose, - ), - child: GlassContainer( - margin: const EdgeInsets.only(bottom: 14), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Row: Signal, Symbol, Status & Live PnL - Row( + return GlassContainer( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - 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: [ - Text( - trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN' - ? trade.companyName - : (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Aktie')), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), - ), - if (trade.isin.isNotEmpty || (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' && trade.symbol != trade.companyName)) - Text( - trade.isin.isNotEmpty ? trade.isin : trade.symbol, - style: TextStyle(color: AppTheme.textMuted, fontSize: 11), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - 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.toStringAsFixed(2)} €', - style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 14), - ), - Text( - '${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%', - style: TextStyle(color: pnlColor, fontSize: 11), - ), - ], - ), - ) - else if (trade.isRejected) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.accentRed.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.4)), - ), - child: Text( - 'ABGELEHNT', - style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 10), - ), - ) - else - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), - ), - child: const Text( - 'VORSCHLAG', - style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 10), - ), - ), - ], - ), - - const SizedBox(height: 14), - - // Price Metrics Grid with Live Kurs - 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.05)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _priceItem( - isActive || isClosed ? 'Ausführung' : '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('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed), - _priceItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald), - ], - ), - ), - - if (trade.reasoning.isNotEmpty) ...[ - const SizedBox(height: 10), - Text( - trade.reasoning, - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - - const SizedBox(height: 12), - - // Footer Action Row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${trade.instrumentType.isNotEmpty ? trade.instrumentType : "Stock"}${trade.derivativeIsin.isNotEmpty ? " (${trade.derivativeIsin})" : ""} • ${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(0)}x Hebel" : ""}', - style: TextStyle(color: AppTheme.textMuted, fontSize: 11), - ), - + // Header Row: Signal, Symbol, Drift-Radar & Live PnL Row( children: [ - IconButton( - onPressed: () => TradeDetailModal.show( - context, - trade: trade, - onAccept: onAccept, - onClose: onClose, + 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)), + ], ), - 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.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + 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), ], - if (isActive && onClose != null) ...[ - const SizedBox(width: 6), - OutlinedButton.icon( - onPressed: onClose, - icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed), - label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12)), - style: OutlinedButton.styleFrom( - side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ], + ), + + // 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), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.accentRed.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'KI-Guardian Ratschlag: Position schließen!', + style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12), + ), + if (trade.pendingExitReason.isNotEmpty) + Text( + trade.pendingExitReason, + style: const TextStyle(color: Colors.white70, fontSize: 11), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), + if (onClose != null) + ElevatedButton( + onPressed: onClose, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentRed, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ], + + const SizedBox(height: 12), + + // 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, ), ], - if (isActive && onSettings != null) ...[ - const SizedBox(width: 6), - IconButton( - onPressed: onSettings, - icon: const Icon(Icons.settings, size: 16, color: Colors.white), - tooltip: 'Einstellungen', - style: IconButton.styleFrom( - backgroundColor: AppTheme.glassSurface, + ), + ), + + if (trade.takeProfitTargets.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: 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(), ), ), ], - ], ), ], - ), - ], - ), + + 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), + ), + 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, + ), + ), + ], + ), + ); + }).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, + ), + ), + ], + ], + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _buildDriftRadarBar(TradeModel t) { + Color col; + String label; + 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'; + icon = Icons.security; + break; + case DriftStatus.driftWarning: + col = Colors.orangeAccent; + label = 'Drift-Radar: Leichte Abweichung von Prognose'; + icon = Icons.tune; + break; + case DriftStatus.onTrack: + col = AppTheme.primaryEmerald; + label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich'; + icon = Icons.radar; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: col.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: col.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Icon(icon, color: col, size: 14), + const SizedBox(width: 6), + Expanded( + child: Text(label, style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], ), ); - }, -); } Widget _priceItem(String label, String val, Color valColor) { diff --git a/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart b/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart new file mode 100644 index 0000000..85e6a1b --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../models/trade_model.dart'; +import '../models/close_trade_request_dto.dart'; + +class TradeClosingCockpit extends StatefulWidget { + final TradeModel trade; + final String defaultSymbol; + final void Function(CloseTradeRequestDto) onClose; + + const TradeClosingCockpit({ + super.key, + required this.trade, + required this.defaultSymbol, + required this.onClose, + }); + + static Future show( + BuildContext context, { + required TradeModel trade, + required String defaultSymbol, + required void Function(CloseTradeRequestDto) onClose, + }) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) => TradeClosingCockpit( + trade: trade, + defaultSymbol: defaultSymbol, + onClose: onClose, + ), + ); + } + + @override + State createState() => _TradeClosingCockpitState(); +} + +class _TradeClosingCockpitState extends State { + late TextEditingController _exitPriceCtrl; + late TextEditingController _exitFeeCtrl; + late TextEditingController _notesCtrl; + + DateTime _exitTimestamp = DateTime.now(); + String _selectedReasonTag = 'Manuell in TR verkauft'; + + @override + void initState() { + super.initState(); + final defaultPrice = widget.trade.currentPrice > 0 + ? widget.trade.currentPrice + : (widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : widget.trade.entryPrice); + + _exitPriceCtrl = TextEditingController(text: defaultPrice.toStringAsFixed(2)); + _exitFeeCtrl = TextEditingController(text: '1.00'); + _notesCtrl = TextEditingController(); + + _exitPriceCtrl.addListener(() => setState(() {})); + _exitFeeCtrl.addListener(() => setState(() {})); + } + + @override + void dispose() { + _exitPriceCtrl.dispose(); + _exitFeeCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + double _parse(TextEditingController ctrl, double fallback) { + final clean = ctrl.text.replaceAll(',', '.').trim(); + return double.tryParse(clean) ?? fallback; + } + + double get _exitPrice => _parse(_exitPriceCtrl, widget.trade.entryPrice); + 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 _calculatedProceeds { + if (_exitPrice <= 0 || _quantity <= 0) return 0.0; + return _quantity * _exitPrice; + } + + 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; + } + + double get _calculatedPnlPct { + if (_posSize <= 0) return 0.0; + return (_calculatedPnlAbs / _posSize) * 100.0; + } + + void _selectTimeOption(String option) { + final now = DateTime.now(); + setState(() { + if (option == 'now') { + _exitTimestamp = now; + } else if (option == 'today_morning') { + _exitTimestamp = DateTime(now.year, now.month, now.day, 9, 15); + } else if (option == 'today_noon') { + _exitTimestamp = DateTime(now.year, now.month, now.day, 13, 0); + } else if (option == 'yesterday') { + final yest = now.subtract(const Duration(days: 1)); + _exitTimestamp = DateTime(yest.year, yest.month, yest.day, 17, 30); + } + }); + } + + Future _pickCustomDateTime() async { + final pickedDate = await showDatePicker( + context: context, + initialDate: _exitTimestamp, + firstDate: DateTime.now().subtract(const Duration(days: 90)), + lastDate: DateTime.now(), + ); + + if (pickedDate != null && mounted) { + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_exitTimestamp), + ); + + if (pickedTime != null && mounted) { + setState(() { + _exitTimestamp = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final isWin = _calculatedPnlAbs >= 0; + final pnlColor = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Container( + width: 580, + constraints: const BoxConstraints(maxHeight: 780), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppTheme.glassBorder), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.6), blurRadius: 30, offset: const Offset(0, 10)), + ], + ), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 14), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.accentRed.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Position Schließen & Nacherfassen', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + Text( + 'Trade #${widget.trade.id} • ${widget.trade.companyName.isNotEmpty ? widget.trade.companyName : widget.defaultSymbol}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white54), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + + // Body + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Entry Recap Box + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _summaryCol('Einstiegskurs', '€${_entryPrice.toStringAsFixed(2)}'), + _summaryCol('Investition', '€${_posSize.toStringAsFixed(0)}'), + _summaryCol('Stückzahl', '${_quantity.toStringAsFixed(2)} Stk.'), + _summaryCol('Instrument', widget.trade.instrumentType), + ], + ), + ), + const SizedBox(height: 18), + + // SECTION 1: VERKAUFSKURS + const Text('1. TATSÄCHLICHER VERKAUFSKURS (€)', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 8), + + Row( + children: [ + Expanded( + child: TextField( + controller: _exitPriceCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.sell_outlined, size: 18, color: Colors.white54), + labelText: 'Verkaufskurs in Trade Republic', + filled: true, + fillColor: AppTheme.glassSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _exitFeeCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.receipt_long, size: 16, color: Colors.white54), + labelText: 'Ausstiegsgebühr (€)', + filled: true, + fillColor: AppTheme.glassSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + ), + ), + ), + ], + ), + const SizedBox(height: 18), + + // SECTION 2: ZEITPUNKT + const Text('2. WANN WURDE DER TRADE GESCHLOSSEN?', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 8), + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _timeChip('Jetzt', 'now'), + _timeChip('Heute Morgen (09:15)', 'today_morning'), + _timeChip('Heute Mittag (13:00)', 'today_noon'), + _timeChip('Gestern (17:30)', 'yesterday'), + ], + ), + ), + const SizedBox(height: 8), + + // Custom Date/Time Picker Trigger + GestureDetector( + onTap: _pickCustomDateTime, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.calendar_today, size: 16, color: AppTheme.accentCyan), + const SizedBox(width: 8), + Text( + 'Ausführungszeit: ${_formatDateTime(_exitTimestamp)}', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + Text('Ändern', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold)), + ], + ), + ), + ), + const SizedBox(height: 18), + + // SECTION 3: GRUND / NOTIZ + const Text('3. AUSSTIEGSGRUND', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 8), + + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _reasonChip('🎯 Take-Profit gegriffen'), + _reasonChip('📱 Manuell in TR verkauft'), + _reasonChip('🛑 Stop-Loss ausgelöst'), + _reasonChip('🕒 Vor Wochenende / Time-Stop'), + _reasonChip('⚠️ Risiko minimiert'), + ], + ), + const SizedBox(height: 20), + + // LIVE REALISIERTER PNL VORSCHAU + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: pnlColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: pnlColor.withValues(alpha: 0.4)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Realisierter Gewinn / Verlust (PnL):', style: const TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)), + Text( + '${(isWin ? "+€" : "-€")}${_calculatedPnlAbs.abs().toStringAsFixed(2)} (${isWin ? "+" : ""}${_calculatedPnlPct.toStringAsFixed(2)}%)', + style: TextStyle(color: pnlColor, fontSize: 17, fontWeight: FontWeight.w900), + ), + ], + ), + const Divider(color: Colors.white12, height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Verkaufserlös (Gesamt): €${_calculatedProceeds.toStringAsFixed(2)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + Text('Netto nach Gebühren: €${(_posSize + _calculatedPnlAbs).toStringAsFixed(2)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + ], + ), + ], + ), + ), + ], + ), + ), + ), + + // Actions Footer + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () { + final req = CloseTradeRequestDto( + userExitPrice: _exitPrice, + userExitTimestamp: _exitTimestamp, + exitFee: _exitFee, + closeReason: _selectedReasonTag, + ); + Navigator.pop(context); + widget.onClose(req); + }, + icon: const Icon(Icons.check_circle, size: 18), + label: const Text('Position Exakt So Buchen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + style: ElevatedButton.styleFrom( + backgroundColor: pnlColor, + foregroundColor: isWin ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _summaryCol(String label, String val) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), + const SizedBox(height: 2), + Text(val, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + ], + ); + } + + Widget _timeChip(String label, String option) { + return GestureDetector( + onTap: () => _selectTimeOption(option), + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ); + } + + Widget _reasonChip(String label) { + final isSelected = _selectedReasonTag == label; + return GestureDetector( + onTap: () => setState(() => _selectedReasonTag = label), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? AppTheme.accentCyan.withValues(alpha: 0.2) : AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isSelected ? AppTheme.accentCyan : AppTheme.glassBorder), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? AppTheme.accentCyan : Colors.white70, + fontSize: 11, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + String _formatDateTime(DateTime dt) { + final d = dt.day.toString().padLeft(2, '0'); + final m = dt.month.toString().padLeft(2, '0'); + final y = dt.year; + final h = dt.hour.toString().padLeft(2, '0'); + final min = dt.minute.toString().padLeft(2, '0'); + return '$d.$m.$y um $h:$min Uhr'; + } +} diff --git a/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart b/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart index 434842a..6f63c02 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../core/theme/app_theme.dart'; import '../models/trade_model.dart'; +import 'trade_calculation_card.dart'; class TradeDetailContent extends StatelessWidget { final TradeModel trade; @@ -18,6 +19,45 @@ class TradeDetailContent extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Drift Radar Status + if (trade.isActive) ...[ + _buildDriftRadarCard(trade), + 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), decoration: BoxDecoration( @@ -31,16 +71,69 @@ class TradeDetailContent extends StatelessWidget { _metricItem( trade.isActive || trade.isClosed ? 'Ausführung' : 'Ziel-Einstieg', trade.actualEntryPrice > 0 - ? '${trade.actualEntryPrice.toStringAsFixed(2)} €' - : (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)} €' : '-'), + ? '€${trade.actualEntryPrice.toStringAsFixed(2)}' + : (trade.entryPrice > 0 ? '€${trade.entryPrice.toStringAsFixed(2)}' : '-'), Colors.white, ), - _metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)} €', AppTheme.accentCyan), - _metricItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed), - _metricItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald), + _metricItem('Live-Kurs', '€${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan), + _metricItem( + trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss', + '€${trade.stopLoss.toStringAsFixed(2)}', + AppTheme.accentRed, + ), + _metricItem( + trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit', + '€${trade.takeProfit.toStringAsFixed(2)}', + AppTheme.primaryEmerald, + ), ], ), ), + if (trade.takeProfitTargets.length > 1) ...[ + const SizedBox(height: 10), + Row( + children: [ + Text( + 'Alle Gewinn-Ziele: ', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.bold), + ), + 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: 8, vertical: 3), + 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: 11, + fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold, + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ], if (trade.isActive || trade.isClosed) ...[ const SizedBox(height: 14), Container( @@ -55,7 +148,7 @@ class TradeDetailContent extends StatelessWidget { children: [ const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)), Text( - '${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)} € (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)', + '${isPnlPos ? '+' : ''}€${pnlAbs.abs().toStringAsFixed(2)} (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)', style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15), ), ], @@ -63,66 +156,12 @@ class TradeDetailContent extends StatelessWidget { ), ], const SizedBox(height: 20), - 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(14), - decoration: BoxDecoration( - color: AppTheme.primaryEmerald.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - 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: 18), - ], - 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(14), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)), - ), - const SizedBox(height: 18), - ], - if (trade.fundamentalRationale.isNotEmpty) ...[ - _sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent), - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)), - ), - const SizedBox(height: 18), - ], - 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(14), - decoration: BoxDecoration( - color: AppTheme.accentRed.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)), - ), - child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)), - ), - const SizedBox(height: 18), - ], + + // 3. LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE + TradeCalculationCard(trade: trade), + const SizedBox(height: 18), + + // Trade Parameters & Instrument _sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70), const SizedBox(height: 8), Container( @@ -137,15 +176,222 @@ class TradeDetailContent extends StatelessWidget { _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(0)}x'), - if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)} €'), + if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(1)}x'), + if (trade.positionSize > 0) _paramRow('Positionsgröße:', '€${trade.positionSize.toStringAsFixed(2)}'), ], ), ), + 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(), + ), + ), + 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)), + ), + ], + ], + ), + ), + ), ], ); } + 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.'; + break; + case DriftStatus.driftWarning: + col = Colors.orangeAccent; + title = 'Leichte Drift / Kursabweichung'; + desc = 'Der Kurs bewegt sich leicht entgegen der primären Prognose.'; + break; + case DriftStatus.onTrack: + col = AppTheme.primaryEmerald; + title = 'Auf Kurs • Prognose intakt'; + desc = 'Die Entwicklung entspricht der statistischen KI-Prognose.'; + break; + } + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: col.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: col.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Icon(Icons.radar, color: col, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Drift-Radar: $title', style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)), + Text(desc, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + ], + ), + ), + ], + ), + ); + } + Widget _sectionTitle(IconData icon, String title, Color color) { return Row( children: [ @@ -179,3 +425,4 @@ class TradeDetailContent extends StatelessWidget { ); } } + diff --git a/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart new file mode 100644 index 0000000..045f6d4 --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart @@ -0,0 +1,1020 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../asset_detail/repositories/asset_repository.dart'; +import '../models/trade_model.dart'; +import '../models/trade_acceptance_dto.dart'; +import '../models/derivative_item_model.dart'; +import 'derivative_picker_modal.dart'; +import 'trade_execution_ai_plan_card.dart'; + +class TradeExecutionCockpit extends StatefulWidget { + final TradeModel trade; + final String defaultSymbol; + final bool isActive; + final Function(TradeAcceptanceDto dto) onAccept; + final Function(String tradeId)? onReject; + + const TradeExecutionCockpit({ + super.key, + required this.trade, + required this.defaultSymbol, + this.isActive = false, + required this.onAccept, + this.onReject, + }); + + static Future show( + BuildContext context, { + required TradeModel trade, + required String defaultSymbol, + bool isActive = false, + required Function(TradeAcceptanceDto dto) onAccept, + Function(String tradeId)? onReject, + }) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) => TradeExecutionCockpit( + trade: trade, + defaultSymbol: defaultSymbol, + isActive: isActive, + onAccept: onAccept, + onReject: onReject, + ), + ); + } + + @override + State createState() => _TradeExecutionCockpitState(); +} + +class _InstrumentOption { + final String label; + final String value; + const _InstrumentOption(this.label, this.value); +} + +class _TradeExecutionCockpitState extends State { + late TextEditingController _entryPriceCtrl; + late TextEditingController _positionSizeCtrl; + late TextEditingController _leverageCtrl; + late TextEditingController _stopLossCtrl; + late TextEditingController _takeProfitCtrl; + late TextEditingController _derivativeIsinCtrl; + late TextEditingController _entryFeeCtrl; + late TextEditingController _exitFeeCtrl; + + late String _selectedInstrument; + DerivativeItemModel? _selectedDerivative; + double? _derivativePrice; + bool _isFetchingDerivativePrice = false; + + List<_InstrumentOption> get _availableInstruments { + final assetType = widget.trade.assetType.toLowerCase(); + final categories = widget.trade.derivativeProductCategories; + final hasCfd = widget.trade.hasCfd; + final list = <_InstrumentOption>[]; + + if (assetType == 'crypto') { + list.add(const _InstrumentOption('Krypto (Spot)', 'Crypto')); + if (hasCfd) { + list.add(const _InstrumentOption('Krypto CFD', 'CFD')); + } + } else if (assetType == 'etf') { + list.add(const _InstrumentOption('ETF (Spot)', 'Stock')); + if (categories.isEmpty || categories.contains('knockOutProduct')) { + list.add(const _InstrumentOption('⚡ Knock-Out (Turbo)', 'KnockOut')); + } + if (categories.contains('vanillaWarrant')) { + list.add(const _InstrumentOption('Optionsschein', 'Option')); + } + if (categories.contains('factorCertificate')) { + list.add(const _InstrumentOption('Faktor-Zertifikat', 'Factor')); + } + if (hasCfd) { + list.add(const _InstrumentOption('CFD', 'CFD')); + } + } else { + // Default: Stock or other + list.add(const _InstrumentOption('Aktie (Spot)', 'Stock')); + if (categories.isEmpty || categories.contains('knockOutProduct')) { + list.add(const _InstrumentOption('⚡ Knock-Out (Turbo)', 'KnockOut')); + } + if (categories.contains('vanillaWarrant')) { + list.add(const _InstrumentOption('Optionsschein', 'Option')); + } + if (categories.contains('factorCertificate')) { + list.add(const _InstrumentOption('Faktor-Zertifikat', 'Factor')); + } + if (hasCfd) { + list.add(const _InstrumentOption('CFD', 'CFD')); + } + } + + return list; + } + + String _formatCurrencyNum(double val) { + if (val <= 0) return '0'; + if (val == val.roundToDouble()) { + return val.toInt().toString(); + } + var s = val.toStringAsFixed(2); + if (s.endsWith('0')) { + s = s.substring(0, s.length - 1); + } + return s.replaceAll('.', ','); + } + + 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'; + } + + String _formatLeverageNum(double lev) { + if (lev <= 0) return '1'; + if (lev == lev.roundToDouble()) { + return lev.toInt().toString(); + } + var s = lev.toStringAsFixed(2); + if (s.endsWith('0')) { + s = s.substring(0, s.length - 1); + } + return s.replaceAll('.', ','); + } + + @override + void initState() { + super.initState(); + final initEntry = widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : (widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 100.0); + final initPos = widget.trade.positionSize > 0 ? widget.trade.positionSize : 1000.0; + final initLev = widget.trade.leverageUsed > 0 ? widget.trade.leverageUsed : (widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1.0); + + _entryPriceCtrl = TextEditingController(text: initEntry.toStringAsFixed(2)); + _positionSizeCtrl = TextEditingController(text: _formatCurrencyNum(initPos)); + _leverageCtrl = TextEditingController(text: _formatLeverageNum(initLev)); + _stopLossCtrl = TextEditingController(text: widget.trade.stopLoss > 0 ? widget.trade.stopLoss.toStringAsFixed(2) : ''); + _takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit > 0 ? widget.trade.takeProfit.toStringAsFixed(2) : ''); + _derivativeIsinCtrl = TextEditingController(text: widget.trade.derivativeIsin); + _entryFeeCtrl = TextEditingController(text: widget.trade.entryFee >= 0 ? _formatCurrencyNum(widget.trade.entryFee > 0 ? widget.trade.entryFee : 1.0) : '1.00'); + _exitFeeCtrl = TextEditingController(text: widget.trade.exitFee >= 0 ? _formatCurrencyNum(widget.trade.exitFee > 0 ? widget.trade.exitFee : 1.0) : '1.00'); + + final available = _availableInstruments; + final normalized = _normalizeInstrumentType(widget.trade.instrumentType); + if (available.any((opt) => opt.value == normalized)) { + _selectedInstrument = normalized; + } else { + _selectedInstrument = available.isNotEmpty ? available.first.value : 'Stock'; + } + + if (widget.trade.derivativeIsin.isNotEmpty) { + _fetchLiveDerivativePrice(widget.trade.derivativeIsin); + } + + _entryPriceCtrl.addListener(() => setState(() {})); + _positionSizeCtrl.addListener(() => setState(() {})); + _leverageCtrl.addListener(() => setState(() {})); + _stopLossCtrl.addListener(() => setState(() {})); + _takeProfitCtrl.addListener(() => setState(() {})); + _entryFeeCtrl.addListener(() => setState(() {})); + _exitFeeCtrl.addListener(() => setState(() {})); + } + + @override + void dispose() { + _entryPriceCtrl.dispose(); + _positionSizeCtrl.dispose(); + _leverageCtrl.dispose(); + _stopLossCtrl.dispose(); + _takeProfitCtrl.dispose(); + _derivativeIsinCtrl.dispose(); + _entryFeeCtrl.dispose(); + _exitFeeCtrl.dispose(); + super.dispose(); + } + + String _normalizeInstrumentType(String raw) { + final clean = raw.toLowerCase().trim(); + if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) return 'KnockOut'; + if (clean.contains('factor') || clean.contains('faktor')) return 'Factor'; + if (clean.contains('option') || clean.contains('warrant')) return 'Option'; + if (clean.contains('cfd')) return 'CFD'; + if (clean.contains('crypto') || clean.contains('krypto')) return 'Crypto'; + return 'Stock'; + } + + double _parse(TextEditingController ctrl, double fallback) { + final clean = ctrl.text.replaceAll(',', '.').trim(); + return double.tryParse(clean) ?? fallback; + } + + double get _entryPrice => _parse(_entryPriceCtrl, widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 1.0); + double get _positionSize => _parse(_positionSizeCtrl, 1000.0); + double get _leverage => _parse(_leverageCtrl, 1.0); + double get _stopLoss => _parse(_stopLossCtrl, widget.trade.stopLoss); + double get _takeProfit => _parse(_takeProfitCtrl, widget.trade.takeProfit); + double get _entryFee => _parse(_entryFeeCtrl, 1.0); + double get _exitFee => _parse(_exitFeeCtrl, 1.0); + double get _totalFees => _entryFee + _exitFee; + + double get _effectiveLeverage { + final isLeveraged = _selectedInstrument == 'KnockOut' || + _selectedInstrument == 'Option' || + _selectedInstrument == 'Factor' || + _selectedInstrument == 'CFD'; + return isLeveraged && _leverage > 0 ? _leverage : 1.0; + } + + double get _quantity => (_entryPrice > 0 && _positionSize > 0) ? (_positionSize / _entryPrice) : 0.0; + int get _derivativeQuantity => (_derivativePrice != null && _derivativePrice! > 0 && _positionSize > 0) ? (_positionSize / _derivativePrice!).floor() : 0; + + double get _riskAmountAbs { + if (_entryPrice <= 0 || _stopLoss <= 0) return 0.0; + final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; + final movePct = isShort ? ((_stopLoss - _entryPrice) / _entryPrice) : ((_entryPrice - _stopLoss) / _entryPrice); + + // Leveraged loss on position + final rawLoss = (movePct * _positionSize * _effectiveLeverage).abs(); + + // Knock-Out and options cannot lose more than the invested position capital (Totalverlust-Kappung) + final isDerivative = _selectedInstrument == 'KnockOut' || _selectedInstrument == 'Option' || _selectedInstrument == 'Factor'; + final cappedLoss = isDerivative ? rawLoss.clamp(0.0, _positionSize) : rawLoss; + + return cappedLoss + _totalFees; + } + + double get _rewardAmountAbs { + if (_entryPrice <= 0 || _takeProfit <= 0) return 0.0; + final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; + final movePct = isShort ? ((_entryPrice - _takeProfit) / _entryPrice) : ((_takeProfit - _entryPrice) / _entryPrice); + + final rawProfit = (movePct * _positionSize * _effectiveLeverage); + final profitAfterFees = rawProfit - _totalFees; + return profitAfterFees > 0 ? profitAfterFees : 0.0; + } + + double get _crv { + if (_riskAmountAbs <= 0 || _rewardAmountAbs <= 0) return 0.0; + return _rewardAmountAbs / _riskAmountAbs; + } + + List get _availableTpTargets { + if (widget.trade.takeProfitTargets.isNotEmpty) { + return widget.trade.takeProfitTargets; + } + if (widget.trade.takeProfit > 0) { + return [widget.trade.takeProfit]; + } + return []; + } + + double _calculateRewardForTarget(double targetPrice) { + if (_entryPrice <= 0 || targetPrice <= 0) return 0.0; + final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; + final movePct = isShort ? ((_entryPrice - targetPrice) / _entryPrice) : ((targetPrice - _entryPrice) / _entryPrice); + final rawProfit = (movePct * _positionSize * _effectiveLeverage); + final profitAfterFees = rawProfit - _totalFees; + return profitAfterFees > 0 ? profitAfterFees : 0.0; + } + + double _calculateCrvForTarget(double targetPrice) { + final reward = _calculateRewardForTarget(targetPrice); + if (_riskAmountAbs <= 0 || reward <= 0) return 0.0; + return reward / _riskAmountAbs; + } + + Future _openDerivativeFinder() async { + final isinVal = widget.trade.isin.isNotEmpty ? widget.trade.isin : widget.defaultSymbol; + final symVal = widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol; + final nameVal = widget.trade.companyName.isNotEmpty ? widget.trade.companyName : symVal; + final priceVal = _entryPrice > 0 ? _entryPrice : (widget.trade.currentPrice > 0 ? widget.trade.currentPrice : widget.trade.entryPrice); + + final selected = await DerivativePickerModal.show( + context, + underlyingIsin: isinVal, + underlyingSymbol: symVal, + underlyingName: nameVal, + initialSignalType: widget.trade.signalType, + currentUnderlyingPrice: priceVal, + ); + + if (selected != null) { + setState(() { + _selectedDerivative = selected; + _derivativeIsinCtrl.text = selected.isin; + _selectedInstrument = 'KnockOut'; + if (selected.leverage > 0) { + _leverageCtrl.text = _formatLeverageNum(selected.leverage); + } + }); + _fetchLiveDerivativePrice(selected.isin); + } + } + + Future _fetchLiveDerivativePrice(String isin) async { + if (isin.trim().isEmpty) return; + setState(() => _isFetchingDerivativePrice = true); + + try { + final apiClient = context.read(); + final assetRepo = AssetRepository(apiClient: apiClient); + final technicals = await assetRepo.getAssetTechnical(isin.trim().toUpperCase(), 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 && mounted) { + setState(() { + _derivativePrice = fetchedPrice; + }); + } + } catch (_) { + } finally { + if (mounted) setState(() => _isFetchingDerivativePrice = false); + } + } + + TradeAcceptanceDto _buildDto() { + final isinVal = widget.trade.isin.isNotEmpty ? widget.trade.isin : (widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol); + final symbolVal = widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol; + + return TradeAcceptanceDto( + userId: widget.trade.userId, + tradeId: widget.trade.id, + analysisId: widget.trade.analysisId, + isin: isinVal, + symbol: symbolVal, + actualEntryPrice: _entryPrice, + positionSize: _positionSize, + leverageUsed: _leverage, + entryFee: _entryFee, + exitFee: _exitFee, + quantity: _quantity, + executionTimestamp: DateTime.now().toUtc(), + signalType: widget.trade.signalType, + entryPrice: widget.trade.entryPrice, + stopLoss: _stopLoss, + takeProfit: _takeProfit, + instrumentType: _selectedInstrument, + derivativeIsin: _derivativeIsinCtrl.text.trim(), + timeframe: widget.trade.timeframe, + reasoning: widget.trade.reasoning, + ); + } + + @override + Widget build(BuildContext context) { + final isBuy = widget.trade.signalType.toUpperCase() == 'BUY' || widget.trade.signalType.toUpperCase() == 'LONG'; + final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Container( + width: 640, + constraints: const BoxConstraints(maxHeight: 820), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppTheme.glassBorder), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.6), blurRadius: 30, offset: const Offset(0, 10)), + ], + ), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 14), + child: Row( + children: [ + StatusBadge(label: widget.trade.signalType.toUpperCase(), color: signalColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.isActive ? 'Einstellungen für Trade #${widget.trade.id}' : '1-Click Trade Execution Cockpit', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + Text( + '${widget.trade.companyName.isNotEmpty ? widget.trade.companyName : widget.defaultSymbol} (${widget.trade.isin.isNotEmpty ? widget.trade.isin : widget.defaultSymbol})', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white54), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + + // Scrollable Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // AI Plan Summary + TradeExecutionAiPlanCard(trade: widget.trade), + const SizedBox(height: 18), + + // SECTION 1: INSTRUMENT & DERIVATIVES + const Text('1. FINANZINSTRUMENT & DERIVATE', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 10), + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _availableInstruments.map((opt) { + return _instrumentChip(opt.label, opt.value); + }).toList(), + ), + ), + const SizedBox(height: 12), + + // Derivat Picker Button + if (_selectedInstrument == 'KnockOut' || _selectedInstrument == 'Option' || _selectedInstrument == 'Factor') ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.accentCyan.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(Icons.bolt, color: AppTheme.accentCyan, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _selectedDerivative != null + ? '${_selectedDerivative!.issuerDisplayName} ${_selectedDerivative!.productCategoryName} (${_formatLeverage(_selectedDerivative!.leverage)})' + : (_derivativeIsinCtrl.text.isNotEmpty ? 'Derivat ISIN: ${_derivativeIsinCtrl.text}' : 'Kein Derivat gewählt (Basiswert aktiv)'), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ), + if (_selectedDerivative != null) ...[ + const SizedBox(height: 2), + Wrap( + spacing: 8, + children: [ + if (_selectedDerivative!.barrier > 0) + Text('KO-Schwelle: €${_selectedDerivative!.barrier.toStringAsFixed(2)}', style: const TextStyle(color: Colors.orangeAccent, fontSize: 11)), + if (_derivativePrice != null && _derivativePrice! > 0) + Text('Derivatkurs: €${_derivativePrice!.toStringAsFixed(2)} ($_derivativeQuantity Stk.)', style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold)), + ], + ), + ], + ], + ), + ), + ElevatedButton.icon( + onPressed: _openDerivativeFinder, + icon: const Icon(Icons.search, size: 14), + label: Text(_selectedDerivative != null ? 'Ändern' : 'Derivat wählen', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentCyan, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // SECTION 2: POSITION & KAPITAL + const Text('2. INVESTITION & POSITION', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 10), + + // Quick Capital Pills + Row( + children: [ + _capitalPill('250 €', 250), + _capitalPill('500 €', 500), + _capitalPill('1.000 €', 1000), + _capitalPill('2.500 €', 2500), + _capitalPill('5.000 €', 5000), + ], + ), + const SizedBox(height: 10), + + Row( + children: [ + Expanded( + child: _buildInput('Investitionsbetrag (€)', _positionSizeCtrl, Icons.account_balance_wallet), + ), + const SizedBox(width: 12), + Expanded( + child: _buildInput( + 'Einstiegskurs (€)', + _entryPriceCtrl, + Icons.price_change, + suffixWidget: _isFetchingDerivativePrice + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.cyan)) + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Stop-Loss & Take-Profit + Row( + children: [ + Expanded(child: _buildInput('Stop-Loss (€)', _stopLossCtrl, Icons.shield_outlined, accentColor: AppTheme.accentRed)), + const SizedBox(width: 12), + Expanded(child: _buildInput('Take-Profit (€)', _takeProfitCtrl, Icons.trending_up, accentColor: AppTheme.primaryEmerald)), + ], + ), + if (_availableTpTargets.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'KI-Ziele (TP-Stufen):', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 4, + children: _availableTpTargets.asMap().entries.map((entry) { + final idx = entry.key; + final targetPrice = entry.value; + final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; + final diffPct = _entryPrice > 0 + ? (isShort ? ((_entryPrice - targetPrice) / _entryPrice * 100) : ((targetPrice - _entryPrice) / _entryPrice * 100)) + : 0.0; + final isSelected = (_takeProfit - targetPrice).abs() < 0.001; + + return InkWell( + onTap: () { + setState(() { + _takeProfitCtrl.text = targetPrice.toStringAsFixed(2); + }); + }, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.primaryEmerald.withValues(alpha: 0.2) + : Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? AppTheme.primaryEmerald + : Colors.white.withValues(alpha: 0.15), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'TP${idx + 1}: €${targetPrice.toStringAsFixed(2)}', + style: TextStyle( + color: isSelected ? AppTheme.primaryEmerald : Colors.white, + fontSize: 11, + fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold, + ), + ), + if (diffPct != 0) ...[ + const SizedBox(width: 4), + Text( + '(${diffPct >= 0 ? "+" : ""}${diffPct.toStringAsFixed(1)}%)', + style: TextStyle( + color: diffPct >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ], + const SizedBox(height: 18), + + // SECTION 3: AUTOMATISCH ERRECHNETE KENNZAHLEN (ZONE 2) + const Text('3. LIVE-KALKULATION (AUTOMATISCH)', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _statTile( + _selectedInstrument == 'KnockOut' || _selectedInstrument == 'Option' || _selectedInstrument == 'Factor' + ? 'Stückzahl (${_derivativePrice != null ? "Derivat" : "Basiswert"})' + : 'Stückzahl', + _derivativePrice != null && _derivativePrice! > 0 + ? '$_derivativeQuantity Stk.' + : '${_quantity.toStringAsFixed(2)} Stk.', + Colors.white, + ), + _statTile('Max. Verlust (SL)', '-€${_riskAmountAbs.toStringAsFixed(2)}', AppTheme.accentRed), + _statTile('Gewinn-Potenzial (TP)', '+€${_rewardAmountAbs.toStringAsFixed(2)}', AppTheme.primaryEmerald), + _statTile('Chance-Risiko (CRV)', _crv > 0 ? '1 : ${_crv.toStringAsFixed(2)}' : '-', AppTheme.accentCyan), + ], + ), + const Divider(color: Colors.white10, height: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Pauschalgebühren (€${_totalFees.toStringAsFixed(2)} gesamt):', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + if (_effectiveLeverage > 1.0) + Text('Effektiver Hebel: ${_formatLeverage(_effectiveLeverage)}', style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _buildSmallFeeInput('Kauf (€)', _entryFeeCtrl), + ), + const SizedBox(width: 8), + Expanded( + child: _buildSmallFeeInput('Verkauf (€)', _exitFeeCtrl), + ), + const SizedBox(width: 8), + InkWell( + onTap: () { + setState(() { + if (_entryFee == 0) { + _entryFeeCtrl.text = '1,00'; + } else { + _entryFeeCtrl.text = '0,00'; + } + }); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: _entryFee == 0 ? AppTheme.accentCyan.withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _entryFee == 0 ? AppTheme.accentCyan : Colors.white12), + ), + child: Text( + _entryFee == 0 ? '✓ Im Investitionsbetrag enthalten' : 'Kaufgebühr im Betrag enthalten?', + style: TextStyle( + color: _entryFee == 0 ? AppTheme.accentCyan : AppTheme.textMuted, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'ℹ️ Hinweis: Rechnerischer Näherungswert. Der effektive Hebel eines Derivats verändert sich dynamisch mit dem Kurs des Basiswerts (Omega/Delta).', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontStyle: FontStyle.italic), + ), + ], + ), + + // MULTI-TARGET GEWINNSTUFEN (TP1 - TP3) + if (_availableTpTargets.length > 1) ...[ + const Divider(color: Colors.white10, height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '🎯 MEHRSTUFIGE GEWINN-KALKULATION', + style: TextStyle( + color: AppTheme.primaryEmerald, + fontSize: 11, + fontWeight: FontWeight.w900, + letterSpacing: 0.5, + ), + ), + Text( + 'Klick zum Aktivieren', + style: TextStyle(color: AppTheme.textMuted, fontSize: 10), + ), + ], + ), + const SizedBox(height: 8), + ..._availableTpTargets.asMap().entries.map((entry) { + final idx = entry.key; + final targetPrice = entry.value; + final isSelected = (_takeProfit - targetPrice).abs() < 0.001; + final reward = _calculateRewardForTarget(targetPrice); + final crv = _calculateCrvForTarget(targetPrice); + final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; + final movePct = _entryPrice > 0 + ? (isShort ? ((_entryPrice - targetPrice) / _entryPrice * 100) : ((targetPrice - _entryPrice) / _entryPrice * 100)) + : 0.0; + final retPct = _positionSize > 0 ? (reward / _positionSize * 100) : 0.0; + + return InkWell( + onTap: () { + setState(() { + _takeProfitCtrl.text = targetPrice.toStringAsFixed(2); + }); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 5), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.primaryEmerald.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? AppTheme.primaryEmerald.withValues(alpha: 0.7) + : Colors.white.withValues(alpha: 0.07), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected ? AppTheme.primaryEmerald : Colors.white12, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'TP${idx + 1}', + style: TextStyle( + color: isSelected ? Colors.black : Colors.white, + fontSize: 10, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 8), + Text( + '€${targetPrice.toStringAsFixed(2)}', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(width: 6), + Text( + '(${movePct >= 0 ? "+" : ""}${movePct.toStringAsFixed(1)}% Basiswert)', + style: TextStyle(color: AppTheme.textMuted, fontSize: 10.5), + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '+€${reward.toStringAsFixed(2)} (+${retPct.toStringAsFixed(1)}%)', + style: TextStyle( + color: AppTheme.primaryEmerald, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'CRV 1 : ${crv > 0 ? crv.toStringAsFixed(2) : "-"}', + style: TextStyle(color: AppTheme.accentCyan, fontSize: 10), + ), + ], + ), + ], + ), + ), + ); + }), + ], + ], + ), + ), + const SizedBox(height: 14), + + // SECTION 4: RECHTLICHER DISCLAIMER + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.amber.withValues(alpha: 0.25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.gavel_outlined, size: 16, color: Colors.amber.withValues(alpha: 0.85)), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Rechtlicher Hinweis: Keine Anlageberatung. Sämtliche Analysen, Kennzahlen und Simulationen dienen ausschließlich Informations- und Bildungszwecken. Der Handel mit Hebelprodukten (Derivate, CFDs) birgt erhebliche Risiken bis hin zum Totalverlust des eingesetzten Kapitals.', + style: TextStyle(color: AppTheme.textMuted, fontSize: 10.5, height: 1.35), + ), + ), + ], + ), + ), + ], + ), + ), + ), + + // Actions Footer + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), + ), + const Spacer(), + if (!widget.isActive && widget.onReject != null) ...[ + OutlinedButton.icon( + onPressed: () { + widget.onReject!(widget.trade.id); + Navigator.pop(context); + }, + icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16), + label: Text('Ablehnen', style: TextStyle(color: AppTheme.accentRed)), + style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5))), + ), + const SizedBox(width: 10), + ], + ElevatedButton.icon( + onPressed: () { + final dto = _buildDto(); + widget.onAccept(dto); + Navigator.pop(context); + }, + icon: Icon(widget.isActive ? Icons.save : Icons.check_circle, size: 18), + label: Text( + widget.isActive ? 'Einstellungen Speichern' : 'Trade Jetzt Eröffnen', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + style: ElevatedButton.styleFrom( + backgroundColor: widget.isActive ? AppTheme.accentCyan : AppTheme.primaryEmerald, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _instrumentChip(String label, String value) { + final isSelected = _selectedInstrument == value; + return GestureDetector( + onTap: () => setState(() => _selectedInstrument = value), + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.glassSurface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? AppTheme.primaryEmerald : Colors.white70, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 12, + ), + ), + ), + ); + } + + Widget _capitalPill(String label, double val) { + return GestureDetector( + onTap: () => setState(() => _positionSizeCtrl.text = val.toStringAsFixed(0)), + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white12), + ), + child: Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ); + } + + Widget _buildInput(String label, TextEditingController controller, IconData icon, {Color? accentColor, Widget? suffixWidget}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: accentColor ?? AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), + decoration: InputDecoration( + prefixIcon: Icon(icon, size: 16, color: accentColor ?? Colors.white54), + suffixIcon: suffixWidget, + filled: true, + fillColor: AppTheme.glassSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: accentColor ?? AppTheme.accentCyan)), + ), + ), + ], + ); + } + + Widget _buildSmallFeeInput(String label, TextEditingController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), + const SizedBox(height: 3), + SizedBox( + height: 32, + child: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), + decoration: InputDecoration( + filled: true, + fillColor: AppTheme.glassSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: AppTheme.glassBorder)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: AppTheme.glassBorder)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: AppTheme.accentCyan)), + ), + ), + ), + ], + ); + } + + Widget _statTile(String label, String value, Color col) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), + const SizedBox(height: 2), + Text(value, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart index 94d6b0b..b8e8624 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart @@ -10,7 +10,6 @@ import 'trade_execution_ai_plan_card.dart'; class TradeExecutionDialog { static const double _defaultPositionSize = 1000.0; static const double _defaultLeverage = 1.0; - static const List _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto']; static String _normalizeInstrumentType(String raw) { final clean = raw.toLowerCase().trim(); @@ -167,25 +166,56 @@ class TradeExecutionDialog { return StatefulBuilder( builder: (stfContext, setModalState) { final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') || - selectedInstrumentType.toLowerCase().contains('zertifikat') || selectedInstrumentType.toLowerCase().contains('option') || - selectedInstrumentType.toLowerCase().contains('cfd'); + selectedInstrumentType.toLowerCase().contains('factor') || + selectedInstrumentType.toLowerCase().contains('derivat'); - final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType) ? selectedInstrumentType : 'KnockOut'; + final assetType = trade.assetType.toLowerCase(); + final categories = trade.derivativeProductCategories; + final hasCfd = trade.hasCfd; + + final availableOptions = >[]; + 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(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22), + Icon(Icons.flash_on, color: AppTheme.primaryEmerald), const SizedBox(width: 8), - Expanded( - child: Text( - isActive ? 'Einstellungen für Trade #${trade.id}' : 'Trade-Ausführung & Parameter', - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), - ), - ), + Text(isActive ? 'Aktiven Trade anpassen' : 'Trade-Vorschlag ausführen', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), ], ), content: SizedBox( @@ -205,13 +235,9 @@ class TradeExecutionDialog { initialValue: safeInstrumentValue, dropdownColor: AppTheme.cardSurface, decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), - items: const [ - DropdownMenuItem(value: 'Stock', child: Text('Aktie / ETF (Direktinvestment)', style: TextStyle(color: Colors.white, fontSize: 13))), - DropdownMenuItem(value: 'KnockOut', child: Text('Knock-Out Zertifikat', style: TextStyle(color: Colors.white, fontSize: 13))), - DropdownMenuItem(value: 'Option', child: Text('Optionsschein / Derivat', style: TextStyle(color: Colors.white, fontSize: 13))), - DropdownMenuItem(value: 'CFD', child: Text('CFD (Hebel-Derivat)', style: TextStyle(color: Colors.white, fontSize: 13))), - DropdownMenuItem(value: 'Crypto', child: Text('Krypto', style: TextStyle(color: Colors.white, fontSize: 13))), - ], + 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); }, @@ -318,6 +344,48 @@ class TradeExecutionDialog { ), ], ), + 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), + ), + ), + ], + ), + ), ], ), ), diff --git a/FinlyticApp/lib/main.dart b/FinlyticApp/lib/main.dart index 5a7ea1a..3053a3e 100644 --- a/FinlyticApp/lib/main.dart +++ b/FinlyticApp/lib/main.dart @@ -5,6 +5,7 @@ import 'core/network/api_client.dart'; import 'core/network/signalr_service.dart'; import 'core/services/secure_storage_service.dart'; import 'core/theme/app_theme.dart'; +import 'core/theme/custom_scroll_behavior.dart'; import 'core/theme/theme_cubit.dart'; import 'features/auth/bloc/auth_bloc.dart'; import 'features/auth/views/login_screen.dart'; @@ -73,6 +74,7 @@ class FinlyticApp extends StatelessWidget { return MaterialApp( title: 'Finlytic Enterprise Terminal', debugShowCheckedModeBanner: false, + scrollBehavior: const CustomAppScrollBehavior(), theme: themeState.preset.toThemeData(), home: BlocBuilder( builder: (context, state) { diff --git a/FinlyticBackend/Util/WebMqttClient.cs b/FinlyticBackend/Util/WebMqttClient.cs index 30d6728..9d791da 100644 --- a/FinlyticBackend/Util/WebMqttClient.cs +++ b/FinlyticBackend/Util/WebMqttClient.cs @@ -47,8 +47,10 @@ public class WebMqttClient : ManagedMqttClient, IHostedService _logger.LogInformation("Web MQTT RPC client connected. Subscribing to RPC response channels..."); await SubscribeAsync("services/response/news_Get/#"); await SubscribeAsync("services/response/news_GetDaily/#"); + await SubscribeAsync("services/response/news_GetById/#"); await SubscribeAsync("services/response/sentiment_GetArticle/#"); await SubscribeAsync("services/response/sentiment_GetIsin/#"); + await SubscribeAsync("services/response/sentiment_Analyze/#"); await SubscribeAsync("services/response/fundamentals_Get/#"); await SubscribeAsync("services/response/events_GetAll/#"); await SubscribeAsync("services/response/events_GetByMonth/#"); @@ -57,6 +59,7 @@ public class WebMqttClient : ManagedMqttClient, IHostedService await SubscribeAsync("services/response/assets_Get/#"); await SubscribeAsync("services/response/assets_Search/#"); await SubscribeAsync("services/response/assets_GetDiscovery/#"); + await SubscribeAsync("services/response/assets_GetDerivatives/#"); await SubscribeAsync("services/response/trades_Get/#"); await SubscribeAsync("services/response/trades_Close/#"); diff --git a/FinlyticTrades/Database/TradesDbContext.cs b/FinlyticTrades/Database/TradesDbContext.cs index e143fca..d5988c9 100644 --- a/FinlyticTrades/Database/TradesDbContext.cs +++ b/FinlyticTrades/Database/TradesDbContext.cs @@ -23,6 +23,19 @@ public class TradesDbContext : DbContext entity.HasIndex(e => e.Key); }); + var stringListConverter = + new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter, string>( + v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), + v => System.Text.Json.JsonSerializer.Deserialize>(v, + (System.Text.Json.JsonSerializerOptions?)null) ?? new List() + ); + + var stringListComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( + (c1, c2) => c1 != null && c2 != null ? c1.SequenceEqual(c2) : c1 == c2, + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList() + ); + modelBuilder.Entity(entity => { entity.HasIndex(e => e.TradeId).IsUnique(); @@ -32,6 +45,9 @@ public class TradesDbContext : DbContext entity.HasIndex(e => e.Sector); entity.HasIndex(e => e.Isin); entity.HasIndex(e => e.CreatedAt); + + entity.Property(e => e.DerivativeProductCategories) + .HasConversion(stringListConverter, stringListComparer); }); modelBuilder.Entity(entity => diff --git a/FinlyticTrades/Entities/TradeEntity.cs b/FinlyticTrades/Entities/TradeEntity.cs index 63518b1..e7ee310 100644 --- a/FinlyticTrades/Entities/TradeEntity.cs +++ b/FinlyticTrades/Entities/TradeEntity.cs @@ -68,6 +68,13 @@ public class TradeEntity [MaxLength(30)] public string InstrumentType { get; set; } = "Stock"; + [MaxLength(50)] + public string AssetType { get; set; } = "stock"; + + public bool HasCfd { get; set; } + + public List DerivativeProductCategories { get; set; } = new(); + [MaxLength(20)] public string? DerivativeIsin { get; set; } diff --git a/FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.Designer.cs b/FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.Designer.cs new file mode 100644 index 0000000..1b76773 --- /dev/null +++ b/FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.Designer.cs @@ -0,0 +1,357 @@ +// +using System; +using FinlyticTrades.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + [DbContext(typeof(TradesDbContext))] + [Migration("20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades")] + partial class AddAssetTypeAndDerivativeCategoriesToTrades + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key"); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualEntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CloseReason") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivativeIsin") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DerivativeProductCategories") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntryFee") + .HasColumnType("decimal(18,4)"); + + b.Property("EntryPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("EntryZoneMax") + .HasColumnType("decimal(18,4)"); + + b.Property("EntryZoneMin") + .HasColumnType("decimal(18,4)"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ExecutionTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ExitFee") + .HasColumnType("decimal(18,4)"); + + b.Property("FundamentalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasCfd") + .HasColumnType("boolean"); + + b.Property("InstrumentType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("IsGlobalProposal") + .HasColumnType("boolean"); + + b.Property("IsRecurring") + .HasColumnType("boolean"); + + b.Property("IsWin") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("KnockoutThreshold") + .HasColumnType("decimal(18,4)"); + + b.Property("LeverageUsed") + .HasColumnType("decimal(18,4)"); + + b.Property("MaxLeverage") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlAbsolute") + .HasColumnType("decimal(18,4)"); + + b.Property("PnlPercent") + .HasColumnType("decimal(18,4)"); + + b.Property("PositionSize") + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("Reasoning") + .IsRequired() + .HasColumnType("text"); + + b.Property("RiskRewardRatio") + .HasColumnType("decimal(18,4)"); + + b.Property("RiskTolerance") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RiskWarning") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignalType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StopLoss") + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("TakeProfit") + .HasColumnType("decimal(18,4)"); + + b.Property("TakeProfitTargets") + .HasColumnType("text"); + + b.Property("TechnicalRationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TradeId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TtlMinutes") + .HasColumnType("integer"); + + b.Property("UserExitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("UserExitTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("decimal(18,4)"); + + b.Property("WinRate") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("AnalysisId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EventId"); + + b.HasIndex("Isin"); + + b.HasIndex("Sector"); + + b.HasIndex("Status"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("trades"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeHourlyUpdateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("FloatingPnlPercent") + .HasColumnType("decimal(18,4)"); + + b.Property("Reasoning") + .IsRequired() + .HasColumnType("text"); + + b.Property("Recommendation") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SuggestedStopLoss") + .HasColumnType("decimal(18,4)"); + + b.Property("SuggestedTakeProfit") + .HasColumnType("decimal(18,4)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TradeId") + .HasColumnType("uuid"); + + b.Property("VixValue") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TradeId"); + + b.HasIndex("TradeId", "Timestamp"); + + b.ToTable("trade_hourly_updates"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradesSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AtrStopLossMultiplier") + .HasColumnType("double precision"); + + b.Property("MaxOpenPositions") + .HasColumnType("integer"); + + b.Property("RiskPerTradePercentage") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeHourlyUpdateEntity", b => + { + b.HasOne("FinlyticTrades.Entities.TradeEntity", "Trade") + .WithMany("HourlyUpdates") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trade"); + }); + + modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b => + { + b.Navigation("HourlyUpdates"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.cs b/FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.cs new file mode 100644 index 0000000..1a63d02 --- /dev/null +++ b/FinlyticTrades/Migrations/20260815100019_AddAssetTypeAndDerivativeCategoriesToTrades.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticTrades.Migrations +{ + /// + public partial class AddAssetTypeAndDerivativeCategoriesToTrades : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AssetType", + table: "trades", + type: "character varying(50)", + maxLength: 50, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "DerivativeProductCategories", + table: "trades", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "HasCfd", + table: "trades", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "DynamicSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + ValueJson = table.Column(type: "text", nullable: false), + ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DynamicSettings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_DynamicSettings_Key", + table: "DynamicSettings", + column: "Key"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DynamicSettings"); + + migrationBuilder.DropColumn( + name: "AssetType", + table: "trades"); + + migrationBuilder.DropColumn( + name: "DerivativeProductCategories", + table: "trades"); + + migrationBuilder.DropColumn( + name: "HasCfd", + table: "trades"); + } + } +} diff --git a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs index aee5726..bb4e6aa 100644 --- a/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs +++ b/FinlyticTrades/Migrations/TradesDbContextModelSnapshot.cs @@ -22,6 +22,36 @@ namespace FinlyticTrades.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key"); + + b.ToTable("DynamicSettings"); + }); + modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b => { b.Property("Id") @@ -36,6 +66,11 @@ namespace FinlyticTrades.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("AssetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + b.Property("CloseReason") .HasMaxLength(50) .HasColumnType("character varying(50)"); @@ -55,6 +90,10 @@ namespace FinlyticTrades.Migrations .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("DerivativeProductCategories") + .IsRequired() + .HasColumnType("text"); + b.Property("EntryFee") .HasColumnType("decimal(18,4)"); @@ -82,6 +121,9 @@ namespace FinlyticTrades.Migrations .IsRequired() .HasColumnType("text"); + b.Property("HasCfd") + .HasColumnType("boolean"); + b.Property("InstrumentType") .IsRequired() .HasMaxLength(30) diff --git a/FinlyticTrades/Services/TradeLifecycleService.cs b/FinlyticTrades/Services/TradeLifecycleService.cs index 9ce2060..12134ce 100644 --- a/FinlyticTrades/Services/TradeLifecycleService.cs +++ b/FinlyticTrades/Services/TradeLifecycleService.cs @@ -101,12 +101,20 @@ public class TradeLifecycleService : ITradeLifecycleService var existingTrade = await _dbContext.Trades .FirstOrDefaultAsync(t => (!string.IsNullOrWhiteSpace(proposal.TradeId) && t.TradeId == proposal.TradeId) || - (!string.IsNullOrWhiteSpace(proposal.AnalysisId) && t.AnalysisId == proposal.AnalysisId), + (!string.IsNullOrWhiteSpace(proposal.AnalysisId) && t.AnalysisId == proposal.AnalysisId) || + (!string.IsNullOrWhiteSpace(proposal.Isin) && t.Isin == proposal.Isin && (t.Status == TradeStatus.Proposed || t.Status == TradeStatus.Active)), cancellationToken); if (existingTrade != null) { - if (existingTrade.Status != TradeStatus.Active && existingTrade.Status != TradeStatus.Closed) + if (existingTrade.Status == TradeStatus.Active) + { + _logger.LogInformation("[{Channel}] An ACTIVE trade {TradeId} already exists for {Symbol} ({Isin}). Skipping duplicate proposed trade creation.", + "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin); + return true; + } + + if (existingTrade.Status != TradeStatus.Closed) { existingTrade.Status = targetStatus; } @@ -115,7 +123,7 @@ public class TradeLifecycleService : ITradeLifecycleService _dbContext.Trades.Update(existingTrade); await _dbContext.SaveChangesAsync(cancellationToken); - _logger.LogInformation("[{Channel}] Successfully UPDATED trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", + _logger.LogInformation("[{Channel}] Successfully UPDATED existing trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status); return true; @@ -297,13 +305,10 @@ public class TradeLifecycleService : ITradeLifecycleService } else { - trade.Status = TradeStatus.Closed; - trade.UserExitPrice = update.CurrentPrice; - trade.UserExitTimestamp = DateTime.UtcNow; - trade.CloseReason = "AiRecommendationClose"; - trade.ClosedAt = DateTime.UtcNow; - - CalculatePnL(trade); + // NO AUTO CLOSE for active user trades! + // Trade remains Active, alert is stored in HourlyUpdates and surfaced in UI for manual confirmation. + _logger.LogInformation("[{Channel}] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.", + "TradesChannel", trade.TradeId, update.Reasoning); } } @@ -359,6 +364,10 @@ public class TradeLifecycleService : ITradeLifecycleService trade.Status = TradeStatus.Closed; trade.UserExitPrice = request.UserExitPrice; trade.UserExitTimestamp = request.UserExitTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; + if (request.ExitFee > 0m) + { + trade.ExitFee = request.ExitFee; + } trade.CloseReason = request.CloseReason; trade.ClosedAt = DateTime.UtcNow; @@ -406,6 +415,9 @@ public class TradeLifecycleService : ITradeLifecycleService entity.RiskTolerance = dto.RiskTolerance; entity.Timeframe = dto.Timeframe; entity.InstrumentType = dto.InstrumentType; + if (!string.IsNullOrWhiteSpace(dto.AssetType)) entity.AssetType = dto.AssetType; + entity.HasCfd = dto.HasCfd; + if (dto.DerivativeProductCategories.Count > 0) entity.DerivativeProductCategories = dto.DerivativeProductCategories; if (!string.IsNullOrWhiteSpace(dto.DerivativeIsin)) entity.DerivativeIsin = dto.DerivativeIsin; entity.WinRate = dto.WinRate; entity.VixRegime = dto.VixRegime; diff --git a/FinlyticTrades/Util/TradesMqttClient.cs b/FinlyticTrades/Util/TradesMqttClient.cs index 0071773..cd3a93b 100644 --- a/FinlyticTrades/Util/TradesMqttClient.cs +++ b/FinlyticTrades/Util/TradesMqttClient.cs @@ -311,6 +311,10 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService RiskTolerance = t.RiskTolerance, Timeframe = t.Timeframe, InstrumentType = t.InstrumentType, + AssetType = t.AssetType, + HasCfd = t.HasCfd, + DerivativeProductCategories = t.DerivativeProductCategories ?? new List(), + DerivativeIsin = t.DerivativeIsin, WinRate = t.WinRate, VixRegime = t.VixRegime, VixValue = t.VixValue, @@ -339,7 +343,22 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService IsRecurring = t.IsRecurring, PnlAbsolute = t.PnlAbsolute, PnlPercent = t.PnlPercent, - CurrentPrice = t.UserExitPrice ?? t.HourlyUpdates?.LastOrDefault()?.CurrentPrice + CurrentPrice = t.UserExitPrice ?? t.HourlyUpdates?.LastOrDefault()?.CurrentPrice, + CloseReason = t.CloseReason, + UserExitTimestamp = t.UserExitTimestamp, + HasPendingExitAlert = t.Status == TradeStatus.Active && t.HourlyUpdates != null && t.HourlyUpdates.Any(u => string.Equals(u.Recommendation, "Close", StringComparison.OrdinalIgnoreCase)), + PendingExitReason = t.Status == TradeStatus.Active ? t.HourlyUpdates?.LastOrDefault(u => string.Equals(u.Recommendation, "Close", StringComparison.OrdinalIgnoreCase))?.Reasoning : null, + HourlyUpdates = t.HourlyUpdates?.OrderBy(u => u.Timestamp).Select(u => new TradeHourlyUpdateDto + { + TradeId = t.TradeId, + Recommendation = u.Recommendation, + CurrentPrice = u.CurrentPrice, + SuggestedStopLoss = u.SuggestedStopLoss, + SuggestedTakeProfit = u.SuggestedTakeProfit, + VixValue = u.VixValue, + Reasoning = u.Reasoning, + Timestamp = u.Timestamp + }).ToList() }; } } \ No newline at end of file