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];
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user