feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../models/backtest_history_entry_model.dart';
|
||||
import '../models/backtest_report_model.dart';
|
||||
import '../repositories/simulation_repository.dart';
|
||||
|
||||
class BacktestState extends Equatable {
|
||||
final bool isLoading;
|
||||
final BacktestReportModel? report;
|
||||
final String? errorMessage;
|
||||
|
||||
/// True while [report] is a past run fetched via [BacktestCubit.viewHistoricalRun] rather than a freshly
|
||||
/// executed backtest - lets the UI label the summary/chart as "aus dem Verlauf" instead of implying a new
|
||||
/// run just completed.
|
||||
final bool isViewingHistoricalRun;
|
||||
|
||||
final bool isHistoryLoading;
|
||||
final List<BacktestHistoryEntryModel> history;
|
||||
final String? historyErrorMessage;
|
||||
|
||||
/// Tunable indicator-parameter overrides for the currently selected strategy, keyed by bare parameter name
|
||||
/// (e.g. `"EmaFast"`, NOT `"TrendPullbackFvg.EmaFast"`) - the `"{strategyKey}."` prefix required by the
|
||||
/// backend (`TechnicalContext.ParameterOverrides`) is applied only when sending/loading, so this map stays
|
||||
/// meaningful regardless of which strategy is currently selected. Reset whenever the strategy changes (see
|
||||
/// [BacktestCubit.resetParameterOverrides]) - the same bare name means something different per strategy.
|
||||
final Map<String, double> parameterOverrides;
|
||||
|
||||
final bool isParametersLoading;
|
||||
final String? parametersMessage;
|
||||
|
||||
const BacktestState({
|
||||
this.isLoading = false,
|
||||
this.report,
|
||||
this.errorMessage,
|
||||
this.isViewingHistoricalRun = false,
|
||||
this.isHistoryLoading = false,
|
||||
this.history = const [],
|
||||
this.historyErrorMessage,
|
||||
this.parameterOverrides = const {},
|
||||
this.isParametersLoading = false,
|
||||
this.parametersMessage,
|
||||
});
|
||||
|
||||
BacktestState copyWith({
|
||||
bool? isLoading,
|
||||
BacktestReportModel? report,
|
||||
String? errorMessage,
|
||||
bool clearError = false,
|
||||
bool? isViewingHistoricalRun,
|
||||
bool? isHistoryLoading,
|
||||
List<BacktestHistoryEntryModel>? history,
|
||||
String? historyErrorMessage,
|
||||
bool clearHistoryError = false,
|
||||
Map<String, double>? parameterOverrides,
|
||||
bool? isParametersLoading,
|
||||
String? parametersMessage,
|
||||
bool clearParametersMessage = false,
|
||||
}) {
|
||||
return BacktestState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
report: report ?? this.report,
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
isViewingHistoricalRun: isViewingHistoricalRun ?? this.isViewingHistoricalRun,
|
||||
isHistoryLoading: isHistoryLoading ?? this.isHistoryLoading,
|
||||
history: history ?? this.history,
|
||||
historyErrorMessage: clearHistoryError ? null : (historyErrorMessage ?? this.historyErrorMessage),
|
||||
parameterOverrides: parameterOverrides ?? this.parameterOverrides,
|
||||
isParametersLoading: isParametersLoading ?? this.isParametersLoading,
|
||||
parametersMessage: clearParametersMessage ? null : (parametersMessage ?? this.parametersMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
isLoading,
|
||||
report,
|
||||
errorMessage,
|
||||
isViewingHistoricalRun,
|
||||
isHistoryLoading,
|
||||
history,
|
||||
historyErrorMessage,
|
||||
parameterOverrides,
|
||||
isParametersLoading,
|
||||
parametersMessage,
|
||||
];
|
||||
}
|
||||
|
||||
class BacktestCubit extends Cubit<BacktestState> {
|
||||
final SimulationRepository repository;
|
||||
|
||||
BacktestCubit({required this.repository}) : super(const BacktestState());
|
||||
|
||||
Future<void> runBacktest({
|
||||
required String isin,
|
||||
required String symbol,
|
||||
required String strategyKey,
|
||||
required String timeframe,
|
||||
}) async {
|
||||
emit(state.copyWith(isLoading: true, clearError: true, isViewingHistoricalRun: false));
|
||||
try {
|
||||
final prefixedParams = state.parameterOverrides.isEmpty
|
||||
? null
|
||||
: state.parameterOverrides.map((name, value) => MapEntry('$strategyKey.$name', value));
|
||||
|
||||
final report = await repository.runBacktest(
|
||||
isin: isin,
|
||||
symbol: symbol,
|
||||
strategyKey: strategyKey,
|
||||
timeframe: timeframe,
|
||||
strategyParameters: prefixedParams,
|
||||
);
|
||||
emit(state.copyWith(isLoading: false, report: report, clearError: true, isViewingHistoricalRun: false));
|
||||
// The run that just completed is now the newest history entry - refresh so it shows up immediately
|
||||
// instead of the user only seeing it after manually reopening the history list.
|
||||
await loadHistory(isin: isin, strategyKey: strategyKey);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: 'Fehler beim Starten des Backtests: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadHistory({required String isin, String? strategyKey}) async {
|
||||
if (isin.trim().isEmpty) {
|
||||
emit(state.copyWith(history: const [], clearHistoryError: true));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isHistoryLoading: true, clearHistoryError: true));
|
||||
try {
|
||||
final history = await repository.getBacktestHistory(isin: isin, strategyKey: strategyKey);
|
||||
emit(state.copyWith(isHistoryLoading: false, history: history, clearHistoryError: true));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isHistoryLoading: false, historyErrorMessage: 'Verlauf konnte nicht geladen werden: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> viewHistoricalRun(String runId) async {
|
||||
emit(state.copyWith(isLoading: true, clearError: true));
|
||||
try {
|
||||
final report = await repository.getBacktestRunDetail(runId);
|
||||
emit(state.copyWith(isLoading: false, report: report, clearError: true, isViewingHistoricalRun: true));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: 'Backtest-Lauf konnte nicht geladen werden: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a single tunable-parameter override (bare name, e.g. `"EmaFast"`) for the currently selected strategy.
|
||||
void setParameterOverride(String paramName, double value) {
|
||||
final updated = Map<String, double>.from(state.parameterOverrides);
|
||||
updated[paramName] = value;
|
||||
emit(state.copyWith(parameterOverrides: updated, clearParametersMessage: true));
|
||||
}
|
||||
|
||||
/// Clears all overrides - called whenever the selected strategy changes, since a bare parameter name means
|
||||
/// something different per strategy (e.g. `"Period"` is an RSI period for one strategy, a Donchian-channel
|
||||
/// length for another).
|
||||
void resetParameterOverrides() {
|
||||
emit(state.copyWith(parameterOverrides: const {}, clearParametersMessage: true));
|
||||
}
|
||||
|
||||
/// Loads a previously saved parameter profile for (isin, strategyKey) into [BacktestState.parameterOverrides].
|
||||
Future<void> loadSavedParameters({required String isin, required String strategyKey}) async {
|
||||
emit(state.copyWith(isParametersLoading: true, clearParametersMessage: true));
|
||||
try {
|
||||
final saved = await repository.getStrategyParameters(isin: isin, strategyKey: strategyKey);
|
||||
if (saved == null) {
|
||||
emit(state.copyWith(
|
||||
isParametersLoading: false,
|
||||
parametersMessage: 'Kein gespeichertes Profil für dieses Asset/diese Strategie.',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
final prefix = '$strategyKey.';
|
||||
final bare = <String, double>{};
|
||||
saved.forEach((key, value) {
|
||||
if (key.startsWith(prefix)) bare[key.substring(prefix.length)] = value;
|
||||
});
|
||||
|
||||
emit(state.copyWith(
|
||||
isParametersLoading: false,
|
||||
parameterOverrides: bare,
|
||||
parametersMessage: 'Gespeichertes Profil geladen.',
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Fehler beim Laden des Profils: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the current [BacktestState.parameterOverrides] as a reusable profile for (isin, strategyKey).
|
||||
Future<void> saveCurrentParameters({required String isin, required String strategyKey}) async {
|
||||
emit(state.copyWith(isParametersLoading: true, clearParametersMessage: true));
|
||||
try {
|
||||
final prefixed = state.parameterOverrides.map((name, value) => MapEntry('$strategyKey.$name', value));
|
||||
await repository.saveStrategyParameters(isin: isin, strategyKey: strategyKey, parameters: prefixed);
|
||||
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Parameter-Profil gespeichert.'));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isParametersLoading: false, parametersMessage: 'Fehler beim Speichern des Profils: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Typed counterpart of the backend `BacktestHistoryEntryDto`
|
||||
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`) - one lightweight
|
||||
/// row of a past backtest run, without the full trade list/equity curve
|
||||
/// (fetch those via `SimulationRepository.getBacktestRunDetail` when the
|
||||
/// user drills into a specific entry).
|
||||
class BacktestHistoryEntryModel extends Equatable {
|
||||
final String runId;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final String strategyKey;
|
||||
final String timeframe;
|
||||
final DateTime startDateUtc;
|
||||
final DateTime endDateUtc;
|
||||
final int totalTrades;
|
||||
final double winRatePercent;
|
||||
final double profitFactor;
|
||||
final double maxDrawdownPercent;
|
||||
final double totalReturnPercent;
|
||||
final double sharpeRatio;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
const BacktestHistoryEntryModel({
|
||||
required this.runId,
|
||||
required this.isin,
|
||||
required this.symbol,
|
||||
required this.strategyKey,
|
||||
required this.timeframe,
|
||||
required this.startDateUtc,
|
||||
required this.endDateUtc,
|
||||
required this.totalTrades,
|
||||
required this.winRatePercent,
|
||||
required this.profitFactor,
|
||||
required this.maxDrawdownPercent,
|
||||
required this.totalReturnPercent,
|
||||
required this.sharpeRatio,
|
||||
required this.createdAtUtc,
|
||||
});
|
||||
|
||||
factory BacktestHistoryEntryModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
DateTime parseDate(dynamic val) {
|
||||
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
return BacktestHistoryEntryModel(
|
||||
runId: json['runId']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
strategyKey: json['strategyKey']?.toString() ?? '',
|
||||
timeframe: json['timeframe']?.toString() ?? '',
|
||||
startDateUtc: parseDate(json['startDateUtc']),
|
||||
endDateUtc: parseDate(json['endDateUtc']),
|
||||
totalTrades: (json['totalTrades'] as num?)?.toInt() ?? 0,
|
||||
winRatePercent: parseDbl(json['winRatePercent']),
|
||||
profitFactor: parseDbl(json['profitFactor']),
|
||||
maxDrawdownPercent: parseDbl(json['maxDrawdownPercent']),
|
||||
totalReturnPercent: parseDbl(json['totalReturnPercent']),
|
||||
sharpeRatio: parseDbl(json['sharpeRatio']),
|
||||
createdAtUtc: parseDate(json['createdAtUtc']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
runId,
|
||||
isin,
|
||||
symbol,
|
||||
strategyKey,
|
||||
timeframe,
|
||||
startDateUtc,
|
||||
endDateUtc,
|
||||
totalTrades,
|
||||
winRatePercent,
|
||||
profitFactor,
|
||||
maxDrawdownPercent,
|
||||
totalReturnPercent,
|
||||
sharpeRatio,
|
||||
createdAtUtc,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Typed counterpart of the backend `EquityPointDto`
|
||||
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||
class EquityPointModel extends Equatable {
|
||||
final DateTime timestampUtc;
|
||||
final double portfolioValue;
|
||||
final double drawdownPercent;
|
||||
|
||||
const EquityPointModel({
|
||||
required this.timestampUtc,
|
||||
required this.portfolioValue,
|
||||
required this.drawdownPercent,
|
||||
});
|
||||
|
||||
factory EquityPointModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
return EquityPointModel(
|
||||
timestampUtc: DateTime.tryParse(json['timestampUtc']?.toString() ?? '') ?? DateTime.now(),
|
||||
portfolioValue: parseDbl(json['portfolioValue']),
|
||||
drawdownPercent: parseDbl(json['drawdownPercent']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [timestampUtc, portfolioValue, drawdownPercent];
|
||||
}
|
||||
|
||||
/// Typed counterpart of the backend `BacktestReportDto`
|
||||
/// (see `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||
///
|
||||
/// `equityCurve` is intentionally a plain (possibly empty) list rather than
|
||||
/// a fallback with a synthetic starting point: an empty list means "no
|
||||
/// equity curve data returned" and MUST be rendered as an explicit empty
|
||||
/// state, never as an invented chart (Rules.md §4).
|
||||
class BacktestReportModel extends Equatable {
|
||||
final String runId;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final String strategyKey;
|
||||
final String timeframe;
|
||||
final int totalTrades;
|
||||
final int winningTrades;
|
||||
final int losingTrades;
|
||||
final double winRatePercent;
|
||||
final double profitFactor;
|
||||
final double maxDrawdownPercent;
|
||||
final double totalReturnPercent;
|
||||
final double expectancyEur;
|
||||
final double sharpeRatio;
|
||||
final List<EquityPointModel> equityCurve;
|
||||
|
||||
const BacktestReportModel({
|
||||
this.runId = '',
|
||||
this.isin = '',
|
||||
this.symbol = '',
|
||||
this.strategyKey = '',
|
||||
this.timeframe = '',
|
||||
this.totalTrades = 0,
|
||||
this.winningTrades = 0,
|
||||
this.losingTrades = 0,
|
||||
this.winRatePercent = 0.0,
|
||||
this.profitFactor = 0.0,
|
||||
this.maxDrawdownPercent = 0.0,
|
||||
this.totalReturnPercent = 0.0,
|
||||
this.expectancyEur = 0.0,
|
||||
this.sharpeRatio = 0.0,
|
||||
this.equityCurve = const [],
|
||||
});
|
||||
|
||||
factory BacktestReportModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
final rawCurve = json['equityCurve'];
|
||||
final curve = rawCurve is List
|
||||
? rawCurve
|
||||
.whereType<Map>()
|
||||
.map((e) => EquityPointModel.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList()
|
||||
: const <EquityPointModel>[];
|
||||
|
||||
return BacktestReportModel(
|
||||
runId: json['runId']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
strategyKey: json['strategyKey']?.toString() ?? '',
|
||||
timeframe: json['timeframe']?.toString() ?? '',
|
||||
totalTrades: (json['totalTrades'] as num?)?.toInt() ?? 0,
|
||||
winningTrades: (json['winningTrades'] as num?)?.toInt() ?? 0,
|
||||
losingTrades: (json['losingTrades'] as num?)?.toInt() ?? 0,
|
||||
winRatePercent: parseDbl(json['winRatePercent']),
|
||||
profitFactor: parseDbl(json['profitFactor']),
|
||||
maxDrawdownPercent: parseDbl(json['maxDrawdownPercent']),
|
||||
totalReturnPercent: parseDbl(json['totalReturnPercent']),
|
||||
expectancyEur: parseDbl(json['expectancyEur']),
|
||||
sharpeRatio: parseDbl(json['sharpeRatio']),
|
||||
equityCurve: curve,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
runId,
|
||||
isin,
|
||||
symbol,
|
||||
strategyKey,
|
||||
timeframe,
|
||||
totalTrades,
|
||||
winningTrades,
|
||||
losingTrades,
|
||||
winRatePercent,
|
||||
profitFactor,
|
||||
maxDrawdownPercent,
|
||||
totalReturnPercent,
|
||||
expectancyEur,
|
||||
sharpeRatio,
|
||||
equityCurve,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../models/backtest_history_entry_model.dart';
|
||||
import '../models/backtest_report_model.dart';
|
||||
|
||||
/// Backend endpoint contract: `FinlyticBackend/Controllers/SimulationController.cs`
|
||||
/// (`POST /api/v1/simulation/run`, body shape `BacktestRequestDto` in
|
||||
/// `FinlyticCore/Dtos/Simulation/SimulationDtos.cs`).
|
||||
class SimulationRepository {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const SimulationRepository({required this.apiClient});
|
||||
|
||||
Future<BacktestReportModel> runBacktest({
|
||||
required String isin,
|
||||
required String symbol,
|
||||
required String strategyKey,
|
||||
required String timeframe,
|
||||
DateTime? startDateUtc,
|
||||
DateTime? endDateUtc,
|
||||
double startingCapital = 10000.0,
|
||||
double riskPerTradePercent = 1.0,
|
||||
bool includeFeesAndSlippage = true,
|
||||
bool simulateKnockOutDerivatives = false,
|
||||
double? targetLeverage,
|
||||
Map<String, double>? strategyParameters,
|
||||
}) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final start = (startDateUtc ?? now.subtract(const Duration(days: 180))).toUtc();
|
||||
final end = (endDateUtc ?? now).toUtc();
|
||||
|
||||
final response = await apiClient.post('/api/v1/simulation/run', data: {
|
||||
'isin': isin,
|
||||
'symbol': symbol,
|
||||
'strategyKey': strategyKey,
|
||||
'timeframe': timeframe,
|
||||
'startDateUtc': start.toIso8601String(),
|
||||
'endDateUtc': end.toIso8601String(),
|
||||
'startingCapital': startingCapital,
|
||||
'riskPerTradePercent': riskPerTradePercent,
|
||||
'includeFeesAndSlippage': includeFeesAndSlippage,
|
||||
'simulateKnockOutDerivatives': simulateKnockOutDerivatives,
|
||||
if (targetLeverage != null) 'targetLeverage': targetLeverage,
|
||||
if (strategyParameters != null && strategyParameters.isNotEmpty) 'strategyParameters': strategyParameters,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
return BacktestReportModel.fromJson(Map<String, dynamic>.from(response.data as Map));
|
||||
}
|
||||
|
||||
throw Exception('Simulation fehlgeschlagen: Status ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Lightweight history of past backtest runs for an asset (`GET /api/v1/simulation/history/{isin}`).
|
||||
Future<List<BacktestHistoryEntryModel>> getBacktestHistory({
|
||||
required String isin,
|
||||
String? strategyKey,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await apiClient.get(
|
||||
'/api/v1/simulation/history/$isin',
|
||||
queryParameters: {
|
||||
if (strategyKey != null && strategyKey.isNotEmpty) 'strategyKey': strategyKey,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data is List) {
|
||||
return (response.data as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => BacktestHistoryEntryModel.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Backtest-Verlauf konnte nicht geladen werden: Status ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Full report (trades + equity curve) for one specific past run (`GET /api/v1/simulation/history/run/{runId}`).
|
||||
Future<BacktestReportModel> getBacktestRunDetail(String runId) async {
|
||||
final response = await apiClient.get('/api/v1/simulation/history/run/$runId');
|
||||
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
return BacktestReportModel.fromJson(Map<String, dynamic>.from(response.data as Map));
|
||||
}
|
||||
|
||||
throw Exception('Backtest-Lauf konnte nicht geladen werden: Status ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Saved indicator-parameter profile for one (isin, strategyKey) pair
|
||||
/// (`GET /api/v1/simulation/parameters/{isin}/{strategyKey}`). Returns `null` when none was ever saved -
|
||||
/// an expected, legitimate empty state (the server responds 404), not an error to surface to the user.
|
||||
Future<Map<String, double>?> getStrategyParameters({
|
||||
required String isin,
|
||||
required String strategyKey,
|
||||
}) async {
|
||||
try {
|
||||
final response = await apiClient.get('/api/v1/simulation/parameters/$isin/$strategyKey');
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
final raw = Map<String, dynamic>.from(response.data as Map)['parameters'];
|
||||
if (raw is Map) {
|
||||
return raw.map((key, value) => MapEntry(key.toString(), (value as num).toDouble()));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves/updates a per-asset/per-strategy indicator-parameter profile (`POST /api/v1/simulation/parameters`).
|
||||
Future<void> saveStrategyParameters({
|
||||
required String isin,
|
||||
required String strategyKey,
|
||||
required Map<String, double> parameters,
|
||||
}) async {
|
||||
final response = await apiClient.post('/api/v1/simulation/parameters', data: {
|
||||
'isin': isin,
|
||||
'strategyKey': strategyKey,
|
||||
'parameters': parameters,
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Parameter-Profil konnte nicht gespeichert werden: Status ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
|
||||
/// Plain-language explanations of all 10 backtestable strategies (`FinlyticTechnicals/Strategies/CoreStrategies.cs`,
|
||||
/// shared 1:1 by FinlyticSimulation - "100% code reuse", see that file's own doc comments). Mirrors
|
||||
/// `FinlyticApp/lib/features/asset_detail/utils/pattern_explanations.dart`'s dictionary shape/detail-sheet
|
||||
/// pattern rather than inventing a new one. Every description below is a plain-language restatement of that
|
||||
/// strategy's actual `Evaluate()` logic as written, not a generic template (Rules.md §4). Every strategy fires
|
||||
/// both a long AND a short setup (mirror-image conditions in the same `Evaluate()` method) - hence
|
||||
/// `'bias': 'BIDIREKTIONAL'` throughout, rather than a single-direction label.
|
||||
class StrategyExplanations {
|
||||
static const Map<String, Map<String, String>> dictionary = {
|
||||
'TrendPullbackFvg': {
|
||||
'title': 'Trend Pullback FVG Retracement',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Ein Trend ist bereits im Gange (EMA20/EMA50/EMA200 in Trendreihenfolge) und der Kurs zieht sich kurz '
|
||||
'in eine Fair-Value-Gap (eine übersprungene Preiszone) zurück. Sobald der Kurs aus dieser Zone '
|
||||
'wieder in Trendrichtung zu laufen beginnt, gilt das als Bestätigung, dass der Trend weiterläuft. '
|
||||
'Long bei Aufwärtstrend + bullischer FVG, Short bei Abwärtstrend + bärischer FVG (Spiegelbild).',
|
||||
'significance':
|
||||
'Genau das "kurzer Rücksetzer, dann Bestätigung durchs erneute Laufen"-Muster: EMA50 bestätigt den '
|
||||
'übergeordneten Trend, die Fair-Value-Gap markiert die Rücksetzer-Zone, in der eingestiegen wird.',
|
||||
'action': 'Einstieg beim erneuten Lauf aus der Fair-Value-Gap-Zone in Trendrichtung, Stop-Loss hinter der Gap bzw. 1,2×ATR vom Entry.',
|
||||
'reliability': 'Hoch (Quality-Score 88, Top-Pick, Rating A+)',
|
||||
'target': 'Gestaffelter Ausstieg: TP1 bei 1,5× Risiko (50% Teilverkauf, Stop wandert auf Break-Even), TP2 bei 3,0× Risiko (30%).',
|
||||
'stop_loss': 'Hinter der Fair-Value-Gap-Grenze bzw. 1,2× ATR vom Einstieg - je nachdem, was enger ist.',
|
||||
},
|
||||
'VolatilitySqueeze': {
|
||||
'title': 'Bollinger/Keltner Squeeze Breakout',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Bollinger-Bänder ziehen sich innerhalb der Keltner-Kanäle zusammen (niedrige Volatilität) und "feuern" '
|
||||
'dann mit Momentum in eine Richtung - der klassische Ausbruch aus einer ruhigen Konsolidierungsphase. '
|
||||
'Long bei positivem, Short bei negativem Momentum-Histogramm.',
|
||||
'significance': 'Eine Phase geringer Schwankung geht typischerweise einer impulsiven Bewegung voraus; das Momentum-Histogramm bestätigt die Richtung.',
|
||||
'action': 'Einstieg direkt beim Squeeze-Ausbruch in Richtung des Momentums.',
|
||||
'reliability': 'Hoch (Quality-Score 84, Top-Pick, Rating A)',
|
||||
'target': 'Festes Ziel bei ±2,0× ATR ab Einstieg (100% Ausstieg).',
|
||||
'stop_loss': '1,0× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'SmcLiquiditySweep': {
|
||||
'title': 'Smart Money Liquidity Sweep & CHoCH',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Der Kurs "fegt" kurz über ein bekanntes Hoch oder unter ein bekanntes Tief (Stop-Loss-Jagd durch '
|
||||
'große Marktteilnehmer) und dreht danach abrupt in die Gegenrichtung - erkennbar an einem '
|
||||
'Liquidity-Sweep-Muster und/oder einem Wechsel der Marktstruktur (Change of Character). Long nach '
|
||||
'einem Sweep unter einem Tief, Short nach einem Sweep über einem Hoch.',
|
||||
'significance': 'Interpretiert als Zeichen, dass institutionelle Marktteilnehmer die durch den Sweep freigesetzte Liquidität aufgenommen haben.',
|
||||
'action': 'Einstieg nach Bestätigung der Ablehnung des Sweeps.',
|
||||
'reliability': 'Sehr hoch (Quality-Score 91, Top-Pick, Rating A+)',
|
||||
'target': 'Gestaffelter Ausstieg: TP1 bei 2,0× Risiko (60%, danach Break-Even), TP2 bei 4,0× Risiko (40%).',
|
||||
'stop_loss': 'Knapp hinter dem Extrempunkt des Sweeps (0,2% Puffer).',
|
||||
},
|
||||
'MeanReversion': {
|
||||
'title': 'Bollinger 2,5-Sigma Mean Reversion',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Nur in ruhigen/schwankenden Seitwärtsmärkten (nicht im Trend) aktiv: Der Kurs berührt das untere '
|
||||
'(Long) oder obere (Short) 2,5-Sigma-Bollingerband bei niedrigem ADX (<22, kein starker Trend) und '
|
||||
'überverkauftem (≤32) bzw. überkauftem (≥68) RSI - eine statistisch übertriebene Bewegung, die '
|
||||
'zum Durchschnitt zurückkehren sollte.',
|
||||
'significance': 'Setzt auf die Rückkehr zum VWAP/Mittelwert nach einer überzogenen kurzfristigen Bewegung, nicht auf eine Trendfortsetzung.',
|
||||
'action': 'Einstieg bei Berührung des äußeren Bandes mit passendem RSI-Extrem.',
|
||||
'reliability': 'Mittel (Quality-Score 79, kein Top-Pick, Rating B) - bewusst konservativer eingestuft als die trendfolgenden Strategien.',
|
||||
'target': 'Ziel ist der VWAP bzw. das mittlere Bollingerband (SMA20), 100% Ausstieg.',
|
||||
'stop_loss': '0,8× ATR hinter dem Extrem der Signalkerze.',
|
||||
},
|
||||
'SuperTrendMultiTf': {
|
||||
'title': 'SuperTrend Multi-Timeframe Alignment',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Zwei Zeitebenen müssen übereinstimmen: Der SuperTrend-Indikator auf 1h UND auf 15m stehen beide auf '
|
||||
'dieselbe Richtung (bullisch für Long, bärisch für Short). Erst wenn beide Zeitrahmen im Einklang '
|
||||
'sind, gilt das Setup als bestätigt.',
|
||||
'significance': 'Reduziert Fehlsignale einzelner Zeitebenen - der übergeordnete (1h) und der feinere (15m) Trend müssen sich decken.',
|
||||
'action': 'Einstieg bei Bestätigung der Multi-Timeframe-Übereinstimmung.',
|
||||
'reliability': 'Hoch (Quality-Score 86, Top-Pick, Rating A)',
|
||||
'target': 'Kein festes Kursziel - reine Trailing-Stop-Strategie entlang der 15m-SuperTrend-Linie, Ausstieg erst bei Trendwechsel.',
|
||||
'stop_loss': 'Dynamisch an der 15m-SuperTrend-Linie, sofortiger Exit bei Flip der Gegenrichtung.',
|
||||
},
|
||||
'MacdCrossover': {
|
||||
'title': 'MACD Signal Line Crossover',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Klassischer Momentum-Wechsel: Die MACD-Linie kreuzt die Signal-Linie (verglichen mit derselben '
|
||||
'Berechnung eine Kerze zuvor) und das Histogramm bestätigt die Richtung. Long bei Kreuzung nach '
|
||||
'oben mit positivem Histogramm, Short bei Kreuzung nach unten mit negativem Histogramm.',
|
||||
'significance': 'Der MACD-Crossover ist eines der bekanntesten Momentum-Signale der technischen Analyse und markiert einen Wechsel im kurzfristigen Trend.',
|
||||
'action': 'Einstieg direkt bei bestätigter Kreuzung.',
|
||||
'reliability': 'Mittel (Quality-Score 80, Rating B)',
|
||||
'target': 'Festes Ziel bei ±2,0× Risiko (100% Ausstieg).',
|
||||
'stop_loss': '1,5× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'MovingAverageCrossover': {
|
||||
'title': 'EMA50/EMA200 Golden & Death Cross',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Der langfristige Trendwechsel-Klassiker: Kreuzt EMA50 von unten nach oben durch EMA200, ist das ein '
|
||||
'"Golden Cross" (Long). Kreuzt sie von oben nach unten, ist das ein "Death Cross" (Short).',
|
||||
'significance': 'Einer der ältesten und am weitesten verbreiteten langfristigen Trendindikatoren - signalisiert einen strukturellen Wechsel der Marktrichtung.',
|
||||
'action': 'Einstieg direkt bei bestätigter Kreuzung.',
|
||||
'reliability': 'Hoch (Quality-Score 82, Top-Pick, Rating A) - aber selten, da EMA50/EMA200 sich nicht oft kreuzen.',
|
||||
'target': 'Kein festes Kursziel - Trailing-Stop mit 2,5× ATR-Abstand, für langfristige Trends gedacht.',
|
||||
'stop_loss': '2,0× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'RsiReversal': {
|
||||
'title': 'RSI Overbought/Oversold Threshold Cross',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Einfaches, richtungsoffenes Momentum-Reversal: Long, wenn der RSI von unterhalb 30 wieder darüber '
|
||||
'steigt (Ende der Überverkauftheit). Short, wenn der RSI von oberhalb 70 wieder darunter fällt '
|
||||
'(Ende der Überkauftheit). Anders als "Bollinger 2,5-Sigma Mean Reversion" wird hier NICHT '
|
||||
'zusätzlich ein niedriger ADX oder eine Bollinger-Band-Berührung verlangt - rein der RSI-Schwellenwert zählt.',
|
||||
'significance': 'Der RSI-Schwellenwert-Durchbruch ist eines der einfachsten und am längsten genutzten Reversal-Signale.',
|
||||
'action': 'Einstieg direkt beim erneuten Über- bzw. Unterschreiten der 30/70-Schwelle.',
|
||||
'reliability': 'Mittel (Quality-Score 75, Rating B) - bewusst einfacher/häufiger auslösend als die kombinierten Strategien.',
|
||||
'target': 'Festes Ziel bei ±1,5× Risiko (100% Ausstieg).',
|
||||
'stop_loss': '1,2× ATR gegen die Einstiegsrichtung.',
|
||||
},
|
||||
'DonchianBreakout': {
|
||||
'title': '20-Perioden Donchian-Kanal-Ausbruch',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Der klassische "Turtle Trading"-Ausbruch: Long, wenn der Schlusskurs über das Hoch der letzten 20 '
|
||||
'Kerzen (vor der aktuellen) ausbricht. Short, wenn er unter das Tief der letzten 20 Kerzen fällt.',
|
||||
'significance': 'Einer der ältesten systematischen Breakout-Ansätze - setzt darauf, dass ein Ausbruch aus einer etablierten Handelsspanne eine neue Bewegung einleitet.',
|
||||
'action': 'Einstieg direkt beim Ausbruch über/unter den 20-Perioden-Kanal.',
|
||||
'reliability': 'Hoch (Quality-Score 83, Top-Pick, Rating A)',
|
||||
'target': 'Kein festes Kursziel - Trailing-Stop mit 2,0× ATR-Abstand.',
|
||||
'stop_loss': 'Am gegenüberliegenden Kanalrand (Tief bei Long, Hoch bei Short).',
|
||||
},
|
||||
'VwapBounce': {
|
||||
'title': 'VWAP Pullback & Bounce Confirmation',
|
||||
'bias': 'BIDIREKTIONAL',
|
||||
'description':
|
||||
'Im Aufwärtstrend (EMA20>EMA50): Der Kurs sinkt kurz zum/unter den VWAP und schließt wieder darüber '
|
||||
'(Bounce) - Long-Einstieg. Im Abwärtstrend (EMA20<EMA50): Der Kurs steigt kurz zum/über den VWAP '
|
||||
'und schließt wieder darunter (Ablehnung) - Short-Einstieg.',
|
||||
'significance': 'Der VWAP wird von vielen institutionellen Marktteilnehmern als fairer Durchschnittspreis behandelt - ein Rücksetzer dorthin mit anschließender Ablehnung gilt als starkes Fortsetzungssignal.',
|
||||
'action': 'Einstieg bei Bestätigung des Bounces/der Ablehnung am VWAP.',
|
||||
'reliability': 'Mittel (Quality-Score 81, Rating B)',
|
||||
'target': 'Festes Ziel bei ±2,0× Risiko (100% Ausstieg).',
|
||||
'stop_loss': 'Hinter dem Hoch/Tief der Rücksetzer-Kerze bzw. 1,0× ATR - je nachdem, was weiter entfernt ist.',
|
||||
},
|
||||
};
|
||||
|
||||
static Map<String, String> _infoFor(String strategyKey) {
|
||||
return dictionary[strategyKey] ??
|
||||
{
|
||||
'title': strategyKey,
|
||||
'bias': 'NEUTRAL',
|
||||
'description': 'Eine von FinlyticTechnicals bereitgestellte technische Strategie ($strategyKey).',
|
||||
'significance': 'Details zu dieser Strategie sind noch nicht dokumentiert.',
|
||||
'action': 'Backtest-Ergebnisse und Score-Aufschlüsselung beachten.',
|
||||
'reliability': 'Unbekannt',
|
||||
'target': 'Abhängig vom jeweiligen Setup.',
|
||||
'stop_loss': 'Siehe individuelles Setup.',
|
||||
};
|
||||
}
|
||||
|
||||
static void showStrategyDetails(BuildContext context, String strategyKey) {
|
||||
final info = _infoFor(strategyKey);
|
||||
final isBullish = info['bias'] == 'BULLISH';
|
||||
final isBearish = info['bias'] == 'BEARISH';
|
||||
final biasColor = isBullish ? AppTheme.primaryEmerald : (isBearish ? AppTheme.accentRed : AppTheme.accentCyan);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (modalContext) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
info['title']!,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: biasColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: biasColor),
|
||||
),
|
||||
child: Text(
|
||||
info['bias']!,
|
||||
style: TextStyle(color: biasColor, fontWeight: FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Funktionsweise:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['description']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Warum das ein Signal ist:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['significance']!, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Einstufung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 2),
|
||||
Text(info['reliability']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Kursziel:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['target']!, style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
const Text('Stop-Loss:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(info['stop_loss']!, style: TextStyle(color: AppTheme.accentRed, fontSize: 13, height: 1.4)),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
const Text('Einstiegsauslöser:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Text(info['action']!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600, height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
child: const Text('Schließen', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/// Describes one tunable indicator parameter for a strategy - mirrors the `context.GetParameter(StrategyKey, "...", default)`
|
||||
/// calls in `FinlyticTechnicals/Strategies/CoreStrategies.cs`. Keys sent to the backend are always
|
||||
/// `"{strategyKey}.{name}"`, matching `TechnicalContext.ParameterOverrides` exactly.
|
||||
class StrategyParameterDef {
|
||||
final String name;
|
||||
final String label;
|
||||
final double defaultValue;
|
||||
|
||||
/// Short plain-language explanation of what changing this value actually does, shown via an info tooltip
|
||||
/// next to the field (Rules.md-style transparency: a bare abbreviation like "EMA schnell" means nothing to
|
||||
/// someone who didn't just read the strategy's source code).
|
||||
final String hint;
|
||||
|
||||
/// Whether the field should be entered/rounded as a whole number (indicator periods) vs. a decimal
|
||||
/// (multipliers, thresholds).
|
||||
final bool isInteger;
|
||||
|
||||
const StrategyParameterDef({
|
||||
required this.name,
|
||||
required this.label,
|
||||
required this.defaultValue,
|
||||
required this.hint,
|
||||
this.isInteger = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-strategy list of tunable parameters, for the "Erweiterte Parameter" section of the backtest screen.
|
||||
/// Every default value here must match the corresponding hardcoded default in `CoreStrategies.cs` exactly -
|
||||
/// these are the same values the strategy already uses when nothing is overridden (Rules.md §4: never imply a
|
||||
/// different default than what the backend actually falls back to).
|
||||
class StrategyParameterDefinitions {
|
||||
static const Map<String, List<StrategyParameterDef>> byStrategy = {
|
||||
'TrendPullbackFvg': [
|
||||
StrategyParameterDef(
|
||||
name: 'EmaFast',
|
||||
label: 'EMA schnell',
|
||||
defaultValue: 20,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der kurzen EMA. Kleiner = reagiert schneller auf Kursänderungen, aber mehr Fehlsignale.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'EmaMid',
|
||||
label: 'EMA mittel',
|
||||
defaultValue: 50,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der mittleren EMA, bestätigt zusammen mit der langsamen EMA den übergeordneten Trend.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'EmaSlow',
|
||||
label: 'EMA langsam',
|
||||
defaultValue: 200,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA. Größer = stabilerer, aber träger erkannter Trend.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.2,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR). Größer = weiterer Stop, weniger vorzeitige Ausstiege.',
|
||||
),
|
||||
],
|
||||
'VolatilitySqueeze': [
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.0,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'TargetAtrMultiplier',
|
||||
label: 'Kursziel (× ATR)',
|
||||
defaultValue: 2.0,
|
||||
hint: 'Abstand des Kursziels vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'SmcLiquiditySweep': [
|
||||
StrategyParameterDef(
|
||||
name: 'StopBufferPercent',
|
||||
label: 'Stop-Puffer (%)',
|
||||
defaultValue: 0.2,
|
||||
hint: 'Zusätzlicher Sicherheitsabstand des Stop-Loss hinter dem Sweep-Extrempunkt, in Prozent.',
|
||||
),
|
||||
],
|
||||
'MeanReversion': [
|
||||
StrategyParameterDef(
|
||||
name: 'BollingerMultiplier',
|
||||
label: 'Bollinger-Multiplikator (σ)',
|
||||
defaultValue: 2.5,
|
||||
hint: 'Breite der Bollinger-Bänder in Standardabweichungen. Größer = seltenere, aber extremere Signale.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'AdxThreshold',
|
||||
label: 'ADX-Schwelle (max.)',
|
||||
defaultValue: 22,
|
||||
hint: 'Nur unterhalb dieser ADX-Trendstärke gilt der Markt als "seitwärts" - Voraussetzung für ein Signal.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'RsiOversold',
|
||||
label: 'RSI überverkauft (≤)',
|
||||
defaultValue: 32,
|
||||
hint: 'RSI-Wert, ab dem der Kurs als überverkauft gilt (Long-Signal-Voraussetzung).',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'RsiOverbought',
|
||||
label: 'RSI überkauft (≥)',
|
||||
defaultValue: 68,
|
||||
hint: 'RSI-Wert, ab dem der Kurs als überkauft gilt (Short-Signal-Voraussetzung).',
|
||||
),
|
||||
],
|
||||
'SuperTrendMultiTf': [
|
||||
StrategyParameterDef(
|
||||
name: 'Period',
|
||||
label: 'SuperTrend-Periode',
|
||||
defaultValue: 10,
|
||||
isInteger: true,
|
||||
hint: 'ATR-Periode, die der SuperTrend-Indikator auf beiden Zeitebenen (1h und 15m) zugrunde legt.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'Multiplier',
|
||||
label: 'SuperTrend-Multiplikator',
|
||||
defaultValue: 3.0,
|
||||
hint: 'Wie weit die SuperTrend-Linie von der ATR-Bandbreite entfernt liegt. Größer = trägere, glattere Linie.',
|
||||
),
|
||||
],
|
||||
'MacdCrossover': [
|
||||
StrategyParameterDef(
|
||||
name: 'FastPeriod',
|
||||
label: 'MACD schnell',
|
||||
defaultValue: 12,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der schnellen EMA im MACD.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'SlowPeriod',
|
||||
label: 'MACD langsam',
|
||||
defaultValue: 26,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA im MACD.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'SignalPeriod',
|
||||
label: 'MACD Signal',
|
||||
defaultValue: 9,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der Signal-Linie (EMA der MACD-Linie), gegen die auf Kreuzung geprüft wird.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.5,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'MovingAverageCrossover': [
|
||||
StrategyParameterDef(
|
||||
name: 'FastPeriod',
|
||||
label: 'EMA schnell',
|
||||
defaultValue: 50,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der schnellen EMA (kreuzt für ein Golden/Death Cross durch die langsame EMA).',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'SlowPeriod',
|
||||
label: 'EMA langsam',
|
||||
defaultValue: 200,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA - je größer, desto seltener und langfristiger die Signale.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 2.0,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'RsiReversal': [
|
||||
StrategyParameterDef(
|
||||
name: 'Period',
|
||||
label: 'RSI-Periode',
|
||||
defaultValue: 14,
|
||||
isInteger: true,
|
||||
hint: 'Anzahl Kerzen, über die der RSI berechnet wird.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'OversoldThreshold',
|
||||
label: 'Überverkauft-Schwelle',
|
||||
defaultValue: 30,
|
||||
hint: 'RSI-Schwelle, deren erneutes Überschreiten von unten ein Long-Signal auslöst.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'OverboughtThreshold',
|
||||
label: 'Überkauft-Schwelle',
|
||||
defaultValue: 70,
|
||||
hint: 'RSI-Schwelle, deren erneutes Unterschreiten von oben ein Short-Signal auslöst.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.2,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
'DonchianBreakout': [
|
||||
StrategyParameterDef(
|
||||
name: 'Period',
|
||||
label: 'Kanal-Periode',
|
||||
defaultValue: 20,
|
||||
isInteger: true,
|
||||
hint: 'Anzahl vorheriger Kerzen, deren Hoch/Tief den Ausbruchskanal bilden. Größer = seltenere, dafür signifikantere Ausbrüche.',
|
||||
),
|
||||
],
|
||||
'VwapBounce': [
|
||||
StrategyParameterDef(
|
||||
name: 'EmaFast',
|
||||
label: 'EMA schnell',
|
||||
defaultValue: 20,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der schnellen EMA, bestimmt zusammen mit der langsamen EMA die Trendrichtung.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'EmaSlow',
|
||||
label: 'EMA langsam',
|
||||
defaultValue: 50,
|
||||
isInteger: true,
|
||||
hint: 'Perioden der langsamen EMA, bestimmt zusammen mit der schnellen EMA die Trendrichtung.',
|
||||
),
|
||||
StrategyParameterDef(
|
||||
name: 'StopAtrMultiplier',
|
||||
label: 'Stop-Loss (× ATR)',
|
||||
defaultValue: 1.0,
|
||||
hint: 'Abstand des Stop-Loss vom Einstieg, als Vielfaches der aktuellen Volatilität (ATR).',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
static List<StrategyParameterDef> forStrategy(String strategyKey) => byStrategy[strategyKey] ?? const [];
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../search/widgets/asset_search_dialog.dart';
|
||||
import '../cubit/backtest_cubit.dart';
|
||||
import '../models/backtest_history_entry_model.dart';
|
||||
import '../models/backtest_report_model.dart';
|
||||
import '../repositories/simulation_repository.dart';
|
||||
import '../utils/strategy_explanations.dart';
|
||||
import '../utils/strategy_parameter_definitions.dart';
|
||||
|
||||
class BacktestVisualizerScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
final String? initialIsin;
|
||||
|
||||
const BacktestVisualizerScreen({
|
||||
super.key,
|
||||
required this.apiClient,
|
||||
this.initialIsin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => BacktestCubit(repository: SimulationRepository(apiClient: apiClient)),
|
||||
child: _BacktestVisualizerContent(apiClient: apiClient, initialIsin: initialIsin),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BacktestVisualizerContent extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
final String? initialIsin;
|
||||
|
||||
const _BacktestVisualizerContent({required this.apiClient, this.initialIsin});
|
||||
|
||||
@override
|
||||
State<_BacktestVisualizerContent> createState() => _BacktestVisualizerContentState();
|
||||
}
|
||||
|
||||
class _BacktestVisualizerContentState extends State<_BacktestVisualizerContent> {
|
||||
static const String _defaultIsin = 'US67066G1040'; // NVDA - only used until the user picks a real asset.
|
||||
|
||||
String _selectedIsin = _defaultIsin;
|
||||
String _selectedAssetName = '';
|
||||
String _selectedStrategy = 'TrendPullbackFvg';
|
||||
String _selectedTimeframe = '1h';
|
||||
|
||||
final List<String> _strategies = [
|
||||
'TrendPullbackFvg',
|
||||
'SmcLiquiditySweep',
|
||||
'VolatilitySqueeze',
|
||||
'MeanReversion',
|
||||
'SuperTrendMultiTf',
|
||||
'MacdCrossover',
|
||||
'MovingAverageCrossover',
|
||||
'RsiReversal',
|
||||
'DonchianBreakout',
|
||||
'VwapBounce',
|
||||
];
|
||||
|
||||
// Yahoo Finance only retains fine-grained intraday history for a limited recent window (documented,
|
||||
// publicly-known limits: 1m ~7 days, 5m/15m/30m ~60 days), then serves 1h/1d/1wk bars over many years.
|
||||
// FinlyticSimulation.QuantSimulationEngine.ResolveYahooRange picks the matching fetch window per timeframe,
|
||||
// so a shorter timeframe here genuinely means "less total history available for this backtest" - see the
|
||||
// info tooltip on the TF field.
|
||||
final List<String> _timeframes = ['1m', '5m', '15m', '30m', '1h', '1d', '1wk'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedIsin = widget.initialIsin ?? _defaultIsin;
|
||||
// Fire-and-forget: the history panel shows an explicit loading/empty/error state on its own, so the
|
||||
// initial screen build does not need to wait on this.
|
||||
context.read<BacktestCubit>().loadHistory(isin: _selectedIsin);
|
||||
}
|
||||
|
||||
Future<void> _pickAsset() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => AssetSearchDialog(
|
||||
apiClient: widget.apiClient,
|
||||
onAssetSelected: (isin, name) {
|
||||
setState(() {
|
||||
_selectedIsin = isin;
|
||||
_selectedAssetName = name;
|
||||
});
|
||||
context.read<BacktestCubit>().loadHistory(isin: isin, strategyKey: _selectedStrategy);
|
||||
// A saved parameter profile is scoped to (isin, strategyKey) - the overrides for the previous
|
||||
// asset almost certainly don't apply to this one.
|
||||
context.read<BacktestCubit>().resetParameterOverrides();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _runBacktest() {
|
||||
if (_selectedIsin.trim().isEmpty) return;
|
||||
|
||||
context.read<BacktestCubit>().runBacktest(
|
||||
isin: _selectedIsin.trim(),
|
||||
symbol: '', // Left blank on purpose: FinlyticSimulation resolves the ticker from the ISIN itself.
|
||||
strategyKey: _selectedStrategy,
|
||||
timeframe: _selectedTimeframe,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.darkBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text('Quant & Backtest Engine', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Parameter Card
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Backtest Parameter', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _pickAsset,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search, size: 18, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedAssetName.isNotEmpty ? _selectedAssetName : 'Asset auswählen',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(_selectedIsin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedStrategy,
|
||||
dropdownColor: AppTheme.cardSurface,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Strategie',
|
||||
labelStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: _strategies.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val == null) return;
|
||||
setState(() => _selectedStrategy = val);
|
||||
context.read<BacktestCubit>().loadHistory(isin: _selectedIsin, strategyKey: val);
|
||||
// A bare parameter name (e.g. "Period") means something different per strategy -
|
||||
// overrides from the previous strategy must not silently carry over.
|
||||
context.read<BacktestCubit>().resetParameterOverrides();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Wie funktioniert diese Strategie?',
|
||||
icon: Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 20),
|
||||
onPressed: () => StrategyExplanations.showStrategyDetails(context, _selectedStrategy),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedTimeframe,
|
||||
dropdownColor: AppTheme.cardSurface,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'TF',
|
||||
labelStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: _timeframes.map((tf) => DropdownMenuItem(value: tf, child: Text(tf))).toList(),
|
||||
onChanged: (val) => setState(() => _selectedTimeframe = val ?? _selectedTimeframe),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip:
|
||||
'Kerzen-Zeitrahmen für den Backtest. Yahoo Finance liefert feine Zeitrahmen nur für '
|
||||
'ein begrenztes, aktuelles Zeitfenster (1m ≈ 7 Tage, 5m/15m/30m ≈ 60 Tage), während '
|
||||
'1h/1d/1wk viele Jahre Historie abdecken - kürzere Zeitrahmen bedeuten also '
|
||||
'automatisch weniger verfügbare Backtest-Historie.',
|
||||
icon: Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 20),
|
||||
onPressed: () => _showTimeframeInfo(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
BlocBuilder<BacktestCubit, BacktestState>(
|
||||
builder: (context, state) => _buildParameterSection(state),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BlocBuilder<BacktestCubit, BacktestState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: state.isLoading ? null : _runBacktest,
|
||||
icon: state.isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(state.isLoading ? 'Replay läuft...' : 'Backtest Ausführen'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryEmerald,
|
||||
// Without an explicit foregroundColor, the default theme-derived text color on
|
||||
// this bright background was effectively invisible until the pressed-state
|
||||
// overlay darkened it enough to read - black is the established convention for
|
||||
// primaryEmerald buttons elsewhere in the app (e.g. admin_users_screen.dart).
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
BlocBuilder<BacktestCubit, BacktestState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state.errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: AppTheme.accentRed.withValues(alpha: 0.15),
|
||||
border: Border.all(color: AppTheme.accentRed),
|
||||
),
|
||||
child: Text(state.errorMessage!, style: TextStyle(color: AppTheme.accentRed)),
|
||||
),
|
||||
if (state.report != null) ...[
|
||||
if (state.isViewingHistoricalRun)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.history, size: 14, color: AppTheme.accentCyan),
|
||||
const SizedBox(width: 6),
|
||||
Text('Aus dem Verlauf geladen', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildMetricsSummary(state.report!),
|
||||
const SizedBox(height: 16),
|
||||
_buildEquityCurveChart(state.report!),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildHistorySection(state),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTimeframeInfo(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
title: const Text('Zeitrahmen (TF)', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
content: Text(
|
||||
'Der Zeitrahmen bestimmt, wie groß eine einzelne Kerze im Backtest ist (z. B. "1h" = eine Kerze pro Stunde).\n\n'
|
||||
'Warum nicht jeder Zeitrahmen die gleiche Historie liefert: Yahoo Finance speichert feine, '
|
||||
'minutengenaue Kursdaten nur für ein begrenztes, aktuelles Zeitfenster:\n\n'
|
||||
'• 1m: nur die letzten ~7 Tage\n'
|
||||
'• 5m / 15m / 30m: nur die letzten ~60 Tage\n'
|
||||
'• 1h: bis zu ~2 Jahre\n'
|
||||
'• 1d / 1wk: viele Jahre\n\n'
|
||||
'Ein Backtest auf "1m" liefert also automatisch nur sehr wenige Trades, weil kaum Historie '
|
||||
'verfügbar ist - für aussagekräftige Backtests eignen sich meist 1h, 1d oder 1wk besser.',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Verstanden', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// "Erweiterte Parameter": lets the user override the currently selected strategy's tunable indicator
|
||||
/// parameters for this backtest run only (see `TechnicalContext.ParameterOverrides`), and optionally save
|
||||
/// the current set as a reusable profile for this (asset, strategy) pair. Renders nothing for a strategy
|
||||
/// with no tunable parameters defined.
|
||||
Widget _buildParameterSection(BacktestState state) {
|
||||
final defs = StrategyParameterDefinitions.forStrategy(_selectedStrategy);
|
||||
if (defs.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(top: 4, bottom: 12),
|
||||
iconColor: AppTheme.textMuted,
|
||||
collapsedIconColor: AppTheme.textMuted,
|
||||
title: Text(
|
||||
'Erweiterte Parameter (${defs.length})',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
children: [
|
||||
...defs.map((def) => _buildParameterRow(def, state)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: state.isParametersLoading
|
||||
? null
|
||||
: () => context
|
||||
.read<BacktestCubit>()
|
||||
.loadSavedParameters(isin: _selectedIsin, strategyKey: _selectedStrategy),
|
||||
icon: const Icon(Icons.folder_open_outlined, size: 16),
|
||||
label: const Text('Laden', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: state.isParametersLoading
|
||||
? null
|
||||
: () => context
|
||||
.read<BacktestCubit>()
|
||||
.saveCurrentParameters(isin: _selectedIsin, strategyKey: _selectedStrategy),
|
||||
icon: const Icon(Icons.save_outlined, size: 16),
|
||||
label: const Text('Speichern', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.isParametersLoading) ...[
|
||||
const SizedBox(height: 10),
|
||||
const Center(child: SizedBox(height: 14, width: 14, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
] else if (state.parametersMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(state.parametersMessage!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One full-width row per tunable parameter: a readable label (wraps instead of truncating), a tap-to-show
|
||||
/// info icon explaining what it controls, and a comfortably-sized value field - replaces the previous
|
||||
/// `Wrap` of fixed-140px fields with floating labels, which squeezed the (often long) German labels down to
|
||||
/// the point of being unreadable.
|
||||
Widget _buildParameterRow(StrategyParameterDef def, BacktestState state) {
|
||||
final currentValue = state.parameterOverrides[def.name] ?? def.defaultValue;
|
||||
final displayValue = def.isInteger ? currentValue.toStringAsFixed(0) : currentValue.toString();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
def.label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: def.hint,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Icon(Icons.info_outline, size: 16, color: AppTheme.accentCyan),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: TextFormField(
|
||||
// Forces the field to redraw with the new value after "Laden" replaces the whole override map -
|
||||
// a plain `initialValue` is otherwise only honored on the very first build.
|
||||
key: ValueKey('$_selectedIsin-$_selectedStrategy-${def.name}-$displayValue'),
|
||||
initialValue: displayValue,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.06),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (text) {
|
||||
final parsed = double.tryParse(text.trim().replaceAll(',', '.'));
|
||||
if (parsed != null) {
|
||||
context.read<BacktestCubit>().setParameterOverride(def.name, parsed);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistorySection(BacktestState state) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Backtest-Verlauf für dieses Asset', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
),
|
||||
if (state.isHistoryLoading) const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (state.historyErrorMessage != null)
|
||||
Text(state.historyErrorMessage!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12))
|
||||
else if (!state.isHistoryLoading && state.history.isEmpty)
|
||||
Text(
|
||||
'Für $_selectedIsin wurde noch kein Backtest ausgeführt.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
)
|
||||
else
|
||||
...state.history.map(_buildHistoryRow),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryRow(BacktestHistoryEntryModel entry) {
|
||||
final isPositive = entry.totalReturnPercent >= 0;
|
||||
final returnColor = isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => context.read<BacktestCubit>().viewHistoricalRun(entry.runId),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.strategyKey} · ${entry.timeframe}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
DateFormat('dd.MM.yy HH:mm').format(entry.createdAtUtc.toLocal()),
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${entry.totalTrades} Trades',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'${isPositive ? '+' : ''}${entry.totalReturnPercent.toStringAsFixed(1)}%',
|
||||
style: TextStyle(color: returnColor, fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricsSummary(BacktestReportModel report) {
|
||||
final isPositive = report.totalReturnPercent >= 0;
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Performance Metriken', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildMetricTile('Winrate', '${report.winRatePercent.toStringAsFixed(1)}%', report.winRatePercent >= 50 ? AppTheme.primaryEmerald : AppTheme.accentRed)),
|
||||
Expanded(child: _buildMetricTile('Profit Factor', report.profitFactor.toStringAsFixed(2), report.profitFactor >= 1.5 ? AppTheme.primaryEmerald : Colors.amber)),
|
||||
Expanded(child: _buildMetricTile('Gesamtrendite', '${isPositive ? '+' : ''}${report.totalReturnPercent.toStringAsFixed(1)}%', isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed)),
|
||||
Expanded(child: _buildMetricTile('Max Drawdown', '-${report.maxDrawdownPercent.toStringAsFixed(1)}%', Colors.orangeAccent)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
report.totalTrades > 0
|
||||
? 'Geprüft über ${report.totalTrades} Trades (${report.winningTrades} Gewinner / ${report.losingTrades} Verlierer).'
|
||||
: 'Keine Trades in diesem Backtest-Zeitraum ausgeführt.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricTile(String label, String value, Color color) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquityCurveChart(BacktestReportModel report) {
|
||||
final equityCurve = report.equityCurve;
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Simulierte Equity-Kurve (€)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 16),
|
||||
if (equityCurve.isEmpty)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.show_chart, size: 32, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Keine Equity-Kurve für diesen Backtest verfügbar.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (_) => FlLine(color: Colors.white10, strokeWidth: 1),
|
||||
),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: [
|
||||
for (int i = 0; i < equityCurve.length; i++) FlSpot(i.toDouble(), equityCurve[i].portfolioValue),
|
||||
],
|
||||
isCurved: true,
|
||||
color: AppTheme.primaryEmerald,
|
||||
barWidth: 2,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user