feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -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