Compare commits
5 Commits
5a6a50a609
...
34fa774cbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 34fa774cbf | |||
| 882d24a316 | |||
| 7e18257e3e | |||
| 960a4bdbd6 | |||
| 067f1bfdd5 |
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
var matching = records.Where(r =>
|
|
||||||
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
r.VixRegime == regime).ToList();
|
|
||||||
|
|
||||||
if (matching.Count > 0)
|
|
||||||
{
|
{
|
||||||
int winningTrades = matching.Count(r => r.IsWin);
|
n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value;
|
||||||
double calculatedWinRate = (double)winningTrades / matching.Count * 100.0;
|
|
||||||
_logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})",
|
|
||||||
"AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, matching.Count);
|
|
||||||
return Math.Round(calculatedWinRate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Technical Score (Weight: 30%)
|
||||||
|
double taComponent = 60.0;
|
||||||
|
if (technicalScore.HasValue && technicalScore.Value > 0)
|
||||||
|
{
|
||||||
|
taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sentiment Score (Weight: 15%)
|
||||||
|
double sentComponent = 58.0;
|
||||||
|
if (sentimentScore.HasValue)
|
||||||
|
{
|
||||||
|
if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0)
|
||||||
|
{
|
||||||
|
// Map sentiment from -1.0..+1.0 into 35.0..85.0
|
||||||
|
sentComponent = 50.0 + (sentimentScore.Value * 25.0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sentComponent = sentimentScore.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fundamental Score (Weight: 15%)
|
||||||
|
double fundComponent = 60.0;
|
||||||
|
if (fundamentalScore.HasValue && fundamentalScore.Value > 0)
|
||||||
|
{
|
||||||
|
fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-factor weighted composite
|
||||||
|
double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15);
|
||||||
|
|
||||||
|
// 5. Market Regime & Volatility Adjustment
|
||||||
|
double vixAdjustment = regime switch
|
||||||
|
{
|
||||||
|
VixMarketRegime.LowVol => +4.0, // Calm trending market
|
||||||
|
VixMarketRegime.Normal => +1.5, // Normal conditions
|
||||||
|
VixMarketRegime.HighVol => -3.5, // Increased whipsaws
|
||||||
|
VixMarketRegime.Panic => -8.0, // High panic / uncertainty
|
||||||
|
_ => 0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
composite += vixAdjustment;
|
||||||
|
|
||||||
|
// 6. Historical track record calibration (if available in feedback records)
|
||||||
|
var records = GetCachedOrLoadRecords();
|
||||||
|
if (records.Count > 0)
|
||||||
|
{
|
||||||
|
var matching = records.Where(r =>
|
||||||
|
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
r.VixRegime == regime).ToList();
|
||||||
|
|
||||||
|
if (matching.Count >= 5)
|
||||||
|
{
|
||||||
|
int winningTrades = matching.Count(r => r.IsWin);
|
||||||
|
double historicalWinRate = (double)winningTrades / matching.Count * 100.0;
|
||||||
|
composite = (composite * 0.75) + (historicalWinRate * 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp between realistic financial statistical bounds (45.0% to 92.0%)
|
||||||
|
double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0);
|
||||||
|
|
||||||
|
_logger.LogInformation("[{Channel}] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]",
|
||||||
|
"AnalyzerChannel", symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime);
|
||||||
|
|
||||||
|
return finalWinRate;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bg = AppTheme.textMuted;
|
||||||
}
|
}
|
||||||
return StatusBadge(label: status.toUpperCase(), color: bg);
|
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,39 +31,49 @@ class AssetDetailScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repository = AssetRepository(apiClient: apiClient);
|
final repository = AssetRepository(apiClient: apiClient);
|
||||||
|
|
||||||
return MultiBlocProvider(
|
return BlocBuilder<FavoritesCubit, FavoritesState>(
|
||||||
providers: [
|
builder: (context, favState) {
|
||||||
BlocProvider(
|
final initialTicker = TickerResolver.resolve(
|
||||||
create: (context) => AssetFundamentalsBloc(repository: repository)
|
isin: isin,
|
||||||
..add(LoadAssetFundamentals(isin, ticker: symbol)),
|
candidateSymbol: symbol,
|
||||||
),
|
favoriteDetails: favState.favoriteDetails,
|
||||||
BlocProvider(
|
);
|
||||||
create: (context) => AssetTechnicalBloc(repository: repository)
|
|
||||||
..add(LoadAssetTechnical(isin, ticker: symbol)),
|
return MultiBlocProvider(
|
||||||
),
|
providers: [
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetTradesBloc(repository: repository)
|
create: (context) => AssetFundamentalsBloc(repository: repository)
|
||||||
..add(LoadAssetTrades(isin)),
|
..add(LoadAssetFundamentals(isin, ticker: initialTicker)),
|
||||||
),
|
),
|
||||||
],
|
BlocProvider(
|
||||||
child: Scaffold(
|
create: (context) => AssetTechnicalBloc(repository: repository)
|
||||||
body: LayoutBuilder(
|
..add(LoadAssetTechnical(isin, ticker: initialTicker)),
|
||||||
builder: (context, constraints) {
|
),
|
||||||
if (constraints.maxWidth >= 900) {
|
BlocProvider(
|
||||||
return AssetPageDesktopLayout(
|
create: (context) => AssetTradesBloc(repository: repository)
|
||||||
isin: isin,
|
..add(LoadAssetTrades(isin)),
|
||||||
name: name,
|
),
|
||||||
selectedTicker: symbol,
|
],
|
||||||
);
|
child: Scaffold(
|
||||||
}
|
body: LayoutBuilder(
|
||||||
return AssetPageMobileLayout(
|
builder: (context, constraints) {
|
||||||
isin: isin,
|
if (constraints.maxWidth >= 900) {
|
||||||
name: name,
|
return AssetPageDesktopLayout(
|
||||||
selectedTicker: symbol,
|
isin: isin,
|
||||||
);
|
name: name,
|
||||||
},
|
selectedTicker: initialTicker ?? symbol,
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
|
return AssetPageMobileLayout(
|
||||||
|
isin: isin,
|
||||||
|
name: name,
|
||||||
|
selectedTicker: initialTicker ?? symbol,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-14
@@ -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,19 +76,48 @@ 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>(
|
||||||
physics: const BouncingScrollPhysics(),
|
listener: (context, state) {
|
||||||
child: Column(
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (_selectedTicker == null ||
|
||||||
children: [
|
_selectedTicker!.trim().isEmpty ||
|
||||||
// 1. Hero Header
|
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
|
||||||
AssetHeroHeader(
|
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
|
||||||
isin: widget.isin,
|
final bestTicker = TickerResolver.resolve(
|
||||||
name: widget.name ?? widget.isin,
|
isin: widget.isin,
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
candidateSymbol: widget.selectedTicker,
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
favoriteDetails: favList,
|
||||||
onForceRefresh: _handleForceRefresh,
|
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(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 1. Hero Header
|
||||||
|
AssetHeroHeader(
|
||||||
|
isin: widget.isin,
|
||||||
|
name: widget.name ?? widget.isin,
|
||||||
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
|
onForceRefresh: _handleForceRefresh,
|
||||||
|
),
|
||||||
|
|
||||||
// 2. Full-Width Interactive Chart Section
|
// 2. Full-Width Interactive Chart Section
|
||||||
Container(
|
Container(
|
||||||
@@ -169,6 +200,6 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-91
@@ -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,102 +76,132 @@ 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>(
|
||||||
physics: const BouncingScrollPhysics(),
|
listener: (context, state) {
|
||||||
child: Column(
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (_selectedTicker == null ||
|
||||||
children: [
|
_selectedTicker!.trim().isEmpty ||
|
||||||
// 1. Hero Header
|
_selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) {
|
||||||
AssetHeroHeader(
|
final favList = context.read<FavoritesCubit>().state.favoriteDetails;
|
||||||
isin: widget.isin,
|
final bestTicker = TickerResolver.resolve(
|
||||||
name: widget.name ?? widget.isin,
|
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
|
||||||
onForceRefresh: _handleForceRefresh,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 2. Interactive Chart
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.cardSurface,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: theme.glassBorder),
|
|
||||||
),
|
|
||||||
child: TechnicalTab(
|
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
symbol: _selectedTicker,
|
candidateSymbol: widget.selectedTicker,
|
||||||
showChartOnly: true,
|
favoriteDetails: favList,
|
||||||
chartHeight: 320,
|
fundamentals: state.data,
|
||||||
),
|
);
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
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(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 1. Hero Header
|
||||||
|
AssetHeroHeader(
|
||||||
|
isin: widget.isin,
|
||||||
|
name: widget.name ?? widget.isin,
|
||||||
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
|
onForceRefresh: _handleForceRefresh,
|
||||||
|
),
|
||||||
|
|
||||||
// 3. Tabbed Detailed Analysis
|
// 2. Interactive Chart
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.cardSurface,
|
color: theme.cardSurface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: theme.glassBorder),
|
border: Border.all(color: theme.glassBorder),
|
||||||
|
),
|
||||||
|
child: TechnicalTab(
|
||||||
|
isin: widget.isin,
|
||||||
|
symbol: _selectedTicker,
|
||||||
|
showChartOnly: true,
|
||||||
|
chartHeight: 320,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const SizedBox(height: 8),
|
||||||
children: [
|
|
||||||
TabBar(
|
// 3. Tabbed Detailed Analysis
|
||||||
controller: _tabController,
|
Container(
|
||||||
labelColor: theme.primaryColor,
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
unselectedLabelColor: theme.textMuted,
|
decoration: BoxDecoration(
|
||||||
indicatorColor: theme.primaryColor,
|
color: theme.cardSurface,
|
||||||
dividerColor: theme.glassBorder,
|
borderRadius: BorderRadius.circular(16),
|
||||||
isScrollable: true,
|
border: Border.all(color: theme.glassBorder),
|
||||||
tabAlignment: TabAlignment.start,
|
),
|
||||||
labelStyle: const TextStyle(
|
child: Column(
|
||||||
fontWeight: FontWeight.bold, fontSize: 12),
|
children: [
|
||||||
tabs: const [
|
TabBar(
|
||||||
Tab(
|
controller: _tabController,
|
||||||
icon: Icon(Icons.analytics_outlined, size: 16),
|
isScrollable: true,
|
||||||
text: 'FUNDAMENTALS'),
|
tabAlignment: TabAlignment.start,
|
||||||
Tab(
|
labelColor: theme.primaryColor,
|
||||||
icon: Icon(Icons.architecture_outlined, size: 16),
|
unselectedLabelColor: theme.textSecondary,
|
||||||
text: 'MUSTER & SIGNALE'),
|
indicatorColor: theme.primaryColor,
|
||||||
Tab(
|
dividerColor: theme.glassBorder,
|
||||||
icon: Icon(Icons.candlestick_chart_outlined, size: 16),
|
labelStyle: const TextStyle(
|
||||||
text: 'TRADES'),
|
fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
],
|
tabs: const [
|
||||||
),
|
Tab(
|
||||||
AnimatedBuilder(
|
icon: Icon(Icons.analytics_outlined, size: 18),
|
||||||
animation: _tabController,
|
text: 'ÜBERSICHT'),
|
||||||
builder: (context, _) {
|
Tab(
|
||||||
switch (_tabController.index) {
|
icon: Icon(Icons.architecture_outlined, size: 18),
|
||||||
case 0:
|
text: 'MUSTER'),
|
||||||
return FundamentalsTab(
|
Tab(
|
||||||
isin: widget.isin,
|
icon:
|
||||||
symbol: _selectedTicker,
|
Icon(Icons.candlestick_chart_outlined, size: 18),
|
||||||
isEmbedded: true,
|
text: 'TRADES'),
|
||||||
);
|
],
|
||||||
case 1:
|
),
|
||||||
return TechnicalTab(
|
AnimatedBuilder(
|
||||||
isin: widget.isin,
|
animation: _tabController,
|
||||||
symbol: _selectedTicker,
|
builder: (context, _) {
|
||||||
showDetailsOnly: true,
|
switch (_tabController.index) {
|
||||||
);
|
case 0:
|
||||||
case 2:
|
return FundamentalsTab(
|
||||||
return SizedBox(
|
isin: widget.isin,
|
||||||
height: 500,
|
symbol: _selectedTicker,
|
||||||
child: TradesTab(symbol: widget.isin),
|
isEmbedded: true,
|
||||||
);
|
);
|
||||||
default:
|
case 1:
|
||||||
return const SizedBox.shrink();
|
return TechnicalTab(
|
||||||
}
|
isin: widget.isin,
|
||||||
},
|
symbol: _selectedTicker,
|
||||||
),
|
showDetailsOnly: true,
|
||||||
],
|
);
|
||||||
|
case 2:
|
||||||
|
return SizedBox(
|
||||||
|
height: 500,
|
||||||
|
child: TradesTab(symbol: widget.isin),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
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,55 +108,36 @@ 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: 50.0,
|
||||||
initialRiskScore: _settings.defaultRiskScore,
|
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} abgeschlossen. Trade-Cockpit öffnet sich...'),
|
||||||
content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'),
|
backgroundColor: AppTheme.accentCyan,
|
||||||
backgroundColor: AppTheme.accentCyan,
|
behavior: SnackBarBehavior.floating,
|
||||||
behavior: SnackBarBehavior.floating,
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.auto_awesome, size: 18),
|
);
|
||||||
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||||
backgroundColor: AppTheme.accentCyan,
|
label: const Text('KI-Analyse Starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
foregroundColor: Colors.black,
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
backgroundColor: AppTheme.accentCyan,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
foregroundColor: Colors.black,
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
),
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: () {
|
|
||||||
LiveTradeSettingsDialog.show(
|
|
||||||
context,
|
|
||||||
currentSettings: _settings,
|
|
||||||
onSave: (newSettings) => setState(() => _settings = newSettings),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings, color: Colors.white),
|
|
||||||
tooltip: 'Live Trade Einstellungen',
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.glassSurface,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -245,7 +218,7 @@ class _TradesTabState extends State<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) &&
|
||||||
orElse: () => tickerOptions.first,
|
(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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -203,73 +211,125 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Interactive Ticker & Exchange Selector Dropdown
|
PopupMenuButton<String>(
|
||||||
PopupMenuButton<String>(
|
initialValue: selectedOption.ticker,
|
||||||
initialValue: selectedOption.ticker,
|
tooltip: 'Börsenplatz & Ticker auswählen',
|
||||||
tooltip: 'Select Exchange & Ticker',
|
color: theme.cardSurface,
|
||||||
onSelected: (newTicker) {
|
onSelected: (newTicker) {
|
||||||
if (onExchangeChanged != null) {
|
if (onExchangeChanged != null) {
|
||||||
final opt = tickerOptions.firstWhere(
|
final opt = tickerOptions.firstWhere(
|
||||||
(t) => t.ticker == newTicker,
|
(t) => t.ticker == newTicker,
|
||||||
orElse: () => tickerOptions.first,
|
orElse: () => tickerOptions.first,
|
||||||
);
|
);
|
||||||
onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker);
|
onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
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 &&
|
||||||
final isSelected = tick == symbol || ex == symbol;
|
(tick.toLowerCase() == primaryTicker.toLowerCase());
|
||||||
|
final isSelected = tick == symbol || ex == symbol;
|
||||||
|
|
||||||
return PopupMenuItem<String>(
|
return PopupMenuItem<String>(
|
||||||
value: tick,
|
value: tick,
|
||||||
child: Row(
|
child: Container(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
|
||||||
Icon(
|
child: Row(
|
||||||
Icons.business,
|
children: [
|
||||||
size: 16,
|
Icon(
|
||||||
color: isSelected ? theme.primaryColor : theme.textMuted,
|
isPrimary ? Icons.star_rounded : Icons.business,
|
||||||
|
size: 18,
|
||||||
|
color: isPrimary
|
||||||
|
? AppTheme.accentCyan
|
||||||
|
: (isSelected ? theme.primaryColor : theme.textMuted),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$tick ($ex)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: (isSelected || isPrimary) ? FontWeight.bold : FontWeight.normal,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
||||||
color: isSelected ? theme.primaryColor : theme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.accentColor.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: theme.accentColor.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.business, size: 14, color: theme.accentColor),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: theme.accentColor,
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
|
||||||
|
? AppTheme.accentCyan.withValues(alpha: 0.15)
|
||||||
|
: theme.accentColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
|
||||||
|
? AppTheme.accentCyan.withValues(alpha: 0.5)
|
||||||
|
: theme.accentColor.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
Icon(Icons.arrow_drop_down, size: 16, color: theme.accentColor),
|
child: Row(
|
||||||
],
|
children: [
|
||||||
|
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),
|
||||||
|
Text(
|
||||||
|
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase())
|
||||||
|
? AppTheme.accentCyan
|
||||||
|
: theme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
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 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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(
|
||||||
|
|||||||
+443
-148
@@ -1,174 +1,469 @@
|
|||||||
|
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(
|
@override
|
||||||
|
State<ManualAnalysisDialog> createState() => _ManualAnalysisDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManualAnalysisDialogState extends State<ManualAnalysisDialog> with SingleTickerProviderStateMixin {
|
||||||
|
late double _riskScore;
|
||||||
|
String _selectedTimeframePreset = 'swing'; // intraday, swing, position, custom
|
||||||
|
final _minTimeframeCtrl = TextEditingController(text: '1');
|
||||||
|
final _maxTimeframeCtrl = TextEditingController(text: '14');
|
||||||
|
String _timeframeUnit = 'Tage';
|
||||||
|
String _instrumentType = 'Knock-Out Zertifikat (Turbo)';
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
|
||||||
|
bool _isAnalyzing = false;
|
||||||
|
int _analysisStage = 0; // 0: Idle, 1: Marktdaten, 2: TA/FA Indikatoren, 3: KI Setup
|
||||||
|
Timer? _stageTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_riskScore = widget.initialRiskScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stageTimer?.cancel();
|
||||||
|
_minTimeframeCtrl.dispose();
|
||||||
|
_maxTimeframeCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectTimeframePreset(String key, int min, int max, String unit) {
|
||||||
|
setState(() {
|
||||||
|
_selectedTimeframePreset = key;
|
||||||
|
_minTimeframeCtrl.text = min.toString();
|
||||||
|
_maxTimeframeCtrl.text = max.toString();
|
||||||
|
_timeframeUnit = unit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectRiskPreset(double score) {
|
||||||
|
setState(() {
|
||||||
|
_riskScore = score;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAnalysis() {
|
||||||
|
setState(() {
|
||||||
|
_isAnalyzing = true;
|
||||||
|
_analysisStage = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
_stageTimer = Timer.periodic(const Duration(milliseconds: 700), (timer) {
|
||||||
|
if (!mounted) {
|
||||||
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_analysisStage < 3) {
|
||||||
|
setState(() {
|
||||||
|
_analysisStage++;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
timer.cancel();
|
||||||
|
final payload = ManualAnalysisRequestDto(
|
||||||
|
isin: widget.symbol,
|
||||||
|
symbol: widget.symbol,
|
||||||
|
riskScore: _riskScore.toInt(),
|
||||||
|
minTimeframeValue: int.tryParse(_minTimeframeCtrl.text) ?? 1,
|
||||||
|
maxTimeframeValue: int.tryParse(_maxTimeframeCtrl.text) ?? 14,
|
||||||
|
timeframeUnit: _timeframeUnit,
|
||||||
|
instrumentType: _instrumentType,
|
||||||
|
userNotes: _notesCtrl.text,
|
||||||
|
headline: 'Manuelle KI-Analyse für ${widget.symbol}',
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onTrigger(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _stageText {
|
||||||
|
switch (_analysisStage) {
|
||||||
|
case 1:
|
||||||
|
return 'Lade Live-Marktdaten & Orderbuch...';
|
||||||
|
case 2:
|
||||||
|
return 'Berechne Technische Indikatoren & Muster...';
|
||||||
|
case 3:
|
||||||
|
return 'KI generiert optimales Trade-Setup...';
|
||||||
|
default:
|
||||||
|
return 'Analyse Jetzt Ausführen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final riskColor = _riskScore < 35
|
||||||
|
? AppTheme.primaryEmerald
|
||||||
|
: (_riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||||
|
child: Container(
|
||||||
|
width: 560,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 18, 16, 14),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
|
Container(
|
||||||
const SizedBox(width: 8),
|
padding: const EdgeInsets.all(8),
|
||||||
Expanded(
|
decoration: BoxDecoration(
|
||||||
child: Text('KI-Analyse für $symbol', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
color: AppTheme.accentCyan.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'KI-Trade Setup Generator',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Asset: ${widget.symbol}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isAnalyzing)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white54),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: SizedBox(
|
),
|
||||||
width: 440,
|
const Divider(color: Colors.white12, height: 1),
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
// Content
|
||||||
mainAxisSize: MainAxisSize.min,
|
Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.all(20),
|
||||||
children: [
|
child: Column(
|
||||||
Text('Wählen Sie Ihre Zielparameter für die Trade-Evaluierung:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
const Text('Zeithorizont (Timeframe):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
// 1. TIMEFRAME PRESETS
|
||||||
const SizedBox(height: 6),
|
const Text('1. ZEITHORIZONT (TIMEFRAME)', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
Row(
|
const SizedBox(height: 8),
|
||||||
children: [
|
|
||||||
Expanded(
|
SingleChildScrollView(
|
||||||
child: TextField(
|
scrollDirection: Axis.horizontal,
|
||||||
controller: minTimeframeController,
|
child: Row(
|
||||||
keyboardType: TextInputType.number,
|
children: [
|
||||||
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
_presetChip('⚡ Intraday (1–4 Std.)', 'intraday', () => _selectTimeframePreset('intraday', 1, 4, 'Stunden')),
|
||||||
),
|
_presetChip('🌊 Swing-Trade (1–14 Tage)', 'swing', () => _selectTimeframePreset('swing', 1, 14, 'Tage')),
|
||||||
|
_presetChip('📈 Positions-Trade (2–8 Wo.)', 'position', () => _selectTimeframePreset('position', 2, 8, 'Wochen')),
|
||||||
|
_presetChip('⚙ Benutzerdefiniert', 'custom', () => setState(() => _selectedTimeframePreset = 'custom')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_selectedTimeframePreset == 'custom') ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _minTimeframeCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: maxTimeframeController,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: DropdownButtonFormField<String>(
|
|
||||||
initialValue: timeframeUnit,
|
|
||||||
dropdownColor: AppTheme.cardSurface,
|
|
||||||
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'Stunden', child: Text('Stunden')),
|
|
||||||
DropdownMenuItem(value: 'Tage', child: Text('Tage')),
|
|
||||||
DropdownMenuItem(value: 'Wochen', child: Text('Wochen')),
|
|
||||||
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
|
|
||||||
],
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val != null) setModalState(() => timeframeUnit = val);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
|
||||||
Text(
|
|
||||||
'${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
|
|
||||||
style: TextStyle(
|
|
||||||
color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Slider(
|
|
||||||
value: riskScore,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
divisions: 100,
|
|
||||||
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
|
|
||||||
inactiveColor: AppTheme.glassSurface,
|
|
||||||
onChanged: (val) => setModalState(() => riskScore = val),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: instrumentType,
|
|
||||||
dropdownColor: AppTheme.cardSurface,
|
|
||||||
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
|
|
||||||
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')),
|
|
||||||
DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')),
|
|
||||||
DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
|
|
||||||
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
|
|
||||||
],
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val != null) setModalState(() => instrumentType = val);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
TextField(
|
|
||||||
controller: notesController,
|
|
||||||
maxLines: 3,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...',
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _maxTimeframeCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
initialValue: _timeframeUnit,
|
||||||
|
dropdownColor: AppTheme.cardSurface,
|
||||||
|
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'Stunden', child: Text('Stunden')),
|
||||||
|
DropdownMenuItem(value: 'Tage', child: Text('Tage')),
|
||||||
|
DropdownMenuItem(value: 'Wochen', child: Text('Wochen')),
|
||||||
|
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setState(() => _timeframeUnit = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
|
// 2. RISIKO-PROFIL
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('2. RISIKOBEREITSCHAFT', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
Text(
|
||||||
|
'${_riskScore.toInt()}/100 (${_riskScore < 35 ? "Konservativ" : (_riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
|
||||||
|
style: TextStyle(color: riskColor, fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_riskProfileChip('🟢 Konservativ', 25, AppTheme.primaryEmerald),
|
||||||
|
_riskProfileChip('🟡 Ausgewogen', 50, Colors.orangeAccent),
|
||||||
|
_riskProfileChip('🔴 Spekulativ', 85, AppTheme.accentRed),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: _riskScore,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
divisions: 100,
|
||||||
|
activeColor: riskColor,
|
||||||
|
inactiveColor: AppTheme.glassSurface,
|
||||||
|
onChanged: _isAnalyzing ? null : (val) => setState(() => _riskScore = val),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// 3. INSTRUMENT
|
||||||
|
const Text('3. FINANZINSTRUMENT', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _instrumentType,
|
||||||
|
dropdownColor: AppTheme.cardSurface,
|
||||||
|
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo) - Hebel')),
|
||||||
|
DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
|
||||||
|
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')),
|
||||||
|
DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
|
||||||
|
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
|
||||||
|
],
|
||||||
|
onChanged: _isAnalyzing ? null : (val) {
|
||||||
|
if (val != null) setState(() => _instrumentType = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// 4. NOTIZEN & QUICK TAGS
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('4. ANWEISUNG FÜR DIE KI (OPTIONAL)', style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
_hintTag('Enge Stops'),
|
||||||
|
_hintTag('Hoher Hebel'),
|
||||||
|
_hintTag('Earnings Play'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: _notesCtrl,
|
||||||
|
maxLines: 2,
|
||||||
|
enabled: !_isAnalyzing,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...',
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Animated Footer Button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 52,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isAnalyzing ? null : _startAnalysis,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.accentCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
elevation: _isAnalyzing ? 0 : 4,
|
||||||
|
),
|
||||||
|
child: _isAnalyzing
|
||||||
|
? Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.black),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Text(
|
||||||
|
_stageText,
|
||||||
|
key: ValueKey<int>(_analysisStage),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.flash_on, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Analyse Jetzt Ausführen', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 15)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
],
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
),
|
||||||
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
|
),
|
||||||
),
|
);
|
||||||
ElevatedButton.icon(
|
}
|
||||||
onPressed: () {
|
|
||||||
final payload = ManualAnalysisRequestDto(
|
Widget _presetChip(String label, String key, VoidCallback onTap) {
|
||||||
isin: symbol,
|
final isSelected = _selectedTimeframePreset == key;
|
||||||
symbol: symbol,
|
return GestureDetector(
|
||||||
riskScore: riskScore.toInt(),
|
onTap: _isAnalyzing ? null : onTap,
|
||||||
minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1,
|
child: Container(
|
||||||
maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14,
|
margin: const EdgeInsets.only(right: 8),
|
||||||
timeframeUnit: timeframeUnit,
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||||
instrumentType: instrumentType,
|
decoration: BoxDecoration(
|
||||||
userNotes: notesController.text,
|
color: isSelected ? AppTheme.accentCyan.withValues(alpha: 0.2) : AppTheme.glassSurface,
|
||||||
headline: 'Manuelle KI-Analyse für $symbol',
|
borderRadius: BorderRadius.circular(8),
|
||||||
);
|
border: Border.all(color: isSelected ? AppTheme.accentCyan : AppTheme.glassBorder),
|
||||||
Navigator.pop(dialogContext);
|
),
|
||||||
onTrigger(payload);
|
child: Text(
|
||||||
},
|
label,
|
||||||
icon: const Icon(Icons.flash_on),
|
style: TextStyle(
|
||||||
label: const Text('Analyse Jetzt Ausführen'),
|
color: isSelected ? AppTheme.accentCyan : Colors.white70,
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black),
|
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;
|
||||||
|
typeLabel = 'Zahlungstag';
|
||||||
|
} else {
|
||||||
|
badgeColor = AppTheme.accentCyan;
|
||||||
|
typeLabel = 'Ex-Dividende';
|
||||||
|
}
|
||||||
|
} else if (tLower.contains('pay') || tLower.contains('zahl') || tLower.contains('auszahl')) {
|
||||||
badgeColor = Colors.amber;
|
badgeColor = Colors.amber;
|
||||||
typeLabel = 'Zahlungstag';
|
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,7 +32,8 @@ 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;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -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: [
|
children: [
|
||||||
const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
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),
|
||||||
listBadge,
|
Row(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () => Navigator.pop(context),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
icon: const Icon(Icons.close, color: Colors.white70),
|
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,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
if (badgeWidget != null) ...[
|
||||||
//badgeWidget,
|
const SizedBox(width: 8),
|
||||||
|
badgeWidget,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (summary.isNotEmpty) ...[
|
||||||
Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
|
const SizedBox(height: 8),
|
||||||
|
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: () {
|
||||||
Navigator.push(
|
if (isin.isNotEmpty) {
|
||||||
context,
|
Navigator.push(
|
||||||
MaterialPageRoute(
|
context,
|
||||||
builder: (_) => AssetDetailScreen(
|
MaterialPageRoute(
|
||||||
isin: assetItem,
|
builder: (_) => AssetDetailScreen(
|
||||||
name: name,
|
isin: isin,
|
||||||
symbol: symbol != null ? symbol.symbol : null,
|
name: name,
|
||||||
apiClient: apiClient,
|
symbol: symbol != null ? symbol.symbol : null,
|
||||||
|
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,
|
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||||
theme: AppTheme.darkClassic,
|
isActive: isActive,
|
||||||
userId: currentUserId,
|
onAccept: (dto) {
|
||||||
),
|
tradeBloc.add(AcceptTradeProposalEvent(dto));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(isActive ? 'Einstellungen für ${trade.symbol} gespeichert!' : 'Trade für ${trade.symbol} eröffnet!'),
|
||||||
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (result != null && mounted) {
|
void _handleCloseTrade(BuildContext context, TradeModel trade) {
|
||||||
context.read<TradeBloc>().add(AcceptTradeProposalEvent(result));
|
final tradeBloc = context.read<TradeBloc>();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
TradeClosingCockpit.show(
|
||||||
content: const Text('Trade wird in dein Portfolio übernommen...'),
|
context,
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
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,224 +49,422 @@ 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(
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
context,
|
padding: const EdgeInsets.all(16),
|
||||||
trade: trade,
|
child: Column(
|
||||||
onAccept: onAccept,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onClose: onClose,
|
|
||||||
),
|
|
||||||
child: GlassContainer(
|
|
||||||
margin: const EdgeInsets.only(bottom: 14),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Header Row: Signal, Symbol, Status & Live PnL
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Header Row: Signal, Symbol, Drift-Radar & Live PnL
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: signalColor.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: signalColor.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
isBuy ? Icons.trending_up : Icons.trending_down,
|
|
||||||
size: 14,
|
|
||||||
color: signalColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
trade.signalType,
|
|
||||||
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
|
|
||||||
? trade.companyName
|
|
||||||
: (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Aktie')),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
|
||||||
),
|
|
||||||
if (trade.isin.isNotEmpty || (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' && trade.symbol != trade.companyName))
|
|
||||||
Text(
|
|
||||||
trade.isin.isNotEmpty ? trade.isin : trade.symbol,
|
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isActive || isClosed)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: pnlColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: pnlColor.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)} €',
|
|
||||||
style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 14),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%',
|
|
||||||
style: TextStyle(color: pnlColor, fontSize: 11),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (trade.isRejected)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'ABGELEHNT',
|
|
||||||
style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 10),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.amber.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'VORSCHLAG',
|
|
||||||
style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Price Metrics Grid with Live Kurs
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
|
||||||
_priceItem(
|
|
||||||
isActive || isClosed ? 'Ausführung' : 'Ziel-Einstieg',
|
|
||||||
trade.actualEntryPrice > 0
|
|
||||||
? '${trade.actualEntryPrice.toStringAsFixed(2)} €'
|
|
||||||
: (trade.entryPrice > 0 ? '${trade.entryPrice.toStringAsFixed(2)} €' : '-'),
|
|
||||||
Colors.white
|
|
||||||
),
|
|
||||||
_priceItem('Live-Kurs', '${currPrice.toStringAsFixed(2)} €', AppTheme.accentCyan),
|
|
||||||
_priceItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed),
|
|
||||||
_priceItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (trade.reasoning.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
trade.reasoning,
|
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Footer Action Row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${trade.instrumentType.isNotEmpty ? trade.instrumentType : "Stock"}${trade.derivativeIsin.isNotEmpty ? " (${trade.derivativeIsin})" : ""} • ${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(0)}x Hebel" : ""}',
|
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
|
||||||
),
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Container(
|
||||||
onPressed: () => TradeDetailModal.show(
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
context,
|
decoration: BoxDecoration(
|
||||||
trade: trade,
|
color: signalColor.withValues(alpha: 0.15),
|
||||||
onAccept: onAccept,
|
borderRadius: BorderRadius.circular(8),
|
||||||
onClose: onClose,
|
border: Border.all(color: signalColor.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(trade.signalType, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
|
|
||||||
tooltip: 'KI-Begründung & Details',
|
|
||||||
),
|
),
|
||||||
if (isProposed && onAccept != null) ...[
|
const SizedBox(width: 10),
|
||||||
const SizedBox(width: 6),
|
Expanded(
|
||||||
ElevatedButton.icon(
|
child: Column(
|
||||||
onPressed: onAccept,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
icon: const Icon(Icons.check_circle_outline, size: 16),
|
children: [
|
||||||
label: const Text('Trade Übernehmen'),
|
Row(
|
||||||
style: ElevatedButton.styleFrom(
|
children: [
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
Flexible(
|
||||||
foregroundColor: Colors.white,
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
trade.companyName.isNotEmpty && trade.companyName != 'UNKNOWN'
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
? trade.companyName
|
||||||
|
: (trade.symbol.isNotEmpty && trade.symbol != 'UNKNOWN' ? trade.symbol : (trade.isin.isNotEmpty ? trade.isin : 'Position')),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trade.instrumentType.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${trade.symbol.isNotEmpty ? trade.symbol : ""} ${trade.isin.isNotEmpty ? "• " + trade.isin : ""}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Status / PnL / Drift-Radar Badge
|
||||||
|
if (isActive || isClosed) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pnlColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: pnlColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${isPnlPos ? '+' : ''}€${pnlAbs.abs().toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%',
|
||||||
|
style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
] else if (trade.isRejected) ...[
|
||||||
|
StatusBadge(label: 'ABGELEHNT', color: AppTheme.accentRed),
|
||||||
|
] else ...[
|
||||||
|
StatusBadge(label: 'VORSCHLAG', color: Colors.amber),
|
||||||
],
|
],
|
||||||
if (isActive && onClose != null) ...[
|
],
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onClose,
|
// Active Drift Radar / Trailing Alert Indicator
|
||||||
icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed),
|
if (isActive) ...[
|
||||||
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12)),
|
const SizedBox(height: 10),
|
||||||
style: OutlinedButton.styleFrom(
|
_buildDriftRadarBar(trade),
|
||||||
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
],
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
// PENDING EXIT ALERT BANNER (Zero Auto-Close notification)
|
||||||
|
if (isActive && trade.hasPendingExitAlert) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'KI-Guardian Ratschlag: Position schließen!',
|
||||||
|
style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
|
if (trade.pendingExitReason.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
trade.pendingExitReason,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (onClose != null)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: onClose,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.accentRed,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Price Metrics Grid with Live Kurs & Trailing SL
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.25),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
_priceItem(
|
||||||
|
isActive || isClosed ? 'Einstieg' : 'Ziel-Einstieg',
|
||||||
|
trade.actualEntryPrice > 0
|
||||||
|
? '€${trade.actualEntryPrice.toStringAsFixed(2)}'
|
||||||
|
: (trade.entryPrice > 0 ? '€${trade.entryPrice.toStringAsFixed(2)}' : '-'),
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
_priceItem('Live-Kurs', '€${currPrice.toStringAsFixed(2)}', AppTheme.accentCyan),
|
||||||
|
_priceItem(
|
||||||
|
trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss',
|
||||||
|
'€${trade.stopLoss.toStringAsFixed(2)}',
|
||||||
|
AppTheme.accentRed,
|
||||||
|
),
|
||||||
|
_priceItem(
|
||||||
|
trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit',
|
||||||
|
'€${trade.takeProfit.toStringAsFixed(2)}',
|
||||||
|
AppTheme.primaryEmerald,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isActive && onSettings != null) ...[
|
),
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
IconButton(
|
|
||||||
onPressed: onSettings,
|
if (trade.takeProfitTargets.length > 1) ...[
|
||||||
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
|
const SizedBox(height: 8),
|
||||||
tooltip: 'Einstellungen',
|
Row(
|
||||||
style: IconButton.styleFrom(
|
children: [
|
||||||
backgroundColor: AppTheme.glassSurface,
|
Text(
|
||||||
|
'Ziele: ',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: trade.takeProfitTargets.asMap().entries.map((entry) {
|
||||||
|
final idx = entry.key;
|
||||||
|
final tpVal = entry.value;
|
||||||
|
final isCurrent = (trade.takeProfit - tpVal).abs() < 0.01;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrent
|
||||||
|
? AppTheme.primaryEmerald.withValues(alpha: 0.2)
|
||||||
|
: Colors.white.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: isCurrent
|
||||||
|
? AppTheme.primaryEmerald
|
||||||
|
: Colors.white.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'TP${idx + 1}: €${tpVal.toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrent ? AppTheme.primaryEmerald : Colors.white70,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
if (trade.reasoning.isNotEmpty) ...[
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
trade.reasoning,
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// KI-Timeline Expansion if updates exist
|
||||||
|
if (trade.hourlyUpdates.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
childrenPadding: const EdgeInsets.only(bottom: 6),
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
|
||||||
|
title: Text(
|
||||||
|
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
|
||||||
|
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
children: trade.hourlyUpdates.reversed.take(4).map((u) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.03),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (u.recommendation.toLowerCase().contains('close')
|
||||||
|
? AppTheme.accentRed
|
||||||
|
: (u.recommendation.toLowerCase().contains('adjust') ? Colors.blue : AppTheme.primaryEmerald))
|
||||||
|
.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(u.recommendation, style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
u.reasoning.isNotEmpty ? u.reasoning : 'Kurs: €${u.currentPrice.toStringAsFixed(2)} | VIX: ${u.vixValue.toStringAsFixed(1)}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Collapsible Live-Kalkulation & TP-Multi-Target Card
|
||||||
|
TradeCalculationCard(trade: trade, isCollapsible: true, initiallyExpanded: false),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Footer Action Row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(1)}x Hebel" : ""}',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => TradeDetailModal.show(
|
||||||
|
context,
|
||||||
|
trade: trade,
|
||||||
|
onAccept: onAccept,
|
||||||
|
onClose: onClose,
|
||||||
|
),
|
||||||
|
icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan),
|
||||||
|
tooltip: 'KI-Begründung & Details',
|
||||||
|
),
|
||||||
|
if (isProposed && onAccept != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onAccept,
|
||||||
|
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||||
|
label: const Text('Trade Übernehmen'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isActive && onClose != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onClose,
|
||||||
|
icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed),
|
||||||
|
label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isActive && onSettings != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onSettings,
|
||||||
|
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
|
||||||
|
tooltip: 'Einstellungen anpassen',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.glassSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDriftRadarBar(TradeModel t) {
|
||||||
|
Color col;
|
||||||
|
String label;
|
||||||
|
IconData icon;
|
||||||
|
|
||||||
|
switch (t.driftStatus) {
|
||||||
|
case DriftStatus.exitAlert:
|
||||||
|
col = AppTheme.accentRed;
|
||||||
|
label = 'Drift-Radar: Ausstieg empfohlen';
|
||||||
|
icon = Icons.warning_rounded;
|
||||||
|
break;
|
||||||
|
case DriftStatus.trailingActive:
|
||||||
|
col = AppTheme.accentCyan;
|
||||||
|
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
|
||||||
|
icon = Icons.security;
|
||||||
|
break;
|
||||||
|
case DriftStatus.driftWarning:
|
||||||
|
col = Colors.orangeAccent;
|
||||||
|
label = 'Drift-Radar: Leichte Abweichung von Prognose';
|
||||||
|
icon = Icons.tune;
|
||||||
|
break;
|
||||||
|
case DriftStatus.onTrack:
|
||||||
|
col = AppTheme.primaryEmerald;
|
||||||
|
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
|
||||||
|
icon = Icons.radar;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: col.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: col.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: col, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(label, style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _priceItem(String label, String val, Color valColor) {
|
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(
|
const SizedBox(height: 18),
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
// Trade Parameters & Instrument
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.2)),
|
|
||||||
),
|
|
||||||
child: Text(trade.reasoning, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.4)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
],
|
|
||||||
if (trade.technicalRationale.isNotEmpty) ...[
|
|
||||||
_sectionTitle(Icons.show_chart, 'Technische Analyse & Indikatoren', AppTheme.accentCyan),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.03),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
|
||||||
),
|
|
||||||
child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
],
|
|
||||||
if (trade.fundamentalRationale.isNotEmpty) ...[
|
|
||||||
_sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.03),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
|
||||||
),
|
|
||||||
child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
],
|
|
||||||
if (trade.riskWarning.isNotEmpty) ...[
|
|
||||||
_sectionTitle(Icons.warning_amber_rounded, 'Risikohinweis & Marktumfeld', AppTheme.accentRed),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.accentRed.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
],
|
|
||||||
_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,15 +176,222 @@ 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _sectionTitle(IconData icon, String title, Color color) {
|
Widget _sectionTitle(IconData icon, String title, Color color) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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." });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+429
@@ -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(yahooPrimaryTicker.Exchange))
|
||||||
|
{
|
||||||
|
yahooPrimaryTicker = new TickerInfoDto
|
||||||
|
{
|
||||||
|
Ticker = yahooPrimaryTicker.Ticker,
|
||||||
|
Exchange = GetExchangeDisplayName(yahooPrimaryTicker.Ticker)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
if (!string.IsNullOrWhiteSpace(requestedTicker))
|
||||||
{
|
{
|
||||||
var match = assetData?.AvailableTickers?
|
var matchDto = resolvedTickers.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
|
||||||
.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
|
var matchEntity = assetData?.AvailableTickers?.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (match != null)
|
if (matchDto != null)
|
||||||
{
|
{
|
||||||
primaryTicker = new TickerInfoDto
|
activeQueryTicker = new TickerInfoDto
|
||||||
{
|
{
|
||||||
Ticker = match.Ticker,
|
Ticker = matchDto.Ticker,
|
||||||
Exchange = !string.IsNullOrWhiteSpace(match.Exchange)
|
Exchange = !string.IsNullOrWhiteSpace(matchDto.Exchange) ? matchDto.Exchange : GetExchangeDisplayName(matchDto.Ticker)
|
||||||
? match.Exchange
|
|
||||||
: GetExchangeDisplayName(match.Ticker)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if (assetData?.PrimaryTicker != null &&
|
else if (matchEntity != null)
|
||||||
string.Equals(assetData.PrimaryTicker.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
primaryTicker = new TickerInfoDto
|
activeQueryTicker = new TickerInfoDto
|
||||||
{
|
{
|
||||||
Ticker = assetData.PrimaryTicker.Ticker,
|
Ticker = matchEntity.Ticker,
|
||||||
Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange)
|
Exchange = !string.IsNullOrWhiteSpace(matchEntity.Exchange) ? matchEntity.Exchange : GetExchangeDisplayName(matchEntity.Ticker)
|
||||||
? assetData.PrimaryTicker.Exchange
|
|
||||||
: GetExchangeDisplayName(assetData.PrimaryTicker.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
|
||||||
|
{
|
||||||
|
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
|
try
|
||||||
{
|
{
|
||||||
// 1. Suche via ISIN
|
// 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 =>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
Generated
+357
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user