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];
}