feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../trades/models/trade_model.dart';
|
||||
import 'asset_trades_event.dart';
|
||||
import 'asset_trades_state.dart';
|
||||
import '../../repositories/asset_repository.dart';
|
||||
@@ -20,28 +19,23 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
||||
on<TriggerManualAnalysis>((event, emit) async {
|
||||
emit(AssetTradesLoading());
|
||||
try {
|
||||
final analysisRes = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||
// Server contract: always 200 -> AssetEvaluationResultDto, whether the
|
||||
// pipeline produced a proposal or rejected the opportunity. The trade
|
||||
// list itself is unaffected until the user actually accepts a
|
||||
// proposal, so it is simply reloaded as-is; the analysis result is
|
||||
// surfaced separately for the UI to react to exactly once.
|
||||
final result = await repository.triggerManualAnalysis(event.isin, payload: event.payload);
|
||||
final existingTrades = await repository.getAssetTrades(event.isin, null);
|
||||
|
||||
final list = List<TradeModel>.from(existingTrades);
|
||||
final newProposal = analysisRes?.proposal;
|
||||
if (newProposal != null) {
|
||||
final isDuplicate = list.any((t) => t.id == newProposal.id || (t.analysisId.isNotEmpty && t.analysisId == newProposal.analysisId));
|
||||
if (!isDuplicate) {
|
||||
list.insert(0, newProposal);
|
||||
}
|
||||
}
|
||||
emit(AssetTradesLoaded(list));
|
||||
emit(AssetTradesLoaded(existingTrades, manualAnalysisResult: result));
|
||||
} catch (e) {
|
||||
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
|
||||
}
|
||||
});
|
||||
on<RejectTradeEvent>((event, emit) async {
|
||||
try {
|
||||
await repository.rejectTrade(event.tradeId);
|
||||
add(LoadAssetTrades(event.isin));
|
||||
} catch (e) {
|
||||
emit(AssetTradesError("Failed to reject trade: $e"));
|
||||
on<DismissTradeEvent>((event, emit) {
|
||||
// Purely local: no server call, see DismissTradeEvent doc comment.
|
||||
final current = state;
|
||||
if (current is AssetTradesLoaded) {
|
||||
emit(AssetTradesLoaded(current.data.where((t) => t.id != event.tradeId).toList()));
|
||||
}
|
||||
});
|
||||
on<AcceptTradeEvent>((event, emit) async {
|
||||
@@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
|
||||
emit(AssetTradesError("Failed to accept trade: $e"));
|
||||
}
|
||||
});
|
||||
on<AddTradeFillEvent>((event, emit) async {
|
||||
try {
|
||||
await repository.addTradeFill(event.tradeId, executedPrice: event.executedPrice, quantity: event.quantity);
|
||||
add(LoadAssetTrades(event.isin));
|
||||
} catch (e) {
|
||||
emit(AssetTradesError("Failed to update trade execution: $e"));
|
||||
}
|
||||
});
|
||||
on<CloseTradeEvent>((event, emit) async {
|
||||
try {
|
||||
await repository.closeTrade(event.tradeId, event.exitPrice);
|
||||
|
||||
@@ -13,16 +13,34 @@ class TriggerManualAnalysis extends AssetTradesEvent {
|
||||
final ManualAnalysisRequestDto? payload;
|
||||
TriggerManualAnalysis(this.isin, {this.payload});
|
||||
}
|
||||
class RejectTradeEvent extends AssetTradesEvent {
|
||||
/// Dismisses a trade proposal from the locally displayed list only.
|
||||
///
|
||||
/// There is no server-side "reject" anymore: a proposal is a system-wide
|
||||
/// opportunity that any user may accept independently, so rejecting it has
|
||||
/// no server-side meaning. This purely removes the card from the current
|
||||
/// in-memory list; the proposal keeps existing server-side until its 24h
|
||||
/// TTL expires, so it can reappear after the next reload (Rules.md §4 —
|
||||
/// no fabricated "permanently rejected" state is invented).
|
||||
class DismissTradeEvent extends AssetTradesEvent {
|
||||
final String tradeId;
|
||||
final String isin;
|
||||
RejectTradeEvent(this.tradeId, this.isin);
|
||||
DismissTradeEvent(this.tradeId);
|
||||
}
|
||||
class AcceptTradeEvent extends AssetTradesEvent {
|
||||
final TradeAcceptanceDto tradeAcceptanceDto;
|
||||
final String isin;
|
||||
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
|
||||
}
|
||||
|
||||
/// Records a corrective/additional fill against an already-active trade
|
||||
/// (review-execution path). Distinct from [AcceptTradeEvent], which targets
|
||||
/// a proposal, not an existing trade — see `AssetRepository.addTradeFill`.
|
||||
class AddTradeFillEvent extends AssetTradesEvent {
|
||||
final String tradeId;
|
||||
final String isin;
|
||||
final double executedPrice;
|
||||
final double quantity;
|
||||
AddTradeFillEvent(this.tradeId, this.isin, this.executedPrice, this.quantity);
|
||||
}
|
||||
class CloseTradeEvent extends AssetTradesEvent {
|
||||
final String tradeId;
|
||||
final String isin;
|
||||
|
||||
@@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {}
|
||||
class AssetTradesLoading extends AssetTradesState {}
|
||||
class AssetTradesLoaded extends AssetTradesState {
|
||||
final List<TradeModel> data;
|
||||
AssetTradesLoaded(this.data);
|
||||
|
||||
/// Transient result of a just-triggered manual analysis. Only set on the
|
||||
/// state instance emitted directly by `TriggerManualAnalysis` — a plain
|
||||
/// reload/dismiss/accept emits a fresh `AssetTradesLoaded` without it, so a
|
||||
/// `BlocConsumer` listener naturally reacts to it exactly once instead of
|
||||
/// on every rebuild.
|
||||
///
|
||||
/// Always fully populated when set: the server contract no longer has a
|
||||
/// silent "204, no proposal" outcome, so unlike the old
|
||||
/// `manualAnalysisProposal`/`manualAnalysisEmpty` pair, a single non-null
|
||||
/// value here already tells the caller everything — check
|
||||
/// `manualAnalysisResult!.hasProposal` to distinguish an accepted
|
||||
/// opportunity from a rejected one with real scores/AI reasoning attached
|
||||
/// (Rules.md §4).
|
||||
final AssetEvaluationResultModel? manualAnalysisResult;
|
||||
|
||||
AssetTradesLoaded(this.data, {this.manualAnalysisResult});
|
||||
}
|
||||
class AssetTradesError extends AssetTradesState {
|
||||
final String message;
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../trades/models/trade_model.dart';
|
||||
|
||||
class ExecutionPlanModel extends Equatable {
|
||||
final double stopLoss;
|
||||
final List<double> takeProfitTargets;
|
||||
final double riskRewardRatio;
|
||||
final double maxLeverage;
|
||||
|
||||
const ExecutionPlanModel({
|
||||
this.stopLoss = 0.0,
|
||||
this.takeProfitTargets = const [],
|
||||
this.riskRewardRatio = 0.0,
|
||||
this.maxLeverage = 1.0,
|
||||
});
|
||||
|
||||
factory ExecutionPlanModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0;
|
||||
return ExecutionPlanModel(
|
||||
stopLoss: parseDbl(json['stopLoss']),
|
||||
takeProfitTargets: (json['takeProfitTargets'] as List<dynamic>? ?? []).map((e) => parseDbl(e)).toList(),
|
||||
riskRewardRatio: parseDbl(json['riskRewardRatio']),
|
||||
maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stopLoss, takeProfitTargets, riskRewardRatio, maxLeverage];
|
||||
}
|
||||
|
||||
class DetailedAnalysisModel extends Equatable {
|
||||
final String technicalRationale;
|
||||
final String fundamentalRationale;
|
||||
final String riskWarning;
|
||||
|
||||
const DetailedAnalysisModel({
|
||||
this.technicalRationale = '',
|
||||
this.fundamentalRationale = '',
|
||||
this.riskWarning = '',
|
||||
});
|
||||
|
||||
factory DetailedAnalysisModel.fromJson(Map<String, dynamic> json) {
|
||||
return DetailedAnalysisModel(
|
||||
technicalRationale: json['technicalRationale']?.toString() ?? '',
|
||||
fundamentalRationale: json['fundamentalRationale']?.toString() ?? '',
|
||||
riskWarning: json['riskWarning']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [technicalRationale, fundamentalRationale, riskWarning];
|
||||
}
|
||||
|
||||
class N8nAnalysisResponseDto extends Equatable {
|
||||
final String aiDecision; // "Proceed", "Rejected", "Hold"
|
||||
final String aiReasoning;
|
||||
final int evalScore;
|
||||
final String suggestedDirection; // "Long", "Short"
|
||||
final String suggestedRisk;
|
||||
final String suggestedTimeframe;
|
||||
final ExecutionPlanModel? executionPlan;
|
||||
final DetailedAnalysisModel? detailedAnalysis;
|
||||
|
||||
const N8nAnalysisResponseDto({
|
||||
this.aiDecision = 'Rejected',
|
||||
this.aiReasoning = '',
|
||||
this.evalScore = 0,
|
||||
this.suggestedDirection = 'Long',
|
||||
this.suggestedRisk = 'Moderate',
|
||||
this.suggestedTimeframe = '1D',
|
||||
this.executionPlan,
|
||||
this.detailedAnalysis,
|
||||
});
|
||||
|
||||
factory N8nAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
||||
ExecutionPlanModel? execPlan;
|
||||
if (json['executionPlan'] != null && json['executionPlan'] is Map<String, dynamic>) {
|
||||
execPlan = ExecutionPlanModel.fromJson(json['executionPlan']);
|
||||
}
|
||||
|
||||
DetailedAnalysisModel? detailAnalysis;
|
||||
if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map<String, dynamic>) {
|
||||
detailAnalysis = DetailedAnalysisModel.fromJson(json['detailedAnalysis']);
|
||||
}
|
||||
|
||||
return N8nAnalysisResponseDto(
|
||||
aiDecision: json['aiDecision']?.toString() ?? 'Rejected',
|
||||
aiReasoning: json['aiReasoning']?.toString() ?? '',
|
||||
evalScore: (json['evalScore'] as num?)?.toInt() ?? 0,
|
||||
suggestedDirection: json['suggestedDirection']?.toString() ?? 'Long',
|
||||
suggestedRisk: json['suggestedRisk']?.toString() ?? 'Moderate',
|
||||
suggestedTimeframe: json['suggestedTimeframe']?.toString() ?? '1D',
|
||||
executionPlan: execPlan,
|
||||
detailedAnalysis: detailAnalysis,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
aiDecision,
|
||||
aiReasoning,
|
||||
evalScore,
|
||||
suggestedDirection,
|
||||
suggestedRisk,
|
||||
suggestedTimeframe,
|
||||
executionPlan,
|
||||
detailedAnalysis,
|
||||
];
|
||||
}
|
||||
|
||||
class ManualAnalysisResponseDto extends Equatable {
|
||||
final String analysisId;
|
||||
final bool isTradeProposed;
|
||||
final String status;
|
||||
final String recommendation; // "RECOMMENDED", "NOT_RECOMMENDED"
|
||||
final N8nAnalysisResponseDto? n8nResponse;
|
||||
final TradeModel? proposal;
|
||||
final String message;
|
||||
|
||||
const ManualAnalysisResponseDto({
|
||||
required this.analysisId,
|
||||
this.isTradeProposed = false,
|
||||
this.status = 'Success',
|
||||
this.recommendation = 'NOT_RECOMMENDED',
|
||||
this.n8nResponse,
|
||||
this.proposal,
|
||||
this.message = '',
|
||||
});
|
||||
|
||||
factory ManualAnalysisResponseDto.fromJson(Map<String, dynamic> json) {
|
||||
N8nAnalysisResponseDto? n8n;
|
||||
if (json['n8nResponse'] != null && json['n8nResponse'] is Map<String, dynamic>) {
|
||||
n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']);
|
||||
}
|
||||
|
||||
TradeModel? prop;
|
||||
if (json['proposal'] != null && json['proposal'] is Map<String, dynamic>) {
|
||||
prop = TradeModel.fromJson(json['proposal']);
|
||||
} else if (n8n != null) {
|
||||
final exec = n8n.executionPlan;
|
||||
final det = n8n.detailedAnalysis;
|
||||
final isProceed = n8n.aiDecision.toLowerCase() == 'proceed';
|
||||
final analysisIdStr = (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '';
|
||||
final tradeIdStr = 'PROP-${analysisIdStr.length > 10 ? analysisIdStr.substring(0, 10).toUpperCase() : 'MANUAL'}';
|
||||
|
||||
prop = TradeModel(
|
||||
id: tradeIdStr,
|
||||
analysisId: analysisIdStr,
|
||||
symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? '',
|
||||
isin: (json['isin'] ?? json['Isin'])?.toString() ?? '',
|
||||
status: isProceed ? 'Proposed' : 'Rejected',
|
||||
signalType: n8n.suggestedDirection.toUpperCase() == 'SHORT' ? 'SELL' : 'BUY',
|
||||
entryPrice: 0.0,
|
||||
stopLoss: exec?.stopLoss ?? 0.0,
|
||||
takeProfit: (exec?.takeProfitTargets.isNotEmpty ?? false) ? exec!.takeProfitTargets.first : 0.0,
|
||||
reasoning: n8n.aiReasoning,
|
||||
technicalRationale: det?.technicalRationale ?? '',
|
||||
fundamentalRationale: det?.fundamentalRationale ?? '',
|
||||
riskWarning: det?.riskWarning ?? '',
|
||||
takeProfitTargets: exec?.takeProfitTargets ?? const [],
|
||||
maxLeverage: exec?.maxLeverage ?? 1.0,
|
||||
riskTolerance: n8n.suggestedRisk,
|
||||
timeframe: n8n.suggestedTimeframe,
|
||||
);
|
||||
}
|
||||
|
||||
return ManualAnalysisResponseDto(
|
||||
analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '',
|
||||
isTradeProposed: json['isTradeProposed'] == true || json['IsTradeProposed'] == true,
|
||||
status: (json['status'] ?? json['Status'])?.toString() ?? 'Success',
|
||||
recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'NOT_RECOMMENDED',
|
||||
n8nResponse: n8n,
|
||||
proposal: prop,
|
||||
message: (json['message'] ?? json['Message'])?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message];
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
|
||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart';
|
||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
|
||||
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
|
||||
@@ -102,23 +101,47 @@ class AssetRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<double?> getLivePrice(String isin) async {
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/assets/$isin/live');
|
||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||
final val = res.data['currentPrice'] ?? res.data['CurrentPrice'];
|
||||
if (val is num) return val.toDouble();
|
||||
if (val != null) return double.tryParse(val.toString());
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
||||
}
|
||||
|
||||
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||
/// Triggers an on-demand manual analysis for [isin] via `POST /api/v1/analyze/manual`.
|
||||
///
|
||||
/// Server contract: always `200 OK` with a full `AssetEvaluationResultDto`
|
||||
/// body — even when the analysis ran but did not clear the bar for a trade
|
||||
/// proposal (`AssetEvaluationResultModel.proposal == null`), the response
|
||||
/// still carries the real, already-computed scores and AI reasoning, so
|
||||
/// there is no more silent `204 No Content` outcome to handle here
|
||||
/// (Rules.md §4). A non-2xx status (missing ISIN, engine unreachable, no
|
||||
/// RPC response, unexpected error) surfaces as a `DioException` that
|
||||
/// propagates to the caller instead of being swallowed into `null`.
|
||||
Future<AssetEvaluationResultModel> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||
return ManualAnalysisResponseDto.fromJson(res.data);
|
||||
if (res.data != null && res.data is Map<String, dynamic>) {
|
||||
return AssetEvaluationResultModel.fromJson(res.data);
|
||||
}
|
||||
return null;
|
||||
throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.');
|
||||
}
|
||||
|
||||
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
|
||||
|
||||
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
||||
|
||||
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
||||
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
||||
|
||||
Future<TradeModel> addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async =>
|
||||
_tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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/glass_container.dart';
|
||||
import '../../../../core/widgets/shimmer_loading.dart';
|
||||
import '../../../../core/widgets/status_badge.dart';
|
||||
import '../../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||
import '../../../bot/repositories/bot_repository.dart';
|
||||
import '../../../proposals/views/proposal_decision_screen.dart';
|
||||
import '../../../trades/models/trade_model.dart';
|
||||
import '../../../trades/widgets/trade_execution_cockpit.dart';
|
||||
import '../../../trades/widgets/trade_closing_cockpit.dart';
|
||||
@@ -22,11 +26,12 @@ class TradesTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TradesTabState extends State<TradesTab> {
|
||||
bool _justTriggeredAnalysis = false;
|
||||
late final BotRepository _botRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_botRepository = BotRepository(apiClient: context.read<ApiClient>());
|
||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
||||
}
|
||||
|
||||
@@ -39,8 +44,23 @@ class _TradesTabState extends State<TradesTab> {
|
||||
defaultSymbol: widget.symbol,
|
||||
isActive: isActive,
|
||||
onAccept: (dto) {
|
||||
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
|
||||
final tId = trade.id;
|
||||
// `TradeExecutionCockpit._buildDto()` already picks the right identifier
|
||||
// (trade.id for isActive, trade.proposalId otherwise) and always fills
|
||||
// actualEntryPrice/quantity from the two fields the dialog actually
|
||||
// collects — but the two identifiers target different server-side
|
||||
// operations: accepting a *proposal* vs. recording a fill against an
|
||||
// already-*existing* trade (`UserTradesController.AcceptTrade` looks
|
||||
// `dto.tradeId` up as a proposal id, which fails for an active trade's
|
||||
// own id). Route accordingly instead of always calling AcceptTradeEvent.
|
||||
if (isActive) {
|
||||
final price = dto.actualEntryPrice ?? dto.entryPrice;
|
||||
final qty = dto.quantity ?? dto.positionSize;
|
||||
if (price == null || qty == null) return;
|
||||
tradesBloc.add(AddTradeFillEvent(tId, widget.symbol, price, qty));
|
||||
} else {
|
||||
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
|
||||
@@ -50,10 +70,15 @@ class _TradesTabState extends State<TradesTab> {
|
||||
);
|
||||
},
|
||||
onReject: (tId) {
|
||||
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
|
||||
// Purely local dismissal — there is no server-side rejection (a
|
||||
// proposal is a system-wide opportunity anyone may still accept).
|
||||
// Wording must not claim a permanence the backend doesn't provide.
|
||||
tradesBloc.add(DismissTradeEvent(tId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Trade $tId abgelehnt.'),
|
||||
content: const Text(
|
||||
'Vorschlag ausgeblendet – er kann beim nächsten Neuladen erneut erscheinen, bis er serverseitig abläuft.',
|
||||
),
|
||||
backgroundColor: AppTheme.textSecondary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@@ -62,20 +87,101 @@ class _TradesTabState extends State<TradesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _executeProposalViaBot(BuildContext context, TradeProposalModel proposal) async {
|
||||
Navigator.of(context).pop();
|
||||
try {
|
||||
await _botRepository.executeProposal(proposal.proposalId);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'),
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Fehler bei der Bot-Übergabe: $e'),
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showProposalDecision(BuildContext context, TradeProposalModel proposal) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProposalDecisionScreen(
|
||||
proposal: proposal,
|
||||
onExecuteBot: () => _executeProposalViaBot(context, proposal),
|
||||
onManualTrade: () {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the real, already-computed score breakdown and AI reasoning for a
|
||||
/// manual analysis that ran but did not produce a trade proposal
|
||||
/// ([AssetEvaluationResultModel.proposal] is `null`). Replaces the old bare
|
||||
/// "kein Vorschlag" snackbar: the user gets to see *why* the opportunity
|
||||
/// was rejected, not just *that* it was (Rules.md §4). Every value shown
|
||||
/// here comes straight from the server response — nothing is invented, and
|
||||
/// [AssetEvaluationResultModel.daysToNextEarnings] is only rendered when
|
||||
/// the server actually sent a value.
|
||||
void _showEvaluationRejectedSheet(BuildContext context, AssetEvaluationResultModel result) {
|
||||
EvaluationScoreBreakdownSheet.show(
|
||||
context,
|
||||
title: 'Analyse abgeschlossen – kein Vorschlag',
|
||||
subtitle:
|
||||
'Für ${widget.symbol} wurde keine aktive Trade-Empfehlung erzeugt. Die berechneten Werte und die KI-Begründung stehen unten.',
|
||||
headerIcon: result.aiApproved ? Icons.psychology_outlined : Icons.block_outlined,
|
||||
headerColor: result.aiApproved ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
compositeScore: result.compositeScore,
|
||||
technicalScore: result.technicalScore,
|
||||
sentimentScore: result.sentimentScore,
|
||||
fundamentalScore: result.fundamentalScore,
|
||||
passedEarningsLockout: result.passedEarningsLockout,
|
||||
daysToNextEarnings: result.daysToNextEarnings,
|
||||
passedDividendGate: result.passedDividendGate,
|
||||
daysToNextExDividend: result.daysToNextExDividend,
|
||||
reasoningLabel: result.aiApproved ? 'KI-These' : 'Ablehnungsgrund',
|
||||
reasoningText: result.aiThesisSummary,
|
||||
identifiedRisks: result.aiIdentifiedRisks,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
|
||||
listener: (context, state) {
|
||||
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
|
||||
final List<TradeModel> tradesList = state.data;
|
||||
if (tradesList.isNotEmpty) {
|
||||
_justTriggeredAnalysis = false;
|
||||
final latestTrade = tradesList.first;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showEditTradeExecutionDialog(context, latestTrade);
|
||||
});
|
||||
if (state is! AssetTradesLoaded) return;
|
||||
|
||||
final result = state.manualAnalysisResult;
|
||||
if (result == null) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (result.hasProposal) {
|
||||
_showProposalDecision(context, result.proposal!);
|
||||
} else {
|
||||
// Rejected (or no technical setup at all) - show the real, already
|
||||
// computed scores and AI reasoning instead of a bare "no proposal"
|
||||
// snackbar, so the user understands *why*, not just *that*
|
||||
// (Rules.md §4).
|
||||
_showEvaluationRejectedSheet(context, result);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
builder: (context, state) {
|
||||
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
|
||||
@@ -117,11 +223,10 @@ class _TradesTabState extends State<TradesTab> {
|
||||
symbol: widget.symbol,
|
||||
initialRiskScore: 50.0,
|
||||
onTrigger: (payload) {
|
||||
setState(() => _justTriggeredAnalysis = true);
|
||||
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('KI-Analyse für ${widget.symbol} abgeschlossen. Trade-Cockpit öffnet sich...'),
|
||||
content: Text('KI-Analyse für ${widget.symbol} wird ausgeführt...'),
|
||||
backgroundColor: AppTheme.accentCyan,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@@ -153,18 +258,12 @@ class _TradesTabState extends State<TradesTab> {
|
||||
else if (state is AssetTradesLoaded) ...[
|
||||
_buildTradeList(
|
||||
'Aktive Trade-Signale & Positionen',
|
||||
tradesList.where((t) {
|
||||
final s = t.status.toUpperCase();
|
||||
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
|
||||
}).toList(),
|
||||
tradesList.where((t) => t.isActive || t.isProposed).toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTradeList(
|
||||
'Historische Trades & KI-Bewertungen',
|
||||
tradesList.where((t) {
|
||||
final s = t.status.toUpperCase();
|
||||
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
|
||||
}).toList(),
|
||||
tradesList.where((t) => t.isClosed || t.isRejected).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -208,8 +307,7 @@ class _TradesTabState extends State<TradesTab> {
|
||||
itemCount: trades.length,
|
||||
itemBuilder: (context, index) {
|
||||
final trade = trades[index];
|
||||
final s = trade.status.toUpperCase();
|
||||
final isActive = s == 'ACTIVE';
|
||||
final isActive = trade.isActive;
|
||||
|
||||
return AssetTradeItemCard(
|
||||
trade: trade,
|
||||
@@ -223,7 +321,7 @@ class _TradesTabState extends State<TradesTab> {
|
||||
trade: trade,
|
||||
defaultSymbol: widget.symbol,
|
||||
onClose: (dto) {
|
||||
final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
|
||||
final isinVal = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : widget.symbol;
|
||||
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -4,6 +4,15 @@ import '../../../../core/widgets/glass_container.dart';
|
||||
import '../../../../core/widgets/status_badge.dart';
|
||||
import '../../../trades/models/trade_model.dart';
|
||||
|
||||
/// Trade summary card for the asset-detail "Trades" tab.
|
||||
///
|
||||
/// Migrated onto `ActiveTradeDto` (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`).
|
||||
/// A number of fields this card used to show no longer exist server-side at
|
||||
/// all (reasoning/technicalRationale/fundamentalRationale/riskWarning,
|
||||
/// hasPendingExitAlert/pendingExitReason, entryZoneMin/Max, maxLeverage,
|
||||
/// timeframe/riskTolerance/companyName, closeReason) — those sections were
|
||||
/// removed rather than kept alive showing an empty/zero placeholder
|
||||
/// (Rules.md §4).
|
||||
class AssetTradeItemCard extends StatelessWidget {
|
||||
final TradeModel trade;
|
||||
final String defaultSymbol;
|
||||
@@ -20,43 +29,42 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
String _fmt(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
return n != null ? n.toStringAsFixed(2) : val.toString();
|
||||
}
|
||||
String _fmt(double val) => val.toStringAsFixed(2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol;
|
||||
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
|
||||
final status = trade.status.toUpperCase();
|
||||
final isBuy = side == 'BUY' || side == 'LONG';
|
||||
final isActive = status == 'ACTIVE';
|
||||
final isin = trade.underlyingIsin.isNotEmpty ? trade.underlyingIsin : defaultSymbol;
|
||||
final isBuy = trade.direction.isLong;
|
||||
final isActive = trade.isActive;
|
||||
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
final entryZoneMin = trade.entryZoneMin;
|
||||
final entryZoneMax = trade.entryZoneMax;
|
||||
final entryPrice = trade.entryPrice;
|
||||
final stopLoss = trade.stopLoss;
|
||||
final takeProfit = trade.takeProfit;
|
||||
final takeProfitTargets = trade.takeProfitTargets;
|
||||
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0)
|
||||
? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2)
|
||||
: null;
|
||||
final maxLeverage = trade.maxLeverage;
|
||||
// Entry price: the real fill-weighted average the engine already
|
||||
// computed, not a planned/target zone (that concept no longer exists
|
||||
// server-side).
|
||||
final entryPrice = trade.averageBuyIn;
|
||||
|
||||
final actualEntry = trade.actualEntryPrice;
|
||||
final posSize = trade.positionSize;
|
||||
final levUsed = trade.leverageUsed;
|
||||
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
|
||||
final entryFee = trade.entryFee;
|
||||
final exitFee = trade.exitFee;
|
||||
// Live protective stop: `currentStopLoss` (not `initialStopLoss`) is
|
||||
// used here because this card shows the trade's live state — the
|
||||
// current stop already reflects any break-even/trailing adjustment the
|
||||
// engine has made. `initialStopLoss` (the original plan value) is only
|
||||
// relevant historically and is shown in the trade detail view instead.
|
||||
final stopLoss = trade.currentStopLoss;
|
||||
|
||||
final reasoning = trade.reasoning;
|
||||
final techRationale = trade.technicalRationale;
|
||||
final fundRationale = trade.fundamentalRationale;
|
||||
final riskWarning = trade.riskWarning;
|
||||
final tpStages = trade.exitPlan.takeProfitStages;
|
||||
// Server-computed reward:risk multiple for the first take-profit stage —
|
||||
// used instead of a client-side recomputation from raw prices.
|
||||
final primaryRMultiple = tpStages.isNotEmpty ? tpStages.first.rMultiple : null;
|
||||
|
||||
final investedCapital = entryPrice > 0 && trade.totalQuantity > 0 ? entryPrice * trade.totalQuantity : null;
|
||||
|
||||
// Never recomputed from raw prices client-side — always the server's
|
||||
// own figure (realized once resolved, otherwise its live unrealized
|
||||
// value; see `TradeModel.pnlEur`).
|
||||
final pnlEur = trade.pnlEur;
|
||||
final pnlPercent = trade.unrealizedPnlPercent;
|
||||
final isPnlWin = pnlEur >= 0;
|
||||
final pnlColor = isPnlWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
final showPnl = isActive || trade.isClosed;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
@@ -71,25 +79,26 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
StatusBadge(label: side, color: sideColor),
|
||||
StatusBadge(label: trade.direction.label, color: sideColor),
|
||||
const SizedBox(width: 8),
|
||||
StatusBadge(
|
||||
label: status,
|
||||
color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted),
|
||||
label: trade.status.label,
|
||||
color: isActive ? AppTheme.primaryEmerald : (trade.isProposed ? AppTheme.accentCyan : AppTheme.textMuted),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (trade.instrumentType.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
trade.derivativeIsin.isNotEmpty ? '${trade.instrumentType} (${trade.derivativeIsin})' : trade.instrumentType,
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty
|
||||
? '${trade.instrumentType.label} (${trade.derivativeIsin})'
|
||||
: trade.instrumentType.label,
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
@@ -120,7 +129,7 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
|
||||
] else if (trade.isProposed) ...[
|
||||
if (onAccept != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAccept,
|
||||
@@ -146,55 +155,14 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
_buildDriftRadarBar(trade),
|
||||
],
|
||||
|
||||
// Pending Exit Alert Banner
|
||||
if (isActive && trade.hasPendingExitAlert) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('KI-Guardian Ratschlag: Position schließen!', style: TextStyle(color: AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
if (trade.pendingExitReason.isNotEmpty)
|
||||
Text(trade.pendingExitReason, style: const TextStyle(color: Colors.white70, fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onClose != null)
|
||||
ElevatedButton(
|
||||
onPressed: onClose,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentRed,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('Schließen', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}',
|
||||
'${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol} ($isin)',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Target Price Metrics Grid
|
||||
// Price Metrics Grid
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -207,22 +175,26 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '€${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '€${_fmt(entryPrice)}', Colors.white),
|
||||
_buildTradeStat('Einstiegskurs', '€${_fmt(entryPrice)}', Colors.white),
|
||||
_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',
|
||||
tpStages.isNotEmpty ? tpStages.map((s) => '€${_fmt(s.targetPrice)}').join(' / ') : 'Kein Fixziel (Trailing-Exit)',
|
||||
AppTheme.primaryEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (crv != null || maxLeverage > 0) ...[
|
||||
const Divider(color: Colors.white12, height: 16),
|
||||
if (primaryRMultiple != null) ...[
|
||||
const Divider(color: Colors.white10, height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
|
||||
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
|
||||
_buildTradeStat('Chance-Risiko (TP1, R-Multiple)', '${_fmt(primaryRMultiple)}R', AppTheme.accentCyan),
|
||||
if (investedCapital != null) _buildTradeStat('Eingesetztes Kapital', '€${_fmt(investedCapital)}', Colors.white70),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -230,8 +202,8 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Execution Details if active
|
||||
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
|
||||
// Position size / quantity
|
||||
if (trade.totalQuantity > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -240,84 +212,62 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Stückzahl: ${_fmt(trade.totalQuantity)}${trade.isDerivative ? ' (Derivat)' : ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// PnL (server-computed, never recalculated client-side)
|
||||
if (showPnl) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pnlColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: pnlColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
|
||||
Icon(isPnlWin ? Icons.trending_up : Icons.trending_down, size: 16, color: pnlColor),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
|
||||
Text(
|
||||
trade.isClosed ? 'Realisierter PnL:' : 'Aktueller PnL (unrealisiert):',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Tatsächl. Einstieg', '€${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
|
||||
_buildTradeStat('Investition', posSize > 0 ? '€${_fmt(posSize)}' : 'N/A', Colors.white),
|
||||
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
|
||||
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
|
||||
_buildTradeStat('Aktueller Kurs', '€${_fmt(trade.currentPrice)}', Colors.white),
|
||||
_buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor),
|
||||
_buildTradeStat('PnL (%)', '${pnlPercent >= 0 ? "+" : ""}${_fmt(pnlPercent)}%', pnlColor),
|
||||
],
|
||||
),
|
||||
if (entryFee > 0 || exitFee > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Realized PnL if closed
|
||||
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final pnlVal = trade.calculatedPnlAbs;
|
||||
final pnlPctVal = trade.calculatedPnlPct;
|
||||
final isWin = pnlVal >= 0;
|
||||
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '€${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white),
|
||||
_buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color),
|
||||
_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) ...[
|
||||
// Execution history (replaces the removed AI-Guardian hourly
|
||||
// check-in timeline, which no backend DTO produces anymore —
|
||||
// this is the trade's real fill history instead).
|
||||
if (trade.fills.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
@@ -325,10 +275,10 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
dense: true,
|
||||
leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18),
|
||||
title: Text(
|
||||
'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)',
|
||||
'Ausführungshistorie (${trade.fills.length} Fills)',
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
children: trade.hourlyUpdates.reversed.take(4).map((u) {
|
||||
children: trade.fills.reversed.map((f) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -339,62 +289,27 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.minute.toString().padLeft(2, '0')}',
|
||||
'${f.executedAtUtc.day.toString().padLeft(2, '0')}.${f.executedAtUtc.month.toString().padLeft(2, '0')} '
|
||||
'${f.executedAtUtc.hour.toString().padLeft(2, '0')}:${f.executedAtUtc.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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),
|
||||
'${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}${f.fee > 0 ? ' (Gebühr €${_fmt(f.fee)})' : ''}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (f.note != null && f.note!.isNotEmpty)
|
||||
Text(f.note!, style: TextStyle(color: AppTheme.textMuted, fontSize: 10, fontStyle: FontStyle.italic)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
// AI Analysis Expansion
|
||||
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
children: [
|
||||
if (reasoning.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (techRationale.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (fundRationale.isNotEmpty) ...[
|
||||
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (riskWarning.isNotEmpty) _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -407,11 +322,6 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
IconData icon;
|
||||
|
||||
switch (t.driftStatus) {
|
||||
case DriftStatus.exitAlert:
|
||||
col = AppTheme.accentRed;
|
||||
label = 'Drift-Radar: Ausstieg empfohlen';
|
||||
icon = Icons.warning_rounded;
|
||||
break;
|
||||
case DriftStatus.trailingActive:
|
||||
col = AppTheme.accentCyan;
|
||||
label = 'Drift-Radar: Trailing-Stop aktiv nachgezogen';
|
||||
@@ -424,7 +334,7 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
break;
|
||||
case DriftStatus.onTrack:
|
||||
col = AppTheme.primaryEmerald;
|
||||
label = 'Drift-Radar: Prognose intakt • KI überwacht stündlich';
|
||||
label = 'Drift-Radar: Prognose intakt';
|
||||
icon = Icons.radar;
|
||||
break;
|
||||
}
|
||||
@@ -458,16 +368,4 @@ class AssetTradeItemCard extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRationaleBlock(String title, String text, Color col) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
|
||||
const SizedBox(height: 2),
|
||||
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class CloseTradeDialog {
|
||||
required String defaultSymbol,
|
||||
required void Function(CloseTradeRequestDto) onClose,
|
||||
}) {
|
||||
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
|
||||
final entry = trade.averageBuyIn;
|
||||
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
|
||||
|
||||
showDialog(
|
||||
@@ -37,14 +37,20 @@ class CloseTradeDialog {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||
Text(
|
||||
(trade.derivativeIsin?.isNotEmpty ?? false)
|
||||
? 'Trade-ID: ${trade.id} | Derivat: ${trade.derivativeIsin} (${trade.instrumentType.label}) | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}'
|
||||
: 'Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: exitController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tatsächlicher Ausstiegskurs (€)',
|
||||
hintText: 'Z.B. 105.50',
|
||||
decoration: InputDecoration(
|
||||
labelText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Derivat-Verkaufskurs (€)' : 'Tatsächlicher Ausstiegskurs (€)',
|
||||
hintText: 'Gekauft zu €${entry.toStringAsFixed(2)}',
|
||||
helperText: (trade.derivativeIsin?.isNotEmpty ?? false) ? 'Gib den Verkaufskurs des Derivats/Zertifikats ein' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user