feat(derivatives): fix Trade Republic derivative pagination streaming and picker modal
This commit is contained in:
@@ -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<PointerDeviceKind> get dragDevices => {
|
||||||
|
PointerDeviceKind.touch,
|
||||||
|
PointerDeviceKind.mouse,
|
||||||
|
PointerDeviceKind.stylus,
|
||||||
|
PointerDeviceKind.trackpad,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Object?> get props => [
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
optionType,
|
||||||
|
productCategoryName,
|
||||||
|
strike,
|
||||||
|
barrier,
|
||||||
|
leverage,
|
||||||
|
issuer,
|
||||||
|
underlyingIsin,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<DerivativeItemModel?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required String underlyingIsin,
|
||||||
|
required String underlyingSymbol,
|
||||||
|
String underlyingName = '',
|
||||||
|
String initialSignalType = 'BUY',
|
||||||
|
double currentUnderlyingPrice = 0.0,
|
||||||
|
}) {
|
||||||
|
return showDialog<DerivativeItemModel>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (ctx) => DerivativePickerModal(
|
||||||
|
underlyingIsin: underlyingIsin,
|
||||||
|
underlyingSymbol: underlyingSymbol,
|
||||||
|
underlyingName: underlyingName,
|
||||||
|
initialSignalType: initialSignalType,
|
||||||
|
currentUnderlyingPrice: currentUnderlyingPrice,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DerivativePickerModal> createState() => _DerivativePickerModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DerivativePickerModalState extends State<DerivativePickerModal> {
|
||||||
|
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<DerivativeItemModel> _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<void> _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<ApiClient>();
|
||||||
|
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<void> _fetchMoreDerivatives() async {
|
||||||
|
if (_isLoadingMore || !_hasMoreData) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoadingMore = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await _fetchDerivatives(reset: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DerivativeItemModel> 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<DerivativeItemModel> 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,4 +84,14 @@ public class AssetsDbContext : DbContext
|
|||||||
.HasMany(a => a.Tags)
|
.HasMany(a => a.Tags)
|
||||||
.WithMany(t => t.Assets);
|
.WithMany(t => t.Assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||||
|
{
|
||||||
|
base.ConfigureConventions(configurationBuilder);
|
||||||
|
|
||||||
|
configurationBuilder.Properties<DateTime>()
|
||||||
|
.HaveConversion(typeof(FinlyticCore.Converters.UtcDateTimeConverter));
|
||||||
|
configurationBuilder.Properties<DateTime?>()
|
||||||
|
.HaveConversion(typeof(FinlyticCore.Converters.NullableUtcDateTimeConverter));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public interface IAssetsDbService
|
|||||||
public Task UpdateAssetImageIdAsync(string isin, string imageId);
|
public Task UpdateAssetImageIdAsync(string isin, string imageId);
|
||||||
public Task<bool> DeleteAssetAsync(string isin);
|
public Task<bool> DeleteAssetAsync(string isin);
|
||||||
public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15);
|
public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15);
|
||||||
public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", bool forceRefresh = false, CancellationToken cancellationToken = default);
|
public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -394,38 +394,56 @@ public class AssetsDbService : IAssetsDbService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Inherits documentation from interface.</summary>
|
/// <summary>Inherits documentation from interface.</summary>
|
||||||
public async Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", bool forceRefresh = false, CancellationToken cancellationToken = default)
|
public async Task<List<DerivativeEntity>> 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 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)
|
decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m;
|
||||||
{
|
|
||||||
var cached = await _context.TradeRepublicAssets
|
|
||||||
.OfType<DerivativeEntity>()
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(a => a.Tags)
|
|
||||||
.Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType && d.LastUpdatedAt >= cutoff)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (cached.Count > 0)
|
// 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");
|
||||||
return cached;
|
|
||||||
}
|
_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);
|
var trResponse = await _tradeRepublicService.GetDerivativesAsync(trReq, cancellationToken);
|
||||||
if (trResponse?.Results != null && trResponse.Results.Count > 0)
|
var fetchedItems = trResponse?.Results ?? new List<TradeRepublicDerivativeItemDto>();
|
||||||
|
|
||||||
|
_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 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
|
var existingDerivatives = await _context.TradeRepublicAssets
|
||||||
.OfType<DerivativeEntity>()
|
.OfType<DerivativeEntity>()
|
||||||
.Where(d => isins.Contains(d.Isin))
|
.Where(d => isins.Contains(d.Isin))
|
||||||
.ToDictionaryAsync(d => d.Isin, cancellationToken);
|
.ToDictionaryAsync(d => d.Isin, cancellationToken);
|
||||||
|
|
||||||
foreach (var item in trResponse.Results)
|
List<DerivativeEntity> resultEntities = new();
|
||||||
|
|
||||||
|
foreach (var item in fetchedItems)
|
||||||
{
|
{
|
||||||
if (!existingDerivatives.TryGetValue(item.Isin, out var entity))
|
if (!existingDerivatives.TryGetValue(item.Isin, out var entity))
|
||||||
{
|
{
|
||||||
@@ -438,8 +456,14 @@ public class AssetsDbService : IAssetsDbService
|
|||||||
await _context.TradeRepublicAssets.AddAsync(entity, cancellationToken);
|
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.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.ProductCategoryName = item.ProductCategoryName;
|
||||||
entity.NextGenProductCategoryName = item.NextGenProductCategoryName;
|
entity.NextGenProductCategoryName = item.NextGenProductCategoryName;
|
||||||
entity.Strike = item.Strike ?? 0m;
|
entity.Strike = item.Strike ?? 0m;
|
||||||
@@ -449,24 +473,42 @@ public class AssetsDbService : IAssetsDbService
|
|||||||
entity.Factor = item.Factor;
|
entity.Factor = item.Factor;
|
||||||
entity.Delta = item.Delta;
|
entity.Delta = item.Delta;
|
||||||
entity.Currency = item.Currency ?? "EUR";
|
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.Issuer = item.Issuer;
|
||||||
entity.IssuerDisplayName = item.IssuerDisplayName;
|
entity.IssuerDisplayName = item.IssuerDisplayName;
|
||||||
entity.IssuerImageId = item.IssuerImageId;
|
entity.IssuerImageId = item.IssuerImageId;
|
||||||
entity.ImageId = item.ImageId;
|
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;
|
entity.LastUpdatedAt = now;
|
||||||
|
|
||||||
|
resultEntities.Add(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
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<DerivativeEntity>()
|
.OfType<DerivativeEntity>()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(a => a.Tags)
|
.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);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Helper & Mapping Methods
|
#region Helper & Mapping Methods
|
||||||
|
|||||||
@@ -225,7 +225,13 @@ public class AssetsMqttClient(
|
|||||||
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDerivativesRequest);
|
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDerivativesRequest);
|
||||||
if (req != null && !string.IsNullOrEmpty(req.UnderlyingIsin))
|
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)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -268,6 +268,101 @@ public class AssetsController : ControllerBase
|
|||||||
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
|
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{isin}/derivatives")]
|
||||||
|
public async Task<IActionResult> 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<List<AssetDto>, GetDerivativesRequest>(
|
||||||
|
"assets_GetDerivatives",
|
||||||
|
req,
|
||||||
|
TimeSpan.FromSeconds(30)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rpcResult != null)
|
||||||
|
{
|
||||||
|
var derivatives = rpcResult.OfType<DerivativeDto>().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<DerivativeDto>());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
|
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user