From 882d24a31607a18e0baa8885af6c33554640a79f Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sat, 15 Aug 2026 19:30:15 +0200 Subject: [PATCH] feat(derivatives): fix Trade Republic derivative pagination streaming and picker modal --- .../core/theme/custom_scroll_behavior.dart | 16 + .../trades/models/derivative_item_model.dart | 95 +++ .../widgets/derivative_picker_modal.dart | 755 ++++++++++++++++++ FinlyticAssets/Database/AssetsDbContext.cs | 10 + FinlyticAssets/Services/AssetsDbService.cs | 92 ++- FinlyticAssets/Util/AssetsMqttClient.cs | 8 +- .../Controllers/AssetsController.cs | 95 +++ 7 files changed, 1045 insertions(+), 26 deletions(-) create mode 100644 FinlyticApp/lib/core/theme/custom_scroll_behavior.dart create mode 100644 FinlyticApp/lib/features/trades/models/derivative_item_model.dart create mode 100644 FinlyticApp/lib/features/trades/widgets/derivative_picker_modal.dart diff --git a/FinlyticApp/lib/core/theme/custom_scroll_behavior.dart b/FinlyticApp/lib/core/theme/custom_scroll_behavior.dart new file mode 100644 index 0000000..f5c4b48 --- /dev/null +++ b/FinlyticApp/lib/core/theme/custom_scroll_behavior.dart @@ -0,0 +1,16 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Global scroll behavior enabling smooth mouse dragging, mouse wheel, and touch scrolling +/// across all platforms (especially Flutter Web on Desktop without requiring Shift-key). +class CustomAppScrollBehavior extends MaterialScrollBehavior { + const CustomAppScrollBehavior(); + + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.trackpad, + }; +} diff --git a/FinlyticApp/lib/features/trades/models/derivative_item_model.dart b/FinlyticApp/lib/features/trades/models/derivative_item_model.dart new file mode 100644 index 0000000..95bed49 --- /dev/null +++ b/FinlyticApp/lib/features/trades/models/derivative_item_model.dart @@ -0,0 +1,95 @@ +import 'package:equatable/equatable.dart'; + +class DerivativeItemModel extends Equatable { + final String isin; + final String name; + final String optionType; + final String productCategoryName; + final String nextGenProductCategoryName; + final double strike; + final double barrier; + final double leverage; + final double? size; + final double? factor; + final double? delta; + final String currency; + final String? expiry; + final String issuer; + final String issuerDisplayName; + final String? issuerImageId; + final String underlyingIsin; + + const DerivativeItemModel({ + required this.isin, + this.name = '', + this.optionType = 'Long', + this.productCategoryName = '', + this.nextGenProductCategoryName = '', + this.strike = 0.0, + this.barrier = 0.0, + this.leverage = 1.0, + this.size, + this.factor, + this.delta, + this.currency = 'EUR', + this.expiry, + this.issuer = '', + this.issuerDisplayName = '', + this.issuerImageId, + this.underlyingIsin = '', + }); + + bool get isLong => optionType.toLowerCase().contains('long') || optionType.toLowerCase().contains('call'); + + double distanceToBarrier(double currentUnderlyingPrice) { + if (barrier <= 0 || currentUnderlyingPrice <= 0) return 0.0; + return ((currentUnderlyingPrice - barrier).abs() / currentUnderlyingPrice) * 100.0; + } + + factory DerivativeItemModel.fromJson(Map 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; + } + + double? parseOptDbl(dynamic val) { + if (val == null) return null; + if (val is num) return val.toDouble(); + return double.tryParse(val.toString()); + } + + return DerivativeItemModel( + isin: json['isin']?.toString() ?? '', + name: json['name']?.toString() ?? '', + optionType: json['optionType']?.toString() ?? 'Long', + productCategoryName: json['productCategoryName']?.toString() ?? '', + nextGenProductCategoryName: json['nextGenProductCategoryName']?.toString() ?? '', + strike: parseDbl(json['strike']), + barrier: parseDbl(json['barrier']), + leverage: parseDbl(json['leverage']), + size: parseOptDbl(json['size']), + factor: parseOptDbl(json['factor']), + delta: parseOptDbl(json['delta']), + currency: json['currency']?.toString() ?? 'EUR', + expiry: json['expiry']?.toString(), + issuer: json['issuer']?.toString() ?? '', + issuerDisplayName: json['issuerDisplayName']?.toString() ?? (json['issuer']?.toString() ?? ''), + issuerImageId: json['issuerImageId']?.toString(), + underlyingIsin: json['underlyingIsin']?.toString() ?? '', + ); + } + + @override + List get props => [ + isin, + name, + optionType, + productCategoryName, + strike, + barrier, + leverage, + issuer, + underlyingIsin, + ]; +} diff --git a/FinlyticApp/lib/features/trades/widgets/derivative_picker_modal.dart b/FinlyticApp/lib/features/trades/widgets/derivative_picker_modal.dart new file mode 100644 index 0000000..f6c3190 --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/derivative_picker_modal.dart @@ -0,0 +1,755 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../models/derivative_item_model.dart'; +import '../repositories/trade_repository.dart'; + +class DerivativePickerModal extends StatefulWidget { + final String underlyingIsin; + final String underlyingSymbol; + final String underlyingName; + final String initialSignalType; + final double currentUnderlyingPrice; + + const DerivativePickerModal({ + super.key, + required this.underlyingIsin, + required this.underlyingSymbol, + this.underlyingName = '', + this.initialSignalType = 'BUY', + this.currentUnderlyingPrice = 0.0, + }); + + static Future show( + BuildContext context, { + required String underlyingIsin, + required String underlyingSymbol, + String underlyingName = '', + String initialSignalType = 'BUY', + double currentUnderlyingPrice = 0.0, + }) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => DerivativePickerModal( + underlyingIsin: underlyingIsin, + underlyingSymbol: underlyingSymbol, + underlyingName: underlyingName, + initialSignalType: initialSignalType, + currentUnderlyingPrice: currentUnderlyingPrice, + ), + ); + } + + @override + State createState() => _DerivativePickerModalState(); +} + +class _DerivativePickerModalState extends State { + late String _optionType; + double _targetLeverage = 5.0; + final TextEditingController _leverageTextCtrl = TextEditingController(text: '5.0'); + String _searchQuery = ''; + final TextEditingController _searchCtrl = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + Timer? _debounceTimer; + + List _allDerivatives = []; + bool _isLoading = true; + bool _isLoadingMore = false; + bool _hasMoreData = true; + int _currentPage = 0; + String? _errorMessage; + + @override + void initState() { + super.initState(); + final isShort = widget.initialSignalType.toUpperCase() == 'SELL' || widget.initialSignalType.toUpperCase() == 'SHORT'; + _optionType = isShort ? 'short' : 'long'; + _scrollController.addListener(_onScroll); + _fetchDerivatives(reset: true); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + _searchCtrl.dispose(); + _leverageTextCtrl.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 250) { + if (!_isLoading && !_isLoadingMore && _hasMoreData) { + _fetchMoreDerivatives(); + } + } + } + + String _formatLeverage(double lev) { + if (lev <= 0) return '1x'; + if (lev == lev.roundToDouble()) { + return '${lev.toInt()}x'; + } + var s = lev.toStringAsFixed(2); + if (s.endsWith('0')) { + s = s.substring(0, s.length - 1); + } + return '${s.replaceAll('.', ',')}x'; + } + + String _formatLeverageNum(double lev) { + if (lev <= 0) return '1'; + if (lev == lev.roundToDouble()) { + return lev.toInt().toString(); + } + var s = lev.toStringAsFixed(2); + if (s.endsWith('0')) { + s = s.substring(0, s.length - 1); + } + return s.replaceAll('.', ','); + } + + void _setTargetLeverage(double lev) { + setState(() { + _targetLeverage = lev; + _leverageTextCtrl.text = _formatLeverageNum(lev); + }); + _fetchDerivatives(reset: true); + } + + Future _fetchDerivatives({bool reset = true, bool forceRefresh = false}) async { + if (reset) { + setState(() { + _isLoading = true; + _isLoadingMore = false; + _hasMoreData = true; + _currentPage = 0; + _errorMessage = null; + }); + } + + try { + final apiClient = context.read(); + final repo = TradeRepository(apiClient: apiClient); + final isinToUse = widget.underlyingIsin.isNotEmpty ? widget.underlyingIsin : widget.underlyingSymbol; + + final results = await repo.fetchDerivatives( + isinToUse, + optionType: _optionType, + targetLeverage: _targetLeverage, + page: _currentPage, + search: _searchQuery.isNotEmpty ? _searchQuery : null, + forceRefresh: forceRefresh, + ); + + if (mounted) { + setState(() { + if (reset) { + _allDerivatives = results; + } else { + // Append and deduplicate by ISIN + final existingIsins = _allDerivatives.map((d) => d.isin).toSet(); + final newItems = results.where((d) => !existingIsins.contains(d.isin)).toList(); + _allDerivatives.addAll(newItems); + } + + if (results.isEmpty || results.length < 50) { + _hasMoreData = false; + } + + _currentPage++; + _isLoading = false; + _isLoadingMore = false; + }); + + if (reset && _scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + _isLoadingMore = false; + }); + } + } + } + + Future _fetchMoreDerivatives() async { + if (_isLoadingMore || !_hasMoreData) return; + + setState(() { + _isLoadingMore = true; + }); + + await _fetchDerivatives(reset: false); + } + + List get _filteredDerivatives { + if (_searchQuery.trim().isEmpty) { + return _allDerivatives; + } + + final q = _searchQuery.toLowerCase().trim(); + return _allDerivatives.where((d) { + final matchIsin = d.isin.toLowerCase().contains(q); + final matchIssuer = d.issuer.toLowerCase().contains(q) || d.issuerDisplayName.toLowerCase().contains(q); + final matchCategory = d.productCategoryName.toLowerCase().contains(q); + final matchBarrier = d.barrier.toString().contains(q); + return matchIsin || matchIssuer || matchCategory || matchBarrier; + }).toList(); + } + + @override + Widget build(BuildContext context) { + final isLong = _optionType == 'long'; + final activeColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed; + final filtered = _filteredDerivatives; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Container( + width: 780, + constraints: const BoxConstraints(maxHeight: 820), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppTheme.glassBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.6), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 14), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.accentCyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.bolt, color: AppTheme.accentCyan, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Trade Republic Derivate-Finder', + style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + if (!_isLoading && _allDerivatives.isNotEmpty) + StatusBadge( + label: '${_allDerivatives.length} Derivate geladen', + color: AppTheme.accentCyan, + ), + ], + ), + const SizedBox(height: 3), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + Text( + 'Basiswert: ${widget.underlyingName.isNotEmpty ? widget.underlyingName : (widget.underlyingSymbol.isNotEmpty && widget.underlyingSymbol != widget.underlyingIsin ? widget.underlyingSymbol : widget.underlyingIsin)} (${widget.underlyingIsin})', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + if (widget.currentUnderlyingPrice > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.white12), + ), + child: Text( + 'Kurs: €${widget.currentUnderlyingPrice.toStringAsFixed(2)}', + style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ], + ), + ), + IconButton( + onPressed: () => _fetchDerivatives(reset: true, forceRefresh: true), + icon: const Icon(Icons.refresh, color: Colors.white70, size: 20), + tooltip: 'Neu von Trade Republic abrufen', + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white54), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + + // Controls Bar + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Direction Selector & Search + Row( + children: [ + // Direction Toggle + Container( + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _directionButton('Long (Turbo Bull)', 'long', AppTheme.primaryEmerald), + _directionButton('Short (Turbo Bear)', 'short', AppTheme.accentRed), + ], + ), + ), + const SizedBox(width: 12), + // Search Input + Expanded( + child: TextField( + controller: _searchCtrl, + onChanged: (val) => setState(() { + _searchQuery = val; + }), + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + hintText: 'Emittent (HSBC, SocGen...), Barriere, ISIN...', + hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12), + prefixIcon: const Icon(Icons.search, size: 16, color: Colors.white54), + filled: true, + fillColor: AppTheme.glassSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Interactive Target-Leverage Control Unit + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Row( + children: [ + Text( + 'Wunsch-Hebel:', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 10), + + // Minus button + _stepButton(Icons.remove, () { + final cur = double.tryParse(_leverageTextCtrl.text.replaceAll(',', '.')) ?? _targetLeverage; + final next = (cur - 1.0 < 1.0) ? 1.0 : (cur - 1.0); + _setTargetLeverage(next); + }, activeColor), + const SizedBox(width: 6), + + // Numeric input + SizedBox( + width: 68, + height: 34, + child: TextField( + controller: _leverageTextCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 6), + filled: true, + fillColor: Colors.black.withValues(alpha: 0.35), + suffixText: 'x', + suffixStyle: TextStyle(color: activeColor, fontSize: 12, fontWeight: FontWeight.bold), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: activeColor.withValues(alpha: 0.4))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: Colors.white24)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: activeColor, width: 1.5)), + ), + onChanged: (val) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () { + final parsed = double.tryParse(val.replaceAll(',', '.')); + if (parsed != null && parsed > 0) { + _targetLeverage = parsed; + _fetchDerivatives(reset: true); + } + }); + }, + ), + ), + const SizedBox(width: 6), + + // Plus button + _stepButton(Icons.add, () { + final cur = double.tryParse(_leverageTextCtrl.text.replaceAll(',', '.')) ?? _targetLeverage; + final next = (cur + 1.0 > 150.0) ? 150.0 : (cur + 1.0); + _setTargetLeverage(next); + }, activeColor), + const SizedBox(width: 14), + + // Quick Pills + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + children: [1.0, 2.0, 3.0, 4.0, 5.0, 7.0, 10.0, 15.0, 20.0, 30.0, 50.0, 75.0].map((lev) { + final isSelected = (_targetLeverage - lev).abs() < 0.2; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: () => _setTargetLeverage(lev), + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? activeColor.withValues(alpha: 0.2) : Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSelected ? activeColor : Colors.white12, + width: isSelected ? 1.5 : 1, + ), + ), + child: Text( + '${lev.toInt()}x', + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold, + color: isSelected ? activeColor : Colors.white70, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + + // Content List (with Streaming Infinite Scroll) + Expanded( + child: _buildListContent(filtered, activeColor), + ), + + // Streaming Status Footer + if (!_isLoading && filtered.isNotEmpty) ...[ + const Divider(color: Colors.white12, height: 1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${filtered.length} Derivate ab ${_formatLeverage(_targetLeverage)} Hebel', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + Row( + children: [ + if (_hasMoreData) ...[ + Icon(Icons.arrow_downward, size: 14, color: AppTheme.accentCyan), + const SizedBox(width: 4), + Text( + 'Scrolle nach unten für höhere Hebel', + style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold), + ), + ] else ...[ + Icon(Icons.check_circle, size: 14, color: AppTheme.primaryEmerald), + const SizedBox(width: 4), + Text( + 'Alle Derivate dieser Spanne geladen', + style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 11, fontWeight: FontWeight.bold), + ), + ], + ], + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Widget _stepButton(IconData icon, VoidCallback onPressed, Color activeColor) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.white12), + ), + child: Icon(icon, size: 16, color: Colors.white), + ), + ); + } + + Widget _directionButton(String label, String value, Color color) { + final isSelected = _optionType == value; + return GestureDetector( + onTap: () { + if (_optionType != value) { + setState(() { + _optionType = value; + }); + _fetchDerivatives(reset: true); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? color.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: isSelected ? Border.all(color: color.withValues(alpha: 0.6)) : null, + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? color : AppTheme.textMuted, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 12, + ), + ), + ), + ); + } + + Widget _buildListContent(List filtered, Color activeColor) { + if (_isLoading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppTheme.accentCyan), + const SizedBox(height: 12), + Text('Lade Live-Derivate ab ${_formatLeverage(_targetLeverage)} Hebel...', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), + ], + ), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 36, color: AppTheme.accentRed), + const SizedBox(height: 8), + Text('Fehler beim Laden der Derivate', style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(_errorMessage!, style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: () => _fetchDerivatives(reset: true, forceRefresh: true), + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Erneut versuchen'), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black), + ), + ], + ), + ), + ); + } + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 36, color: AppTheme.textMuted), + const SizedBox(height: 8), + Text('Keine Derivate für diesen Hebel gefunden', style: TextStyle(color: AppTheme.textSecondary, fontSize: 14, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('Wähle einen anderen Hebel (z. B. 2x, 5x, 10x) oder leere die Suche.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)), + const SizedBox(height: 12), + TextButton( + onPressed: () { + _searchCtrl.clear(); + _setTargetLeverage(5.0); + }, + child: Text('Zurücksetzen auf 5x Hebel', style: TextStyle(color: AppTheme.accentCyan)), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: filtered.length + (_isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == filtered.length) { + // Bottom loading indicator when streaming next 50 items + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.accentCyan), + ), + const SizedBox(width: 10), + Text('Lade nächste 50 Derivate von Trade Republic...', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)), + ], + ), + ); + } + + final item = filtered[index]; + final dist = widget.currentUnderlyingPrice > 0 ? item.distanceToBarrier(widget.currentUnderlyingPrice) : 0.0; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Row( + children: [ + // Leverage Badge + Container( + width: 58, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: activeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: activeColor.withValues(alpha: 0.4)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatLeverage(item.leverage), + style: TextStyle(color: activeColor, fontWeight: FontWeight.bold, fontSize: 14), + ), + Text( + 'HEBEL', + style: TextStyle(color: activeColor.withValues(alpha: 0.8), fontSize: 9, fontWeight: FontWeight.w600), + ), + ], + ), + ), + const SizedBox(width: 12), + + // Derivative Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + item.issuerDisplayName.isNotEmpty ? item.issuerDisplayName : item.issuer, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.nextGenProductCategoryName.isNotEmpty ? item.nextGenProductCategoryName : item.productCategoryName, + style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text('ISIN: ${item.isin}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + const SizedBox(width: 10), + if (item.barrier > 0) + Text( + 'KO: €${item.barrier.toStringAsFixed(2)}', + style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold), + ), + if (dist > 0) ...[ + const SizedBox(width: 6), + Text( + '(${dist.toStringAsFixed(1)}% Abst.)', + style: TextStyle(color: dist < 5.0 ? AppTheme.accentRed : AppTheme.textMuted, fontSize: 11), + ), + ], + ], + ), + ], + ), + ), + + // Action Select Button + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(item); + }, + style: ElevatedButton.styleFrom( + backgroundColor: activeColor, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Wählen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + ), + ], + ), + ); + }, + ); + } +} diff --git a/FinlyticAssets/Database/AssetsDbContext.cs b/FinlyticAssets/Database/AssetsDbContext.cs index 0ed1663..3213c31 100644 --- a/FinlyticAssets/Database/AssetsDbContext.cs +++ b/FinlyticAssets/Database/AssetsDbContext.cs @@ -84,4 +84,14 @@ public class AssetsDbContext : DbContext .HasMany(a => a.Tags) .WithMany(t => t.Assets); } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + + configurationBuilder.Properties() + .HaveConversion(typeof(FinlyticCore.Converters.UtcDateTimeConverter)); + configurationBuilder.Properties() + .HaveConversion(typeof(FinlyticCore.Converters.NullableUtcDateTimeConverter)); + } } diff --git a/FinlyticAssets/Services/AssetsDbService.cs b/FinlyticAssets/Services/AssetsDbService.cs index 384a058..90e7db6 100644 --- a/FinlyticAssets/Services/AssetsDbService.cs +++ b/FinlyticAssets/Services/AssetsDbService.cs @@ -20,7 +20,7 @@ public interface IAssetsDbService public Task UpdateAssetImageIdAsync(string isin, string imageId); public Task DeleteAssetAsync(string isin); public Task> GetDiscoveryAssetsAsync(int limit = 15); - public Task> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", bool forceRefresh = false, CancellationToken cancellationToken = default); + public Task> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default); } /// @@ -394,38 +394,56 @@ public class AssetsDbService : IAssetsDbService } /// Inherits documentation from interface. - public async Task> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", bool forceRefresh = false, CancellationToken cancellationToken = default) + public async Task> GetDerivativesByUnderlyingAsync( + string underlyingIsin, + string optionType = "long", + decimal? targetLeverage = null, + string? after = null, + int? page = null, + bool forceRefresh = false, + CancellationToken cancellationToken = default) { var targetOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? OptionType.Short : OptionType.Long; - var cutoff = DateTime.UtcNow.AddDays(-7); + string cleanOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? "short" : "long"; + const int pageSize = 50; + int pageIndex = Math.Max(0, page ?? 0); - if (!forceRefresh) - { - var cached = await _context.TradeRepublicAssets - .OfType() - .AsNoTracking() - .Include(a => a.Tags) - .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType && d.LastUpdatedAt >= cutoff) - .ToListAsync(cancellationToken); + decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m; - if (cached.Count > 0) - { - return cached; - } - } + // Trade Republic uses page index (0, 1, 2, 3...) for the 'after' pagination parameter in derivatives + string trAfter = !string.IsNullOrEmpty(after) ? after : (pageIndex > 0 ? pageIndex.ToString() : "0"); + + _logger.LogInformation("[{Channel}] Fetching derivatives for {Isin} (OptionType: {Option}, Leverage: {Lev}, Page: {Page}, TR-After: {After})", + "AssetsChannel", underlyingIsin, cleanOptionType, levQuery, pageIndex, trAfter); + + var trReq = new TradeRepublicDerivativesRequest( + Underlying: underlyingIsin, + OptionType: cleanOptionType, + ProductCategory: "knockOutProduct", + Leverage: levQuery, + SortBy: "leverage", + SortDirection: "asc", + PageSize: pageSize, + After: trAfter); - var trReq = new TradeRepublicDerivativesRequest(Underlying: underlyingIsin, OptionType: optionType, ProductCategory: "knockOutProduct", PageSize: 50, After: "0"); var trResponse = await _tradeRepublicService.GetDerivativesAsync(trReq, cancellationToken); - if (trResponse?.Results != null && trResponse.Results.Count > 0) + var fetchedItems = trResponse?.Results ?? new List(); + + _logger.LogInformation("[{Channel}] TR returned {Count} derivatives for {Isin} (Cursors.After: {NextAfter})", + "AssetsChannel", fetchedItems.Count, underlyingIsin, trResponse?.Cursors?.After ?? "null"); + + if (fetchedItems.Count > 0) { var now = DateTime.UtcNow; - var isins = trResponse.Results.Select(r => r.Isin).Distinct().ToList(); + var isins = fetchedItems.Select(r => r.Isin).ToList(); var existingDerivatives = await _context.TradeRepublicAssets .OfType() .Where(d => isins.Contains(d.Isin)) .ToDictionaryAsync(d => d.Isin, cancellationToken); - foreach (var item in trResponse.Results) + List resultEntities = new(); + + foreach (var item in fetchedItems) { if (!existingDerivatives.TryGetValue(item.Isin, out var entity)) { @@ -438,8 +456,14 @@ public class AssetsDbService : IAssetsDbService await _context.TradeRepublicAssets.AddAsync(entity, cancellationToken); } + bool isShortItem = string.Equals(item.OptionType, "short", StringComparison.OrdinalIgnoreCase) || + string.Equals(item.OptionType, "put", StringComparison.OrdinalIgnoreCase) || + item.OptionType.Contains("short", StringComparison.OrdinalIgnoreCase) || + item.OptionType.Contains("put", StringComparison.OrdinalIgnoreCase) || + item.OptionType.Contains("bear", StringComparison.OrdinalIgnoreCase); + entity.UnderlyingIsin = underlyingIsin; - entity.OptionType = item.OptionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? OptionType.Short : OptionType.Long; + entity.OptionType = isShortItem ? OptionType.Short : OptionType.Long; entity.ProductCategoryName = item.ProductCategoryName; entity.NextGenProductCategoryName = item.NextGenProductCategoryName; entity.Strike = item.Strike ?? 0m; @@ -449,24 +473,42 @@ public class AssetsDbService : IAssetsDbService entity.Factor = item.Factor; entity.Delta = item.Delta; entity.Currency = item.Currency ?? "EUR"; - entity.Expiry = DateTime.TryParse(item.Expiry, out var exp) ? exp : null; + entity.Expiry = DateTime.TryParse(item.Expiry, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var exp) + ? DateTime.SpecifyKind(exp, DateTimeKind.Utc) + : (DateTime?)null; entity.Issuer = item.Issuer; entity.IssuerDisplayName = item.IssuerDisplayName; entity.IssuerImageId = item.IssuerImageId; entity.ImageId = item.ImageId; - entity.Name = $"{item.IssuerDisplayName} {item.NextGenProductCategoryName} ({item.OptionType.ToUpper()})"; + entity.Name = $"{item.IssuerDisplayName} {item.NextGenProductCategoryName} ({(isShortItem ? "SHORT" : "LONG")})"; entity.LastUpdatedAt = now; + + resultEntities.Add(entity); } await _context.SaveChangesAsync(cancellationToken); + return resultEntities; } - return await _context.TradeRepublicAssets + // Fallback: Query from DB if Trade Republic returned 0 or was unreachable + var dbQuery = _context.TradeRepublicAssets .OfType() .AsNoTracking() .Include(a => a.Tags) - .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType) + .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType); + + if (levQuery > 0) + { + dbQuery = dbQuery.Where(d => d.Leverage >= (levQuery - 0.2m)); + } + + var results = await dbQuery + .OrderBy(d => d.Leverage) + .Skip(pageIndex * pageSize) + .Take(pageSize) .ToListAsync(cancellationToken); + + return results; } #region Helper & Mapping Methods diff --git a/FinlyticAssets/Util/AssetsMqttClient.cs b/FinlyticAssets/Util/AssetsMqttClient.cs index f6d6872..3532b02 100644 --- a/FinlyticAssets/Util/AssetsMqttClient.cs +++ b/FinlyticAssets/Util/AssetsMqttClient.cs @@ -225,7 +225,13 @@ public class AssetsMqttClient( var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDerivativesRequest); if (req != null && !string.IsNullOrEmpty(req.UnderlyingIsin)) { - return await dbService.GetDerivativesByUnderlyingAsync(req.UnderlyingIsin, req.OptionType, req.ShouldForceRefresh); + return await dbService.GetDerivativesByUnderlyingAsync( + req.UnderlyingIsin, + req.OptionType, + req.TargetLeverage, + req.After, + req.Page, + req.ShouldForceRefresh); } } catch (Exception ex) diff --git a/FinlyticBackend/Controllers/AssetsController.cs b/FinlyticBackend/Controllers/AssetsController.cs index 12d0e2b..237a99c 100644 --- a/FinlyticBackend/Controllers/AssetsController.cs +++ b/FinlyticBackend/Controllers/AssetsController.cs @@ -268,6 +268,101 @@ public class AssetsController : ControllerBase return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." }); } + /// + /// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC. + /// + [HttpGet("{isin}/derivatives")] + public async Task GetDerivatives( + [FromRoute] string isin, + [FromQuery] string optionType = "long", + [FromQuery] decimal? targetLeverage = null, + [FromQuery] decimal? minLeverage = null, + [FromQuery] decimal? maxLeverage = null, + [FromQuery] string? search = null, + [FromQuery] string? after = null, + [FromQuery] int? page = null, + [FromQuery] int? pageSize = null, + [FromQuery] bool forceRefresh = false, + CancellationToken cancellationToken = default) + { + string cleanIsin = isin.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(cleanIsin)) + { + return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." }); + } + + try + { + if (_mqttClient.IsConnected) + { + var req = new GetDerivativesRequest( + UnderlyingIsin: cleanIsin, + OptionType: optionType.ToLowerInvariant(), + TargetLeverage: targetLeverage, + After: after, + Page: page, + ForceRefresh: forceRefresh + ); + + var rpcResult = await _mqttClient.SendRpcRequestAsync, GetDerivativesRequest>( + "assets_GetDerivatives", + req, + TimeSpan.FromSeconds(30) + ); + + if (rpcResult != null) + { + var derivatives = rpcResult.OfType().ToList(); + + if (minLeverage.HasValue) + { + derivatives = derivatives.Where(d => d.Leverage >= minLeverage.Value).ToList(); + } + + if (maxLeverage.HasValue) + { + derivatives = derivatives.Where(d => d.Leverage <= maxLeverage.Value).ToList(); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + string q = search.Trim(); + derivatives = derivatives.Where(d => + (d.Isin != null && d.Isin.Contains(q, StringComparison.OrdinalIgnoreCase)) || + (d.Issuer != null && d.Issuer.Contains(q, StringComparison.OrdinalIgnoreCase)) || + (d.IssuerDisplayName != null && d.IssuerDisplayName.Contains(q, StringComparison.OrdinalIgnoreCase)) || + (d.ProductCategoryName != null && d.ProductCategoryName.Contains(q, StringComparison.OrdinalIgnoreCase)) + ).ToList(); + } + + derivatives = derivatives.OrderBy(d => d.Leverage).ToList(); + + int totalCount = derivatives.Count; + if (page.HasValue && pageSize.HasValue && page.Value > 0 && pageSize.Value > 0) + { + int pSize = Math.Clamp(pageSize.Value, 1, 200); + int pIndex = Math.Max(1, page.Value); + int totalPages = (int)Math.Ceiling((double)totalCount / pSize); + + Response.Headers["X-Total-Count"] = totalCount.ToString(); + Response.Headers["X-Total-Pages"] = totalPages.ToString(); + Response.Headers["X-Current-Page"] = pIndex.ToString(); + + derivatives = derivatives.Skip((pIndex - 1) * pSize).Take(pSize).ToList(); + } + + return Ok(derivatives); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[AssetsController] Fehler beim Abrufen der Derivate für {Isin}", cleanIsin); + } + + return Ok(new List()); + } + /// /// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath). ///