feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user