feat(trades): add live execution cockpit, closing cockpit, calculation cards and precision trade settings

This commit is contained in:
2026-08-15 19:30:25 +02:00
parent 882d24a316
commit 34fa774cbf
31 changed files with 4235 additions and 630 deletions
@@ -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) : "{}",
@@ -8,4 +8,18 @@ public interface IWinRateCalculator
/// Calculates the win rate for a given sector and symbol under the specified market regime.
/// </summary>
double CalculateWinRate(string sector, string symbol, VixMarketRegime regime);
/// <summary>
/// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime.
/// </summary>
double CalculateDynamicWinRate(
string sector,
string symbol,
VixMarketRegime regime,
double? n8nEvalScore = null,
double? technicalScore = null,
double? sentimentScore = null,
double? fundamentalScore = null,
string signalType = "BUY");
}
+84 -10
View File
@@ -34,31 +34,105 @@ public class WinRateCalculator : IWinRateCalculator
/// Uses cached feedback records (3-minute TTL) to prevent disk I/O bottlenecks.
/// </summary>
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
{
return CalculateDynamicWinRate(sector, symbol, regime);
}
/// <summary>
/// Calculates a multi-factor dynamic AI Win-Rate / Confidence Score using technicals, sentiment, fundamentals, AI eval score, and market regime.
/// </summary>
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;
// 1. N8n AI Confidence Score (Weight: 40%)
double n8nComponent = 62.0;
if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0)
{
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 > 0)
if (matching.Count >= 5)
{
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);
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<TradeFeedbackRecord> GetCachedOrLoadRecords()
+37 -7
View File
@@ -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<AnalyzerDbContext>();
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,
@@ -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,15 +31,23 @@ class AssetDetailScreen extends StatelessWidget {
Widget build(BuildContext context) {
final repository = AssetRepository(apiClient: apiClient);
return BlocBuilder<FavoritesCubit, FavoritesState>(
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: symbol)),
..add(LoadAssetFundamentals(isin, ticker: initialTicker)),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository)
..add(LoadAssetTechnical(isin, ticker: symbol)),
..add(LoadAssetTechnical(isin, ticker: initialTicker)),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)
@@ -51,18 +61,20 @@ class AssetDetailScreen extends StatelessWidget {
return AssetPageDesktopLayout(
isin: isin,
name: name,
selectedTicker: symbol,
selectedTicker: initialTicker ?? symbol,
);
}
return AssetPageMobileLayout(
isin: isin,
name: name,
selectedTicker: symbol,
selectedTicker: initialTicker ?? symbol,
);
},
),
),
);
},
);
}
}
@@ -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<TradesTab> {
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<TradesTab> {
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradesBloc = context.read<AssetTradesBloc>();
TradeExecutionDialog.show(
TradeExecutionCockpit.show(
context,
trade: trade,
defaultSymbol: widget.symbol,
@@ -116,21 +108,20 @@ class _TradesTabState extends State<TradesTab> {
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
ManualAnalysisDialog.show(
context,
symbol: widget.symbol,
initialRiskScore: _settings.defaultRiskScore,
initialRiskScore: 50.0,
onTrigger: (payload) {
setState(() => _justTriggeredAnalysis = true);
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'),
content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
@@ -139,33 +130,15 @@ class _TradesTabState extends State<TradesTab> {
);
},
icon: const Icon(Icons.auto_awesome, size: 18),
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
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(10)),
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<TradesTab> {
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<TradesTab> {
context.read<AssetTradesBloc>().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<TradesTab> {
);
}
}
@@ -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 {
);
}
}
@@ -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(
@@ -1,57 +1,227 @@
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),
barrierDismissible: false,
builder: (dialogContext) => ManualAnalysisDialog(
symbol: symbol,
initialRiskScore: initialRiskScore,
onTrigger: onTrigger,
),
title: 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)),
);
}
@override
State<ManualAnalysisDialog> createState() => _ManualAnalysisDialogState();
}
class _ManualAnalysisDialogState extends State<ManualAnalysisDialog> 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),
),
],
),
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),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 14),
child: Row(
children: [
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),
),
],
),
),
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 (14 Std.)', 'intraday', () => _selectTimeframePreset('intraday', 1, 4, 'Stunden')),
_presetChip('🌊 Swing-Trade (114 Tage)', 'swing', () => _selectTimeframePreset('swing', 1, 14, 'Tage')),
_presetChip('📈 Positions-Trade (28 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: minTimeframeController,
controller: _minTimeframeCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
@@ -59,7 +229,7 @@ class ManualAnalysisDialog {
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: maxTimeframeController,
controller: _maxTimeframeCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
@@ -67,7 +237,7 @@ class ManualAnalysisDialog {
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: timeframeUnit,
initialValue: _timeframeUnit,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [
@@ -77,98 +247,223 @@ class ManualAnalysisDialog {
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
],
onChanged: (val) {
if (val != null) setModalState(() => timeframeUnit = val);
if (val != null) setState(() => _timeframeUnit = val);
},
),
),
],
),
const SizedBox(height: 16),
],
const SizedBox(height: 18),
// 2. RISIKO-PROFIL
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const Text('2. RISIKOBEREITSCHAFT', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
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,
'${_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,
value: _riskScore,
min: 0,
max: 100,
divisions: 100,
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
activeColor: riskColor,
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => riskScore = val),
onChanged: _isAnalyzing ? null : (val) => setState(() => _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),
// 3. INSTRUMENT
const Text('3. FINANZINSTRUMENT', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: instrumentType,
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: '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);
onChanged: _isAnalyzing ? null : (val) {
if (val != null) setState(() => _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: 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: notesController,
maxLines: 3,
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,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
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<int>(_analysisStage),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
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),
),
],
)
: 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)),
],
),
),
),
),
],
),
),
);
},
}
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),
),
),
);
}
}
@@ -26,7 +26,7 @@ class TradeBloc extends Bloc<TradeEvent, TradeState> {
Future<void> _onCloseTrade(CloseTrade event, Emitter<TradeState> 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) {
@@ -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<Object?> get props => [tradeId];
List<Object?> get props => [tradeId, dto];
}
class AcceptTradeProposalEvent extends TradeEvent {
@@ -35,3 +37,4 @@ class AcceptTradeProposalEvent extends TradeEvent {
@override
List<Object?> get props => [dto];
}
@@ -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<String, dynamic> toJson() {
return {
'userExitPrice': userExitPrice,
if (userExitTimestamp != null) 'userExitTimestamp': userExitTimestamp!.toUtc().toIso8601String(),
'exitFee': exitFee,
'closeReason': closeReason,
};
}
}
@@ -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<String, dynamic> json) {
double parseDbl(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
DateTime 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<Object?> 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<String> 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<TradeHourlyUpdateModel> 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<TradeHourlyUpdateModel> updates = [];
final rawUpdates = json['hourlyUpdates'] ?? json['HourlyUpdates'];
if (rawUpdates is List) {
updates = rawUpdates.map((u) => TradeHourlyUpdateModel.fromJson(Map<String, dynamic>.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,
];
}
@@ -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<List<DerivativeItemModel>> 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 = <String, dynamic>{
'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<dynamic> data = response.data;
return data.map((json) => DerivativeItemModel.fromJson(json)).toList();
}
return [];
} catch (e) {
throw Exception('Derivate konnten nicht geladen werden: $e');
}
}
Future<void> 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 {
}
}
}
@@ -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<void> _handleAcceptProposal(BuildContext context, TradeModel trade) async {
final authState = context.read<AuthBloc>().state;
final currentUserId = (authState is Authenticated) ? authState.user.userId : 'default_user';
void _handleAcceptProposal(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradeBloc = context.read<TradeBloc>();
final result = await showDialog<TradeAcceptanceDto>(
context: context,
builder: (ctx) => TradeAcceptanceDialog(
TradeExecutionCockpit.show(
context,
trade: trade,
theme: AppTheme.darkClassic,
userId: currentUserId,
),
);
if (result != null && mounted) {
context.read<TradeBloc>().add(AcceptTradeProposalEvent(result));
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
isActive: isActive,
onAccept: (dto) {
tradeBloc.add(AcceptTradeProposalEvent(dto));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Trade wird in dein Portfolio übernommen...'),
content: Text(isActive ? 'Einstellungen für ${trade.symbol} gespeichert!' : 'Trade für ${trade.symbol} eröffnet!'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
void _handleCloseTrade(BuildContext context, TradeModel trade) {
final tradeBloc = context.read<TradeBloc>();
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<TradeBloc>().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<TradeBloc>();
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,
),
);
},
);
}
}
@@ -37,7 +37,8 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
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();
@@ -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] : <double>[]);
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)),
],
);
}
}
@@ -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,24 +49,17 @@ 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(
return 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
// Header Row: Signal, Symbol, Drift-Radar & Live PnL
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: signalColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
@@ -73,16 +68,9 @@ class TradeCard extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isBuy ? Icons.trending_up : Icons.trending_down,
size: 14,
color: signalColor,
),
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),
),
Text(trade.signalType, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
],
),
),
@@ -91,22 +79,41 @@ class TradeCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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 : 'Aktie')),
: (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),
),
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 (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),
),
],
),
),
if (isActive || isClosed)
// Status / PnL / Drift-Radar Badge
if (isActive || isClosed) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
@@ -118,72 +125,160 @@ class TradeCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)}',
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 14),
'${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),
style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
)
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),
),
),
] else if (trade.isRejected) ...[
StatusBadge(label: 'ABGELEHNT', color: AppTheme.accentRed),
] else ...[
StatusBadge(label: 'VORSCHLAG', color: Colors.amber),
],
],
),
const SizedBox(height: 14),
// Active Drift Radar / Trailing Alert Indicator
if (isActive) ...[
const SizedBox(height: 10),
_buildDriftRadarBar(trade),
],
// Price Metrics Grid with Live Kurs
// 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: Colors.black.withValues(alpha: 0.2),
color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
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 ? 'Ausführung' : 'Ziel-Einstieg',
isActive || isClosed ? 'Einstieg' : 'Ziel-Einstieg',
trade.actualEntryPrice > 0
? '${trade.actualEntryPrice.toStringAsFixed(2)}'
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'),
Colors.white
? '${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,
),
_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.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(
@@ -194,6 +289,65 @@ class TradeCard extends StatelessWidget {
),
],
// 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
@@ -201,7 +355,7 @@ class TradeCard extends StatelessWidget {
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" : ""}',
'${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? "${trade.leverageUsed.toStringAsFixed(1)}x Hebel" : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
@@ -225,7 +379,7 @@ class TradeCard extends StatelessWidget {
label: const Text('Trade Übernehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
@@ -235,11 +389,11 @@ class TradeCard extends StatelessWidget {
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)),
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: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
@@ -249,7 +403,7 @@ class TradeCard extends StatelessWidget {
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
tooltip: 'Einstellungen',
tooltip: 'Einstellungen anpassen',
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
),
@@ -261,10 +415,56 @@ class TradeCard 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 _priceItem(String label, String val, Color valColor) {
@@ -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<void> 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<TradeClosingCockpit> createState() => _TradeClosingCockpitState();
}
class _TradeClosingCockpitState extends State<TradeClosingCockpit> {
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<void> _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';
}
}
@@ -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)),
),
// 3. LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
TradeCalculationCard(trade: trade),
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),
],
// Trade Parameters & Instrument
_sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70),
const SizedBox(height: 8),
Container(
@@ -137,12 +176,219 @@ 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)),
],
),
),
],
),
);
}
@@ -179,3 +425,4 @@ class TradeDetailContent extends StatelessWidget {
);
}
}
File diff suppressed because it is too large Load Diff
@@ -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<String> _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 = <MapEntry<String, String>>[];
if (assetType == 'crypto') {
availableOptions.add(const MapEntry('Crypto', 'Krypto'));
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'Krypto CFD'));
} else if (assetType == 'etf') {
availableOptions.add(const MapEntry('Stock', 'ETF (Direktinvestment)'));
if (categories.isEmpty || categories.contains('knockOutProduct')) {
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
}
if (categories.contains('vanillaWarrant')) {
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
}
if (categories.contains('factorCertificate')) {
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
}
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
} else {
availableOptions.add(const MapEntry('Stock', 'Aktie (Direktinvestment)'));
if (categories.isEmpty || categories.contains('knockOutProduct')) {
availableOptions.add(const MapEntry('KnockOut', 'Knock-Out Zertifikat'));
}
if (categories.contains('vanillaWarrant')) {
availableOptions.add(const MapEntry('Option', 'Optionsschein'));
}
if (categories.contains('factorCertificate')) {
availableOptions.add(const MapEntry('Factor', 'Faktor-Zertifikat'));
}
if (hasCfd) availableOptions.add(const MapEntry('CFD', 'CFD (Hebel-Derivat)'));
}
final safeInstrumentValue = availableOptions.any((o) => o.key == selectedInstrumentType)
? selectedInstrumentType
: (availableOptions.isNotEmpty ? availableOptions.first.key : 'Stock');
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)),
title: Row(
children: [
Icon(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),
),
),
],
),
),
],
),
),
+2
View File
@@ -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<AuthBloc, AuthState>(
builder: (context, state) {
+3
View File
@@ -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/#");
@@ -23,6 +23,19 @@ public class TradesDbContext : DbContext
entity.HasIndex(e => e.Key);
});
var stringListConverter =
new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => System.Text.Json.JsonSerializer.Deserialize<List<string>>(v,
(System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>()
);
var stringListComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
(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<TradeEntity>(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<TradeHourlyUpdateEntity>(entity =>
+7
View File
@@ -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<string> DerivativeProductCategories { get; set; } = new();
[MaxLength(20)]
public string? DerivativeIsin { get; set; }
@@ -0,0 +1,357 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key");
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("ActualEntryPrice")
.HasColumnType("decimal(18,4)");
b.Property<string>("AnalysisId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("CloseReason")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DerivativeIsin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("EntryFee")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("EntryPrice")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("EntryZoneMax")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("EntryZoneMin")
.HasColumnType("decimal(18,4)");
b.Property<string>("EventId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("ExecutionTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("ExitFee")
.HasColumnType("decimal(18,4)");
b.Property<string>("FundamentalRationale")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("InstrumentType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<bool>("IsGlobalProposal")
.HasColumnType("boolean");
b.Property<bool>("IsRecurring")
.HasColumnType("boolean");
b.Property<bool?>("IsWin")
.HasColumnType("boolean");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal?>("KnockoutThreshold")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("LeverageUsed")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("MaxLeverage")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PnlAbsolute")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PnlPercent")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PositionSize")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<string>("Reasoning")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("RiskRewardRatio")
.HasColumnType("decimal(18,4)");
b.Property<string>("RiskTolerance")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("RiskWarning")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Sector")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SignalType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<decimal>("StopLoss")
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal>("TakeProfit")
.HasColumnType("decimal(18,4)");
b.Property<string>("TakeProfitTargets")
.HasColumnType("text");
b.Property<string>("TechnicalRationale")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Timeframe")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TradeId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("TtlMinutes")
.HasColumnType("integer");
b.Property<decimal?>("UserExitPrice")
.HasColumnType("decimal(18,4)");
b.Property<DateTime?>("UserExitTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("VixRegime")
.HasColumnType("integer");
b.Property<decimal>("VixValue")
.HasColumnType("decimal(18,4)");
b.Property<double>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("CurrentPrice")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("FloatingPnlPercent")
.HasColumnType("decimal(18,4)");
b.Property<string>("Reasoning")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Recommendation")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal?>("SuggestedStopLoss")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("SuggestedTakeProfit")
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TradeId")
.HasColumnType("uuid");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("AtrStopLossMultiplier")
.HasColumnType("double precision");
b.Property<int>("MaxOpenPositions")
.HasColumnType("integer");
b.Property<double>("RiskPerTradePercentage")
.HasColumnType("double precision");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticTrades.Migrations
{
/// <inheritdoc />
public partial class AddAssetTypeAndDerivativeCategoriesToTrades : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AssetType",
table: "trades",
type: "character varying(50)",
maxLength: 50,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "DerivativeProductCategories",
table: "trades",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "HasCfd",
table: "trades",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -22,6 +22,36 @@ namespace FinlyticTrades.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key");
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b =>
{
b.Property<Guid>("Id")
@@ -36,6 +66,11 @@ namespace FinlyticTrades.Migrations
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("CloseReason")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -55,6 +90,10 @@ namespace FinlyticTrades.Migrations
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("EntryFee")
.HasColumnType("decimal(18,4)");
@@ -82,6 +121,9 @@ namespace FinlyticTrades.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("InstrumentType")
.IsRequired()
.HasMaxLength(30)
@@ -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;
+20 -1
View File
@@ -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<string>(),
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()
};
}
}