feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core

This commit is contained in:
2026-08-24 21:37:43 +02:00
parent 676496b77d
commit 0894c40f07
113 changed files with 12413 additions and 3613 deletions
@@ -0,0 +1,188 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/signalr_service.dart';
import '../models/bot_models.dart';
import '../repositories/bot_repository.dart';
import 'bot_event.dart';
import 'bot_state.dart';
class BotBloc extends Bloc<BotEvent, BotState> {
final BotRepository repository;
final SignalRService? signalRService;
StreamSubscription? _botPositionSub;
StreamSubscription? _portfolioSummarySub;
BotBloc({
required this.repository,
this.signalRService,
}) : super(const BotInitial()) {
on<FetchBotDashboard>(_onFetchBotDashboard);
on<OnBotPositionStreamReceived>(_onBotPositionStreamReceived);
on<OnPortfolioSummaryStreamReceived>(_onPortfolioSummaryStreamReceived);
on<TriggerBotPanicClose>(_onTriggerBotPanicClose);
on<ExecuteManualBotProposal>(_onExecuteManualBotProposal);
on<UpdateBotConfigSettings>(_onUpdateBotConfigSettings);
_initSignalRListeners();
}
void _initSignalRListeners() {
if (signalRService != null) {
_botPositionSub = signalRService!.botPositionStream.listen((data) {
try {
final position = BotTradeOrderModel.fromJson(data);
add(OnBotPositionStreamReceived(position));
} catch (_) {}
});
_portfolioSummarySub = signalRService!.portfolioSummaryStream.listen((data) {
try {
final summary = AccountSummaryModel.fromJson(data);
add(OnPortfolioSummaryStreamReceived(summary));
} catch (_) {}
});
}
}
Future<void> _onFetchBotDashboard(FetchBotDashboard event, Emitter<BotState> emit) async {
emit(const BotLoading());
try {
final results = await Future.wait([
repository.fetchStatus(),
repository.fetchSummary(),
repository.fetchActivePositions(),
]);
final status = results[0] as BotStatusModel;
final summary = results[1] as AccountSummaryModel;
final positions = results[2] as List<BotTradeOrderModel>;
emit(BotLoaded(
status: status,
summary: summary,
positions: positions,
));
} catch (e) {
emit(BotError(e.toString()));
}
}
void _onBotPositionStreamReceived(OnBotPositionStreamReceived event, Emitter<BotState> emit) {
if (state is BotLoaded) {
final current = state as BotLoaded;
final updatedList = List<BotTradeOrderModel>.from(current.positions);
final index = updatedList.indexWhere((p) => p.orderId == event.position.orderId);
if (index != -1) {
updatedList[index] = event.position;
} else {
updatedList.insert(0, event.position);
}
emit(current.copyWith(positions: updatedList));
}
}
void _onPortfolioSummaryStreamReceived(OnPortfolioSummaryStreamReceived event, Emitter<BotState> emit) {
if (state is BotLoaded) {
final current = state as BotLoaded;
emit(current.copyWith(summary: event.summary));
}
}
Future<void> _onTriggerBotPanicClose(TriggerBotPanicClose event, Emitter<BotState> emit) async {
if (state is BotLoaded) {
final current = state as BotLoaded;
emit(current.copyWith(isPanicClosing: true));
try {
final result = await repository.panicCloseAll();
final updatedPositions = await repository.fetchActivePositions();
final updatedSummary = await repository.fetchSummary();
// A partial result (some Alpaca positions could not be confirmed as closed by the broker) must
// never be presented as a full success (Rules.md §4) - surface the skipped count explicitly.
final message = result.skippedCount > 0
? '${result.closedCount} Position(en) geschlossen, aber ${result.skippedCount} konnte(n) NICHT bestätigt geschlossen werden (Broker nicht erreichbar/konfiguriert). Bitte manuell prüfen!'
: '${result.closedCount} Position(en) erfolgreich geschlossen.';
emit(current.copyWith(
isPanicClosing: false,
positions: updatedPositions,
summary: updatedSummary,
actionMessage: message,
actionIsWarning: result.skippedCount > 0,
));
} catch (e) {
emit(current.copyWith(
isPanicClosing: false,
actionMessage: 'Fehler beim Notverkauf: $e',
actionIsWarning: true,
));
}
}
}
Future<void> _onExecuteManualBotProposal(ExecuteManualBotProposal event, Emitter<BotState> emit) async {
if (state is BotLoaded) {
final current = state as BotLoaded;
try {
final order = await repository.executeProposal(
event.proposalId,
venue: event.venue,
quantity: event.quantity,
);
final updatedList = List<BotTradeOrderModel>.from(current.positions);
final index = updatedList.indexWhere((p) => p.orderId == order.orderId);
if (index != -1) {
updatedList[index] = order;
} else {
updatedList.insert(0, order);
}
emit(current.copyWith(
positions: updatedList,
actionMessage: 'Trade ${order.symbol} erfolgreich ausgeführt (${order.venue}).',
));
} catch (e) {
emit(current.copyWith(
actionMessage: 'Ausführungsfehler: $e',
actionIsWarning: true,
));
}
}
}
Future<void> _onUpdateBotConfigSettings(UpdateBotConfigSettings event, Emitter<BotState> emit) async {
if (state is BotLoaded) {
final current = state as BotLoaded;
try {
final updatedStatus = await repository.updateSettings(
autoExecutionEnabled: event.autoExecutionEnabled,
maxPositions: event.maxPositions,
riskPerTradePercent: event.riskPerTradePercent,
minCompositeScore: event.minCompositeScore,
);
emit(current.copyWith(
status: updatedStatus,
actionMessage: 'Bot-Konfiguration aktualisiert.',
));
} catch (e) {
emit(current.copyWith(
actionMessage: 'Fehler beim Speichern der Einstellungen: $e',
actionIsWarning: true,
));
}
}
}
@override
Future<void> close() {
_botPositionSub?.cancel();
_portfolioSummarySub?.cancel();
return super.close();
}
}
@@ -0,0 +1,67 @@
import 'package:equatable/equatable.dart';
import '../models/bot_models.dart';
abstract class BotEvent extends Equatable {
const BotEvent();
@override
List<Object?> get props => [];
}
class FetchBotDashboard extends BotEvent {
const FetchBotDashboard();
}
class OnBotPositionStreamReceived extends BotEvent {
final BotTradeOrderModel position;
const OnBotPositionStreamReceived(this.position);
@override
List<Object?> get props => [position];
}
class OnPortfolioSummaryStreamReceived extends BotEvent {
final AccountSummaryModel summary;
const OnPortfolioSummaryStreamReceived(this.summary);
@override
List<Object?> get props => [summary];
}
class TriggerBotPanicClose extends BotEvent {
const TriggerBotPanicClose();
}
class ExecuteManualBotProposal extends BotEvent {
final String proposalId;
final String? venue;
final double? quantity;
const ExecuteManualBotProposal({
required this.proposalId,
this.venue,
this.quantity,
});
@override
List<Object?> get props => [proposalId, venue, quantity];
}
class UpdateBotConfigSettings extends BotEvent {
final bool? autoExecutionEnabled;
final int? maxPositions;
final double? riskPerTradePercent;
final int? minCompositeScore;
const UpdateBotConfigSettings({
this.autoExecutionEnabled,
this.maxPositions,
this.riskPerTradePercent,
this.minCompositeScore,
});
@override
List<Object?> get props => [autoExecutionEnabled, maxPositions, riskPerTradePercent, minCompositeScore];
}
@@ -0,0 +1,73 @@
import 'package:equatable/equatable.dart';
import '../models/bot_models.dart';
abstract class BotState extends Equatable {
const BotState();
@override
List<Object?> get props => [];
}
class BotInitial extends BotState {
const BotInitial();
}
class BotLoading extends BotState {
const BotLoading();
}
class BotLoaded extends BotState {
final BotStatusModel status;
final AccountSummaryModel summary;
final List<BotTradeOrderModel> positions;
final bool isPanicClosing;
final String? actionMessage;
/// True when [actionMessage] describes a failure or a partial success (e.g. a panic-close that could not
/// confirm every position was closed) rather than a full, unqualified success - the UI must not present
/// this the same way as a genuine success (Rules.md §4).
final bool actionIsWarning;
const BotLoaded({
required this.status,
required this.summary,
required this.positions,
this.isPanicClosing = false,
this.actionMessage,
this.actionIsWarning = false,
});
int get activePositionsCount => positions.where((p) => p.isActive).length;
double get totalUnrealizedPnL => positions.where((p) => p.isActive).fold(0.0, (sum, p) => sum + p.unrealizedPnlEur);
double get totalRealizedPnL => positions.fold(0.0, (sum, p) => sum + p.realizedPnlEur);
BotLoaded copyWith({
BotStatusModel? status,
AccountSummaryModel? summary,
List<BotTradeOrderModel>? positions,
bool? isPanicClosing,
String? actionMessage,
bool actionIsWarning = false,
}) {
return BotLoaded(
status: status ?? this.status,
summary: summary ?? this.summary,
positions: positions ?? this.positions,
isPanicClosing: isPanicClosing ?? this.isPanicClosing,
actionMessage: actionMessage,
actionIsWarning: actionIsWarning,
);
}
@override
List<Object?> get props => [status, summary, positions, isPanicClosing, actionMessage, actionIsWarning];
}
class BotError extends BotState {
final String message;
const BotError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,220 @@
import 'package:equatable/equatable.dart';
enum BotExecutionVenue {
alpacaPaperTrading,
syntheticPaperBroker,
}
enum BotPositionStatus {
pending,
active,
breakEvenTriggered,
tp1Hit,
tp2Hit,
closed,
stoppedOut,
knockedOut,
canceled,
}
class BotTradeOrderModel extends Equatable {
final String orderId;
final String proposalId;
final String isin;
final String symbol;
final String venue;
final String direction;
final double requestedQuantity;
final double filledQuantity;
final double entryPrice;
final double averageBuyIn;
final double initialStopLoss;
final double currentStopLoss;
final double takeProfit1;
final double takeProfit2;
final double currentPrice;
final double unrealizedPnlEur;
final double realizedPnlEur;
final String status;
final DateTime createdAt;
final DateTime? filledAt;
final DateTime? closedAt;
const BotTradeOrderModel({
required this.orderId,
required this.proposalId,
required this.isin,
required this.symbol,
required this.venue,
required this.direction,
required this.requestedQuantity,
required this.filledQuantity,
required this.entryPrice,
required this.averageBuyIn,
required this.initialStopLoss,
required this.currentStopLoss,
required this.takeProfit1,
required this.takeProfit2,
required this.currentPrice,
required this.unrealizedPnlEur,
required this.realizedPnlEur,
required this.status,
required this.createdAt,
this.filledAt,
this.closedAt,
});
bool get isLong => direction.toUpperCase() == 'BUY' || direction.toUpperCase() == 'LONG';
bool get isActive => status.toLowerCase() == 'active' || status.toLowerCase() == 'breakeventriggered' || status.toLowerCase() == 'tp1hit';
bool get isBreakEven => status.toLowerCase() == 'breakeventriggered';
bool get isTp1Hit => status.toLowerCase() == 'tp1hit';
bool get isClosed => status.toLowerCase() == 'closed' || status.toLowerCase() == 'stoppedout' || status.toLowerCase() == 'knockedout';
double get pnlPercent {
if (averageBuyIn <= 0) return 0.0;
return isLong
? ((currentPrice - averageBuyIn) / averageBuyIn) * 100.0
: ((averageBuyIn - currentPrice) / averageBuyIn) * 100.0;
}
double get rMultiple {
final risk = (entryPrice - initialStopLoss).abs();
if (risk <= 0) return 0.0;
final reward = isLong ? (currentPrice - entryPrice) : (entryPrice - currentPrice);
return reward / risk;
}
factory BotTradeOrderModel.fromJson(Map<String, dynamic> json) {
return BotTradeOrderModel(
orderId: json['orderId']?.toString() ?? '',
proposalId: json['proposalId']?.toString() ?? '',
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
venue: json['venue']?.toString() ?? 'SyntheticPaperBroker',
direction: json['direction']?.toString() ?? 'BUY',
requestedQuantity: (json['requestedQuantity'] as num?)?.toDouble() ?? 0.0,
filledQuantity: (json['filledQuantity'] as num?)?.toDouble() ?? 0.0,
entryPrice: (json['entryPrice'] as num?)?.toDouble() ?? 0.0,
averageBuyIn: (json['averageBuyIn'] as num?)?.toDouble() ?? 0.0,
initialStopLoss: (json['initialStopLoss'] as num?)?.toDouble() ?? 0.0,
currentStopLoss: (json['currentStopLoss'] as num?)?.toDouble() ?? 0.0,
takeProfit1: (json['takeProfit1'] as num?)?.toDouble() ?? 0.0,
takeProfit2: (json['takeProfit2'] as num?)?.toDouble() ?? 0.0,
currentPrice: (json['currentPrice'] as num?)?.toDouble() ?? 0.0,
unrealizedPnlEur: (json['unrealizedPnlEur'] as num?)?.toDouble() ?? 0.0,
realizedPnlEur: (json['realizedPnlEur'] as num?)?.toDouble() ?? 0.0,
status: json['status']?.toString() ?? 'Active',
createdAt: json['createdAtUtc'] != null ? DateTime.tryParse(json['createdAtUtc'].toString()) ?? DateTime.now() : DateTime.now(),
filledAt: json['filledAtUtc'] != null ? DateTime.tryParse(json['filledAtUtc'].toString()) : null,
closedAt: json['closedAtUtc'] != null ? DateTime.tryParse(json['closedAtUtc'].toString()) : null,
);
}
@override
List<Object?> get props => [
orderId, proposalId, isin, symbol, venue, direction,
requestedQuantity, filledQuantity, entryPrice, averageBuyIn,
currentPrice, unrealizedPnlEur, realizedPnlEur, status, currentStopLoss
];
}
class AccountSummaryModel extends Equatable {
/// Nullable: a `null` value means the server did not report this field
/// (e.g. broker/account service unavailable). The UI MUST show an explicit
/// "not available" state in that case rather than a fabricated number
/// (Rules.md §4).
final double? equity;
final double? cash;
final double? buyingPower;
final String currency;
final String status;
const AccountSummaryModel({
required this.equity,
required this.cash,
required this.buyingPower,
required this.currency,
required this.status,
});
bool get hasAccountData => equity != null && buyingPower != null;
factory AccountSummaryModel.fromJson(Map<String, dynamic> json) {
return AccountSummaryModel(
equity: (json['equity'] as num?)?.toDouble(),
cash: (json['cash'] as num?)?.toDouble(),
buyingPower: (json['buyingPower'] as num?)?.toDouble(),
currency: json['currency']?.toString() ?? 'EUR',
status: json['status']?.toString() ?? 'Active',
);
}
@override
List<Object?> get props => [equity, cash, buyingPower, currency, status];
}
/// Result of an emergency "panic close" (`POST /api/v1/bot/orders/panic-close`). [skippedCount] is non-zero
/// whenever an Alpaca position could not be confirmed as liquidated by the broker (not configured, or the
/// broker call failed) - the UI MUST surface that count rather than only celebrating [closedCount] as if the
/// whole operation fully succeeded (Rules.md §4: no fabricated full success on a partial result).
class PanicCloseResultModel extends Equatable {
final int closedCount;
final int skippedCount;
final List<BotTradeOrderModel> closedOrders;
const PanicCloseResultModel({
required this.closedCount,
required this.skippedCount,
required this.closedOrders,
});
factory PanicCloseResultModel.fromJson(Map<String, dynamic> json) {
final List<dynamic> orders = json['closedOrders'] as List<dynamic>? ?? const [];
return PanicCloseResultModel(
closedCount: (json['closedCount'] as num?)?.toInt() ?? 0,
skippedCount: (json['skippedCount'] as num?)?.toInt() ?? 0,
closedOrders: orders.map((o) => BotTradeOrderModel.fromJson(o as Map<String, dynamic>)).toList(),
);
}
@override
List<Object?> get props => [closedCount, skippedCount, closedOrders];
}
class BotStatusModel extends Equatable {
final bool isRunning;
final bool autoExecutionEnabled;
final int activePositionsCount;
final int maxPositions;
final double riskPerTradePercent;
final int minCompositeScore;
final String venuesActive;
const BotStatusModel({
required this.isRunning,
required this.autoExecutionEnabled,
required this.activePositionsCount,
required this.maxPositions,
required this.riskPerTradePercent,
required this.minCompositeScore,
required this.venuesActive,
});
factory BotStatusModel.fromJson(Map<String, dynamic> json) {
return BotStatusModel(
isRunning: json['isRunning'] == true,
autoExecutionEnabled: json['autoExecutionEnabled'] == true,
activePositionsCount: (json['activePositionsCount'] as num?)?.toInt() ?? 0,
maxPositions: (json['maxPositions'] as num?)?.toInt() ?? 5,
riskPerTradePercent: (json['riskPerTradePercent'] as num?)?.toDouble() ?? 1.0,
minCompositeScore: (json['minCompositeScore'] as num?)?.toInt() ?? 75,
venuesActive: json['venuesActive']?.toString() ?? 'AlpacaPaper/Synthetic',
);
}
@override
List<Object?> get props => [
isRunning, autoExecutionEnabled, activePositionsCount,
maxPositions, riskPerTradePercent, minCompositeScore, venuesActive
];
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:finlytic_app/core/network/api_client.dart';
import '../models/bot_models.dart';
class BotRepository {
final ApiClient apiClient;
const BotRepository({required this.apiClient});
Future<BotStatusModel> fetchStatus() async {
final response = await apiClient.get('/api/v1/bot/status');
if (response.statusCode == 200 && response.data != null) {
return BotStatusModel.fromJson(response.data);
}
throw Exception('Failed to fetch bot status');
}
Future<AccountSummaryModel> fetchSummary() async {
final response = await apiClient.get('/api/v1/bot/portfolio/summary');
if (response.statusCode == 200 && response.data != null) {
return AccountSummaryModel.fromJson(response.data);
}
throw Exception('Failed to fetch account summary');
}
Future<List<BotTradeOrderModel>> fetchActivePositions() async {
final response = await apiClient.get('/api/v1/bot/positions/active');
if (response.statusCode == 200 && response.data != null) {
final List<dynamic> list = response.data;
return list.map((json) => BotTradeOrderModel.fromJson(json)).toList();
}
return [];
}
Future<BotTradeOrderModel> executeProposal(String proposalId, {String? venue, double? quantity}) async {
final response = await apiClient.post('/api/v1/bot/orders/execute', data: {
'proposalId': proposalId,
if (venue != null) 'preferredVenue': venue,
if (quantity != null) 'customQuantity': quantity,
});
if (response.statusCode == 200 && response.data != null) {
return BotTradeOrderModel.fromJson(response.data);
}
throw Exception('Failed to execute bot proposal');
}
Future<PanicCloseResultModel> panicCloseAll() async {
final response = await apiClient.post('/api/v1/bot/orders/panic-close');
if (response.statusCode == 200 && response.data != null) {
return PanicCloseResultModel.fromJson(response.data);
}
// A non-200 (e.g. 503 when FinlyticBot is unreachable) must NOT be swallowed into a fake "0
// closed / 0 skipped" result for an emergency action - the caller needs to know the attempt did
// not even go through (Rules.md §4).
throw Exception('Failed to trigger panic close');
}
/// Updates FinlyticBot's dynamic settings. The backend now responds with the raw list of persisted
/// `DynamicSettingDto` entries (see FinlyticBackend BotController.UpdateBotSettings), not a BotStatusModel,
/// so the canonical status is re-fetched afterward instead of being reconstructed from that list.
Future<BotStatusModel> updateSettings({
bool? autoExecutionEnabled,
int? maxPositions,
double? riskPerTradePercent,
int? minCompositeScore,
}) async {
final response = await apiClient.post('/api/v1/bot/settings/update', data: {
if (autoExecutionEnabled != null) 'autoExecutionEnabled': autoExecutionEnabled,
if (maxPositions != null) 'maxPositions': maxPositions,
if (riskPerTradePercent != null) 'riskPerTradePercent': riskPerTradePercent,
if (minCompositeScore != null) 'minCompositeScore': minCompositeScore,
});
if (response.statusCode != 200) {
throw Exception('Failed to update bot settings');
}
return fetchStatus();
}
}
@@ -0,0 +1,305 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/bot_bloc.dart';
import '../bloc/bot_event.dart';
import '../bloc/bot_state.dart';
import '../repositories/bot_repository.dart';
import '../widgets/bot_kpi_header.dart';
import '../widgets/bot_position_card.dart';
import '../widgets/bot_settings_sheet.dart';
class BotControlPanelScreen extends StatelessWidget {
final ApiClient apiClient;
final SignalRService signalRService;
const BotControlPanelScreen({
super.key,
required this.apiClient,
required this.signalRService,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => BotBloc(
repository: BotRepository(apiClient: apiClient),
signalRService: signalRService,
)..add(const FetchBotDashboard()),
child: const _BotControlPanelContent(),
);
}
}
class _BotControlPanelContent extends StatelessWidget {
const _BotControlPanelContent();
void _openSettings(BuildContext context, BotLoaded state) {
final botBloc = context.read<BotBloc>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => BotSettingsSheet(
currentStatus: state.status,
onSave: (autoExec, maxPos, risk, minScore) {
botBloc.add(UpdateBotConfigSettings(
autoExecutionEnabled: autoExec,
maxPositions: maxPos,
riskPerTradePercent: risk,
minCompositeScore: minScore,
));
},
onPanicClose: () {
botBloc.add(const TriggerBotPanicClose());
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.darkBackground,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: const Text('FinlyticBot Control Panel', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
actions: [
BlocBuilder<BotBloc, BotState>(
builder: (context, state) {
if (state is BotLoaded) {
return IconButton(
onPressed: () => _openSettings(context, state),
icon: const Icon(Icons.settings, color: Colors.white70),
tooltip: 'Bot Einstellungen & Kill-Switch',
);
}
return const SizedBox.shrink();
},
),
IconButton(
onPressed: () => context.read<BotBloc>().add(const FetchBotDashboard()),
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Neu laden',
),
],
),
body: BlocConsumer<BotBloc, BotState>(
listener: (context, state) {
if (state is BotLoaded && state.actionMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.actionMessage!),
// A failed or partially-successful action (e.g. a panic-close that could not confirm every
// position was closed) must never be shown in the same "all good" green as a full success
// (Rules.md §4).
backgroundColor: state.actionIsWarning ? AppTheme.accentRed : AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
if (state is BotLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is BotError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: AppTheme.accentRed),
const SizedBox(height: 12),
Text(state.message, style: TextStyle(color: AppTheme.textMuted)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<BotBloc>().add(const FetchBotDashboard()),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
child: const Text('Erneut Versuchen'),
),
],
),
);
}
if (state is BotLoaded) {
final activePositions = state.positions.where((p) => p.isActive).toList();
final closedPositions = state.positions.where((p) => p.isClosed).toList();
return RefreshIndicator(
onRefresh: () async {
context.read<BotBloc>().add(const FetchBotDashboard());
},
color: AppTheme.primaryEmerald,
backgroundColor: AppTheme.cardSurface,
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
sliver: SliverList(
delegate: SliverChildListDelegate([
BotKpiHeader(
summary: state.summary,
status: state.status,
totalUnrealizedPnL: state.totalUnrealizedPnL,
totalRealizedPnL: state.totalRealizedPnL,
),
const SizedBox(height: 20),
_buildAlphaDecayMonitor(state),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Aktive Positionen (${activePositions.length}/${state.status.maxPositions})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
),
child: Text(
'Risk: ${(activePositions.length * state.status.riskPerTradePercent).toStringAsFixed(1)}%',
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
]),
),
),
if (activePositions.isEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: AppTheme.cardSurface.withValues(alpha: 0.5),
border: Border.all(color: Colors.white10),
),
child: Column(
children: [
Icon(Icons.radar, size: 48, color: AppTheme.textMuted),
const SizedBox(height: 12),
const Text(
'Keine aktiven Positionen',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'Der Bot scannt das Universum nach Setup-Konfluenzen ab Score ≥ ${state.status.minCompositeScore}.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: activePositions.length,
itemBuilder: (context, index) {
final position = activePositions[index];
return BotPositionCard(
position: position,
onClosePressed: () {
// Manual emergency close of this specific position
},
);
},
),
),
if (closedPositions.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
sliver: SliverToBoxAdapter(
child: const Text(
'Kürzlich Geschlossene Trades',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white70),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: closedPositions.length > 5 ? 5 : closedPositions.length,
itemBuilder: (context, index) {
final position = closedPositions[index];
return Opacity(
opacity: 0.7,
child: BotPositionCard(position: position),
);
},
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
return const SizedBox.shrink();
},
),
);
}
// NOTE: This used to be an "Alpha-Decay & Strategy Reliability Monitor"
// comparing hardcoded fake "Live vs Simulation" win rates / profit factors
// per strategy (Rules.md §4 violation). There is no real data source for
// that comparison: `GET /api/v1/simulation/matrix/{isin}`
// (`StrategyAssetReliabilityDto`) only provides a simulated reliability
// score per (ISIN, StrategyKey) pair — it has no "live" counterpart, and
// this screen isn't scoped to a single asset, so there's no ISIN to query
// it with in the first place. Rather than inventing numbers, or bolting on
// an asset picker that isn't part of this task, this is now an explicit
// empty state until a real live-vs-simulation data source exists.
Widget _buildAlphaDecayMonitor(BotLoaded state) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: AppTheme.cardSurface,
border: Border.all(color: Colors.white10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.analytics_outlined, size: 16, color: Colors.cyanAccent),
SizedBox(width: 6),
Text('Alpha-Decay & Strategy Reliability Monitor', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
],
),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
const SizedBox(width: 8),
Expanded(
child: Text(
'Keine Live-vs-Simulation-Daten verfügbar.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/bot_models.dart';
class BotKpiHeader extends StatelessWidget {
final AccountSummaryModel summary;
final BotStatusModel status;
final double totalUnrealizedPnL;
final double totalRealizedPnL;
const BotKpiHeader({
super.key,
required this.summary,
required this.status,
required this.totalUnrealizedPnL,
required this.totalRealizedPnL,
});
@override
Widget build(BuildContext context) {
final isPositiveUnrealized = totalUnrealizedPnL >= 0;
final isPositiveRealized = totalRealizedPnL >= 0;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed,
boxShadow: [
BoxShadow(
color: (status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed).withValues(alpha: 0.5),
blurRadius: 8,
spreadRadius: 2,
),
],
),
),
const SizedBox(width: 8),
Text(
status.isRunning ? 'AUTONOMOUS BOT ONLINE' : 'BOT PAUSED',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: status.isRunning ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.amber.withValues(alpha: 0.15),
border: Border.all(
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald : Colors.amber,
width: 1,
),
),
child: Text(
status.autoExecutionEnabled ? 'AUTO-EXECUTE ON' : 'MANUAL APPROVAL',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: status.autoExecutionEnabled ? AppTheme.primaryEmerald : Colors.amber,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: summary.hasAccountData
? _buildMetricCard(
title: 'Portfolio Equity',
value: '${summary.equity!.toStringAsFixed(2)}',
subtitle: 'Buying Power: €${summary.buyingPower!.toStringAsFixed(0)}',
valueColor: Colors.white,
)
: _buildUnavailableMetricCard('Portfolio Equity'),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
title: 'Unrealized PnL',
value: '${isPositiveUnrealized ? '+' : ''}${totalUnrealizedPnL.toStringAsFixed(2)}',
subtitle: 'Realized: ${isPositiveRealized ? '+' : ''}${totalRealizedPnL.toStringAsFixed(2)}',
valueColor: isPositiveUnrealized ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
],
),
);
}
Widget _buildUnavailableMetricCard(String title) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.white.withValues(alpha: 0.03),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.error_outline, size: 14, color: AppTheme.textMuted),
const SizedBox(width: 4),
Expanded(
child: Text(
'Portfoliodaten nicht verfügbar',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
);
}
Widget _buildMetricCard({
required String title,
required String value,
required String subtitle,
required Color valueColor,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.white.withValues(alpha: 0.03),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(value, style: TextStyle(color: valueColor, fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
],
),
);
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/asset_logo_widget.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/bot_models.dart';
class BotPositionCard extends StatelessWidget {
final BotTradeOrderModel position;
final VoidCallback? onClosePressed;
const BotPositionCard({
super.key,
required this.position,
this.onClosePressed,
});
@override
Widget build(BuildContext context) {
final isLong = position.isLong;
final isProfitable = position.unrealizedPnlEur >= 0;
final pnlPercent = position.pnlPercent;
final rMult = position.rMultiple;
return GlassContainer(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AssetLogoWidget(
symbolOrName: position.symbol.isNotEmpty ? position.symbol : position.isin,
imageUrl: '/api/v1/logo/${position.isin}',
size: 36,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
position.symbol,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isLong ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.accentRed.withValues(alpha: 0.2),
),
child: Text(
position.direction.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isLong ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
),
const SizedBox(width: 6),
_buildVenueBadge(position.venue),
],
),
const SizedBox(height: 2),
Text(
position.isin,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isProfitable ? '+' : ''}${position.unrealizedPnlEur.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isProfitable ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
Text(
'${isProfitable ? '+' : ''}${pnlPercent.toStringAsFixed(2)}% (${rMult >= 0 ? '+' : ''}${rMult.toStringAsFixed(1)}R)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isProfitable ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
],
),
const SizedBox(height: 12),
const Divider(height: 1, color: Colors.white10),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildPriceInfo('Entry / Buy-In', '${position.averageBuyIn > 0 ? position.averageBuyIn.toStringAsFixed(2) : position.entryPrice.toStringAsFixed(2)}'),
_buildPriceInfo('Current Price', '${position.currentPrice.toStringAsFixed(2)}'),
_buildPriceInfo('Stop Loss', '${position.currentStopLoss.toStringAsFixed(2)}'),
_buildPriceInfo('Take Profit 1', '${position.takeProfit1.toStringAsFixed(2)}'),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildDynamicStateBadge(position),
if (position.isActive && onClosePressed != null)
TextButton.icon(
onPressed: onClosePressed,
icon: Icon(Icons.close, size: 14, color: AppTheme.accentRed),
label: Text('Glattstellen', style: TextStyle(fontSize: 12, color: AppTheme.accentRed)),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
visualDensity: VisualDensity.compact,
),
),
],
),
],
),
);
}
Widget _buildPriceInfo(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(value, style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w600)),
],
);
}
Widget _buildVenueBadge(String venue) {
final isAlpaca = venue.toLowerCase().contains('alpaca');
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isAlpaca ? Colors.blue.withValues(alpha: 0.15) : Colors.purple.withValues(alpha: 0.15),
),
child: Text(
isAlpaca ? 'Alpaca US' : 'Synthetic KO',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: isAlpaca ? Colors.lightBlueAccent : Colors.purpleAccent,
),
),
);
}
Widget _buildDynamicStateBadge(BotTradeOrderModel position) {
String text = 'Pending TP1';
Color color = Colors.amber;
if (position.isBreakEven) {
text = 'Free-Roll Active (BE)';
color = AppTheme.primaryEmerald;
} else if (position.isTp1Hit) {
text = 'TP1 Hit (Trailing Active)';
color = Colors.cyanAccent;
} else if (position.isClosed) {
text = 'Closed';
color = Colors.grey;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: color.withValues(alpha: 0.15),
border: Border.all(color: color.withValues(alpha: 0.4), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt, size: 12, color: color),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
);
}
}
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../models/bot_models.dart';
class BotSettingsSheet extends StatefulWidget {
final BotStatusModel currentStatus;
final Function(bool autoExec, int maxPos, double risk, int minScore) onSave;
final VoidCallback onPanicClose;
const BotSettingsSheet({
super.key,
required this.currentStatus,
required this.onSave,
required this.onPanicClose,
});
@override
State<BotSettingsSheet> createState() => _BotSettingsSheetState();
}
class _BotSettingsSheetState extends State<BotSettingsSheet> {
late bool _autoExec;
late int _maxPositions;
late double _riskPerTrade;
late int _minScore;
@override
void initState() {
super.initState();
_autoExec = widget.currentStatus.autoExecutionEnabled;
_maxPositions = widget.currentStatus.maxPositions;
_riskPerTrade = widget.currentStatus.riskPerTradePercent;
_minScore = widget.currentStatus.minCompositeScore;
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Bot Konfiguration & Kill-Switch',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white70),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Automatische Ausführung (Auto-Trade)', style: TextStyle(color: Colors.white)),
subtitle: Text('Führt geprüfte Signale ab Score ≥ $_minScore automatisch aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
value: _autoExec,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: (val) => setState(() => _autoExec = val),
),
const SizedBox(height: 12),
Text('Max. Parallele Positionen: $_maxPositions', style: const TextStyle(color: Colors.white70)),
Slider(
value: _maxPositions.toDouble(),
min: 1,
max: 10,
divisions: 9,
activeColor: AppTheme.primaryEmerald,
label: '$_maxPositions',
onChanged: (val) => setState(() => _maxPositions = val.toInt()),
),
const SizedBox(height: 8),
Text('Risiko pro Trade: ${_riskPerTrade.toStringAsFixed(1)}% des Portfolios', style: const TextStyle(color: Colors.white70)),
Slider(
value: _riskPerTrade,
min: 0.2,
max: 3.0,
divisions: 28,
activeColor: AppTheme.primaryEmerald,
label: '${_riskPerTrade.toStringAsFixed(1)}%',
onChanged: (val) => setState(() => _riskPerTrade = val),
),
const SizedBox(height: 8),
Text('Mindest-Score für Einstieg: $_minScore Punkte', style: const TextStyle(color: Colors.white70)),
Slider(
value: _minScore.toDouble(),
min: 60,
max: 95,
divisions: 35,
activeColor: AppTheme.primaryEmerald,
label: '$_minScore Pkt',
onChanged: (val) => setState(() => _minScore = val.toInt()),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
widget.onSave(_autoExec, _maxPositions, _riskPerTrade, _minScore);
Navigator.of(context).pop();
},
icon: const Icon(Icons.check),
label: const Text('Speichern'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showPanicConfirmation(context);
},
icon: const Icon(Icons.warning, color: Colors.white),
label: const Text('PANIC CLOSE', style: TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
),
),
],
),
const SizedBox(height: 16),
],
),
);
}
void _showPanicConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
title: Text('🚨 Notverkauf bestätigen', style: TextStyle(color: AppTheme.accentRed)),
content: const Text(
'Möchtest du wirklich SOFORT alle offenen Bot-Positionen schließen? Dieser Vorgang kann nicht rückgängig gemacht werden.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Abbrechen', style: TextStyle(color: Colors.white70)),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
widget.onPanicClose();
},
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentRed),
child: const Text('ALLE POSITIONEN SCHLIESSEN'),
),
],
),
);
}
}