Compare commits

..

5 Commits

68 changed files with 7036 additions and 989 deletions
@@ -115,12 +115,19 @@ public class ManualAnalysisController : ControllerBase
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
bool shouldProceed = n8nResponse != null && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase); 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; TradeProposalDto? proposal = null;
if (shouldProceed && n8nResponse != null) if (shouldProceed && n8nResponse != null)
{ {
proposal = new TradeProposalDto proposal = new TradeProposalDto
{ {
TradeId = "PROP-" + Guid.NewGuid().ToString("N"), TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
AnalysisId = analysisId, AnalysisId = analysisId,
EventId = analysisId, EventId = analysisId,
Sector = request.Sector, Sector = request.Sector,
@@ -132,11 +139,21 @@ public class ManualAnalysisController : ControllerBase
RiskTolerance = n8nResponse.SuggestedRisk, RiskTolerance = n8nResponse.SuggestedRisk,
Timeframe = timeframeFormatted, Timeframe = timeframeFormatted,
InstrumentType = request.InstrumentType, InstrumentType = request.InstrumentType,
WinRate = winRate, WinRate = dynamicWinRate,
VixRegime = regime, VixRegime = regime,
VixValue = currentVix, VixValue = currentVix,
TtlMinutes = 60, TtlMinutes = 60,
Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}", 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 CreatedAt = DateTime.UtcNow
}; };
} }
@@ -151,7 +168,7 @@ public class ManualAnalysisController : ControllerBase
VixRegime = regime, VixRegime = regime,
VixValue = currentVix, VixValue = currentVix,
ImpactScore = 1.0, ImpactScore = 1.0,
WinRate = winRate, WinRate = dynamicWinRate,
RawDataJson = JsonSerializer.Serialize(request), RawDataJson = JsonSerializer.Serialize(request),
AiOutputJson = proposal != null ? JsonSerializer.Serialize(proposal) : "{}", AiOutputJson = proposal != null ? JsonSerializer.Serialize(proposal) : "{}",
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", 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. /// Calculates the win rate for a given sector and symbol under the specified market regime.
/// </summary> /// </summary>
double CalculateWinRate(string sector, string symbol, VixMarketRegime regime); 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. /// Uses cached feedback records (3-minute TTL) to prevent disk I/O bottlenecks.
/// </summary> /// </summary>
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime) 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 try
{ {
var records = GetCachedOrLoadRecords(); // 1. N8n AI Confidence Score (Weight: 40%)
if (records.Count == 0) return 65.0; 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 => var matching = records.Where(r =>
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) && string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
r.VixRegime == regime).ToList(); r.VixRegime == regime).ToList();
if (matching.Count > 0) if (matching.Count >= 5)
{ {
int winningTrades = matching.Count(r => r.IsWin); int winningTrades = matching.Count(r => r.IsWin);
double calculatedWinRate = (double)winningTrades / matching.Count * 100.0; double historicalWinRate = (double)winningTrades / matching.Count * 100.0;
_logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})", composite = (composite * 0.75) + (historicalWinRate * 0.25);
"AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, matching.Count);
return Math.Round(calculatedWinRate, 1);
} }
} }
// 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) 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() private List<TradeFeedbackRecord> GetCachedOrLoadRecords()
+37 -7
View File
@@ -329,11 +329,19 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
var settings = await settingsService.GetSettingsAsync(); var settings = await settingsService.GetSettingsAsync();
double minSignalScore = settings.MinSignalScore; 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 && bool shouldProceed = n8nResponse != null &&
string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) &&
(confidenceScore * 100.0) >= minSignalScore && (confidenceScore * 100.0) >= minSignalScore &&
winRate >= minSignalScore; dynamicWinRate >= minSignalScore;
TradeProposalDto? proposalDto = null; TradeProposalDto? proposalDto = null;
if (n8nResponse != null) if (n8nResponse != null)
@@ -353,7 +361,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
RiskTolerance = n8nResponse.SuggestedRisk, RiskTolerance = n8nResponse.SuggestedRisk,
Timeframe = timeframeFormatted, Timeframe = timeframeFormatted,
InstrumentType = manualReq.InstrumentType, InstrumentType = manualReq.InstrumentType,
WinRate = winRate, WinRate = dynamicWinRate,
VixRegime = regime, VixRegime = regime,
VixValue = currentVix, VixValue = currentVix,
TtlMinutes = 60, TtlMinutes = 60,
@@ -384,7 +392,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
VixRegime = regime, VixRegime = regime,
VixValue = currentVix, VixValue = currentVix,
ImpactScore = 1.0, ImpactScore = 1.0,
WinRate = winRate, WinRate = dynamicWinRate,
RawDataJson = JsonSerializer.Serialize(manualReq), RawDataJson = JsonSerializer.Serialize(manualReq),
AiOutputJson = proposalDto != null ? JsonSerializer.Serialize(proposalDto) : "{}", AiOutputJson = proposalDto != null ? JsonSerializer.Serialize(proposalDto) : "{}",
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
@@ -527,6 +535,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? taResp = null; FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? taResp = null;
FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? fundResp = null; FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? fundResp = null;
FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto? livePriceResp = null; FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto? livePriceResp = null;
FinlyticCore.Dtos.Sentiment.IsinSentimentSummaryDto? sentResp = null;
try try
{ {
@@ -549,7 +558,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
livePriceResp = livePriceTask.Result; livePriceResp = livePriceTask.Result;
taResp = taTask.Result; taResp = taTask.Result;
fundResp = fundTask.Result; fundResp = fundTask.Result;
var sentResp = sentTask.Result; sentResp = sentTask.Result;
if (taResp?.Indicators != null) if (taResp?.Indicators != null)
{ {
@@ -735,10 +744,31 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
ActionRequired = isHighConviction ? "PROMPT_USER_FOR_MANUAL_TRADE" : "NO_ACTION" 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()) using (var scope = _scopeFactory.CreateScope())
{ {
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>(); 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 var analysisEntity = new AnalysisEntity
{ {
AnalysisId = analysisId, AnalysisId = analysisId,
@@ -749,7 +779,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
VixRegime = regime, VixRegime = regime,
VixValue = currentVix, VixValue = currentVix,
ImpactScore = filterResult.ImpactScore, ImpactScore = filterResult.ImpactScore,
WinRate = winRate, WinRate = dynamicWinRate,
RawDataJson = payloadStr, RawDataJson = payloadStr,
AiOutputJson = JsonSerializer.Serialize(recommendation), AiOutputJson = JsonSerializer.Serialize(recommendation),
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
@@ -780,7 +810,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
RiskTolerance = n8nResponse.SuggestedRisk ?? "Balanced", RiskTolerance = n8nResponse.SuggestedRisk ?? "Balanced",
Timeframe = $"{minTf}-{maxTf} Tage", Timeframe = $"{minTf}-{maxTf} Tage",
InstrumentType = "KnockOut", InstrumentType = "KnockOut",
WinRate = winRate, WinRate = dynamicWinRate,
VixRegime = regime, VixRegime = regime,
VixValue = currentVix, VixValue = currentVix,
TtlMinutes = 180, TtlMinutes = 180,
@@ -0,0 +1,16 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Global scroll behavior enabling smooth mouse dragging, mouse wheel, and touch scrolling
/// across all platforms (especially Flutter Web on Desktop without requiring Shift-key).
class CustomAppScrollBehavior extends MaterialScrollBehavior {
const CustomAppScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.trackpad,
};
}
+16 -5
View File
@@ -15,15 +15,26 @@ class StatusBadge extends StatelessWidget {
}); });
factory StatusBadge.sentiment(String status, {double? score}) { factory StatusBadge.sentiment(String status, {double? score}) {
Color bg = AppTheme.textMuted; final sUpper = status.trim().toUpperCase();
if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) { Color bg;
if (sUpper.contains('POS')) {
bg = AppTheme.primaryEmerald; bg = AppTheme.primaryEmerald;
} else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) { } else if (sUpper.contains('NEG')) {
bg = AppTheme.accentRed; bg = AppTheme.accentRed;
} else if (status.toUpperCase().contains('NEU')) { } else if (sUpper.contains('NEU')) {
bg = AppTheme.accentCyan;
} else if (score != null) {
if (score > 0.15) {
bg = AppTheme.primaryEmerald;
} else if (score < -0.15) {
bg = AppTheme.accentRed;
} else {
bg = AppTheme.accentCyan; bg = AppTheme.accentCyan;
} }
return StatusBadge(label: status.toUpperCase(), color: bg); } else {
bg = AppTheme.textMuted;
}
return StatusBadge(label: sUpper.isNotEmpty ? sUpper : 'NEUTRAL', color: bg);
} }
@override @override
@@ -0,0 +1,64 @@
import '../../favorites/models/favorite_asset_model.dart';
import '../models/fundamental_data_model.dart';
class TickerResolver {
/// Resolves the optimal ticker according to user prioritization:
/// 1. Candidate / active user-selected symbol (if valid and not equal to ISIN)
/// 2. Favorite selected ticker (if asset is in favorites and has a valid ticker)
/// 3. Primary Ticker (from Fundamentals header)
/// 4. First available ticker from AvailableTickers list
/// 5. ISIN fallback
static String? resolve({
required String isin,
String? candidateSymbol,
List<FavoriteAssetModel>? favoriteDetails,
FundamentalDataModel? fundamentals,
}) {
final cleanIsin = isin.trim().toUpperCase();
// 1. Check Candidate / User-selected symbol
if (candidateSymbol != null && candidateSymbol.trim().isNotEmpty) {
final cClean = candidateSymbol.trim();
if (cClean.toUpperCase() != cleanIsin) {
return cClean;
}
}
// 2. Check Favorite selected ticker
if (favoriteDetails != null && favoriteDetails.isNotEmpty) {
for (final f in favoriteDetails) {
final fIsin = f.isin.trim().toUpperCase();
final fSym = f.symbol.trim().toUpperCase();
if (fIsin == cleanIsin || fSym == cleanIsin) {
if (f.symbol.trim().isNotEmpty && f.symbol.trim().toUpperCase() != cleanIsin) {
return f.symbol.trim();
}
}
}
}
// 3. Check Fundamentals Primary Ticker
if (fundamentals != null) {
final primary = fundamentals.primaryTicker.trim();
if (primary.isNotEmpty && primary.toUpperCase() != cleanIsin) {
return primary;
}
final fTicker = fundamentals.ticker.trim();
if (fTicker.isNotEmpty && fTicker.toUpperCase() != cleanIsin) {
return fTicker;
}
// 4. First available ticker in availableTickers
for (final t in fundamentals.availableTickers) {
final tTick = t.ticker.trim();
if (tTick.isNotEmpty && tTick.toUpperCase() != cleanIsin) {
return tTick;
}
}
}
// 5. Fallback: ISIN
return isin.trim().isNotEmpty ? isin.trim() : null;
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.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_bloc.dart';
import '../bloc/fundamentals/asset_fundamentals_event.dart'; import '../bloc/fundamentals/asset_fundamentals_event.dart';
import '../bloc/technical/asset_technical_bloc.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_bloc.dart';
import '../bloc/trades/asset_trades_event.dart'; import '../bloc/trades/asset_trades_event.dart';
import '../repositories/asset_repository.dart'; import '../repositories/asset_repository.dart';
import '../utils/ticker_resolver.dart';
import 'layouts/asset_page_desktop_layout.dart'; import 'layouts/asset_page_desktop_layout.dart';
import 'layouts/asset_page_mobile_layout.dart'; import 'layouts/asset_page_mobile_layout.dart';
@@ -29,15 +31,23 @@ class AssetDetailScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repository = AssetRepository(apiClient: apiClient); 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( return MultiBlocProvider(
providers: [ providers: [
BlocProvider( BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository) create: (context) => AssetFundamentalsBloc(repository: repository)
..add(LoadAssetFundamentals(isin, ticker: symbol)), ..add(LoadAssetFundamentals(isin, ticker: initialTicker)),
), ),
BlocProvider( BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository) create: (context) => AssetTechnicalBloc(repository: repository)
..add(LoadAssetTechnical(isin, ticker: symbol)), ..add(LoadAssetTechnical(isin, ticker: initialTicker)),
), ),
BlocProvider( BlocProvider(
create: (context) => AssetTradesBloc(repository: repository) create: (context) => AssetTradesBloc(repository: repository)
@@ -51,18 +61,20 @@ class AssetDetailScreen extends StatelessWidget {
return AssetPageDesktopLayout( return AssetPageDesktopLayout(
isin: isin, isin: isin,
name: name, name: name,
selectedTicker: symbol, selectedTicker: initialTicker ?? symbol,
); );
} }
return AssetPageMobileLayout( return AssetPageMobileLayout(
isin: isin, isin: isin,
name: name, name: name,
selectedTicker: symbol, selectedTicker: initialTicker ?? symbol,
); );
}, },
), ),
), ),
); );
},
);
} }
} }
@@ -4,10 +4,12 @@ import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart'; import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart'; import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart'; import '../../bloc/trades/asset_trades_event.dart';
import '../../utils/ticker_resolver.dart';
import '../../widgets/header/asset_hero_header.dart'; import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart'; import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart'; import '../tabs/technical_tab.dart';
@@ -74,7 +76,36 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.activePreset; final theme = AppTheme.activePreset;
return SingleChildScrollView( return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
listener: (context, state) {
if (state is AssetFundamentalsLoaded && state.data != null) {
if (_selectedTicker == null ||
_selectedTicker!.trim().isEmpty ||
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
final bestTicker = TickerResolver.resolve(
isin: widget.isin,
candidateSymbol: widget.selectedTicker,
favoriteDetails: favList,
fundamentals: state.data,
);
if (bestTicker != null &&
bestTicker.isNotEmpty &&
bestTicker.toUpperCase() != widget.isin.toUpperCase()) {
setState(() {
_selectedTicker = bestTicker;
});
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: bestTicker,
forceRefresh: false,
));
}
}
}
},
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -169,6 +200,6 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
); ));
} }
} }
@@ -4,10 +4,12 @@ import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart'; import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart'; import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart'; import '../../bloc/trades/asset_trades_event.dart';
import '../../utils/ticker_resolver.dart';
import '../../widgets/header/asset_hero_header.dart'; import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart'; import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart'; import '../tabs/technical_tab.dart';
@@ -74,7 +76,36 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.activePreset; final theme = AppTheme.activePreset;
return SingleChildScrollView( return BlocListener<AssetFundamentalsBloc, AssetFundamentalsState>(
listener: (context, state) {
if (state is AssetFundamentalsLoaded && state.data != null) {
if (_selectedTicker == null ||
_selectedTicker!.trim().isEmpty ||
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
final bestTicker = TickerResolver.resolve(
isin: widget.isin,
candidateSymbol: widget.selectedTicker,
favoriteDetails: favList,
fundamentals: state.data,
);
if (bestTicker != null &&
bestTicker.isNotEmpty &&
bestTicker.toUpperCase() != widget.isin.toUpperCase()) {
setState(() {
_selectedTicker = bestTicker;
});
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: bestTicker,
forceRefresh: false,
));
}
}
}
},
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -115,27 +146,27 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
border: Border.all(color: theme.glassBorder), border: Border.all(color: theme.glassBorder),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TabBar( TabBar(
controller: _tabController, controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
isScrollable: true, isScrollable: true,
tabAlignment: TabAlignment.start, tabAlignment: TabAlignment.start,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textSecondary,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle( labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 12), fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [ tabs: const [
Tab( Tab(
icon: Icon(Icons.analytics_outlined, size: 16), icon: Icon(Icons.analytics_outlined, size: 18),
text: 'FUNDAMENTALS'), text: 'ÜBERSICHT'),
Tab( Tab(
icon: Icon(Icons.architecture_outlined, size: 16), icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER & SIGNALE'), text: 'MUSTER'),
Tab( Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 16), icon:
Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'), text: 'TRADES'),
], ],
), ),
@@ -171,6 +202,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
),
); );
} }
} }
@@ -5,13 +5,12 @@ import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart'; import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/status_badge.dart';
import '../../../trades/models/trade_model.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_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart'; import '../../bloc/trades/asset_trades_event.dart';
import '../../bloc/trades/asset_trades_state.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/manual_analysis_dialog.dart';
import '../../widgets/trades/close_trade_dialog.dart';
import '../../widgets/trades/asset_trade_item_card.dart'; import '../../widgets/trades/asset_trade_item_card.dart';
class TradesTab extends StatefulWidget { class TradesTab extends StatefulWidget {
@@ -24,13 +23,6 @@ class TradesTab extends StatefulWidget {
class _TradesTabState extends State<TradesTab> { class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false; bool _justTriggeredAnalysis = false;
LiveTradeSettings _settings = const LiveTradeSettings(
defaultPositionSize: 2500.0,
defaultLeverage: 5.0,
defaultRiskScore: 50.0,
defaultOrderFee: 1.0,
autoAcceptSignals: false,
);
@override @override
void initState() { void initState() {
@@ -41,7 +33,7 @@ class _TradesTabState extends State<TradesTab> {
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) { void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradesBloc = context.read<AssetTradesBloc>(); final tradesBloc = context.read<AssetTradesBloc>();
TradeExecutionDialog.show( TradeExecutionCockpit.show(
context, context,
trade: trade, trade: trade,
defaultSymbol: widget.symbol, defaultSymbol: widget.symbol,
@@ -116,21 +108,20 @@ class _TradesTabState extends State<TradesTab> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( SizedBox(
children: [ width: double.infinity,
Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
ManualAnalysisDialog.show( ManualAnalysisDialog.show(
context, context,
symbol: widget.symbol, symbol: widget.symbol,
initialRiskScore: _settings.defaultRiskScore, initialRiskScore: 50.0,
onTrigger: (payload) { onTrigger: (payload) {
setState(() => _justTriggeredAnalysis = true); setState(() => _justTriggeredAnalysis = true);
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload)); context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( 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, backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
@@ -139,33 +130,15 @@ class _TradesTabState extends State<TradesTab> {
); );
}, },
icon: const Icon(Icons.auto_awesome, size: 18), 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( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan, backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14), 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), onSettings: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
onClose: isActive onClose: isActive
? () { ? () {
CloseTradeDialog.show( TradeClosingCockpit.show(
context, context,
trade: trade, trade: trade,
defaultSymbol: widget.symbol, defaultSymbol: widget.symbol,
@@ -254,7 +227,7 @@ class _TradesTabState extends State<TradesTab> {
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice)); context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( 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, backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
@@ -270,3 +243,4 @@ class _TradesTabState extends State<TradesTab> {
); );
} }
} }
@@ -33,6 +33,7 @@ class AssetHeroHeader extends StatelessWidget {
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>( return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, fundState) { builder: (context, fundState) {
String displayName = name; String displayName = name;
String primaryTicker = '';
final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null; final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null;
List<TickerModel> tickerOptions = [ List<TickerModel> tickerOptions = [
TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null) TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null)
@@ -43,14 +44,21 @@ class AssetHeroHeader extends StatelessWidget {
if (data.companyName.isNotEmpty) { if (data.companyName.isNotEmpty) {
displayName = data.companyName; displayName = data.companyName;
} }
if (data.primaryTicker.isNotEmpty) {
primaryTicker = data.primaryTicker;
}
if (data.availableTickers.isNotEmpty) { if (data.availableTickers.isNotEmpty) {
tickerOptions = data.availableTickers; tickerOptions = data.availableTickers;
} }
} }
final selectedOption = tickerOptions.firstWhere( final selectedOption = tickerOptions.firstWhere(
(t) => t.ticker == symbol || t.exchange == symbol, (t) => (symbol != null && symbol!.isNotEmpty) &&
(t.ticker.toLowerCase() == symbol!.toLowerCase() || (t.exchange != null && t.exchange!.toLowerCase() == symbol!.toLowerCase())),
orElse: () => tickerOptions.firstWhere(
(t) => primaryTicker.isNotEmpty && t.ticker.toLowerCase() == primaryTicker.toLowerCase(),
orElse: () => tickerOptions.first, orElse: () => tickerOptions.first,
),
); );
return Container( return Container(
@@ -203,10 +211,10 @@ class AssetHeroHeader extends StatelessWidget {
); );
}, },
), ),
// Interactive Ticker & Exchange Selector Dropdown
PopupMenuButton<String>( PopupMenuButton<String>(
initialValue: selectedOption.ticker, initialValue: selectedOption.ticker,
tooltip: 'Select Exchange & Ticker', tooltip: 'Börsenplatz & Ticker auswählen',
color: theme.cardSurface,
onSelected: (newTicker) { onSelected: (newTicker) {
if (onExchangeChanged != null) { if (onExchangeChanged != null) {
final opt = tickerOptions.firstWhere( final opt = tickerOptions.firstWhere(
@@ -220,27 +228,57 @@ class AssetHeroHeader extends StatelessWidget {
return tickerOptions.map((opt) { return tickerOptions.map((opt) {
final ex = opt.exchange ?? 'Unknown'; final ex = opt.exchange ?? 'Unknown';
final tick = opt.ticker; final tick = opt.ticker;
final label = '$tick ($ex)'; final isPrimary = primaryTicker.isNotEmpty &&
(tick.toLowerCase() == primaryTicker.toLowerCase());
final isSelected = tick == symbol || ex == symbol; final isSelected = tick == symbol || ex == symbol;
return PopupMenuItem<String>( return PopupMenuItem<String>(
value: tick, value: tick,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.business, isPrimary ? Icons.star_rounded : Icons.business,
size: 16, size: 18,
color: isSelected ? theme.primaryColor : theme.textMuted, color: isPrimary
? AppTheme.accentCyan
: (isSelected ? theme.primaryColor : theme.textMuted),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
label, child: Text(
'$tick ($ex)',
style: TextStyle( style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight: (isSelected || isPrimary) ? FontWeight.bold : FontWeight.normal,
color: isSelected ? theme.primaryColor : theme.textPrimary, color: isSelected
? theme.primaryColor
: (isPrimary ? Colors.white : theme.textPrimary),
),
),
),
if (isPrimary) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.5)),
),
child: Text(
'PRIMARY',
style: TextStyle(
color: AppTheme.accentCyan,
fontSize: 9,
fontWeight: FontWeight.w900,
letterSpacing: 0.6,
),
), ),
), ),
], ],
],
),
), ),
); );
}).toList(); }).toList();
@@ -248,24 +286,46 @@ class AssetHeroHeader extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.accentColor.withValues(alpha: 0.15), color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan.withValues(alpha: 0.15)
: theme.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.accentColor.withValues(alpha: 0.4)), border: Border.all(
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan.withValues(alpha: 0.5)
: theme.accentColor.withValues(alpha: 0.4),
),
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.business, size: 14, color: theme.accentColor), Icon(
(primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? Icons.star_rounded
: Icons.business,
size: 15,
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan
: theme.accentColor,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})', '${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.accentColor, color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan
: theme.accentColor,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon(Icons.arrow_drop_down, size: 16, color: theme.accentColor), Icon(
Icons.arrow_drop_down,
size: 16,
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
? AppTheme.accentCyan
: theme.accentColor,
),
], ],
), ),
), ),
@@ -65,6 +65,7 @@ class AssetTradeItemCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header Row
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -84,7 +85,10 @@ class AssetTradeItemCard extends StatelessWidget {
color: AppTheme.glassSurface, color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6), 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( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed, backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero, minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
@@ -125,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald, backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero, minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, 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), const SizedBox(height: 12),
Text( Text(
'${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', '${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12), style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Target Price Metrics Grid
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -154,7 +208,11 @@ class AssetTradeItemCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white), _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), _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) ...[ if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@@ -208,6 +268,8 @@ class AssetTradeItemCard extends StatelessWidget {
), ),
), ),
], ],
// Realized PnL if closed
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[ if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Builder( Builder(
@@ -243,12 +305,72 @@ class AssetTradeItemCard extends StatelessWidget {
_buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed), _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) ...[ if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
ExpansionTile( 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) { Widget _buildTradeStat(String title, String val, Color col) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -301,3 +470,4 @@ class AssetTradeItemCard extends StatelessWidget {
); );
} }
} }
@@ -29,8 +29,8 @@ class LiveTradeSettingsDialog {
double tempFee = currentSettings.defaultOrderFee; double tempFee = currentSettings.defaultOrderFee;
bool tempAuto = currentSettings.autoAcceptSignals; bool tempAuto = currentSettings.autoAcceptSignals;
final posController = TextEditingController(text: tempPos.toStringAsFixed(0)); final posController = TextEditingController(text: tempPos == tempPos.roundToDouble() ? tempPos.toInt().toString() : tempPos.toStringAsFixed(2));
final levController = TextEditingController(text: tempLev.toStringAsFixed(1)); final levController = TextEditingController(text: tempLev == tempLev.roundToDouble() ? tempLev.toInt().toString() : tempLev.toStringAsFixed(2));
final feeController = TextEditingController(text: tempFee.toStringAsFixed(2)); final feeController = TextEditingController(text: tempFee.toStringAsFixed(2));
showDialog( showDialog(
@@ -1,57 +1,227 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../models/manual_analysis_request_dto.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( static void show(
BuildContext context, { BuildContext context, {
required String symbol, required String symbol,
required double initialRiskScore, required double initialRiskScore,
required void Function(ManualAnalysisRequestDto) onTrigger, 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( showDialog(
context: context, context: context,
builder: (dialogContext) { barrierDismissible: false,
return StatefulBuilder( builder: (dialogContext) => ManualAnalysisDialog(
builder: (builderContext, setModalState) { symbol: symbol,
return AlertDialog( initialRiskScore: initialRiskScore,
backgroundColor: AppTheme.cardSurface, onTrigger: onTrigger,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
), ),
title: Row( );
children: [ }
Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8), @override
Expanded( State<ManualAnalysisDialog> createState() => _ManualAnalysisDialogState();
child: Text('KI-Analyse für $symbol', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), }
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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Wählen Sie Ihre Zielparameter für die Trade-Evaluierung:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), // Header
const SizedBox(height: 16), Padding(
const Text('Zeithorizont (Timeframe):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), padding: const EdgeInsets.fromLTRB(20, 18, 16, 14),
const SizedBox(height: 6), 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( Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
controller: minTimeframeController, controller: _minTimeframeCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
), ),
@@ -59,7 +229,7 @@ class ManualAnalysisDialog {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: maxTimeframeController, controller: _maxTimeframeCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
), ),
@@ -67,7 +237,7 @@ class ManualAnalysisDialog {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
initialValue: timeframeUnit, initialValue: _timeframeUnit,
dropdownColor: AppTheme.cardSurface, dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [ items: const [
@@ -77,98 +247,223 @@ class ManualAnalysisDialog {
DropdownMenuItem(value: 'Monate', child: Text('Monate')), DropdownMenuItem(value: 'Monate', child: Text('Monate')),
], ],
onChanged: (val) { 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( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( Text(
'${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})', '${_riskScore.toInt()}/100 (${_riskScore < 35 ? "Konservativ" : (_riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
style: TextStyle( style: TextStyle(color: riskColor, fontWeight: FontWeight.bold, fontSize: 12),
color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
fontWeight: FontWeight.bold,
fontSize: 13,
), ),
],
), ),
const SizedBox(height: 8),
Row(
children: [
_riskProfileChip('🟢 Konservativ', 25, AppTheme.primaryEmerald),
_riskProfileChip('🟡 Ausgewogen', 50, Colors.orangeAccent),
_riskProfileChip('🔴 Spekulativ', 85, AppTheme.accentRed),
], ],
), ),
Slider( Slider(
value: riskScore, value: _riskScore,
min: 0, min: 0,
max: 100, max: 100,
divisions: 100, divisions: 100,
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), activeColor: riskColor,
inactiveColor: AppTheme.glassSurface, inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => riskScore = val), onChanged: _isAnalyzing ? null : (val) => setState(() => _riskScore = val),
), ),
const SizedBox(height: 12), 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>( DropdownButtonFormField<String>(
initialValue: instrumentType, initialValue: _instrumentType,
dropdownColor: AppTheme.cardSurface, dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)), decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
items: const [ 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: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')), 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: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')), DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
], ],
onChanged: (val) { onChanged: _isAnalyzing ? null : (val) {
if (val != null) setModalState(() => instrumentType = 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), const SizedBox(height: 6),
TextField( TextField(
controller: notesController, controller: _notesCtrl,
maxLines: 3, maxLines: 2,
enabled: !_isAnalyzing,
style: const TextStyle(color: Colors.white, fontSize: 12),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...', 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: [ child: _isAnalyzing
TextButton( ? Row(
onPressed: () => Navigator.pop(dialogContext), mainAxisAlignment: MainAxisAlignment.center,
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)), 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),
),
),
); );
} }
} }
@@ -25,9 +25,29 @@ class CalendarLoaded extends CalendarState {
this.selectedDate, this.selectedDate,
}); });
static bool matchesCategory(String eventType, String category) {
if (category == 'Alle' || category.isEmpty) return true;
final catKey = category.toLowerCase();
final t = eventType.toLowerCase();
if (catKey.contains('earn') || catKey.contains('quartal') || catKey.contains('ergebnis')) {
return t.contains('earn') || t.contains('quart') || t.contains('ergebnis') || t.contains('finan') || t.contains('report') || t.contains('bilanz') || t == 'event';
}
if (catKey.contains('ex') || catKey.contains('div')) {
return (t.contains('ex') || t.contains('div') || t.contains('ausschütt')) && !t.contains('pay') && !t.contains('zahl');
}
if (catKey.contains('pay') || catKey.contains('zahl')) {
return t.contains('pay') || t.contains('zahl') || t.contains('auszahl');
}
if (catKey.contains('split')) {
return t.contains('split');
}
return t.contains(catKey) || t == catKey;
}
List<CorporateEventModel> get filteredEvents { List<CorporateEventModel> get filteredEvents {
return allEvents.where((e) { return allEvents.where((e) {
if (selectedCategory != 'Alle' && e.eventType != selectedCategory) { if (!matchesCategory(e.eventType, selectedCategory)) {
return false; return false;
} }
if (selectedDate != null) { if (selectedDate != null) {
@@ -33,7 +33,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget {
const _CorporateCalendarScreenContent({required this.apiClient}); const _CorporateCalendarScreenContent({required this.apiClient});
static const List<String> categories = ['Alle', 'Earnings', 'ExDividend', 'Payout']; static const List<String> categories = ['Alle', 'Earnings', 'ExDividend', 'Payout', 'Split'];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -86,6 +86,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget {
if (cat == 'Earnings') label = 'Quartalsergebnisse'; if (cat == 'Earnings') label = 'Quartalsergebnisse';
if (cat == 'ExDividend') label = 'Ex-Dividendentage'; if (cat == 'ExDividend') label = 'Ex-Dividendentage';
if (cat == 'Payout') label = 'Zahlungstage'; if (cat == 'Payout') label = 'Zahlungstage';
if (cat == 'Split') label = 'Aktiensplits';
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
@@ -36,12 +36,21 @@ class CalendarEventTile extends StatelessWidget {
Color badgeColor = AppTheme.primaryEmerald; Color badgeColor = AppTheme.primaryEmerald;
String typeLabel = 'Quartalszahlen'; String typeLabel = 'Quartalszahlen';
if (type == 'ExDividend') { final tLower = type.toLowerCase();
badgeColor = AppTheme.accentCyan; if (tLower.contains('ex') || tLower.contains('div') || tLower.contains('ausschütt')) {
typeLabel = 'Ex-Dividende'; if (tLower.contains('pay') || tLower.contains('zahl')) {
} else if (type == 'Payout') {
badgeColor = Colors.amber; badgeColor = Colors.amber;
typeLabel = 'Zahlungstag'; typeLabel = 'Zahlungstag';
} else {
badgeColor = AppTheme.accentCyan;
typeLabel = 'Ex-Dividende';
}
} else if (tLower.contains('pay') || tLower.contains('zahl') || tLower.contains('auszahl')) {
badgeColor = Colors.amber;
typeLabel = 'Zahlungstag';
} else if (tLower.contains('split')) {
badgeColor = Colors.purpleAccent;
typeLabel = 'Aktiensplit';
} }
return GlassContainer( return GlassContainer(
@@ -53,6 +53,19 @@ class NewsRepository {
} }
} }
Future<NewsArticleModel> reanalyzeArticle(String articleId) async {
try {
final response = await apiClient.post('/api/v1/news/sentiment/article/$articleId/analyze');
if (response.statusCode == 200 && response.data != null) {
final Map<String, dynamic> data = response.data is Map ? Map<String, dynamic>.from(response.data) : {};
return NewsArticleModel.fromJson(data);
}
throw Exception('Unerwartete Server-Antwort');
} catch (e) {
throw Exception('Sentiment-Analyse fehlgeschlagen: $e');
}
}
Future<void> connectToLiveFeed() async { Future<void> connectToLiveFeed() async {
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) { if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
return; return;
@@ -7,7 +7,7 @@ import '../repositories/news_repository.dart';
import '../widgets/news_card_item.dart'; import '../widgets/news_card_item.dart';
import '../widgets/advanced_news_filter_bar.dart'; import '../widgets/advanced_news_filter_bar.dart';
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting. /// Paginated Infinite Scroll Daily News Feed screen with sentiment toggle filters, real-time update handling, and clean navigation.
class NewsFeedScreen extends StatefulWidget { class NewsFeedScreen extends StatefulWidget {
final ApiClient apiClient; final ApiClient apiClient;
final NewsRepository? repository; final NewsRepository? repository;
@@ -23,7 +23,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final List<NewsArticleModel> _newsItems = []; final List<NewsArticleModel> _newsItems = [];
int _currentPage = 1; int _currentPage = 1;
static const int _pageSize = 15; static const int _pageSize = 20;
bool _isLoading = false; bool _isLoading = false;
bool _hasMore = true; bool _hasMore = true;
@@ -32,6 +32,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
DateTime? _selectedDate; DateTime? _selectedDate;
String? _selectedIsin; String? _selectedIsin;
bool _hasSentimentOnly = false; bool _hasSentimentOnly = false;
String? _selectedSentimentFilter; // null, 'POSITIVE', 'NEUTRAL', 'NEGATIVE'
Timer? _debounceTimer; Timer? _debounceTimer;
@@ -72,7 +73,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
date: _selectedDate?.toIso8601String().substring(0, 10), date: _selectedDate?.toIso8601String().substring(0, 10),
query: _searchQuery?.trim(), query: _searchQuery?.trim(),
isin: _selectedIsin?.trim(), isin: _selectedIsin?.trim(),
hasSentiment: _hasSentimentOnly, hasSentiment: _hasSentimentOnly || _selectedSentimentFilter != null,
); );
setState(() { setState(() {
@@ -97,6 +98,27 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
} }
} }
List<NewsArticleModel> get _filteredNewsItems {
if (_selectedSentimentFilter == null) return _newsItems;
final target = _selectedSentimentFilter!.toUpperCase();
return _newsItems.where((item) {
final s = item.sentiment.toUpperCase();
if (target == 'POSITIVE') return s.contains('POS');
if (target == 'NEGATIVE') return s.contains('NEG');
if (target == 'NEUTRAL') return s.contains('NEU');
return s == target;
}).toList();
}
void _onArticleUpdated(NewsArticleModel updated) {
setState(() {
final idx = _newsItems.indexWhere((e) => e.id == updated.id);
if (idx != -1) {
_newsItems[idx] = updated;
}
});
}
void _onSearchChanged(String? val) { void _onSearchChanged(String? val) {
_searchQuery = val; _searchQuery = val;
_debounceTimer?.cancel(); _debounceTimer?.cancel();
@@ -120,25 +142,39 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
_selectedDate = null; _selectedDate = null;
_selectedIsin = null; _selectedIsin = null;
_hasSentimentOnly = false; _hasSentimentOnly = false;
_selectedSentimentFilter = null;
}); });
_loadNews(refresh: true); _loadNews(refresh: true);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final displayedItems = _filteredNewsItems;
return Scaffold( return Scaffold(
body: Padding( body: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
if (_newsItems.isNotEmpty)
Text(
'${displayedItems.length} Artikel angezeigt',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
AdvancedNewsFilterBar( AdvancedNewsFilterBar(
searchQuery: _searchQuery, searchQuery: _searchQuery,
selectedDate: _selectedDate, selectedDate: _selectedDate,
selectedIsin: _selectedIsin, selectedIsin: _selectedIsin,
hasSentimentOnly: _hasSentimentOnly, hasSentimentOnly: _hasSentimentOnly,
selectedSentimentFilter: _selectedSentimentFilter,
onSearchChanged: _onSearchChanged, onSearchChanged: _onSearchChanged,
onIsinChanged: _onIsinChanged, onIsinChanged: _onIsinChanged,
onDateChanged: (val) { onDateChanged: (val) {
@@ -149,27 +185,46 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
setState(() => _hasSentimentOnly = val); setState(() => _hasSentimentOnly = val);
_loadNews(refresh: true); _loadNews(refresh: true);
}, },
onSentimentFilterChanged: (val) {
setState(() => _selectedSentimentFilter = val);
},
onResetFilters: _resetFilters, onResetFilters: _resetFilters,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: _newsItems.isEmpty && !_isLoading child: displayedItems.isEmpty && !_isLoading
? Center( ? Center(
child: Text('Keine Nachrichten für diese Filterkriterien gefunden.', style: TextStyle(color: AppTheme.textMuted)), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.feed_outlined, size: 40, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine Nachrichten für diese Filterkriterien gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
const SizedBox(height: 10),
TextButton(
onPressed: _resetFilters,
child: Text('Filter zurücksetzen', style: TextStyle(color: AppTheme.accentCyan)),
),
],
),
) )
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
itemCount: _newsItems.length + (_hasMore ? 1 : 0), itemCount: displayedItems.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == _newsItems.length) { if (index == displayedItems.length) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: CircularProgressIndicator(color: AppTheme.primaryEmerald), child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
), ),
); );
} }
return NewsCardItem(item: _newsItems[index], apiClient: widget.apiClient); return NewsCardItem(
item: displayedItems[index],
apiClient: widget.apiClient,
onArticleUpdated: _onArticleUpdated,
);
}, },
), ),
), ),
@@ -179,4 +234,3 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
); );
} }
} }
@@ -7,10 +7,12 @@ class AdvancedNewsFilterBar extends StatelessWidget {
final DateTime? selectedDate; final DateTime? selectedDate;
final String? selectedIsin; final String? selectedIsin;
final bool hasSentimentOnly; final bool hasSentimentOnly;
final String? selectedSentimentFilter; // null (Alle), 'POSITIVE', 'NEUTRAL', 'NEGATIVE'
final Function(String?) onSearchChanged; final Function(String?) onSearchChanged;
final Function(DateTime?) onDateChanged; final Function(DateTime?) onDateChanged;
final Function(String?) onIsinChanged; final Function(String?) onIsinChanged;
final Function(bool) onSentimentToggleChanged; final Function(bool) onSentimentToggleChanged;
final Function(String?) onSentimentFilterChanged;
final VoidCallback onResetFilters; final VoidCallback onResetFilters;
const AdvancedNewsFilterBar({ const AdvancedNewsFilterBar({
@@ -19,10 +21,12 @@ class AdvancedNewsFilterBar extends StatelessWidget {
required this.selectedDate, required this.selectedDate,
required this.selectedIsin, required this.selectedIsin,
required this.hasSentimentOnly, required this.hasSentimentOnly,
this.selectedSentimentFilter,
required this.onSearchChanged, required this.onSearchChanged,
required this.onDateChanged, required this.onDateChanged,
required this.onIsinChanged, required this.onIsinChanged,
required this.onSentimentToggleChanged, required this.onSentimentToggleChanged,
required this.onSentimentFilterChanged,
required this.onResetFilters, required this.onResetFilters,
}); });
@@ -38,6 +42,7 @@ class AdvancedNewsFilterBar extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Row 1: Search Query & ISIN Input
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -102,8 +107,14 @@ class AdvancedNewsFilterBar extends StatelessWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row(
// Row 2: Date Picker, Sentiment Toggle Chips (Gut, Neutral, Schlecht), and Reset
Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
// Date picker
InkWell( InkWell(
onTap: () async { onTap: () async {
final date = await showDatePicker( final date = await showDatePicker(
@@ -138,6 +149,7 @@ class AdvancedNewsFilterBar extends StatelessWidget {
border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder), border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted), Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -160,9 +172,10 @@ class AdvancedNewsFilterBar extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(width: 16),
// AI Analyzed Only Chip
FilterChip( FilterChip(
label: const Text('Nur Analysiert (KI)'), label: const Text('Nur KI-Analysiert'),
selected: hasSentimentOnly, selected: hasSentimentOnly,
onSelected: onSentimentToggleChanged, onSelected: onSentimentToggleChanged,
backgroundColor: Colors.black.withValues(alpha: 0.2), backgroundColor: Colors.black.withValues(alpha: 0.2),
@@ -171,14 +184,33 @@ class AdvancedNewsFilterBar extends StatelessWidget {
side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder), side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder),
labelStyle: TextStyle( labelStyle: TextStyle(
color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.textMuted, color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.textMuted,
fontSize: 13, fontSize: 12,
), ),
), ),
const Spacer(),
// Sentiment Toggle Buttons (Alle, Gut, Neutral, Schlecht)
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_sentimentToggleItem('Alle', null, Colors.white70),
_sentimentToggleItem('Gut (Positiv)', 'POSITIVE', AppTheme.primaryEmerald, icon: Icons.trending_up),
_sentimentToggleItem('Neutral', 'NEUTRAL', AppTheme.accentCyan, icon: Icons.remove),
_sentimentToggleItem('Schlecht (Negativ)', 'NEGATIVE', AppTheme.accentRed, icon: Icons.trending_down),
],
),
),
// Reset Filter Button
TextButton.icon( TextButton.icon(
onPressed: onResetFilters, onPressed: onResetFilters,
icon: Icon(Icons.refresh, size: 16, color: AppTheme.textMuted), icon: Icon(Icons.refresh, size: 15, color: AppTheme.textMuted),
label: Text('Reset Filter', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), label: Text('Reset', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
), ),
], ],
), ),
@@ -186,5 +218,36 @@ class AdvancedNewsFilterBar extends StatelessWidget {
), ),
); );
} }
}
Widget _sentimentToggleItem(String label, String? value, Color activeColor, {IconData? icon}) {
final isSelected = selectedSentimentFilter == value;
return GestureDetector(
onTap: () => onSentimentFilterChanged(value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: isSelected ? activeColor.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: isSelected ? Border.all(color: activeColor.withValues(alpha: 0.6)) : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: isSelected ? activeColor : AppTheme.textMuted),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
color: isSelected ? activeColor : AppTheme.textMuted,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 11.5,
),
),
],
),
),
);
}
}
@@ -1,16 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../models/news_article_model.dart'; import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/status_badge.dart'; import '../../../core/widgets/status_badge.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../../favorites/cubit/favorites_cubit.dart';
import '../models/news_article_model.dart';
import '../repositories/news_repository.dart';
import 'finbert_sentiment_tab.dart'; import 'finbert_sentiment_tab.dart';
class ArticleSentimentDialog extends StatefulWidget { class ArticleSentimentDialog extends StatefulWidget {
final NewsArticleModel articleData; final NewsArticleModel articleData;
final ApiClient? apiClient;
final ValueChanged<NewsArticleModel>? onArticleUpdated;
const ArticleSentimentDialog({ const ArticleSentimentDialog({
super.key, super.key,
required this.articleData, required this.articleData,
this.apiClient,
this.onArticleUpdated,
}); });
@override @override
@@ -19,10 +28,14 @@ class ArticleSentimentDialog extends StatefulWidget {
class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with SingleTickerProviderStateMixin { class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late NewsArticleModel _currentArticle;
bool _isReanalyzing = false;
String? _reanalyzeError;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentArticle = widget.articleData;
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
} }
@@ -33,7 +46,7 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
} }
void _openOriginalSource() async { void _openOriginalSource() async {
final urlStr = widget.articleData.sourceUrl; final urlStr = _currentArticle.sourceUrl;
if (urlStr.isNotEmpty) { if (urlStr.isNotEmpty) {
final uri = Uri.parse(urlStr); final uri = Uri.parse(urlStr);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
@@ -42,15 +55,60 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
} }
} }
Future<void> _reanalyzeSentiment() async {
if (_isReanalyzing || _currentArticle.id.isEmpty) return;
setState(() {
_isReanalyzing = true;
_reanalyzeError = null;
});
try {
final client = widget.apiClient ?? context.read<ApiClient>();
final repo = NewsRepository(apiClient: client, backendUrl: ApiClient.baseUrl);
final updatedArticle = await repo.reanalyzeArticle(_currentArticle.id);
if (mounted) {
setState(() {
_currentArticle = updatedArticle;
_isReanalyzing = false;
});
widget.onArticleUpdated?.call(updatedArticle);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
const Text('Sentiment-Analyse erfolgreich erneuert!'),
],
),
backgroundColor: AppTheme.cardSurface,
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
setState(() {
_isReanalyzing = false;
_reanalyzeError = e.toString();
});
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final article = widget.articleData; final article = _currentArticle;
final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel'; final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel';
final author = article.author.isNotEmpty ? article.author : 'Finlytic News'; final author = article.author.isNotEmpty ? article.author : 'Finlytic News';
final summary = article.summary; final summary = article.summary;
final sourceUrl = article.sourceUrl; final sourceUrl = article.sourceUrl;
final contentRaw = article.contentRaw; final contentRaw = article.contentRaw;
final publishedAt = "${article.publishedAt.day}.${article.publishedAt.month}.${article.publishedAt.year}"; final publishedAt = "${article.publishedAt.day.toString().padLeft(2, '0')}.${article.publishedAt.month.toString().padLeft(2, '0')}.${article.publishedAt.year}";
final status = article.status.isNotEmpty ? article.status : 'Completed'; final status = article.status.isNotEmpty ? article.status : 'Completed';
final rawSentiment = article.sentiment; final rawSentiment = article.sentiment;
@@ -63,6 +121,9 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan, color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan,
); );
final client = widget.apiClient ?? (context.mounted ? context.read<ApiClient>() : null);
final favourites = context.read<FavoritesCubit>().state.favoriteDetails;
return Dialog( return Dialog(
backgroundColor: AppTheme.cardSurface, backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -70,13 +131,15 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
side: BorderSide(color: AppTheme.glassBorder), side: BorderSide(color: AppTheme.glassBorder),
), ),
child: Container( child: Container(
width: 650, width: 720,
height: 600, height: 640,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
@@ -88,7 +151,7 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( Text(
'Quelle: $author$publishedAt', 'Quelle: $author$publishedAt',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12), style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
@@ -96,15 +159,64 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 12),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Sentiment Reanalyze Icon Button (directly left of badge)
IconButton(
tooltip: 'KI-Sentiment neu analysieren',
onPressed: _isReanalyzing ? null : _reanalyzeSentiment,
icon: _isReanalyzing
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.accentCyan),
)
: Icon(Icons.auto_awesome, size: 18, color: AppTheme.accentCyan),
padding: const EdgeInsets.all(6),
constraints: const BoxConstraints(),
),
const SizedBox(width: 6),
listBadge, listBadge,
const SizedBox(width: 8),
IconButton( IconButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, color: Colors.white70), icon: const Icon(Icons.close, color: Colors.white70, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
), ),
], ],
), ),
],
),
if (_reanalyzeError != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 16, color: AppTheme.accentRed),
const SizedBox(width: 6),
Expanded(
child: Text(
_reanalyzeError!,
style: TextStyle(color: AppTheme.accentRed, fontSize: 11),
),
),
],
),
),
],
const SizedBox(height: 12), const SizedBox(height: 12),
// Tab Bar
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassSurface, color: AppTheme.glassSurface,
@@ -118,12 +230,14 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
unselectedLabelColor: AppTheme.textMuted, unselectedLabelColor: AppTheme.textMuted,
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
tabs: const [ tabs: const [
Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel-Inhalt'), Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel & Assets'),
Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT Sentiment'), Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT KI-Sentiment'),
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Tab Views
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@@ -134,37 +248,84 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
children: [ children: [
if (summary.isNotEmpty) ...[ if (summary.isNotEmpty) ...[
const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
const SizedBox(height: 4), const SizedBox(height: 6),
Container( Container(
padding: const EdgeInsets.all(10), width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassSurface, color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
), ),
child: Text(summary, style: const TextStyle(color: Colors.white70, fontSize: 13)), child: Text(summary, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
], ],
if (article.matchedAssets.isNotEmpty) ...[ if (article.matchedAssets.isNotEmpty) ...[
const Text('Zugeordnete Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), Row(
const SizedBox(height: 6), children: [
const Text('Erkannte Unternehmen & Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
const SizedBox(width: 6),
Text('(Klick öffnet Asset-Details)', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
const SizedBox(height: 8),
Wrap( Wrap(
spacing: 6, spacing: 8,
runSpacing: 8,
children: article.matchedAssets.map((asset) { children: article.matchedAssets.map((asset) {
return Chip( final isin = asset.isin;
label: Text('${asset.symbol} (${asset.isin})', style: const TextStyle(fontSize: 11, color: Colors.white)), final name = asset.name.isNotEmpty ? asset.name : (asset.symbol.isNotEmpty ? asset.symbol : isin);
backgroundColor: AppTheme.glassSurface, final match = favourites.where((e) => e.isin == isin);
side: BorderSide(color: AppTheme.glassBorder), final symbol = match.isEmpty ? null : match.first;
return ActionChip(
avatar: Icon(Icons.open_in_new, size: 14, color: AppTheme.accentCyan),
label: Text(
name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.accentCyan,
),
),
backgroundColor: AppTheme.accentCyan.withValues(alpha: 0.1),
side: BorderSide(color: AppTheme.accentCyan.withValues(alpha: 0.35)),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
onPressed: () {
if (client != null && isin.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
isin: isin,
name: name,
symbol: symbol != null ? symbol.symbol : asset.symbol,
apiClient: client,
),
),
);
}
},
); );
}).toList(), }).toList(),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
], ],
const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Container(
contentRaw.isNotEmpty ? contentRaw : 'Kein vollständiger Text verfügbar.', width: double.infinity,
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Text(
contentRaw.isNotEmpty ? contentRaw : (summary.isNotEmpty ? summary : 'Kein vollständiger Text verfügbar.'),
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.5),
),
), ),
], ],
), ),
@@ -174,6 +335,8 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Footer
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -181,15 +344,20 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
TextButton.icon( TextButton.icon(
onPressed: _openOriginalSource, onPressed: _openOriginalSource,
icon: const Icon(Icons.open_in_new, size: 16), icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Originalquelle im Browser öffnen'), label: const Text('Originalquelle im Web öffnen'),
style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan), style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan),
) )
else else
const SizedBox.shrink(), const SizedBox.shrink(),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black), style: ElevatedButton.styleFrom(
child: const Text('Schließen'), backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Schließen', style: TextStyle(fontWeight: FontWeight.bold)),
), ),
], ],
), ),
@@ -1,3 +1,4 @@
import 'package:finlytic_app/core/widgets/status_badge.dart';
import 'package:finlytic_app/features/favorites/cubit/favorites_cubit.dart'; import 'package:finlytic_app/features/favorites/cubit/favorites_cubit.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -10,15 +11,17 @@ import '../../asset_detail/views/asset_detail_screen.dart';
import '../models/news_article_model.dart'; import '../models/news_article_model.dart';
import 'article_sentiment_dialog.dart'; import 'article_sentiment_dialog.dart';
/// News Card Item displaying news info, tagged assets, timestamp, and sentiment. /// News Card Item displaying news info, tagged assets, timestamp, and sentiment badge (wie auf dem Dashboard).
class NewsCardItem extends StatelessWidget { class NewsCardItem extends StatelessWidget {
final dynamic item; final dynamic item;
final ApiClient apiClient; final ApiClient apiClient;
final ValueChanged<NewsArticleModel>? onArticleUpdated;
const NewsCardItem({ const NewsCardItem({
super.key, super.key,
required this.item, required this.item,
required this.apiClient, required this.apiClient,
this.onArticleUpdated,
}); });
@override @override
@@ -44,7 +47,9 @@ class NewsCardItem extends StatelessWidget {
final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString(); final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString();
final double? score = ((sentimentObj?['compoundScore'] ?? sentimentObj?['compound_score'] ?? article['sentimentScore']) as num?)?.toDouble(); final double? score = ((sentimentObj?['compoundScore'] ?? sentimentObj?['compound_score'] ?? article['sentimentScore']) as num?)?.toDouble();
final Widget? badgeWidget = (sentimentLabel != null && sentimentLabel.trim().isNotEmpty)
? StatusBadge.sentiment(sentimentLabel, score: score)
: null;
final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets']; final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets'];
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : []; final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
@@ -79,7 +84,11 @@ class NewsCardItem extends StatelessWidget {
} }
showDialog( showDialog(
context: context, context: context,
builder: (_) => ArticleSentimentDialog(articleData: articleModel), builder: (_) => ArticleSentimentDialog(
articleData: articleModel,
apiClient: apiClient,
onArticleUpdated: onArticleUpdated,
),
); );
}, },
child: Column( child: Column(
@@ -89,43 +98,61 @@ class NewsCardItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14.5),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
),
if (badgeWidget != null) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
//badgeWidget, badgeWidget,
],
], ],
), ),
if (summary.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), Text(
summary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12.5),
),
],
const SizedBox(height: 10), const SizedBox(height: 10),
Text('$author$pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), Text('$author$pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
if (matchedAssets.isNotEmpty) ...[ if (matchedAssets.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 4, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: matchedAssets.map((assetItem) { children: matchedAssets.map((assetItem) {
final isin = assetItem['isin']!; final String isin = (assetItem is Map ? (assetItem['isin'] ?? assetItem['Isin']) : assetItem)?.toString() ?? '';
final name = assetItem['name']!; final String name = (assetItem is Map ? (assetItem['name'] ?? assetItem['Name'] ?? assetItem['symbol'] ?? isin) : isin)?.toString() ?? isin;
final match = favourites.where((e) => e.isin == isin); final match = favourites.where((e) => e.isin == isin);
final symbol = match.isEmpty ? null : match.first; final symbol = match.isEmpty ? null : match.first;
return ActionChip( return ActionChip(
label: Text(name, style: TextStyle(fontSize: 10, color: AppTheme.accentCyan)), avatar: Icon(Icons.show_chart, size: 14, color: AppTheme.accentCyan),
label: Text(name, style: TextStyle(fontSize: 11, color: AppTheme.accentCyan, fontWeight: FontWeight.bold)),
backgroundColor: AppTheme.glassSurface, backgroundColor: AppTheme.glassSurface,
padding: EdgeInsets.zero, side: BorderSide(color: AppTheme.glassBorder),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () { onPressed: () {
if (isin.isNotEmpty) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AssetDetailScreen( builder: (_) => AssetDetailScreen(
isin: assetItem, isin: isin,
name: name, name: name,
symbol: symbol != null ? symbol.symbol : null, symbol: symbol != null ? symbol.symbol : null,
apiClient: apiClient, apiClient: apiClient,
), ),
), ),
); );
}
}, },
); );
}).toList(), }).toList(),
@@ -26,7 +26,7 @@ class TradeBloc extends Bloc<TradeEvent, TradeState> {
Future<void> _onCloseTrade(CloseTrade event, Emitter<TradeState> emit) async { Future<void> _onCloseTrade(CloseTrade event, Emitter<TradeState> emit) async {
emit(TradeLoading()); emit(TradeLoading());
try { try {
await repository.closeTrade(event.tradeId); await repository.closeTrade(event.tradeId, dto: event.dto);
final trades = await repository.fetchTrades(); final trades = await repository.fetchTrades();
emit(TradeLoaded(trades)); emit(TradeLoaded(trades));
} catch (e) { } catch (e) {
@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../models/trade_acceptance_dto.dart'; import '../models/trade_acceptance_dto.dart';
import '../models/close_trade_request_dto.dart';
abstract class TradeEvent extends Equatable { abstract class TradeEvent extends Equatable {
const TradeEvent(); const TradeEvent();
@@ -20,11 +21,12 @@ class FetchTrades extends TradeEvent {
class CloseTrade extends TradeEvent { class CloseTrade extends TradeEvent {
final String tradeId; final String tradeId;
final CloseTradeRequestDto? dto;
const CloseTrade(this.tradeId); const CloseTrade(this.tradeId, {this.dto});
@override @override
List<Object?> get props => [tradeId]; List<Object?> get props => [tradeId, dto];
} }
class AcceptTradeProposalEvent extends TradeEvent { class AcceptTradeProposalEvent extends TradeEvent {
@@ -35,3 +37,4 @@ class AcceptTradeProposalEvent extends TradeEvent {
@override @override
List<Object?> get props => [dto]; List<Object?> get props => [dto];
} }
@@ -1,12 +1,24 @@
/// Typed DTO for requesting a trade exit/close. /// Typed DTO for requesting a trade exit/close.
class CloseTradeRequestDto { class CloseTradeRequestDto {
final double userExitPrice; 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() { Map<String, dynamic> toJson() {
return { return {
'userExitPrice': userExitPrice, 'userExitPrice': userExitPrice,
if (userExitTimestamp != null) 'userExitTimestamp': userExitTimestamp!.toUtc().toIso8601String(),
'exitFee': exitFee,
'closeReason': closeReason,
}; };
} }
} }
@@ -0,0 +1,95 @@
import 'package:equatable/equatable.dart';
class DerivativeItemModel extends Equatable {
final String isin;
final String name;
final String optionType;
final String productCategoryName;
final String nextGenProductCategoryName;
final double strike;
final double barrier;
final double leverage;
final double? size;
final double? factor;
final double? delta;
final String currency;
final String? expiry;
final String issuer;
final String issuerDisplayName;
final String? issuerImageId;
final String underlyingIsin;
const DerivativeItemModel({
required this.isin,
this.name = '',
this.optionType = 'Long',
this.productCategoryName = '',
this.nextGenProductCategoryName = '',
this.strike = 0.0,
this.barrier = 0.0,
this.leverage = 1.0,
this.size,
this.factor,
this.delta,
this.currency = 'EUR',
this.expiry,
this.issuer = '',
this.issuerDisplayName = '',
this.issuerImageId,
this.underlyingIsin = '',
});
bool get isLong => optionType.toLowerCase().contains('long') || optionType.toLowerCase().contains('call');
double distanceToBarrier(double currentUnderlyingPrice) {
if (barrier <= 0 || currentUnderlyingPrice <= 0) return 0.0;
return ((currentUnderlyingPrice - barrier).abs() / currentUnderlyingPrice) * 100.0;
}
factory DerivativeItemModel.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;
}
double? parseOptDbl(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return DerivativeItemModel(
isin: json['isin']?.toString() ?? '',
name: json['name']?.toString() ?? '',
optionType: json['optionType']?.toString() ?? 'Long',
productCategoryName: json['productCategoryName']?.toString() ?? '',
nextGenProductCategoryName: json['nextGenProductCategoryName']?.toString() ?? '',
strike: parseDbl(json['strike']),
barrier: parseDbl(json['barrier']),
leverage: parseDbl(json['leverage']),
size: parseOptDbl(json['size']),
factor: parseOptDbl(json['factor']),
delta: parseOptDbl(json['delta']),
currency: json['currency']?.toString() ?? 'EUR',
expiry: json['expiry']?.toString(),
issuer: json['issuer']?.toString() ?? '',
issuerDisplayName: json['issuerDisplayName']?.toString() ?? (json['issuer']?.toString() ?? ''),
issuerImageId: json['issuerImageId']?.toString(),
underlyingIsin: json['underlyingIsin']?.toString() ?? '',
);
}
@override
List<Object?> get props => [
isin,
name,
optionType,
productCategoryName,
strike,
barrier,
leverage,
issuer,
underlyingIsin,
];
}
@@ -1,5 +1,59 @@
import 'package:equatable/equatable.dart'; 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 { class TradeModel extends Equatable {
final String id; final String id;
final String analysisId; final String analysisId;
@@ -27,6 +81,9 @@ class TradeModel extends Equatable {
final double winRate; final double winRate;
final String timeframe; final String timeframe;
final String instrumentType; final String instrumentType;
final String assetType;
final bool hasCfd;
final List<String> derivativeProductCategories;
final String derivativeIsin; final String derivativeIsin;
final DateTime? createdAt; final DateTime? createdAt;
@@ -41,6 +98,12 @@ class TradeModel extends Equatable {
final double exitFee; final double exitFee;
final double quantity; final double quantity;
final String closeReason;
final DateTime? userExitTimestamp;
final bool hasPendingExitAlert;
final String pendingExitReason;
final List<TradeHourlyUpdateModel> hourlyUpdates;
const TradeModel({ const TradeModel({
required this.id, required this.id,
this.analysisId = '', this.analysisId = '',
@@ -68,6 +131,9 @@ class TradeModel extends Equatable {
this.winRate = 50.0, this.winRate = 50.0,
this.timeframe = '1D', this.timeframe = '1D',
this.instrumentType = 'Stock', this.instrumentType = 'Stock',
this.assetType = 'stock',
this.hasCfd = false,
this.derivativeProductCategories = const [],
this.derivativeIsin = '', this.derivativeIsin = '',
this.createdAt, this.createdAt,
this.riskTolerance = 'Moderate', this.riskTolerance = 'Moderate',
@@ -80,6 +146,11 @@ class TradeModel extends Equatable {
this.entryFee = 0.0, this.entryFee = 0.0,
this.exitFee = 0.0, this.exitFee = 0.0,
this.quantity = 0.0, this.quantity = 0.0,
this.closeReason = '',
this.userExitTimestamp,
this.hasPendingExitAlert = false,
this.pendingExitReason = '',
this.hourlyUpdates = const [],
}); });
bool get isActive => status.toLowerCase() == 'active'; bool get isActive => status.toLowerCase() == 'active';
@@ -87,6 +158,15 @@ class TradeModel extends Equatable {
bool get isRejected => status.toLowerCase() == 'rejected'; bool get isRejected => status.toLowerCase() == 'rejected';
bool get isProposed => (status.toLowerCase() == 'proposed' || isGlobalProposal) && !isRejected && !isActive && !isClosed; 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 { double get effectiveCurrentPrice {
if (currentPrice > 0) return currentPrice; if (currentPrice > 0) return currentPrice;
if (actualEntryPrice > 0) return actualEntryPrice; if (actualEntryPrice > 0) return actualEntryPrice;
@@ -162,6 +242,18 @@ class TradeModel extends Equatable {
dt = DateTime.tryParse(createdStr); 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( return TradeModel(
id: idVal, id: idVal,
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '', analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
@@ -189,6 +281,11 @@ class TradeModel extends Equatable {
winRate: parseDbl(json['winRate'] ?? json['WinRate']), winRate: parseDbl(json['winRate'] ?? json['WinRate']),
timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D', timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D',
instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock', 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() ?? '', derivativeIsin: (json['derivativeIsin'] ?? json['DerivativeIsin'] ?? json['knockoutIsin'] ?? json['KnockoutIsin'])?.toString() ?? '',
createdAt: dt, createdAt: dt,
riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate', riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate',
@@ -203,6 +300,11 @@ class TradeModel extends Equatable {
entryFee: parseDbl(json['entryFee'] ?? json['EntryFee']), entryFee: parseDbl(json['entryFee'] ?? json['EntryFee']),
exitFee: parseDbl(json['exitFee'] ?? json['ExitFee']), exitFee: parseDbl(json['exitFee'] ?? json['ExitFee']),
quantity: parseDbl(json['quantity'] ?? json['Quantity']), 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, 'winRate': winRate,
'timeframe': timeframe, 'timeframe': timeframe,
'instrumentType': instrumentType, 'instrumentType': instrumentType,
'assetType': assetType,
'hasCfd': hasCfd,
'derivativeProductCategories': derivativeProductCategories,
'derivativeIsin': derivativeIsin, 'derivativeIsin': derivativeIsin,
'createdAt': createdAt?.toIso8601String(), 'createdAt': createdAt?.toIso8601String(),
'riskTolerance': riskTolerance, 'riskTolerance': riskTolerance,
@@ -246,6 +351,10 @@ class TradeModel extends Equatable {
'entryFee': entryFee, 'entryFee': entryFee,
'exitFee': exitFee, 'exitFee': exitFee,
'quantity': quantity, 'quantity': quantity,
'closeReason': closeReason,
'userExitTimestamp': userExitTimestamp?.toIso8601String(),
'hasPendingExitAlert': hasPendingExitAlert,
'pendingExitReason': pendingExitReason,
}; };
} }
@@ -263,5 +372,8 @@ class TradeModel extends Equatable {
currentPrice, currentPrice,
pnlAbsolute, pnlAbsolute,
pnlPercent, 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_model.dart';
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.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/close_trade_request_dto.dart';
import 'package:finlytic_app/features/trades/models/derivative_item_model.dart';
class TradeRepository { class TradeRepository {
final ApiClient apiClient; 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 { Future<void> acceptTrade(TradeAcceptanceDto dto) async {
final response = await apiClient.post('/api/v1/user/trades/accept', data: dto.toJson()); final response = await apiClient.post('/api/v1/user/trades/accept', data: dto.toJson());
if (response.statusCode != 200) { 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/api_client.dart';
import '../../../core/network/signalr_service.dart'; import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../bloc/trade_bloc.dart'; import '../bloc/trade_bloc.dart';
import '../bloc/trade_event.dart'; import '../bloc/trade_event.dart';
import '../bloc/trade_state.dart'; import '../bloc/trade_state.dart';
import '../models/trade_model.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 '../repositories/trade_repository.dart';
import '../widgets/trade_card.dart'; import '../widgets/trade_card.dart';
import '../widgets/proposed_auto_trades_card.dart'; import '../widgets/proposed_auto_trades_card.dart';
import '../widgets/trade_acceptance_dialog.dart'; import '../widgets/trade_execution_cockpit.dart';
import '../widgets/trade_execution_dialog.dart'; import '../widgets/trade_closing_cockpit.dart';
import '../widgets/trade_performance_bar.dart'; import '../widgets/trade_performance_bar.dart';
class TradesFeedScreen extends StatelessWidget { class TradesFeedScreen extends StatelessWidget {
@@ -55,28 +54,45 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
super.dispose(); super.dispose();
} }
Future<void> _handleAcceptProposal(BuildContext context, TradeModel trade) async { void _handleAcceptProposal(BuildContext context, TradeModel trade, {bool isActive = false}) {
final authState = context.read<AuthBloc>().state; final tradeBloc = context.read<TradeBloc>();
final currentUserId = (authState is Authenticated) ? authState.user.userId : 'default_user';
final result = await showDialog<TradeAcceptanceDto>( TradeExecutionCockpit.show(
context: context, context,
builder: (ctx) => TradeAcceptanceDialog(
trade: trade, trade: trade,
theme: AppTheme.darkClassic, defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
userId: currentUserId, isActive: isActive,
), onAccept: (dto) {
); tradeBloc.add(AcceptTradeProposalEvent(dto));
if (result != null && mounted) {
context.read<TradeBloc>().add(AcceptTradeProposalEvent(result));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( 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, 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 @override
@@ -102,12 +118,12 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'Live Portfolio & Trading', 'Live Portfolio & Trades',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'KI-Erkennungen, Vorschläge & Aktive Positionen', 'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12), style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
), ),
], ],
@@ -211,11 +227,11 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
_filterChip('Alle', allTrades.length),
_filterChip('Offen', activeTrades.length), _filterChip('Offen', activeTrades.length),
_filterChip('Vorschläge', proposals.length), _filterChip('Vorschläge', proposals.length),
_filterChip('Abgelehnt', rejectedTrades.length),
_filterChip('Geschlossen', closedTrades.length), _filterChip('Geschlossen', closedTrades.length),
_filterChip('Abgelehnt', rejectedTrades.length),
_filterChip('Alle', allTrades.length),
], ],
), ),
), ),
@@ -228,7 +244,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
children: [ children: [
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted), Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
const SizedBox(height: 8), 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( return TradeCard(
trade: trade, trade: trade,
onAccept: () => _handleAcceptProposal(context, trade), onAccept: () => _handleAcceptProposal(context, trade),
onSettings: () => _showTradeSettingsDialog(context, trade), onSettings: () => _handleAcceptProposal(context, trade, isActive: true),
onClose: () { onClose: () => _handleCloseTrade(context, trade),
context.read<TradeBloc>().add(CloseTrade(trade.id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Position wird geschlossen...')),
);
},
); );
}, },
), ),
@@ -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,
),
);
},
);
}
} }
@@ -0,0 +1,755 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/status_badge.dart';
import '../models/derivative_item_model.dart';
import '../repositories/trade_repository.dart';
class DerivativePickerModal extends StatefulWidget {
final String underlyingIsin;
final String underlyingSymbol;
final String underlyingName;
final String initialSignalType;
final double currentUnderlyingPrice;
const DerivativePickerModal({
super.key,
required this.underlyingIsin,
required this.underlyingSymbol,
this.underlyingName = '',
this.initialSignalType = 'BUY',
this.currentUnderlyingPrice = 0.0,
});
static Future<DerivativeItemModel?> show(
BuildContext context, {
required String underlyingIsin,
required String underlyingSymbol,
String underlyingName = '',
String initialSignalType = 'BUY',
double currentUnderlyingPrice = 0.0,
}) {
return showDialog<DerivativeItemModel>(
context: context,
barrierDismissible: true,
builder: (ctx) => DerivativePickerModal(
underlyingIsin: underlyingIsin,
underlyingSymbol: underlyingSymbol,
underlyingName: underlyingName,
initialSignalType: initialSignalType,
currentUnderlyingPrice: currentUnderlyingPrice,
),
);
}
@override
State<DerivativePickerModal> createState() => _DerivativePickerModalState();
}
class _DerivativePickerModalState extends State<DerivativePickerModal> {
late String _optionType;
double _targetLeverage = 5.0;
final TextEditingController _leverageTextCtrl = TextEditingController(text: '5.0');
String _searchQuery = '';
final TextEditingController _searchCtrl = TextEditingController();
final ScrollController _scrollController = ScrollController();
Timer? _debounceTimer;
List<DerivativeItemModel> _allDerivatives = [];
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMoreData = true;
int _currentPage = 0;
String? _errorMessage;
@override
void initState() {
super.initState();
final isShort = widget.initialSignalType.toUpperCase() == 'SELL' || widget.initialSignalType.toUpperCase() == 'SHORT';
_optionType = isShort ? 'short' : 'long';
_scrollController.addListener(_onScroll);
_fetchDerivatives(reset: true);
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchCtrl.dispose();
_leverageTextCtrl.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 250) {
if (!_isLoading && !_isLoadingMore && _hasMoreData) {
_fetchMoreDerivatives();
}
}
}
String _formatLeverage(double lev) {
if (lev <= 0) return '1x';
if (lev == lev.roundToDouble()) {
return '${lev.toInt()}x';
}
var s = lev.toStringAsFixed(2);
if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return '${s.replaceAll('.', ',')}x';
}
String _formatLeverageNum(double lev) {
if (lev <= 0) return '1';
if (lev == lev.roundToDouble()) {
return lev.toInt().toString();
}
var s = lev.toStringAsFixed(2);
if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return s.replaceAll('.', ',');
}
void _setTargetLeverage(double lev) {
setState(() {
_targetLeverage = lev;
_leverageTextCtrl.text = _formatLeverageNum(lev);
});
_fetchDerivatives(reset: true);
}
Future<void> _fetchDerivatives({bool reset = true, bool forceRefresh = false}) async {
if (reset) {
setState(() {
_isLoading = true;
_isLoadingMore = false;
_hasMoreData = true;
_currentPage = 0;
_errorMessage = null;
});
}
try {
final apiClient = context.read<ApiClient>();
final repo = TradeRepository(apiClient: apiClient);
final isinToUse = widget.underlyingIsin.isNotEmpty ? widget.underlyingIsin : widget.underlyingSymbol;
final results = await repo.fetchDerivatives(
isinToUse,
optionType: _optionType,
targetLeverage: _targetLeverage,
page: _currentPage,
search: _searchQuery.isNotEmpty ? _searchQuery : null,
forceRefresh: forceRefresh,
);
if (mounted) {
setState(() {
if (reset) {
_allDerivatives = results;
} else {
// Append and deduplicate by ISIN
final existingIsins = _allDerivatives.map((d) => d.isin).toSet();
final newItems = results.where((d) => !existingIsins.contains(d.isin)).toList();
_allDerivatives.addAll(newItems);
}
if (results.isEmpty || results.length < 50) {
_hasMoreData = false;
}
_currentPage++;
_isLoading = false;
_isLoadingMore = false;
});
if (reset && _scrollController.hasClients) {
_scrollController.jumpTo(0);
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
_isLoadingMore = false;
});
}
}
}
Future<void> _fetchMoreDerivatives() async {
if (_isLoadingMore || !_hasMoreData) return;
setState(() {
_isLoadingMore = true;
});
await _fetchDerivatives(reset: false);
}
List<DerivativeItemModel> get _filteredDerivatives {
if (_searchQuery.trim().isEmpty) {
return _allDerivatives;
}
final q = _searchQuery.toLowerCase().trim();
return _allDerivatives.where((d) {
final matchIsin = d.isin.toLowerCase().contains(q);
final matchIssuer = d.issuer.toLowerCase().contains(q) || d.issuerDisplayName.toLowerCase().contains(q);
final matchCategory = d.productCategoryName.toLowerCase().contains(q);
final matchBarrier = d.barrier.toString().contains(q);
return matchIsin || matchIssuer || matchCategory || matchBarrier;
}).toList();
}
@override
Widget build(BuildContext context) {
final isLong = _optionType == 'long';
final activeColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
final filtered = _filteredDerivatives;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Container(
width: 780,
constraints: const BoxConstraints(maxHeight: 820),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20),
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.accentCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.bolt, color: AppTheme.accentCyan, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Trade Republic Derivate-Finder',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (!_isLoading && _allDerivatives.isNotEmpty)
StatusBadge(
label: '${_allDerivatives.length} Derivate geladen',
color: AppTheme.accentCyan,
),
],
),
const SizedBox(height: 3),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
'Basiswert: ${widget.underlyingName.isNotEmpty ? widget.underlyingName : (widget.underlyingSymbol.isNotEmpty && widget.underlyingSymbol != widget.underlyingIsin ? widget.underlyingSymbol : widget.underlyingIsin)} (${widget.underlyingIsin})',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
if (widget.currentUnderlyingPrice > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white12),
),
child: Text(
'Kurs: €${widget.currentUnderlyingPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
],
),
),
IconButton(
onPressed: () => _fetchDerivatives(reset: true, forceRefresh: true),
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
tooltip: 'Neu von Trade Republic abrufen',
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white54),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
// Controls Bar
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Direction Selector & Search
Row(
children: [
// Direction Toggle
Container(
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_directionButton('Long (Turbo Bull)', 'long', AppTheme.primaryEmerald),
_directionButton('Short (Turbo Bear)', 'short', AppTheme.accentRed),
],
),
),
const SizedBox(width: 12),
// Search Input
Expanded(
child: TextField(
controller: _searchCtrl,
onChanged: (val) => setState(() {
_searchQuery = val;
}),
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
hintText: 'Emittent (HSBC, SocGen...), Barriere, ISIN...',
hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12),
prefixIcon: const Icon(Icons.search, size: 16, color: Colors.white54),
filled: true,
fillColor: AppTheme.glassSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
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: 12),
// Interactive Target-Leverage Control Unit
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
children: [
Text(
'Wunsch-Hebel:',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
// Minus button
_stepButton(Icons.remove, () {
final cur = double.tryParse(_leverageTextCtrl.text.replaceAll(',', '.')) ?? _targetLeverage;
final next = (cur - 1.0 < 1.0) ? 1.0 : (cur - 1.0);
_setTargetLeverage(next);
}, activeColor),
const SizedBox(width: 6),
// Numeric input
SizedBox(
width: 68,
height: 34,
child: TextField(
controller: _leverageTextCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 6),
filled: true,
fillColor: Colors.black.withValues(alpha: 0.35),
suffixText: 'x',
suffixStyle: TextStyle(color: activeColor, fontSize: 12, fontWeight: FontWeight.bold),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: activeColor.withValues(alpha: 0.4))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: Colors.white24)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: activeColor, width: 1.5)),
),
onChanged: (val) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () {
final parsed = double.tryParse(val.replaceAll(',', '.'));
if (parsed != null && parsed > 0) {
_targetLeverage = parsed;
_fetchDerivatives(reset: true);
}
});
},
),
),
const SizedBox(width: 6),
// Plus button
_stepButton(Icons.add, () {
final cur = double.tryParse(_leverageTextCtrl.text.replaceAll(',', '.')) ?? _targetLeverage;
final next = (cur + 1.0 > 150.0) ? 150.0 : (cur + 1.0);
_setTargetLeverage(next);
}, activeColor),
const SizedBox(width: 14),
// Quick Pills
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: [1.0, 2.0, 3.0, 4.0, 5.0, 7.0, 10.0, 15.0, 20.0, 30.0, 50.0, 75.0].map((lev) {
final isSelected = (_targetLeverage - lev).abs() < 0.2;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: InkWell(
onTap: () => _setTargetLeverage(lev),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? activeColor.withValues(alpha: 0.2) : Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSelected ? activeColor : Colors.white12,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
'${lev.toInt()}x',
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold,
color: isSelected ? activeColor : Colors.white70,
),
),
),
),
);
}).toList(),
),
),
),
],
),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
// Content List (with Streaming Infinite Scroll)
Expanded(
child: _buildListContent(filtered, activeColor),
),
// Streaming Status Footer
if (!_isLoading && filtered.isNotEmpty) ...[
const Divider(color: Colors.white12, height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${filtered.length} Derivate ab ${_formatLeverage(_targetLeverage)} Hebel',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
Row(
children: [
if (_hasMoreData) ...[
Icon(Icons.arrow_downward, size: 14, color: AppTheme.accentCyan),
const SizedBox(width: 4),
Text(
'Scrolle nach unten für höhere Hebel',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold),
),
] else ...[
Icon(Icons.check_circle, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 4),
Text(
'Alle Derivate dieser Spanne geladen',
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 11, fontWeight: FontWeight.bold),
),
],
],
),
],
),
),
],
],
),
),
);
}
Widget _stepButton(IconData icon, VoidCallback onPressed, Color activeColor) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white12),
),
child: Icon(icon, size: 16, color: Colors.white),
),
);
}
Widget _directionButton(String label, String value, Color color) {
final isSelected = _optionType == value;
return GestureDetector(
onTap: () {
if (_optionType != value) {
setState(() {
_optionType = value;
});
_fetchDerivatives(reset: true);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? color.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: isSelected ? Border.all(color: color.withValues(alpha: 0.6)) : null,
),
child: Text(
label,
style: TextStyle(
color: isSelected ? color : AppTheme.textMuted,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
),
);
}
Widget _buildListContent(List<DerivativeItemModel> filtered, Color activeColor) {
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: AppTheme.accentCyan),
const SizedBox(height: 12),
Text('Lade Live-Derivate ab ${_formatLeverage(_targetLeverage)} Hebel...', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 36, color: AppTheme.accentRed),
const SizedBox(height: 8),
Text('Fehler beim Laden der Derivate', style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(_errorMessage!, style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: () => _fetchDerivatives(reset: true, forceRefresh: true),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Erneut versuchen'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black),
),
],
),
),
);
}
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 36, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine Derivate für diesen Hebel gefunden', style: TextStyle(color: AppTheme.textSecondary, fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('Wähle einen anderen Hebel (z. B. 2x, 5x, 10x) oder leere die Suche.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
const SizedBox(height: 12),
TextButton(
onPressed: () {
_searchCtrl.clear();
_setTargetLeverage(5.0);
},
child: Text('Zurücksetzen auf 5x Hebel', style: TextStyle(color: AppTheme.accentCyan)),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: filtered.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == filtered.length) {
// Bottom loading indicator when streaming next 50 items
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.accentCyan),
),
const SizedBox(width: 10),
Text('Lade nächste 50 Derivate von Trade Republic...', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
],
),
);
}
final item = filtered[index];
final dist = widget.currentUnderlyingPrice > 0 ? item.distanceToBarrier(widget.currentUnderlyingPrice) : 0.0;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
children: [
// Leverage Badge
Container(
width: 58,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: activeColor.withValues(alpha: 0.4)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatLeverage(item.leverage),
style: TextStyle(color: activeColor, fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'HEBEL',
style: TextStyle(color: activeColor.withValues(alpha: 0.8), fontSize: 9, fontWeight: FontWeight.w600),
),
],
),
),
const SizedBox(width: 12),
// Derivative Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.issuerDisplayName.isNotEmpty ? item.issuerDisplayName : item.issuer,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.nextGenProductCategoryName.isNotEmpty ? item.nextGenProductCategoryName : item.productCategoryName,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text('ISIN: ${item.isin}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(width: 10),
if (item.barrier > 0)
Text(
'KO: €${item.barrier.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold),
),
if (dist > 0) ...[
const SizedBox(width: 6),
Text(
'(${dist.toStringAsFixed(1)}% Abst.)',
style: TextStyle(color: dist < 5.0 ? AppTheme.accentRed : AppTheme.textMuted, fontSize: 11),
),
],
],
),
],
),
),
// Action Select Button
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(item);
},
style: ElevatedButton.styleFrom(
backgroundColor: activeColor,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Wählen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
),
],
),
);
},
);
}
}
@@ -37,7 +37,8 @@ class _TradeAcceptanceDialogState extends State<TradeAcceptanceDialog> {
super.initState(); super.initState();
_entryPriceCtrl = TextEditingController(text: widget.trade.entryPrice.toStringAsFixed(2)); _entryPriceCtrl = TextEditingController(text: widget.trade.entryPrice.toStringAsFixed(2));
_positionSizeCtrl = TextEditingController(text: '1000'); _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)); _stopLossCtrl = TextEditingController(text: widget.trade.stopLoss.toStringAsFixed(2));
_takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit.toStringAsFixed(2)); _takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit.toStringAsFixed(2));
_notesCtrl = TextEditingController(); _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 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../../favorites/cubit/favorites_cubit.dart'; import '../../favorites/cubit/favorites_cubit.dart';
import '../../favorites/models/favorite_asset_model.dart'; import '../../favorites/models/favorite_asset_model.dart';
import '../models/trade_model.dart'; import '../models/trade_model.dart';
import 'trade_calculation_card.dart';
import 'trade_detail_modal.dart'; import 'trade_detail_modal.dart';
class TradeCard extends StatelessWidget { class TradeCard extends StatelessWidget {
@@ -23,7 +25,7 @@ class TradeCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
final isProposed = trade.isProposed; final isProposed = trade.isProposed;
final isActive = trade.isActive; final isActive = trade.isActive;
@@ -47,24 +49,17 @@ class TradeCard extends StatelessWidget {
final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed; final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed;
final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice; final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice;
return GestureDetector( return GlassContainer(
onTap: () => TradeDetailModal.show(
context,
trade: trade,
onAccept: onAccept,
onClose: onClose,
),
child: GlassContainer(
margin: const EdgeInsets.only(bottom: 14), margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header Row: Signal, Symbol, Status & Live PnL // Header Row: Signal, Symbol, Drift-Radar & Live PnL
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: signalColor.withValues(alpha: 0.15), color: signalColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -73,16 +68,9 @@ class TradeCard extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor),
isBuy ? Icons.trending_up : Icons.trending_down,
size: 14,
color: signalColor,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(trade.signalType, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
trade.signalType,
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12),
),
], ],
), ),
), ),
@@ -91,22 +79,41 @@ class TradeCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Flexible(
child: Text(
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN' trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
? trade.companyName ? 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), 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, 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( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -118,72 +125,160 @@ class TradeCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)}', '${isPnlPos ? '+' : ''}${pnlAbs.abs().toStringAsFixed(2)}',
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 14), style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14),
), ),
Text( Text(
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%', '${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( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2), color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), 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( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_priceItem( _priceItem(
isActive || isClosed ? 'Ausführung' : 'Ziel-Einstieg', isActive || isClosed ? 'Einstieg' : 'Ziel-Einstieg',
trade.actualEntryPrice > 0 trade.actualEntryPrice > 0
? '${trade.actualEntryPrice.toStringAsFixed(2)}' ? '${trade.actualEntryPrice.toStringAsFixed(2)}'
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'), : (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'),
Colors.white 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) ...[ if (trade.reasoning.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text( 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), const SizedBox(height: 12),
// Footer Action Row // Footer Action Row
@@ -201,7 +355,7 @@ class TradeCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( 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), style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
), ),
@@ -225,7 +379,7 @@ class TradeCard extends StatelessWidget {
label: const Text('Trade Übernehmen'), label: const Text('Trade Übernehmen'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald, backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.white, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
@@ -235,11 +389,11 @@ class TradeCard extends StatelessWidget {
const SizedBox(width: 6), const SizedBox(width: 6),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onClose, onPressed: onClose,
icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed), icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed),
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12)), label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)), 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)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
), ),
@@ -249,7 +403,7 @@ class TradeCard extends StatelessWidget {
IconButton( IconButton(
onPressed: onSettings, onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white), icon: const Icon(Icons.settings, size: 16, color: Colors.white),
tooltip: 'Einstellungen', tooltip: 'Einstellungen anpassen',
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface, 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) { 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 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../models/trade_model.dart'; import '../models/trade_model.dart';
import 'trade_calculation_card.dart';
class TradeDetailContent extends StatelessWidget { class TradeDetailContent extends StatelessWidget {
final TradeModel trade; final TradeModel trade;
@@ -18,6 +19,45 @@ class TradeDetailContent extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -31,16 +71,69 @@ class TradeDetailContent extends StatelessWidget {
_metricItem( _metricItem(
trade.isActive || trade.isClosed ? 'Ausführung' : 'Ziel-Einstieg', trade.isActive || trade.isClosed ? 'Ausführung' : 'Ziel-Einstieg',
trade.actualEntryPrice > 0 trade.actualEntryPrice > 0
? '${trade.actualEntryPrice.toStringAsFixed(2)}' ? '${trade.actualEntryPrice.toStringAsFixed(2)}'
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'), : (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)}' : '-'),
Colors.white, Colors.white,
), ),
_metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan), _metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
_metricItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)}', AppTheme.accentRed), _metricItem(
_metricItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)}', AppTheme.primaryEmerald), 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) ...[ if (trade.isActive || trade.isClosed) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
Container( Container(
@@ -55,7 +148,7 @@ class TradeDetailContent extends StatelessWidget {
children: [ children: [
const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)), const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)),
Text( 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), style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15),
), ),
], ],
@@ -63,66 +156,12 @@ class TradeDetailContent extends StatelessWidget {
), ),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
if (trade.reasoning.isNotEmpty) ...[
_sectionTitle(Icons.auto_awesome, 'KI-Gesamteinschätzung & Begründung', AppTheme.primaryEmerald), // 3. LIVE-KALKULATION (AUTOMATISCH) & MEHRSTUFIGE TP-ZIELE
const SizedBox(height: 8), TradeCalculationCard(trade: trade),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.2)),
),
child: Text(trade.reasoning, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
),
const SizedBox(height: 18), const SizedBox(height: 18),
],
if (trade.technicalRationale.isNotEmpty) ...[ // Trade Parameters & Instrument
_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),
],
_sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70), _sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -137,12 +176,219 @@ class TradeDetailContent extends StatelessWidget {
_paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'), _paramRow('Instrument Typ:', trade.instrumentType.isNotEmpty ? trade.instrumentType : 'Stock'),
if (trade.derivativeIsin.isNotEmpty) _paramRow('Derivat / Hebel ISIN:', trade.derivativeIsin), if (trade.derivativeIsin.isNotEmpty) _paramRow('Derivat / Hebel ISIN:', trade.derivativeIsin),
_paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'), _paramRow('Zeithorizont:', trade.timeframe.isNotEmpty ? trade.timeframe : '1D'),
if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(0)}x'), if (trade.leverageUsed > 1) _paramRow('Hebel:', '${trade.leverageUsed.toStringAsFixed(1)}x'),
if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)}'), 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 { class TradeExecutionDialog {
static const double _defaultPositionSize = 1000.0; static const double _defaultPositionSize = 1000.0;
static const double _defaultLeverage = 1.0; static const double _defaultLeverage = 1.0;
static const List<String> _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto'];
static String _normalizeInstrumentType(String raw) { static String _normalizeInstrumentType(String raw) {
final clean = raw.toLowerCase().trim(); final clean = raw.toLowerCase().trim();
@@ -167,25 +166,56 @@ class TradeExecutionDialog {
return StatefulBuilder( return StatefulBuilder(
builder: (stfContext, setModalState) { builder: (stfContext, setModalState) {
final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') || final isKnockout = selectedInstrumentType.toLowerCase().contains('knock') ||
selectedInstrumentType.toLowerCase().contains('zertifikat') ||
selectedInstrumentType.toLowerCase().contains('option') || 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( return AlertDialog(
backgroundColor: AppTheme.cardSurface, backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)),
title: Row( title: Row(
children: [ 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), const SizedBox(width: 8),
Expanded( Text(isActive ? 'Aktiven Trade anpassen' : 'Trade-Vorschlag ausführen', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
child: Text(
isActive ? 'Einstellungen für Trade #${trade.id}' : 'Trade-Ausführung & Parameter',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
),
], ],
), ),
content: SizedBox( content: SizedBox(
@@ -205,13 +235,9 @@ class TradeExecutionDialog {
initialValue: safeInstrumentValue, initialValue: safeInstrumentValue,
dropdownColor: AppTheme.cardSurface, dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [ items: availableOptions.map((opt) {
DropdownMenuItem(value: 'Stock', child: Text('Aktie / ETF (Direktinvestment)', style: TextStyle(color: Colors.white, fontSize: 13))), return DropdownMenuItem(value: opt.key, child: Text(opt.value, style: const TextStyle(color: Colors.white, fontSize: 13)));
DropdownMenuItem(value: 'KnockOut', child: Text('Knock-Out Zertifikat', style: TextStyle(color: Colors.white, fontSize: 13))), }).toList(),
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))),
],
onChanged: (val) { onChanged: (val) {
if (val != null) setModalState(() => selectedInstrumentType = 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/network/signalr_service.dart';
import 'core/services/secure_storage_service.dart'; import 'core/services/secure_storage_service.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/theme/custom_scroll_behavior.dart';
import 'core/theme/theme_cubit.dart'; import 'core/theme/theme_cubit.dart';
import 'features/auth/bloc/auth_bloc.dart'; import 'features/auth/bloc/auth_bloc.dart';
import 'features/auth/views/login_screen.dart'; import 'features/auth/views/login_screen.dart';
@@ -73,6 +74,7 @@ class FinlyticApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Finlytic Enterprise Terminal', title: 'Finlytic Enterprise Terminal',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
scrollBehavior: const CustomAppScrollBehavior(),
theme: themeState.preset.toThemeData(), theme: themeState.preset.toThemeData(),
home: BlocBuilder<AuthBloc, AuthState>( home: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) { builder: (context, state) {
@@ -84,4 +84,14 @@ public class AssetsDbContext : DbContext
.HasMany(a => a.Tags) .HasMany(a => a.Tags)
.WithMany(t => t.Assets); .WithMany(t => t.Assets);
} }
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);
configurationBuilder.Properties<DateTime>()
.HaveConversion(typeof(FinlyticCore.Converters.UtcDateTimeConverter));
configurationBuilder.Properties<DateTime?>()
.HaveConversion(typeof(FinlyticCore.Converters.NullableUtcDateTimeConverter));
}
} }
+67 -25
View File
@@ -20,7 +20,7 @@ public interface IAssetsDbService
public Task UpdateAssetImageIdAsync(string isin, string imageId); public Task UpdateAssetImageIdAsync(string isin, string imageId);
public Task<bool> DeleteAssetAsync(string isin); public Task<bool> DeleteAssetAsync(string isin);
public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15); public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15);
public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", bool forceRefresh = false, CancellationToken cancellationToken = default); public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -394,38 +394,56 @@ public class AssetsDbService : IAssetsDbService
} }
/// <summary>Inherits documentation from interface.</summary> /// <summary>Inherits documentation from interface.</summary>
public async Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", bool forceRefresh = false, CancellationToken cancellationToken = default) public async Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(
string underlyingIsin,
string optionType = "long",
decimal? targetLeverage = null,
string? after = null,
int? page = null,
bool forceRefresh = false,
CancellationToken cancellationToken = default)
{ {
var targetOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? OptionType.Short : OptionType.Long; var targetOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? OptionType.Short : OptionType.Long;
var cutoff = DateTime.UtcNow.AddDays(-7); string cleanOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? "short" : "long";
const int pageSize = 50;
int pageIndex = Math.Max(0, page ?? 0);
if (!forceRefresh) decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m;
{
var cached = await _context.TradeRepublicAssets
.OfType<DerivativeEntity>()
.AsNoTracking()
.Include(a => a.Tags)
.Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType && d.LastUpdatedAt >= cutoff)
.ToListAsync(cancellationToken);
if (cached.Count > 0) // Trade Republic uses page index (0, 1, 2, 3...) for the 'after' pagination parameter in derivatives
{ string trAfter = !string.IsNullOrEmpty(after) ? after : (pageIndex > 0 ? pageIndex.ToString() : "0");
return cached;
} _logger.LogInformation("[{Channel}] Fetching derivatives for {Isin} (OptionType: {Option}, Leverage: {Lev}, Page: {Page}, TR-After: {After})",
} "AssetsChannel", underlyingIsin, cleanOptionType, levQuery, pageIndex, trAfter);
var trReq = new TradeRepublicDerivativesRequest(
Underlying: underlyingIsin,
OptionType: cleanOptionType,
ProductCategory: "knockOutProduct",
Leverage: levQuery,
SortBy: "leverage",
SortDirection: "asc",
PageSize: pageSize,
After: trAfter);
var trReq = new TradeRepublicDerivativesRequest(Underlying: underlyingIsin, OptionType: optionType, ProductCategory: "knockOutProduct", PageSize: 50, After: "0");
var trResponse = await _tradeRepublicService.GetDerivativesAsync(trReq, cancellationToken); var trResponse = await _tradeRepublicService.GetDerivativesAsync(trReq, cancellationToken);
if (trResponse?.Results != null && trResponse.Results.Count > 0) var fetchedItems = trResponse?.Results ?? new List<TradeRepublicDerivativeItemDto>();
_logger.LogInformation("[{Channel}] TR returned {Count} derivatives for {Isin} (Cursors.After: {NextAfter})",
"AssetsChannel", fetchedItems.Count, underlyingIsin, trResponse?.Cursors?.After ?? "null");
if (fetchedItems.Count > 0)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var isins = trResponse.Results.Select(r => r.Isin).Distinct().ToList(); var isins = fetchedItems.Select(r => r.Isin).ToList();
var existingDerivatives = await _context.TradeRepublicAssets var existingDerivatives = await _context.TradeRepublicAssets
.OfType<DerivativeEntity>() .OfType<DerivativeEntity>()
.Where(d => isins.Contains(d.Isin)) .Where(d => isins.Contains(d.Isin))
.ToDictionaryAsync(d => d.Isin, cancellationToken); .ToDictionaryAsync(d => d.Isin, cancellationToken);
foreach (var item in trResponse.Results) List<DerivativeEntity> resultEntities = new();
foreach (var item in fetchedItems)
{ {
if (!existingDerivatives.TryGetValue(item.Isin, out var entity)) if (!existingDerivatives.TryGetValue(item.Isin, out var entity))
{ {
@@ -438,8 +456,14 @@ public class AssetsDbService : IAssetsDbService
await _context.TradeRepublicAssets.AddAsync(entity, cancellationToken); await _context.TradeRepublicAssets.AddAsync(entity, cancellationToken);
} }
bool isShortItem = string.Equals(item.OptionType, "short", StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.OptionType, "put", StringComparison.OrdinalIgnoreCase) ||
item.OptionType.Contains("short", StringComparison.OrdinalIgnoreCase) ||
item.OptionType.Contains("put", StringComparison.OrdinalIgnoreCase) ||
item.OptionType.Contains("bear", StringComparison.OrdinalIgnoreCase);
entity.UnderlyingIsin = underlyingIsin; entity.UnderlyingIsin = underlyingIsin;
entity.OptionType = item.OptionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? OptionType.Short : OptionType.Long; entity.OptionType = isShortItem ? OptionType.Short : OptionType.Long;
entity.ProductCategoryName = item.ProductCategoryName; entity.ProductCategoryName = item.ProductCategoryName;
entity.NextGenProductCategoryName = item.NextGenProductCategoryName; entity.NextGenProductCategoryName = item.NextGenProductCategoryName;
entity.Strike = item.Strike ?? 0m; entity.Strike = item.Strike ?? 0m;
@@ -449,24 +473,42 @@ public class AssetsDbService : IAssetsDbService
entity.Factor = item.Factor; entity.Factor = item.Factor;
entity.Delta = item.Delta; entity.Delta = item.Delta;
entity.Currency = item.Currency ?? "EUR"; entity.Currency = item.Currency ?? "EUR";
entity.Expiry = DateTime.TryParse(item.Expiry, out var exp) ? exp : null; entity.Expiry = DateTime.TryParse(item.Expiry, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var exp)
? DateTime.SpecifyKind(exp, DateTimeKind.Utc)
: (DateTime?)null;
entity.Issuer = item.Issuer; entity.Issuer = item.Issuer;
entity.IssuerDisplayName = item.IssuerDisplayName; entity.IssuerDisplayName = item.IssuerDisplayName;
entity.IssuerImageId = item.IssuerImageId; entity.IssuerImageId = item.IssuerImageId;
entity.ImageId = item.ImageId; entity.ImageId = item.ImageId;
entity.Name = $"{item.IssuerDisplayName} {item.NextGenProductCategoryName} ({item.OptionType.ToUpper()})"; entity.Name = $"{item.IssuerDisplayName} {item.NextGenProductCategoryName} ({(isShortItem ? "SHORT" : "LONG")})";
entity.LastUpdatedAt = now; entity.LastUpdatedAt = now;
resultEntities.Add(entity);
} }
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return resultEntities;
} }
return await _context.TradeRepublicAssets // Fallback: Query from DB if Trade Republic returned 0 or was unreachable
var dbQuery = _context.TradeRepublicAssets
.OfType<DerivativeEntity>() .OfType<DerivativeEntity>()
.AsNoTracking() .AsNoTracking()
.Include(a => a.Tags) .Include(a => a.Tags)
.Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType) .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType);
if (levQuery > 0)
{
dbQuery = dbQuery.Where(d => d.Leverage >= (levQuery - 0.2m));
}
var results = await dbQuery
.OrderBy(d => d.Leverage)
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return results;
} }
#region Helper & Mapping Methods #region Helper & Mapping Methods
+7 -1
View File
@@ -225,7 +225,13 @@ public class AssetsMqttClient(
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDerivativesRequest); var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDerivativesRequest);
if (req != null && !string.IsNullOrEmpty(req.UnderlyingIsin)) if (req != null && !string.IsNullOrEmpty(req.UnderlyingIsin))
{ {
return await dbService.GetDerivativesByUnderlyingAsync(req.UnderlyingIsin, req.OptionType, req.ShouldForceRefresh); return await dbService.GetDerivativesByUnderlyingAsync(
req.UnderlyingIsin,
req.OptionType,
req.TargetLeverage,
req.After,
req.Page,
req.ShouldForceRefresh);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -268,6 +268,101 @@ public class AssetsController : ControllerBase
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." }); return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
} }
/// <summary>
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
/// </summary>
[HttpGet("{isin}/derivatives")]
public async Task<IActionResult> GetDerivatives(
[FromRoute] string isin,
[FromQuery] string optionType = "long",
[FromQuery] decimal? targetLeverage = null,
[FromQuery] decimal? minLeverage = null,
[FromQuery] decimal? maxLeverage = null,
[FromQuery] string? search = null,
[FromQuery] string? after = null,
[FromQuery] int? page = null,
[FromQuery] int? pageSize = null,
[FromQuery] bool forceRefresh = false,
CancellationToken cancellationToken = default)
{
string cleanIsin = isin.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(cleanIsin))
{
return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
}
try
{
if (_mqttClient.IsConnected)
{
var req = new GetDerivativesRequest(
UnderlyingIsin: cleanIsin,
OptionType: optionType.ToLowerInvariant(),
TargetLeverage: targetLeverage,
After: after,
Page: page,
ForceRefresh: forceRefresh
);
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDerivativesRequest>(
"assets_GetDerivatives",
req,
TimeSpan.FromSeconds(30)
);
if (rpcResult != null)
{
var derivatives = rpcResult.OfType<DerivativeDto>().ToList();
if (minLeverage.HasValue)
{
derivatives = derivatives.Where(d => d.Leverage >= minLeverage.Value).ToList();
}
if (maxLeverage.HasValue)
{
derivatives = derivatives.Where(d => d.Leverage <= maxLeverage.Value).ToList();
}
if (!string.IsNullOrWhiteSpace(search))
{
string q = search.Trim();
derivatives = derivatives.Where(d =>
(d.Isin != null && d.Isin.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(d.Issuer != null && d.Issuer.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(d.IssuerDisplayName != null && d.IssuerDisplayName.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(d.ProductCategoryName != null && d.ProductCategoryName.Contains(q, StringComparison.OrdinalIgnoreCase))
).ToList();
}
derivatives = derivatives.OrderBy(d => d.Leverage).ToList();
int totalCount = derivatives.Count;
if (page.HasValue && pageSize.HasValue && page.Value > 0 && pageSize.Value > 0)
{
int pSize = Math.Clamp(pageSize.Value, 1, 200);
int pIndex = Math.Max(1, page.Value);
int totalPages = (int)Math.Ceiling((double)totalCount / pSize);
Response.Headers["X-Total-Count"] = totalCount.ToString();
Response.Headers["X-Total-Pages"] = totalPages.ToString();
Response.Headers["X-Current-Page"] = pIndex.ToString();
derivatives = derivatives.Skip((pIndex - 1) * pSize).Take(pSize).ToList();
}
return Ok(derivatives);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[AssetsController] Fehler beim Abrufen der Derivate für {Isin}", cleanIsin);
}
return Ok(new List<DerivativeDto>());
}
/// <summary> /// <summary>
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath). /// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
/// </summary> /// </summary>
@@ -105,8 +105,28 @@ public class CalendarController : ControllerBase
!string.Equals(category, "Alle", StringComparison.OrdinalIgnoreCase) && !string.Equals(category, "Alle", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(category, "all", StringComparison.OrdinalIgnoreCase)) !string.Equals(category, "all", StringComparison.OrdinalIgnoreCase))
{ {
string catKey = category.ToLowerInvariant();
filtered = filtered.Where(e => filtered = filtered.Where(e =>
string.Equals(e.EventType, category, StringComparison.OrdinalIgnoreCase)); {
string t = (e.EventType ?? string.Empty).ToLowerInvariant();
if (catKey.Contains("earn") || catKey.Contains("quartal") || catKey.Contains("ergebnis"))
{
return t.Contains("earn") || t.Contains("quart") || t.Contains("ergebnis") || t.Contains("finan") || t.Contains("report") || t.Contains("bilanz") || string.Equals(t, "event", StringComparison.OrdinalIgnoreCase);
}
if (catKey.Contains("ex") || catKey.Contains("div"))
{
return (t.Contains("ex") || t.Contains("div") || t.Contains("ausschütt")) && !t.Contains("pay") && !t.Contains("zahl");
}
if (catKey.Contains("pay") || catKey.Contains("zahl"))
{
return t.Contains("pay") || t.Contains("zahl") || t.Contains("auszahl");
}
if (catKey.Contains("split"))
{
return t.Contains("split");
}
return t.Contains(catKey) || string.Equals(e.EventType, category, StringComparison.OrdinalIgnoreCase);
});
} }
if (date.HasValue) if (date.HasValue)
@@ -240,4 +240,87 @@ public class NewsController : ControllerBase
return NotFound(new { message = $"No sentiment analysis found for article {articleId}." }); return NotFound(new { message = $"No sentiment analysis found for article {articleId}." });
} }
/// <summary>
/// Triggers or renews sentiment analysis for a specific news article via FinlyticSentiment.
/// </summary>
[HttpPost("sentiment/article/{articleId}/analyze")]
[HttpPost("{articleId}/reanalyze")]
public async Task<IActionResult> ReanalyzeArticleSentiment(string articleId)
{
if (string.IsNullOrWhiteSpace(articleId)) return BadRequest(new { message = "ArticleId ist erforderlich." });
var targetId = articleId.Trim();
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync<IsinAnalysisEntry, AnalyzeSentimentRequest>(
"sentiment_Analyze",
new AnalyzeSentimentRequest(ArticleId: targetId, ForceReload: true),
TimeSpan.FromSeconds(20)
);
if (rpcResult != null)
{
// Fetch full article from FinlyticNews to return complete updated DTO
var article = await _mqttClient.SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
"news_GetById",
new ArticleRequest(targetId, targetId),
TimeSpan.FromSeconds(5)
);
if (article != null && rpcResult.FinbertResult != null)
{
var res = rpcResult.FinbertResult;
var enriched = article with
{
Sentiment = res.Label,
SentimentScore = res.CompoundScore,
Confidence = res.Confidence,
FinbertResult = res,
Status = "Analyzed"
};
return Ok(enriched);
}
if (article != null)
{
return Ok(article);
}
// Fallback to synthetic DTO from entry
var synthetic = new NewsArticleDto
{
Id = Guid.TryParse(targetId, out var g) ? g : Guid.NewGuid(),
Title = rpcResult.Article?.Title ?? "Artikel",
Author = rpcResult.Article?.Source ?? "FinlyticNews",
Summary = rpcResult.SummarySnippet,
ContentRaw = "",
SourceUrl = "",
ScrapedAt = DateTime.UtcNow,
PublishedAt = DateTime.TryParse(rpcResult.Article?.PublishedAt, out var pDate) ? pDate : DateTime.UtcNow,
Status = "Analyzed",
Sentiment = rpcResult.FinbertResult?.Label ?? "NEUTRAL",
SentimentScore = rpcResult.FinbertResult?.CompoundScore ?? 0.0,
Confidence = rpcResult.FinbertResult?.Confidence ?? 0.0,
FinbertResult = rpcResult.FinbertResult
};
return Ok(synthetic);
}
}
else
{
return StatusCode(503, new { message = "MQTT Broker nicht verbunden." });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim erneuten Analysieren des Artikels {ArticleId}", targetId);
return StatusCode(500, new { message = $"Analyse fehlgeschlagen: {ex.Message}" });
}
return NotFound(new { message = $"Artikel {targetId} konnte nicht analysiert werden." });
}
} }
+3
View File
@@ -47,8 +47,10 @@ public class WebMqttClient : ManagedMqttClient, IHostedService
_logger.LogInformation("Web MQTT RPC client connected. Subscribing to RPC response channels..."); _logger.LogInformation("Web MQTT RPC client connected. Subscribing to RPC response channels...");
await SubscribeAsync("services/response/news_Get/#"); await SubscribeAsync("services/response/news_Get/#");
await SubscribeAsync("services/response/news_GetDaily/#"); await SubscribeAsync("services/response/news_GetDaily/#");
await SubscribeAsync("services/response/news_GetById/#");
await SubscribeAsync("services/response/sentiment_GetArticle/#"); await SubscribeAsync("services/response/sentiment_GetArticle/#");
await SubscribeAsync("services/response/sentiment_GetIsin/#"); await SubscribeAsync("services/response/sentiment_GetIsin/#");
await SubscribeAsync("services/response/sentiment_Analyze/#");
await SubscribeAsync("services/response/fundamentals_Get/#"); await SubscribeAsync("services/response/fundamentals_Get/#");
await SubscribeAsync("services/response/events_GetAll/#"); await SubscribeAsync("services/response/events_GetAll/#");
await SubscribeAsync("services/response/events_GetByMonth/#"); 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_Get/#");
await SubscribeAsync("services/response/assets_Search/#"); await SubscribeAsync("services/response/assets_Search/#");
await SubscribeAsync("services/response/assets_GetDiscovery/#"); await SubscribeAsync("services/response/assets_GetDiscovery/#");
await SubscribeAsync("services/response/assets_GetDerivatives/#");
await SubscribeAsync("services/response/trades_Get/#"); await SubscribeAsync("services/response/trades_Get/#");
await SubscribeAsync("services/response/trades_Close/#"); await SubscribeAsync("services/response/trades_Close/#");
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace FinlyticCore.Converters;
/// <summary>
/// ValueConverter for DateTime guaranteeing DateTimeKind.Utc when writing to and reading from PostgreSQL.
/// </summary>
public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
{
public UtcDateTimeConverter()
: base(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
{
}
}
/// <summary>
/// ValueConverter for nullable DateTime? guaranteeing DateTimeKind.Utc when writing to and reading from PostgreSQL.
/// </summary>
public class NullableUtcDateTimeConverter : ValueConverter<DateTime?, DateTime?>
{
public NullableUtcDateTimeConverter()
: base(
v => v.HasValue ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) : v,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v)
{
}
}
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Fundamentals; namespace FinlyticCore.Dtos.Fundamentals;
@@ -19,4 +19,7 @@ public record KeyExecutiveDto
[JsonPropertyName("payment")] [JsonPropertyName("payment")]
public string Payment { get; init; } = string.Empty; public string Payment { get; init; } = string.Empty;
[JsonPropertyName("sortOrder")]
public int SortOrder { get; init; } = 0;
} }
+1
View File
@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" /> <PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
<PackageReference Include="MQTTnet" Version="5.1.0.1559" /> <PackageReference Include="MQTTnet" Version="5.1.0.1559" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -9,5 +9,7 @@ public class CloseTradeRequest
{ {
public decimal UserExitPrice { get; set; } public decimal UserExitPrice { get; set; }
public DateTime? UserExitTimestamp { get; set; } public DateTime? UserExitTimestamp { get; set; }
public decimal ExitFee { get; set; } = 1.0m;
public string CloseReason { get; set; } = "ManualClosure"; // "TakeProfitHit", "StopLossHit", "ManualClosure", "TimeExpired" public string CloseReason { get; set; } = "ManualClosure"; // "TakeProfitHit", "StopLossHit", "ManualClosure", "TimeExpired"
} }
@@ -61,6 +61,15 @@ public class TradeProposalDto
[JsonPropertyName("instrumentType")] [JsonPropertyName("instrumentType")]
public string InstrumentType { get; set; } = "Stock"; // "Stock", "Option", "CFD", "Crypto" public string InstrumentType { get; set; } = "Stock"; // "Stock", "Option", "CFD", "Crypto"
[JsonPropertyName("assetType")]
public string AssetType { get; set; } = "stock"; // "stock", "etf", "crypto", "bond"
[JsonPropertyName("hasCfd")]
public bool HasCfd { get; set; }
[JsonPropertyName("derivativeProductCategories")]
public List<string> DerivativeProductCategories { get; set; } = new();
[JsonPropertyName("derivativeIsin")] [JsonPropertyName("derivativeIsin")]
public string? DerivativeIsin { get; set; } public string? DerivativeIsin { get; set; }
@@ -141,6 +150,21 @@ public class TradeProposalDto
[JsonPropertyName("pnlPercent")] [JsonPropertyName("pnlPercent")]
public decimal? PnlPercent { get; set; } public decimal? PnlPercent { get; set; }
[JsonPropertyName("closeReason")]
public string? CloseReason { get; set; }
[JsonPropertyName("userExitTimestamp")]
public DateTime? UserExitTimestamp { get; set; }
[JsonPropertyName("hasPendingExitAlert")]
public bool HasPendingExitAlert { get; set; } = false;
[JsonPropertyName("pendingExitReason")]
public string? PendingExitReason { get; set; }
[JsonPropertyName("hourlyUpdates")]
public List<TradeHourlyUpdateDto>? HourlyUpdates { get; set; }
[JsonPropertyName("createdAt")] [JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Npgsql;
namespace FinlyticCore.Utils;
/// <summary>
/// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'.
/// </summary>
public static class CryptoSubtitleResolver
{
private static readonly ConcurrentDictionary<string, (string Subtitle, string? Name)> _cache = new();
/// <summary>
/// Checks if an ISIN is a Trade Republic internal crypto ISIN (starts with 'X') and resolves its Subtitle from DB or heuristic.
/// </summary>
public static async Task<(string? Subtitle, string? Name)> ResolveCryptoInfoAsync(
string isin,
string? defaultConnectionString = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return (null, null);
var cleanIsin = isin.Trim().ToUpperInvariant();
if (!cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
{
return (null, null);
}
if (_cache.TryGetValue(cleanIsin, out var cached))
{
return (cached.Subtitle, cached.Name);
}
// 1. Try querying PostgreSQL database (finlytic_assets)
if (!string.IsNullOrWhiteSpace(defaultConnectionString))
{
try
{
var assetsConnStr = Regex.Replace(defaultConnectionString, @"Database=[^;]+", "Database=finlytic_assets", RegexOptions.IgnoreCase);
await using var conn = new NpgsqlConnection(assetsConnStr);
await conn.OpenAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(
"SELECT \"Subtitle\", \"SearchSubtitle\", \"Name\" FROM \"TradeRepublicAssets\" " +
"WHERE \"Isin\" = @isin AND (\"AssetType\" = 'Crypto' OR \"InstrumentCategory\" = 'crypto' OR \"Subtitle\" IS NOT NULL) " +
"LIMIT 1",
conn);
cmd.Parameters.AddWithValue("isin", cleanIsin);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
string? sub = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(sub) && !reader.IsDBNull(1))
{
sub = reader.GetString(1);
}
string? name = reader.IsDBNull(2) ? null : reader.GetString(2);
if (!string.IsNullOrWhiteSpace(sub))
{
var cleanSub = sub.Trim().ToUpperInvariant();
_cache[cleanIsin] = (cleanSub, name);
return (cleanSub, name);
}
}
}
catch
{
// Fall through to heuristic if DB unreachable or different server
}
}
// 2. Heuristic fallback for Trade Republic internal ISIN patterns (e.g. XF000BTC0017 -> BTC)
var match = Regex.Match(cleanIsin, @"^X[A-Z0-9]*?000([A-Z0-9]{3,6})\d*$");
if (match.Success)
{
var extracted = match.Groups[1].Value;
_cache[cleanIsin] = (extracted, null);
return (extracted, null);
}
return (null, null);
}
/// <summary>
/// Convenience method returning just the crypto subtitle (e.g. "BTC").
/// </summary>
public static async Task<string?> ResolveCryptoSubtitleAsync(
string isin,
string? defaultConnectionString = null,
CancellationToken cancellationToken = default)
{
var (sub, _) = await ResolveCryptoInfoAsync(isin, defaultConnectionString, cancellationToken);
return sub;
}
}
@@ -77,6 +77,8 @@ public class FundamentalsDbContext : DbContext
modelBuilder.Entity<KeyExecutiveEntity>(entity => modelBuilder.Entity<KeyExecutiveEntity>(entity =>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.Property(e => e.SortOrder).HasDefaultValue(0);
entity.HasIndex(e => new { e.AssetDataIsin, e.SortOrder });
}); });
modelBuilder.Entity<AssetEventEntity>(entity => modelBuilder.Entity<AssetEventEntity>(entity =>
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticFundamentals.Entities; namespace FinlyticFundamentals.Entities;
@@ -10,6 +10,7 @@ public class KeyExecutiveEntity
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Payment { get; set; } = string.Empty; public string Payment { get; set; } = string.Empty;
public int SortOrder { get; set; } = 0;
[Required] public string AssetDataIsin { get; set; } = string.Empty; [Required] public string AssetDataIsin { get; set; } = string.Empty;
@@ -0,0 +1,429 @@
// <auto-generated />
using System;
using FinlyticFundamentals.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 FinlyticFundamentals.Migrations
{
[DbContext(typeof(FundamentalsDbContext))]
[Migration("20260815161429_AddSortOrderToKeyExecutives")]
partial class AddSortOrderToKeyExecutives
{
/// <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("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Isin");
b.ToTable("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("AssetEvents");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConsensusRating")
.HasColumnType("text");
b.Property<decimal?>("CurrentRatio")
.HasColumnType("numeric");
b.Property<decimal?>("DebtToEquity")
.HasColumnType("numeric");
b.Property<decimal?>("DilutedEps")
.HasColumnType("numeric");
b.Property<decimal?>("Ebitda")
.HasColumnType("numeric");
b.Property<decimal?>("EnterpriseValue")
.HasColumnType("numeric");
b.Property<decimal?>("EvToEbitda")
.HasColumnType("numeric");
b.Property<decimal?>("FiftyTwoWeekHigh")
.HasColumnType("numeric");
b.Property<decimal?>("FiftyTwoWeekLow")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardDividendYield")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardPe")
.HasColumnType("numeric");
b.Property<decimal?>("FreeCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("GrossProfit")
.HasColumnType("numeric");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("MarketCap")
.HasColumnType("numeric");
b.Property<decimal?>("NetIncome")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingIncome")
.HasColumnType("numeric");
b.Property<decimal?>("PayoutRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PegRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PercentHeldByInsiders")
.HasColumnType("numeric");
b.Property<decimal?>("PercentHeldByInstitutions")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetHigh")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetLow")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetMean")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToBook")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToSales")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnAssets")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnEquity")
.HasColumnType("numeric");
b.Property<decimal?>("RevenueGrowthYoY")
.HasColumnType("numeric");
b.Property<decimal?>("ShortPercentOfFloat")
.HasColumnType("numeric");
b.Property<decimal?>("ShortRatio")
.HasColumnType("numeric");
b.Property<decimal?>("TotalCash")
.HasColumnType("numeric");
b.Property<decimal?>("TotalDebt")
.HasColumnType("numeric");
b.Property<decimal?>("TotalRevenue")
.HasColumnType("numeric");
b.Property<decimal?>("TrailingPe")
.HasColumnType("numeric");
b.HasKey("Isin");
b.HasIndex("AssetDataIsin");
b.ToTable("FundamentalData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Payment")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin", "SortOrder");
b.ToTable("KeyExecutives");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "PrimaryTicker", b1 =>
{
b1.Property<string>("AssetDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTicker");
b1.HasKey("AssetDataEntityIsin");
b1.ToTable("AssetData");
b1.WithOwner()
.HasForeignKey("AssetDataEntityIsin");
});
b.OwnsMany("FinlyticFundamentals.Entities.TickerEntity", "AvailableTickers", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Exchange");
b1.Property<string>("Ticker")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Ticker");
b1.HasKey("Id");
b1.HasIndex("AssetDataIsin");
b1.HasIndex("Ticker");
b1.ToTable("Tickers", (string)null);
b1.WithOwner()
.HasForeignKey("AssetDataIsin");
});
b.Navigation("AvailableTickers");
b.Navigation("PrimaryTicker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("AssetEvents")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<Guid>("AssetEventEntityId")
.HasColumnType("uuid");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("AssetEventEntityId");
b1.ToTable("AssetEvents");
b1.WithOwner()
.HasForeignKey("AssetEventEntityId");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("FundamentalData")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<string>("FundamentalDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("FundamentalDataEntityIsin");
b1.ToTable("FundamentalData");
b1.WithOwner()
.HasForeignKey("FundamentalDataEntityIsin");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("KeyExecutives")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Navigation("AssetEvents");
b.Navigation("FundamentalData");
b.Navigation("KeyExecutives");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticFundamentals.Migrations
{
/// <inheritdoc />
public partial class AddSortOrderToKeyExecutives : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_KeyExecutives_AssetDataIsin",
table: "KeyExecutives");
migrationBuilder.AddColumn<int>(
name: "SortOrder",
table: "KeyExecutives",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_KeyExecutives_AssetDataIsin_SortOrder",
table: "KeyExecutives",
columns: new[] { "AssetDataIsin", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_KeyExecutives_AssetDataIsin_SortOrder",
table: "KeyExecutives");
migrationBuilder.DropColumn(
name: "SortOrder",
table: "KeyExecutives");
migrationBuilder.CreateIndex(
name: "IX_KeyExecutives_AssetDataIsin",
table: "KeyExecutives",
column: "AssetDataIsin");
}
}
}
@@ -236,13 +236,18 @@ namespace FinlyticFundamentals.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AssetDataIsin"); b.HasIndex("AssetDataIsin", "SortOrder");
b.ToTable("KeyExecutives"); b.ToTable("KeyExecutives");
}); });
@@ -129,92 +129,85 @@ public class FundamentalsDbService : IFundamentalsDbService
"[DEBUG-TR-ERROR] Could not fetch Trade Republic details for {Isin}", cleanIsin); "[DEBUG-TR-ERROR] Could not fetch Trade Republic details for {Isin}", cleanIsin);
} }
// --- STEP 2: Ticker auflösen (Null-safe) --- // --- STEP 2: Ticker auflösen (Der Primary Ticker ist IMMER der 1. von Yahoo Finance) ---
TickerInfoDto primaryTicker; var resolvedTickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken);
var yahooPrimaryTicker = resolvedTickers.FirstOrDefault()
?? (assetData?.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker)
? new TickerInfoDto { Ticker = assetData.PrimaryTicker.Ticker, Exchange = assetData.PrimaryTicker.Exchange ?? "Unknown" }
: new TickerInfoDto { Ticker = cleanIsin, Exchange = "Unknown" });
if (!string.IsNullOrWhiteSpace(requestedTicker)) if (string.IsNullOrWhiteSpace(yahooPrimaryTicker.Exchange))
{ {
var match = assetData?.AvailableTickers? yahooPrimaryTicker = new TickerInfoDto
.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
if (match != null)
{ {
primaryTicker = new TickerInfoDto Ticker = yahooPrimaryTicker.Ticker,
{ Exchange = GetExchangeDisplayName(yahooPrimaryTicker.Ticker)
Ticker = match.Ticker,
Exchange = !string.IsNullOrWhiteSpace(match.Exchange)
? match.Exchange
: GetExchangeDisplayName(match.Ticker)
}; };
} }
else if (assetData?.PrimaryTicker != null &&
string.Equals(assetData.PrimaryTicker.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) // Der activeQueryTicker wird für die aktuelle Kurs- und Modulabfrage verwendet (z. B. wenn der User im Web UI einen bestimmten Börsenplatz wählt)
TickerInfoDto activeQueryTicker;
if (!string.IsNullOrWhiteSpace(requestedTicker))
{ {
primaryTicker = new TickerInfoDto var matchDto = resolvedTickers.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
var matchEntity = assetData?.AvailableTickers?.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
if (matchDto != null)
{ {
Ticker = assetData.PrimaryTicker.Ticker, activeQueryTicker = new TickerInfoDto
Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange) {
? assetData.PrimaryTicker.Exchange Ticker = matchDto.Ticker,
: GetExchangeDisplayName(assetData.PrimaryTicker.Ticker) Exchange = !string.IsNullOrWhiteSpace(matchDto.Exchange) ? matchDto.Exchange : GetExchangeDisplayName(matchDto.Ticker)
};
}
else if (matchEntity != null)
{
activeQueryTicker = new TickerInfoDto
{
Ticker = matchEntity.Ticker,
Exchange = !string.IsNullOrWhiteSpace(matchEntity.Exchange) ? matchEntity.Exchange : GetExchangeDisplayName(matchEntity.Ticker)
}; };
} }
else else
{ {
primaryTicker = new TickerInfoDto activeQueryTicker = new TickerInfoDto
{ {
Ticker = requestedTicker, Ticker = requestedTicker,
Exchange = GetExchangeDisplayName(requestedTicker) Exchange = GetExchangeDisplayName(requestedTicker)
}; };
} }
} }
else if (assetData?.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker))
{
primaryTicker = new TickerInfoDto
{
Ticker = assetData.PrimaryTicker.Ticker,
Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange)
? assetData.PrimaryTicker.Exchange
: GetExchangeDisplayName(assetData.PrimaryTicker.Ticker)
};
}
else else
{ {
var resolved = await _scraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); activeQueryTicker = yahooPrimaryTicker;
primaryTicker = resolved != null && !string.IsNullOrWhiteSpace(resolved.Ticker)
? resolved
: new TickerInfoDto
{
Ticker = cleanIsin,
Exchange = "Unknown"
};
} }
if (string.IsNullOrWhiteSpace(primaryTicker.Exchange)) if (string.IsNullOrWhiteSpace(activeQueryTicker.Exchange))
{ {
primaryTicker = new TickerInfoDto activeQueryTicker = new TickerInfoDto
{ {
Ticker = primaryTicker.Ticker, Ticker = activeQueryTicker.Ticker,
Exchange = GetExchangeDisplayName(primaryTicker.Ticker) Exchange = GetExchangeDisplayName(activeQueryTicker.Ticker)
}; };
} }
await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel,
"[DEBUG-TICKER-RESOLVED] Ticker aufgelöst zu: '{Ticker}' (Exchange: '{Exchange}') für ISIN {Isin}", "[DEBUG-TICKER-RESOLVED] PrimaryTicker: '{Primary}' | ActiveQueryTicker: '{Active}' für ISIN {Isin}",
primaryTicker.Ticker, primaryTicker.Exchange ?? "Unknown", cleanIsin); yahooPrimaryTicker.Ticker, activeQueryTicker.Ticker, cleanIsin);
// --- STEP 3 & 4: Yahoo Finance API & HTML Fallback über Scraper --- // --- STEP 3 & 4: Yahoo Finance API & HTML Fallback über Scraper ---
YahooQuoteSummaryModulesDto? modulesDto = null; YahooQuoteSummaryModulesDto? modulesDto = null;
if (!string.IsNullOrWhiteSpace(primaryTicker.Ticker) && primaryTicker.Ticker != cleanIsin) if (!string.IsNullOrWhiteSpace(activeQueryTicker.Ticker) && activeQueryTicker.Ticker != cleanIsin)
{ {
modulesDto = await _scraper.GetQuoteSummaryModulesAsync( modulesDto = await _scraper.GetQuoteSummaryModulesAsync(
primaryTicker.Ticker, activeQueryTicker.Ticker,
forceHtmlScrape: false, forceHtmlScrape: false,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
else else
{ {
await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel, await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel,
"[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", primaryTicker.Ticker); "[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", activeQueryTicker.Ticker);
} }
// --- Update AssetDataEntity --- // --- Update AssetDataEntity ---
@@ -227,8 +220,8 @@ public class FundamentalsDbService : IFundamentalsDbService
Isin = cleanIsin, Isin = cleanIsin,
PrimaryTicker = new TickerEntity PrimaryTicker = new TickerEntity
{ {
Ticker = primaryTicker.Ticker, Ticker = yahooPrimaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown" Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
}, },
KeyExecutives = new List<KeyExecutiveEntity>(), KeyExecutives = new List<KeyExecutiveEntity>(),
AssetEvents = new List<AssetEventEntity>() AssetEvents = new List<AssetEventEntity>()
@@ -241,27 +234,27 @@ public class FundamentalsDbService : IFundamentalsDbService
string fallbackName = modulesDto?.QuoteType?.ShortName string fallbackName = modulesDto?.QuoteType?.ShortName
?? modulesDto?.QuoteType?.LongName ?? modulesDto?.QuoteType?.LongName
?? primaryTicker.Ticker; ?? activeQueryTicker.Ticker;
assetData.Name = !string.IsNullOrWhiteSpace(trName) ? trName : fallbackName; assetData.Name = !string.IsNullOrWhiteSpace(trName) ? trName : fallbackName;
assetData.Description = !string.IsNullOrWhiteSpace(trDescription) assetData.Description = !string.IsNullOrWhiteSpace(trDescription)
? trDescription ? trDescription
: (modulesDto?.AssetProfile?.LongBusinessSummary ?? string.Empty); : (modulesDto?.AssetProfile?.LongBusinessSummary ?? string.Empty);
// PrimaryTicker ist FEST der erste von Yahoo Finance
assetData.PrimaryTicker = new TickerEntity assetData.PrimaryTicker = new TickerEntity
{ {
Ticker = primaryTicker.Ticker, Ticker = yahooPrimaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown" Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
}; };
var tickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken); if (!resolvedTickers.Any(t => string.Equals(t.Ticker, yahooPrimaryTicker.Ticker, StringComparison.OrdinalIgnoreCase)))
if (!tickers.Any(t => string.Equals(t.Ticker, primaryTicker.Ticker, StringComparison.OrdinalIgnoreCase)))
{ {
tickers.Insert(0, primaryTicker); resolvedTickers.Insert(0, yahooPrimaryTicker);
} }
assetData.AvailableTickers.Clear(); assetData.AvailableTickers.Clear();
foreach (var a in tickers) foreach (var a in resolvedTickers)
{ {
assetData.AvailableTickers.Add(new TickerEntity assetData.AvailableTickers.Add(new TickerEntity
{ {
@@ -278,7 +271,21 @@ public class FundamentalsDbService : IFundamentalsDbService
// --- Process Trade Republic Corporate Events --- // --- Process Trade Republic Corporate Events ---
if (trDetails != null && (shouldUpdateAssetData || effectiveForceRefresh) && assetData != null) if (trDetails != null && (shouldUpdateAssetData || effectiveForceRefresh) && assetData != null)
{ {
assetData.AssetEvents ??= new List<AssetEventEntity>(); // 1. Alte Events direkt in der DB löschen (bypasses Change Tracker)
await context.AssetEvents
.Where(e => e.AssetDataIsin == cleanIsin)
.ExecuteDeleteAsync(cancellationToken);
// 2. ALLE tracked AssetEventEntity-Einträge aus dem Change Tracker entfernen
foreach (var entry in context.ChangeTracker.Entries<AssetEventEntity>()
.Where(e => e.Entity.AssetDataIsin == cleanIsin)
.ToList())
{
entry.State = EntityState.Detached;
}
// 3. Navigation-Collection zurücksetzen
assetData.AssetEvents = new List<AssetEventEntity>();
var trEventList = new List<TradeRepublicEventDto>(); var trEventList = new List<TradeRepublicEventDto>();
if (trDetails.Events != null) trEventList.AddRange(trDetails.Events); if (trDetails.Events != null) trEventList.AddRange(trDetails.Events);
@@ -298,17 +305,19 @@ public class FundamentalsDbService : IFundamentalsDbService
if (!isDuplicate) if (!isDuplicate)
{ {
assetData.AssetEvents.Add(new AssetEventEntity var newEvent = new AssetEventEntity
{ {
AssetDataIsin = cleanIsin, AssetDataIsin = cleanIsin,
Ticker = new TickerEntity Ticker = new TickerEntity
{ {
Ticker = primaryTicker.Ticker, Ticker = yahooPrimaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown" Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
}, },
Type = evtType, Type = evtType,
Date = evtDate Date = evtDate
}); };
context.AssetEvents.Add(newEvent);
assetData.AssetEvents.Add(newEvent);
} }
} }
} }
@@ -339,6 +348,7 @@ public class FundamentalsDbService : IFundamentalsDbService
// 4. Neue Executives aufbauen und direkt über den DbSet hinzufügen // 4. Neue Executives aufbauen und direkt über den DbSet hinzufügen
if (modulesDto.AssetProfile?.CompanyOfficers != null) if (modulesDto.AssetProfile?.CompanyOfficers != null)
{ {
int sortIdx = 0;
foreach (var officer in modulesDto.AssetProfile.CompanyOfficers) foreach (var officer in modulesDto.AssetProfile.CompanyOfficers)
{ {
if (!string.IsNullOrWhiteSpace(officer.Name)) if (!string.IsNullOrWhiteSpace(officer.Name))
@@ -349,7 +359,8 @@ public class FundamentalsDbService : IFundamentalsDbService
Name = officer.Name, Name = officer.Name,
Title = officer.Title ?? string.Empty, Title = officer.Title ?? string.Empty,
Payment = officer.TotalPay?.Fmt ?? Payment = officer.TotalPay?.Fmt ??
(officer.TotalPay?.Raw?.ToString() ?? string.Empty) (officer.TotalPay?.Raw?.ToString() ?? string.Empty),
SortOrder = sortIdx++
}; };
context.KeyExecutives.Add(newExec); context.KeyExecutives.Add(newExec);
assetData.KeyExecutives.Add(newExec); assetData.KeyExecutives.Add(newExec);
@@ -377,8 +388,8 @@ public class FundamentalsDbService : IFundamentalsDbService
fundamentalData.Ticker = new TickerEntity fundamentalData.Ticker = new TickerEntity
{ {
Ticker = primaryTicker.Ticker, Ticker = activeQueryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown" Exchange = activeQueryTicker.Exchange ?? "Unknown"
}; };
fundamentalData.MarketCap = (decimal?)modulesDto.SummaryDetail?.MarketCap?.Raw; fundamentalData.MarketCap = (decimal?)modulesDto.SummaryDetail?.MarketCap?.Raw;
fundamentalData.EnterpriseValue = fundamentalData.EnterpriseValue =
@@ -452,7 +463,10 @@ public class FundamentalsDbService : IFundamentalsDbService
if (assetData == null) return null; if (assetData == null) return null;
var executivesList = assetData.KeyExecutives?.ToList() ?? new List<KeyExecutiveEntity>(); var executivesList = (assetData.KeyExecutives ?? Enumerable.Empty<KeyExecutiveEntity>())
.OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title))
.ThenBy(e => GetExecutiveRank(e.Title))
.ToList();
var eventsList = assetData.AssetEvents?.ToList() ?? new List<AssetEventEntity>(); var eventsList = assetData.AssetEvents?.ToList() ?? new List<AssetEventEntity>();
return MapToDto(assetData, fundamentalData, executivesList, eventsList); return MapToDto(assetData, fundamentalData, executivesList, eventsList);
@@ -610,12 +624,16 @@ public class FundamentalsDbService : IFundamentalsDbService
LastUpdatedUtc = fundData.LastUpdatedUtc LastUpdatedUtc = fundData.LastUpdatedUtc
} }
: null, : null,
Executives = executives.Select(e => new KeyExecutiveDto Executives = executives
.OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title))
.ThenBy(e => GetExecutiveRank(e.Title))
.Select(e => new KeyExecutiveDto
{ {
Id = e.Id, Id = e.Id,
Name = e.Name, Name = e.Name,
Title = e.Title, Title = e.Title,
Payment = e.Payment Payment = e.Payment,
SortOrder = e.SortOrder
}).ToList(), }).ToList(),
Events = events.Select(e => new CorporateEventDto Events = events.Select(e => new CorporateEventDto
{ {
@@ -666,4 +684,21 @@ public class FundamentalsDbService : IFundamentalsDbService
return "Other"; return "Other";
} }
private static int GetExecutiveRank(string title)
{
if (string.IsNullOrWhiteSpace(title)) return 99;
var t = title.ToUpperInvariant();
if (t.Contains("CEO") || t.Contains("CHIEF EXECUTIVE") || t.Contains("VORSTANDSVORSITZEND") || t.Contains("MANAGING DIRECTOR")) return 1;
if (t.Contains("CFO") || t.Contains("CHIEF FINANCIAL") || t.Contains("FINANZVORSTAND")) return 2;
if (t.Contains("COO") || t.Contains("CHIEF OPERATING")) return 3;
if (t.Contains("CTO") || t.Contains("CHIEF TECHNOLOGY") || t.Contains("CIO") || t.Contains("CHIEF INFORMATION")) return 4;
if (t.Contains("CMO") || t.Contains("CHIEF MARKETING") || t.Contains("CHIEF COMMERCIAL")) return 5;
if (t.Contains("PRESIDENT") || t.Contains("EXECUTIVE VICE PRESIDENT") || t.Contains("EVP") || t.Contains("GENERAL COUNSEL") || t.Contains("CHIEF LEGAL")) return 6;
if (t.Contains("SENIOR VICE PRESIDENT") || t.Contains("SVP") || t.Contains("VICE PRESIDENT") || t.Contains("VP")) return 7;
if (t.Contains("DIRECTOR") || t.Contains("AUFSICHTSRAT") || t.Contains("VORSTAND") || t.Contains("BOARD")) return 8;
return 10;
}
} }
@@ -45,15 +45,18 @@ public class YahooFinanceScraper : IYahooFinanceScraper
{ {
private readonly YahooFinanceClient _yahooApiClient; private readonly YahooFinanceClient _yahooApiClient;
private readonly IYahooFinanceHtmlClient _htmlScraperClient; private readonly IYahooFinanceHtmlClient _htmlScraperClient;
private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration;
private readonly IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> _finlyticLogger; private readonly IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> _finlyticLogger;
public YahooFinanceScraper( public YahooFinanceScraper(
YahooFinanceClient yahooApiClient, YahooFinanceClient yahooApiClient,
IYahooFinanceHtmlClient htmlScraperClient, IYahooFinanceHtmlClient htmlScraperClient,
Microsoft.Extensions.Configuration.IConfiguration configuration,
IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> finlyticLogger) IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> finlyticLogger)
{ {
_yahooApiClient = yahooApiClient; _yahooApiClient = yahooApiClient;
_htmlScraperClient = htmlScraperClient; _htmlScraperClient = htmlScraperClient;
_configuration = configuration;
_finlyticLogger = finlyticLogger; _finlyticLogger = finlyticLogger;
} }
@@ -72,21 +75,73 @@ public class YahooFinanceScraper : IYahooFinanceScraper
var cleanIsin = isin.Trim().ToUpperInvariant(); var cleanIsin = isin.Trim().ToUpperInvariant();
var symbols = new List<(string symbol, string exchange, int priority)>(); var symbols = new List<(string symbol, string exchange, int priority)>();
// Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017)
if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
{
var (cryptoSubtitle, cryptoName) = await FinlyticCore.Utils.CryptoSubtitleResolver.ResolveCryptoInfoAsync(
cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken);
if (!string.IsNullOrWhiteSpace(cryptoSubtitle))
{
var cryptoEur = $"{cryptoSubtitle}-EUR";
var cryptoUsd = $"{cryptoSubtitle}-USD";
symbols.Add((cryptoEur, "Crypto", 0));
symbols.Add((cryptoUsd, "Crypto", 1));
try try
{ {
// 1. Suche via ISIN var searchRes = await _yahooApiClient.SearchAsync(cryptoSubtitle, quotesCount: 10, cancellationToken: cancellationToken);
if (searchRes?.Quotes != null)
{
foreach (var q in searchRes.Quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)))
{
if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
{
symbols.Add((q.Symbol, q.Exchange ?? "Crypto", 2));
}
}
}
}
catch { }
await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel,
"[YahooFinanceScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}",
cleanIsin, cryptoEur, cryptoSubtitle);
return symbols
.OrderBy(s => s.priority)
.Select(s => new TickerInfoDto { Ticker = s.symbol, Exchange = s.exchange })
.ToList();
}
}
try
{
// 1. Suche via ISIN - der allererste Ticker von Yahoo Finance ist der absolute Primary Ticker
var primary = await _yahooApiClient.SearchAsync(cleanIsin, quotesCount: 20, cancellationToken: cancellationToken); var primary = await _yahooApiClient.SearchAsync(cleanIsin, quotesCount: 20, cancellationToken: cancellationToken);
var quotes = primary?.Quotes ?? new List<YahooSearchQuoteDto>(); var quotes = primary?.Quotes ?? new List<YahooSearchQuoteDto>();
var validQuotes = quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)).ToList();
foreach (var q in quotes.Where(q => !string.IsNullOrEmpty(q.Symbol))) if (validQuotes.Count > 0)
{ {
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin))); var first = validQuotes[0];
symbols.Add((first.Symbol, first.Exchange ?? string.Empty, 0));
foreach (var q in validQuotes.Skip(1))
{
if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
{
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin))));
}
}
} }
// 2. Falls Ticker gefunden, aber mit Unternehmensname noch mehr Exchangeticker auffindbar sind // 2. Falls Ticker gefunden, aber mit Unternehmensname noch mehr Exchangeticker auffindbar sind
if (quotes.Count > 0) if (validQuotes.Count > 0)
{ {
var companyName = quotes[0].LongName ?? quotes[0].ShortName; var companyName = validQuotes[0].LongName ?? validQuotes[0].ShortName;
if (!string.IsNullOrWhiteSpace(companyName)) if (!string.IsNullOrWhiteSpace(companyName))
{ {
var secondary = await _yahooApiClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken); var secondary = await _yahooApiClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken);
@@ -95,7 +150,7 @@ public class YahooFinanceScraper : IYahooFinanceScraper
if (!string.IsNullOrEmpty(q.Symbol) && if (!string.IsNullOrEmpty(q.Symbol) &&
!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase))) !symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
{ {
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin))); symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin))));
} }
} }
} }
@@ -125,7 +125,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService
var macroTask = FetchMacroDataAsync(cancellationToken); var macroTask = FetchMacroDataAsync(cancellationToken);
string? ticker = requestedTicker; string? ticker = requestedTicker;
if (string.IsNullOrWhiteSpace(ticker)) if (string.IsNullOrWhiteSpace(ticker) || string.Equals(ticker.Trim(), cleanIsin, StringComparison.OrdinalIgnoreCase))
{ {
ticker = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); ticker = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken);
} }
@@ -326,7 +326,9 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService
if (cached != null && DateTime.UtcNow - cached.CalculatedAt < DbCacheTtl) if (cached != null && DateTime.UtcNow - cached.CalculatedAt < DbCacheTtl)
{ {
if (!string.IsNullOrWhiteSpace(requestedTicker) && !string.Equals(cached.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrWhiteSpace(requestedTicker) &&
!string.Equals(requestedTicker.Trim(), cleanIsin, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(cached.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase))
{ {
return null; // Ticker mismatch, force refresh required return null; // Ticker mismatch, force refresh required
} }
@@ -40,16 +40,21 @@ public interface IYahooMarketDataScraper
public class YahooMarketDataScraper : IYahooMarketDataScraper public class YahooMarketDataScraper : IYahooMarketDataScraper
{ {
private readonly YahooFinanceClient _yahooClient; private readonly YahooFinanceClient _yahooClient;
private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration;
private readonly ILogger<YahooMarketDataScraper> _logger; private readonly ILogger<YahooMarketDataScraper> _logger;
public YahooMarketDataScraper(YahooFinanceClient yahooClient, ILogger<YahooMarketDataScraper> logger) public YahooMarketDataScraper(
YahooFinanceClient yahooClient,
Microsoft.Extensions.Configuration.IConfiguration configuration,
ILogger<YahooMarketDataScraper> logger)
{ {
_yahooClient = yahooClient; _yahooClient = yahooClient;
_configuration = configuration;
_logger = logger; _logger = logger;
} }
/// <summary> /// <summary>
/// Resolves ticker from ISIN using Yahoo Search API. /// Resolves ticker from ISIN using Yahoo Search API or Crypto Subtitle resolution for internal ISINs.
/// </summary> /// </summary>
public async Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default) public async Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
{ {
@@ -61,6 +66,34 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper
return cleanIsin; return cleanIsin;
} }
// Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017)
if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
{
var (cryptoSubtitle, cryptoName) = await FinlyticCore.Utils.CryptoSubtitleResolver.ResolveCryptoInfoAsync(
cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken);
if (!string.IsNullOrWhiteSpace(cryptoSubtitle))
{
var candidates = new[] { $"{cryptoSubtitle}-EUR", $"{cryptoSubtitle}-USD", cryptoSubtitle };
foreach (var candidate in candidates)
{
try
{
var res = await FetchHistoricalCandlesWithCurrencyAsync(candidate, "5d", "1d", cancellationToken);
if (res.Candles.Count > 0)
{
_logger.LogInformation("[{Channel}] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}",
"TechnicalAnalysisChannel", cleanIsin, candidate, cryptoSubtitle);
return candidate;
}
}
catch { }
}
return $"{cryptoSubtitle}-EUR";
}
}
try try
{ {
var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken); var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken);
@@ -23,6 +23,19 @@ public class TradesDbContext : DbContext
entity.HasIndex(e => e.Key); 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 => modelBuilder.Entity<TradeEntity>(entity =>
{ {
entity.HasIndex(e => e.TradeId).IsUnique(); entity.HasIndex(e => e.TradeId).IsUnique();
@@ -32,6 +45,9 @@ public class TradesDbContext : DbContext
entity.HasIndex(e => e.Sector); entity.HasIndex(e => e.Sector);
entity.HasIndex(e => e.Isin); entity.HasIndex(e => e.Isin);
entity.HasIndex(e => e.CreatedAt); entity.HasIndex(e => e.CreatedAt);
entity.Property(e => e.DerivativeProductCategories)
.HasConversion(stringListConverter, stringListComparer);
}); });
modelBuilder.Entity<TradeHourlyUpdateEntity>(entity => modelBuilder.Entity<TradeHourlyUpdateEntity>(entity =>
+7
View File
@@ -68,6 +68,13 @@ public class TradeEntity
[MaxLength(30)] [MaxLength(30)]
public string InstrumentType { get; set; } = "Stock"; 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)] [MaxLength(20)]
public string? DerivativeIsin { get; set; } 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); 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 => modelBuilder.Entity("FinlyticTrades.Entities.TradeEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -36,6 +66,11 @@ namespace FinlyticTrades.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("CloseReason") b.Property<string>("CloseReason")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -55,6 +90,10 @@ namespace FinlyticTrades.Migrations
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("EntryFee") b.Property<decimal?>("EntryFee")
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
@@ -82,6 +121,9 @@ namespace FinlyticTrades.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("InstrumentType") b.Property<string>("InstrumentType")
.IsRequired() .IsRequired()
.HasMaxLength(30) .HasMaxLength(30)
@@ -101,12 +101,20 @@ public class TradeLifecycleService : ITradeLifecycleService
var existingTrade = await _dbContext.Trades var existingTrade = await _dbContext.Trades
.FirstOrDefaultAsync(t => .FirstOrDefaultAsync(t =>
(!string.IsNullOrWhiteSpace(proposal.TradeId) && t.TradeId == proposal.TradeId) || (!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); cancellationToken);
if (existingTrade != null) 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; existingTrade.Status = targetStatus;
} }
@@ -115,7 +123,7 @@ public class TradeLifecycleService : ITradeLifecycleService
_dbContext.Trades.Update(existingTrade); _dbContext.Trades.Update(existingTrade);
await _dbContext.SaveChangesAsync(cancellationToken); 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); "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status);
return true; return true;
@@ -297,13 +305,10 @@ public class TradeLifecycleService : ITradeLifecycleService
} }
else else
{ {
trade.Status = TradeStatus.Closed; // NO AUTO CLOSE for active user trades!
trade.UserExitPrice = update.CurrentPrice; // Trade remains Active, alert is stored in HourlyUpdates and surfaced in UI for manual confirmation.
trade.UserExitTimestamp = DateTime.UtcNow; _logger.LogInformation("[{Channel}] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.",
trade.CloseReason = "AiRecommendationClose"; "TradesChannel", trade.TradeId, update.Reasoning);
trade.ClosedAt = DateTime.UtcNow;
CalculatePnL(trade);
} }
} }
@@ -359,6 +364,10 @@ public class TradeLifecycleService : ITradeLifecycleService
trade.Status = TradeStatus.Closed; trade.Status = TradeStatus.Closed;
trade.UserExitPrice = request.UserExitPrice; trade.UserExitPrice = request.UserExitPrice;
trade.UserExitTimestamp = request.UserExitTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; trade.UserExitTimestamp = request.UserExitTimestamp?.ToUniversalTime() ?? DateTime.UtcNow;
if (request.ExitFee > 0m)
{
trade.ExitFee = request.ExitFee;
}
trade.CloseReason = request.CloseReason; trade.CloseReason = request.CloseReason;
trade.ClosedAt = DateTime.UtcNow; trade.ClosedAt = DateTime.UtcNow;
@@ -406,6 +415,9 @@ public class TradeLifecycleService : ITradeLifecycleService
entity.RiskTolerance = dto.RiskTolerance; entity.RiskTolerance = dto.RiskTolerance;
entity.Timeframe = dto.Timeframe; entity.Timeframe = dto.Timeframe;
entity.InstrumentType = dto.InstrumentType; 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; if (!string.IsNullOrWhiteSpace(dto.DerivativeIsin)) entity.DerivativeIsin = dto.DerivativeIsin;
entity.WinRate = dto.WinRate; entity.WinRate = dto.WinRate;
entity.VixRegime = dto.VixRegime; entity.VixRegime = dto.VixRegime;
+20 -1
View File
@@ -311,6 +311,10 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService
RiskTolerance = t.RiskTolerance, RiskTolerance = t.RiskTolerance,
Timeframe = t.Timeframe, Timeframe = t.Timeframe,
InstrumentType = t.InstrumentType, InstrumentType = t.InstrumentType,
AssetType = t.AssetType,
HasCfd = t.HasCfd,
DerivativeProductCategories = t.DerivativeProductCategories ?? new List<string>(),
DerivativeIsin = t.DerivativeIsin,
WinRate = t.WinRate, WinRate = t.WinRate,
VixRegime = t.VixRegime, VixRegime = t.VixRegime,
VixValue = t.VixValue, VixValue = t.VixValue,
@@ -339,7 +343,22 @@ public class TradesMqttClient : ManagedMqttClient, IHostedService
IsRecurring = t.IsRecurring, IsRecurring = t.IsRecurring,
PnlAbsolute = t.PnlAbsolute, PnlAbsolute = t.PnlAbsolute,
PnlPercent = t.PnlPercent, 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()
}; };
} }
} }