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 _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 _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().loadHistory(isin: _selectedIsin); } Future _pickAsset() async { await showDialog( context: context, builder: (_) => AssetSearchDialog( apiClient: widget.apiClient, onAssetSelected: (isin, name) { setState(() { _selectedIsin = isin; _selectedAssetName = name; }); context.read().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().resetParameterOverrides(); }, ), ); } void _runBacktest() { if (_selectedIsin.trim().isEmpty) return; context.read().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( 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().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().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( 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( builder: (context, state) => _buildParameterSection(state), ), const SizedBox(height: 16), BlocBuilder( 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( 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() .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() .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().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().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), ), ), ], ), ), ), ], ), ); } }