import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart' show DioException, DioExceptionType; import '../core/network/api_response.dart'; import '../core/network/dio_client.dart'; import '../data/services/futures_service.dart'; import 'app_provider.dart'; import 'auth_provider.dart'; import 'copy_trading_provider.dart'; import 'market_detail_provider.dart'; import 'ws_provider.dart'; enum OrderSide { long, short } enum OrderType { market, limit, conditionalMarket, conditionalLimit } enum PositionMode { open, close } enum MarginMode { cross, isolated, split } enum AmountUnit { lots, usdt, btc } enum FuturesTab { positions, orders, assets } bool _isFuturesTabActive(int tabIndex) => tabIndex == 2 || tabIndex == 3; class FuturesPosition { final String id; final String symbol; final OrderSide side; final double size; // 持仓量(基础币) final double availableSize; // 可平仓位 final double entryPrice; final double markPrice; final double leverage; final double unrealizedPnl; final double liquidationPrice; final double margin; final String marginMode; final double? profitPrice; final double? lossPrice; final int? cutEntrustId; // 止盈止损委托单 ID,有值时修改走 modify-cut final double contractSize; // 每张合约对应基础币数量 final double? apiMarginRate; final double? apiCurrentMargin; final double realizedPnl; final double commissionFee; // 累计手续费(JSON: commissionFee) const FuturesPosition({ required this.id, required this.symbol, required this.side, required this.size, required this.availableSize, required this.entryPrice, required this.markPrice, required this.leverage, required this.unrealizedPnl, required this.liquidationPrice, required this.margin, this.marginMode = '全仓', this.profitPrice, this.lossPrice, this.cutEntrustId, this.contractSize = 1.0, this.apiMarginRate, this.apiCurrentMargin, this.realizedPnl = 0.0, this.commissionFee = 0.0, }); double get lots => contractSize > 0 ? size / contractSize : size; double get currentMargin => (size > 0 && markPrice > 0 && leverage > 0) ? (size * markPrice) / leverage : margin; double get roe { final denom = (apiCurrentMargin != null && apiCurrentMargin! > 0) ? apiCurrentMargin! : currentMargin; return denom > 0 ? (unrealizedPnl / denom) * 100 : 0; } // 保证金比率 = 保证金 / 合约账户权益 × 100,不超过100% double get marginRatio { if (apiMarginRate != null && apiMarginRate! > 0) return apiMarginRate!; if (margin > 0 && (margin + unrealizedPnl) > 0) { return (margin / (margin + unrealizedPnl) * 100).clamp(0.0, 100.0); } return 0; } FuturesPosition withMarkPrice(double price) { return FuturesPosition( id: id, symbol: symbol, side: side, size: size, availableSize: availableSize, entryPrice: entryPrice, markPrice: price, leverage: leverage, unrealizedPnl: unrealizedPnl, liquidationPrice: liquidationPrice, margin: margin, marginMode: marginMode, profitPrice: profitPrice, lossPrice: lossPrice, cutEntrustId: cutEntrustId, contractSize: contractSize, apiMarginRate: apiMarginRate, apiCurrentMargin: apiCurrentMargin, realizedPnl: realizedPnl, commissionFee: commissionFee, ); } FuturesPosition withContractSize(double cs) => FuturesPosition( id: id, symbol: symbol, side: side, size: size, availableSize: availableSize, entryPrice: entryPrice, markPrice: markPrice, leverage: leverage, unrealizedPnl: unrealizedPnl, liquidationPrice: liquidationPrice, margin: margin, marginMode: marginMode, profitPrice: profitPrice, lossPrice: lossPrice, cutEntrustId: cutEntrustId, contractSize: cs, apiMarginRate: apiMarginRate, apiCurrentMargin: apiCurrentMargin, realizedPnl: realizedPnl, commissionFee: commissionFee, ); /// 从 /swap/wallet-new/get-with-positions 的 currentPositionWithCutList 解析 factory FuturesPosition.fromJson(Map json) { final coin = json['coin'] as Map? ?? {}; final symbol = coin['symbol'] as String? ?? ''; final direction = json['direction'] as String? ?? 'BUY'; final side = direction == 'BUY' ? OrderSide.long : OrderSide.short; final size = _toDouble(json['currentPosition']); final frozen = _toDouble(json['frozenPosition']); final explicitAvail = _toDouble(json['availablePosition']); final available = explicitAvail > 0 ? explicitAvail : (size - frozen).clamp(0.0, double.infinity); final entryPrice = _toDouble(json['openPrice']); final leverage = _toDouble(json['leverage']); final currentPrice = _toDouble(json['currentPrice'] ?? entryPrice); final pnl = json['currentProfit'] != null ? _toDouble(json['currentProfit']) : (side == OrderSide.long ? (currentPrice - entryPrice) * size : (entryPrice - currentPrice) * size); // type 1 → 分仓;否则 → 全仓 final posType = json['type']; final String marginMode = (posType == 1 || posType == '1') ? '分仓' : '全仓'; // cutList 为止盈止损委托单列表,取第一条作为回显数据源 final cutList = json['cutList']; Map? cutOrder; if (cutList is List && cutList.isNotEmpty) { cutOrder = Map.from(cutList.first as Map); } final rawProfit = cutOrder != null ? _toDouble(cutOrder['profitPrice']) : _toDouble(json['profitPrice']); final rawLoss = cutOrder != null ? _toDouble(cutOrder['lossPrice']) : _toDouble(json['lossPrice']); final cutEntrustId = cutOrder != null ? (cutOrder['id'] is int ? cutOrder['id'] as int : int.tryParse('${cutOrder['id'] ?? ''}')) : null; final rawMarginRate = json['marginRate']; final apiMarginRate = rawMarginRate != null ? _toDouble(rawMarginRate) : null; final rawCurrentMargin = _toDouble(json['currentMargin']); final realizedPnl = _toDouble( json['usdtProfit'] ?? json['realizedPnl'] ?? json['realizedProfit'] ?? 0); return FuturesPosition( id: json['id']?.toString() ?? '', symbol: symbol, side: side, size: size, availableSize: available, entryPrice: entryPrice, markPrice: currentPrice, leverage: leverage, unrealizedPnl: pnl, liquidationPrice: _calcLiquidationPrice( apiValue: _toDouble(json['estimatedBlastPrice']), ), margin: _toDouble(json['principalAmount']), marginMode: marginMode, profitPrice: rawProfit > 0 ? rawProfit : null, lossPrice: rawLoss > 0 ? rawLoss : null, cutEntrustId: cutEntrustId, apiMarginRate: apiMarginRate != null && apiMarginRate > 0 ? apiMarginRate : null, apiCurrentMargin: rawCurrentMargin > 0 ? rawCurrentMargin : null, realizedPnl: realizedPnl, commissionFee: _toDouble(json['commissionFee']), ); } /// 从 /swap/order/history 历史仓位记录解析 factory FuturesPosition.fromHistoryJson(Map json) { final rawSym = (json['symbol'] ?? json['coinSymbol'] ?? '').toString(); final direction = (json['direction'] ?? 'BUY').toString().toUpperCase(); final side = direction == 'BUY' || direction == '0' ? OrderSide.long : OrderSide.short; final size = _toDouble(json['tradedVolume'] ?? json['volume'] ?? 0); final entryPrice = _toDouble(json['usdtOpenPrice'] ?? json['openPrice'] ?? 0); final closePrice = _toDouble(json['tradedPrice'] ?? json['closePrice'] ?? entryPrice); final leverage = _toDouble(json['leverage'] ?? 20); final realizedPnl = _toDouble( json['profitAndLoss'] ?? json['profit'] ?? json['realizedPnl'] ?? json['realizedProfit'] ?? json['closeProfitLoss'] ?? 0); final marginMode = _mapHistoryPositionType(json['positionType'], json['patterns']); return FuturesPosition( id: json['id']?.toString() ?? '', symbol: rawSym, side: side, size: size, availableSize: 0, entryPrice: entryPrice, markPrice: closePrice, leverage: leverage, unrealizedPnl: 0, liquidationPrice: 0, margin: _toDouble(json['principalAmount'] ?? 0), marginMode: marginMode, realizedPnl: realizedPnl, ); } static String _mapHistoryPositionType(dynamic posType, dynamic patterns) { final pt = posType?.toString() ?? ''; if (pt == '0') return '全仓'; if (pt == '1') return '逐仓'; final p = (patterns?.toString() ?? '').toUpperCase(); if (p == 'ISOLATED') return '逐仓'; return '全仓'; } @override bool operator ==(Object other) => identical(this, other) || other is FuturesPosition && id == other.id && symbol == other.symbol && side == other.side && size == other.size && entryPrice == other.entryPrice && markPrice == other.markPrice && leverage == other.leverage && unrealizedPnl == other.unrealizedPnl && liquidationPrice == other.liquidationPrice && margin == other.margin && marginMode == other.marginMode; @override int get hashCode => Object.hash(id, symbol, side, size, entryPrice, markPrice, leverage, unrealizedPnl, liquidationPrice, margin, marginMode); } class FuturesOrder { final String id; final String symbol; final OrderSide side; final OrderType type; final double price; // 委托价(entrustPrice) final double tradedPrice; // 成交均价(tradedPrice) final double triggerPrice; // 计划委托触发价 final double size; final double filledSize; final String status; final String action; // 开多/开空/平多/平空 final String marginMode; final double leverage; final DateTime? createTime; final DateTime? dealTime; // 成交时间 final double? profitPrice; // 止盈价(>0 时有效) final double? lossPrice; // 止损价(>0 时有效) final double fee; // 手续费(openFee + closeFee) const FuturesOrder({ required this.id, required this.symbol, required this.side, required this.type, required this.price, required this.size, required this.filledSize, required this.status, this.tradedPrice = 0, this.triggerPrice = 0, this.action = '开多', this.marginMode = '全仓', this.leverage = 20, this.createTime, this.dealTime, this.profitPrice, this.lossPrice, this.fee = 0, }); /// 是否为开仓方向(开多/开空) bool get isOpenOrder => action.contains('开'); /// 是否处于委托中(可撤销)状态 bool get isPending => status == '委托中'; /// 展示用类型标签 String get typeLabel { switch (type) { case OrderType.market: return '市价'; case OrderType.limit: return '限价'; case OrderType.conditionalMarket: case OrderType.conditionalLimit: return '计划委托'; } } String get priceDisplay { String fmt(double v) => v == v.truncateToDouble() ? '${v.toInt()}' : v.toString(); switch (type) { case OrderType.market: case OrderType.conditionalMarket: return '市价'; case OrderType.limit: return price > 0 ? fmt(price) : '--'; case OrderType.conditionalLimit: // 计划市价:entrustPrice=0;计划限价:entrustPrice=实际限价 return price > 0 ? fmt(price) : '市价'; } } /// 从 /swap/order/current 解析 factory FuturesOrder.fromJson(Map json) { // direction 可能是字符串 "BUY"/"SELL" 或整数 0/1 final dirRaw = json['direction']; final bool isLong; if (dirRaw is int) { isLong = dirRaw == 0; // 0=买多,1=卖空 } else { isLong = (dirRaw as String? ?? 'BUY') == 'BUY'; } final side = isLong ? OrderSide.long : OrderSide.short; // type 可能是字符串枚举或整数(0=市价,1=限价,2=计划委托) // 计划委托中 entrustPrice=0 为计划市价,entrustPrice>0 为计划限价 // 注意:不能用 json['price'] 作为 fallback,否则市价单会被误判为限价单 final typeRaw = json['type']; final entrustPriceRaw = _toDouble(json['entrustPrice']); final OrderType orderType; if (typeRaw is int) { orderType = switch (typeRaw) { 0 => OrderType.market, 2 => entrustPriceRaw > 0 ? OrderType.conditionalLimit : OrderType.conditionalMarket, _ => OrderType.limit, }; } else { final s = (typeRaw as String? ?? '').toUpperCase(); if (s == 'SPOT_LIMIT' || s == 'PLAN') { orderType = entrustPriceRaw > 0 ? OrderType.conditionalLimit : OrderType.conditionalMarket; } else { orderType = s == 'MARKET_PRICE' || s == 'MARKET' ? OrderType.market : OrderType.limit; } } // entrustType:0=OPEN(开仓),1=CLOSE(平仓) // 注意:positionType 是全仓/逐仓模式,不是开平仓标志,不要用它判断 final entrustTypeRaw = json['entrustType']; final bool isClose; if (entrustTypeRaw is int) { isClose = entrustTypeRaw == 1; } else if (entrustTypeRaw is String) { final et = entrustTypeRaw.toUpperCase(); isClose = et == '1' || et == 'CLOSE'; } else { // entrustType 不存在时,看 closeType 是否有值(有值说明是平仓单) final closeTypeRaw = json['closeType']; isClose = closeTypeRaw != null; } final action = isClose ? (isLong ? '平多' : '平空') : (isLong ? '开多' : '开空'); // createTime 为 13 位毫秒时间戳 final ts = json['createTime']; DateTime? createTime; if (ts is int) { createTime = DateTime.fromMillisecondsSinceEpoch(ts); } else if (ts is String) { createTime = DateTime.tryParse(ts); } // dealTime:成交时间(Long 毫秒时间戳) final dealTs = json['dealTime']; DateTime? dealTime; if (dealTs is int && dealTs > 0) { dealTime = DateTime.fromMillisecondsSinceEpoch(dealTs); } else if (dealTs is String) { dealTime = DateTime.tryParse(dealTs); } // status 可能是字符串或整数 final statusRaw = json['status']; final status = _mapStatus(statusRaw is String ? statusRaw : statusRaw?.toString() ?? ''); // symbol 可能是直接字段(EntrustBean),也可能嵌套在 coin 中(position list) final coin = json['coin'] as Map? ?? {}; final symbol = (json['symbol'] as String?) ?? (coin['symbol'] as String?) ?? ''; // 止盈止损(>0 时有效) final rawProfit = _toDouble(json['profitPrice'] ?? json['stopProfitPrice']); final rawLoss = _toDouble(json['lossPrice'] ?? json['stopLossPrice']); return FuturesOrder( id: json['id']?.toString() ?? '', symbol: symbol, side: side, type: orderType, price: _toDouble(json['entrustPrice']), tradedPrice: _toDouble(json['tradedPrice']), triggerPrice: _toDouble(json['triggerPrice']), size: _toDouble(json['volume'] ?? json['size']), filledSize: _toDouble(json['tradedVolume'] ?? json['filledSize']), status: status, action: action, marginMode: _parsePatterns(json['patterns']), leverage: _toDouble(json['leverage'] ?? 20).toInt().toDouble(), createTime: createTime, dealTime: dealTime, profitPrice: rawProfit > 0 ? rawProfit : null, lossPrice: rawLoss > 0 ? rawLoss : null, fee: _toDouble(json['openFee']) + _toDouble(json['closeFee']), ); } /// 从 /swap/order/history-open 历史委托记录解析 factory FuturesOrder.fromHistoryJson(Map json) { final rawSym = (json['symbol'] ?? json['coinSymbol'] ?? '').toString(); final dirRaw = json['direction']; final bool isLong; if (dirRaw is int) { isLong = dirRaw == 0; } else { final s = (dirRaw?.toString() ?? 'BUY').toUpperCase(); isLong = s == 'BUY' || s == '0'; } final side = isLong ? OrderSide.long : OrderSide.short; // 使用 type 字段判断委托类型;openType 是仓位开仓方式,平仓单中继承自仓位,不能用于判断平仓单类型 final typeRaw = json['type']; final entrustPriceRaw = _toDouble(json['entrustPrice']); final OrderType orderType; if (typeRaw is int) { orderType = switch (typeRaw) { 0 => OrderType.market, 2 => entrustPriceRaw > 0 ? OrderType.conditionalLimit : OrderType.conditionalMarket, _ => OrderType.limit, }; } else { final s = (typeRaw?.toString() ?? '').toUpperCase(); if (s == 'SPOT_LIMIT' || s == 'PLAN') { orderType = entrustPriceRaw > 0 ? OrderType.conditionalLimit : OrderType.conditionalMarket; } else { orderType = (s == 'MARKET_PRICE' || s == 'MARKET') ? OrderType.market : OrderType.limit; } } // 历史记录同样用 entrustType 判断开平仓,fallback 看 closeType 是否有值 final entrustTypeRaw = json['entrustType']; final bool isClose; if (entrustTypeRaw is int) { isClose = entrustTypeRaw == 1; } else if (entrustTypeRaw is String) { final et = entrustTypeRaw.toUpperCase(); isClose = et == '1' || et == 'CLOSE'; } else { isClose = json['closeType'] != null; } final action = isClose ? (isLong ? '平多' : '平空') : (isLong ? '开多' : '开空'); final ts = json['createTime'] ?? json['usdtOpenTime']; DateTime? createTime; if (ts is int) { createTime = DateTime.fromMillisecondsSinceEpoch(ts); } else if (ts is String) { createTime = DateTime.tryParse(ts); } final dealTs = json['dealTime']; DateTime? dealTime; if (dealTs is int && dealTs > 0) { dealTime = DateTime.fromMillisecondsSinceEpoch(dealTs); } else if (dealTs is String) { dealTime = DateTime.tryParse(dealTs); } final statusRaw = json['status']; final status = _mapStatus(statusRaw is String ? statusRaw : statusRaw?.toString() ?? ''); return FuturesOrder( id: json['id']?.toString() ?? '', symbol: rawSym, side: side, type: orderType, price: _toDouble(json['entrustPrice'] ?? 0), tradedPrice: _toDouble(json['tradedPrice'] ?? 0), triggerPrice: _toDouble(json['triggerPrice'] ?? 0), size: _toDouble(json['volume'] ?? json['size'] ?? 0), filledSize: _toDouble(json['tradedVolume'] ?? json['dealVolume'] ?? 0), status: status, action: action, marginMode: _parsePatterns(json['patterns']), leverage: _toDouble(json['leverage'] ?? 20).toInt().toDouble(), createTime: createTime, dealTime: dealTime, fee: _toDouble(json['openFee']) + _toDouble(json['closeFee']), ); } static String _mapStatus(String raw) { return switch (raw) { 'ENTRUST_ING' => '委托中', 'ENTRUST_SUCCESS' => '已成交', 'ENTRUST_CANCEL' => '已撤销', _ => raw, }; } /// 解析仓位模式:CROSSED=全仓,FIXED=分仓;ordinal 0=全仓,1=分仓 static String _parsePatterns(dynamic raw) { if (raw == null) return '全仓'; final s = raw.toString().toUpperCase(); if (s == 'FIXED' || s == '1') return '分仓'; return '全仓'; } @override bool operator ==(Object other) => identical(this, other) || other is FuturesOrder && id == other.id && symbol == other.symbol && side == other.side && type == other.type && price == other.price && triggerPrice == other.triggerPrice && size == other.size && filledSize == other.filledSize && status == other.status && action == other.action && marginMode == other.marginMode && leverage == other.leverage && profitPrice == other.profitPrice && lossPrice == other.lossPrice; @override int get hashCode => Object.hash(id, symbol, side, type, price, triggerPrice, size, filledSize, status, action, marginMode, leverage, profitPrice, lossPrice); } class FuturesAccountInfo { final double totalBalance; final double availableMargin; final double usedMargin; final double unrealizedPnl; const FuturesAccountInfo({ required this.totalBalance, required this.availableMargin, required this.usedMargin, required this.unrealizedPnl, }); factory FuturesAccountInfo.fromJson(Map json, {double unrealizedPnl = 0, double positionsMargin = 0}) { // currentCapital = 账户当前权益(合约账户总余额) // 已用保证金 = 持仓 principalAmount 之和(最准确),其次 frozenMargin // 可用保证金 = 总余额 - 已用保证金 final total = _toDouble(json['currentCapital'] ?? json['balance']); final frozen = _toDouble(json['frozenMargin'] ?? json['frozenBalance']); final usedMargin = positionsMargin > 0 ? positionsMargin : frozen > 0 ? frozen : 0.0; final available = (total - usedMargin).clamp(0.0, double.infinity); return FuturesAccountInfo( totalBalance: total, usedMargin: usedMargin, availableMargin: available, unrealizedPnl: unrealizedPnl, ); } const FuturesAccountInfo.empty() : totalBalance = 0, availableMargin = 0, usedMargin = 0, unrealizedPnl = 0; @override bool operator ==(Object other) => identical(this, other) || other is FuturesAccountInfo && totalBalance == other.totalBalance && availableMargin == other.availableMargin && usedMargin == other.usedMargin && unrealizedPnl == other.unrealizedPnl; @override int get hashCode => Object.hash(totalBalance, availableMargin, usedMargin, unrealizedPnl); } class FuturesState { final String symbol; final int contractCoinId; final double contractSize; // 每张对应基础币数量 final int volScale; // 张数精度 final String coinSymbol; // 基础资产名称 final double lastPrice; final String? lastPriceStr; // WS 返回的原始价格字符串 final double markPrice; final double change24h; final double leverage; final MarginMode marginMode; final PositionMode positionMode; final OrderSide orderSide; final OrderType orderType; final AmountUnit amountUnit; final double inputPrice; final double inputSize; final double sliderPercent; final bool isSliderInput; // true=滑块输入,false=手动输入 final bool tpslEnabled; final double? tpPrice; final double? slPrice; final double fundingRate; final String fundingCountdown; final FuturesTab activeTab; final List positions; final List openOrders; final bool ordersHasMore; final int ordersPage; final bool ordersLoadingMore; final FuturesAccountInfo accountInfo; final bool isLoading; // 首屏骨架 final bool isTabLoading; // 底部列表骨架 final bool isSwitchingMode; // 模式切换中(切换完成前禁止下单) final String? errorMsg; final List> orderBookAsks; final List> orderBookBids; final bool hideOtherSymbols; final int pricePrecision; final int coinPrecision; final int usdtPrecision; final int leverageMin; final int leverageMax; final List leverageOptions; final bool isDiscreteLeverage; // true=固定档位,false=区间任意值 final double openFeeRate; const FuturesState({ required this.symbol, this.contractCoinId = 0, this.contractSize = 1.0, this.volScale = 0, this.coinSymbol = '', required this.lastPrice, this.lastPriceStr, required this.markPrice, this.change24h = 0, this.leverage = 20, this.marginMode = MarginMode.cross, this.positionMode = PositionMode.open, this.orderSide = OrderSide.long, this.orderType = OrderType.market, this.amountUnit = AmountUnit.btc, this.inputPrice = 0, this.inputSize = 0, this.sliderPercent = 0, this.isSliderInput = false, this.tpslEnabled = false, this.tpPrice, this.slPrice, this.fundingRate = 0, this.fundingCountdown = '--:--:--', this.activeTab = FuturesTab.positions, required this.positions, required this.openOrders, this.ordersHasMore = false, this.ordersPage = 1, this.ordersLoadingMore = false, required this.accountInfo, this.isLoading = false, this.isTabLoading = false, this.isSwitchingMode = false, this.errorMsg, this.orderBookAsks = const [], this.orderBookBids = const [], this.hideOtherSymbols = false, this.pricePrecision = 2, this.coinPrecision = 4, this.usdtPrecision = 2, this.leverageMin = 1, this.leverageMax = 125, this.leverageOptions = const [], this.isDiscreteLeverage = false, this.openFeeRate = 0.0005, }); /// 当前数量单位对应的精度 int get currentAmountPrecision { switch (amountUnit) { case AmountUnit.lots: return 0; case AmountUnit.btc: return coinPrecision; case AmountUnit.usdt: return usdtPrecision; } } static String _norm(String s) => s.replaceAll('/', '').toUpperCase(); List get displayPositions { if (!hideOtherSymbols) return positions; final cur = _norm(symbol); return positions.where((p) => _norm(p.symbol) == cur).toList(); } List get displayOrders { if (!hideOtherSymbols) return openOrders; final cur = _norm(symbol); return openOrders.where((o) => _norm(o.symbol) == cur).toList(); } String get orderTypeLabel { switch (orderType) { case OrderType.market: return '市价单'; case OrderType.limit: return '限价单'; case OrderType.conditionalMarket: return '市价条件委托单'; case OrderType.conditionalLimit: return '限价条件委托单'; } } String get marginModeLabel => switch (marginMode) { MarginMode.cross => '全仓', MarginMode.isolated => '逐仓', MarginMode.split => '分仓', }; String get amountUnitLabel { switch (amountUnit) { case AmountUnit.lots: return '张'; case AmountUnit.usdt: return 'USDT'; case AmountUnit.btc: if (coinSymbol.isNotEmpty) return coinSymbol; final base = symbol.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); return base.isNotEmpty ? base : 'BTC'; } } bool get isConditionalOrder => orderType == OrderType.conditionalMarket || orderType == OrderType.conditionalLimit; bool get showPriceInput => orderType == OrderType.limit || orderType == OrderType.conditionalLimit; FuturesState copyWith({ String? symbol, int? contractCoinId, double? contractSize, int? volScale, String? coinSymbol, double? lastPrice, String? lastPriceStr, double? markPrice, double? change24h, double? leverage, MarginMode? marginMode, PositionMode? positionMode, OrderSide? orderSide, OrderType? orderType, AmountUnit? amountUnit, double? inputPrice, double? inputSize, double? sliderPercent, bool? isSliderInput, bool? tpslEnabled, double? tpPrice, double? slPrice, double? fundingRate, String? fundingCountdown, FuturesTab? activeTab, List? positions, List? openOrders, bool? ordersHasMore, int? ordersPage, bool? ordersLoadingMore, FuturesAccountInfo? accountInfo, bool? isLoading, bool? isTabLoading, bool? isSwitchingMode, String? errorMsg, List>? orderBookAsks, List>? orderBookBids, bool? hideOtherSymbols, int? pricePrecision, int? coinPrecision, int? usdtPrecision, int? leverageMin, int? leverageMax, List? leverageOptions, bool? isDiscreteLeverage, double? openFeeRate, }) => FuturesState( symbol: symbol ?? this.symbol, contractCoinId: contractCoinId ?? this.contractCoinId, contractSize: contractSize ?? this.contractSize, volScale: volScale ?? this.volScale, coinSymbol: coinSymbol ?? this.coinSymbol, lastPrice: lastPrice ?? this.lastPrice, lastPriceStr: lastPriceStr ?? this.lastPriceStr, markPrice: markPrice ?? this.markPrice, change24h: change24h ?? this.change24h, leverage: leverage ?? this.leverage, marginMode: marginMode ?? this.marginMode, positionMode: positionMode ?? this.positionMode, orderSide: orderSide ?? this.orderSide, orderType: orderType ?? this.orderType, amountUnit: amountUnit ?? this.amountUnit, inputPrice: inputPrice ?? this.inputPrice, inputSize: inputSize ?? this.inputSize, sliderPercent: sliderPercent ?? this.sliderPercent, isSliderInput: isSliderInput ?? this.isSliderInput, tpslEnabled: tpslEnabled ?? this.tpslEnabled, tpPrice: tpPrice ?? this.tpPrice, slPrice: slPrice ?? this.slPrice, fundingRate: fundingRate ?? this.fundingRate, fundingCountdown: fundingCountdown ?? this.fundingCountdown, activeTab: activeTab ?? this.activeTab, positions: positions ?? this.positions, openOrders: openOrders ?? this.openOrders, ordersHasMore: ordersHasMore ?? this.ordersHasMore, ordersPage: ordersPage ?? this.ordersPage, ordersLoadingMore: ordersLoadingMore ?? this.ordersLoadingMore, accountInfo: accountInfo ?? this.accountInfo, isLoading: isLoading ?? this.isLoading, isTabLoading: isTabLoading ?? this.isTabLoading, isSwitchingMode: isSwitchingMode ?? this.isSwitchingMode, errorMsg: errorMsg, orderBookAsks: orderBookAsks ?? this.orderBookAsks, orderBookBids: orderBookBids ?? this.orderBookBids, hideOtherSymbols: hideOtherSymbols ?? this.hideOtherSymbols, pricePrecision: pricePrecision ?? this.pricePrecision, coinPrecision: coinPrecision ?? this.coinPrecision, usdtPrecision: usdtPrecision ?? this.usdtPrecision, leverageMin: leverageMin ?? this.leverageMin, leverageMax: leverageMax ?? this.leverageMax, leverageOptions: leverageOptions ?? this.leverageOptions, isDiscreteLeverage: isDiscreteLeverage ?? this.isDiscreteLeverage, openFeeRate: openFeeRate ?? this.openFeeRate, ); } class FuturesNotifier extends AutoDisposeFamilyNotifier { StreamSubscription>? _tickerSub; StreamSubscription>? _depthSub; StreamSubscription>? _markSub; Timer? _fundingTimer; Timer? _pollTimer; bool _manuallyPaused = false; // 子页面推入时手动暂停,防止 tab 切换后误恢复 int _nextSettleTimeMs = 0; final Map _lastTickerUpdateMs = {}; // ticker 节流 int _lastDepthUpdateMs = 0; // 盘口节流 int _lastPositionTickUpdateMs = 0; // 持仓 mark price 节流(ticker 路径) Timer? _depthTimer; List>? _pendingAsks; List>? _pendingBids; // 切换到平仓模式时,若原 orderType 为计划委托类型则暂存,切回开仓时恢复 OrderType? _savedConditionalType; // 路由 symbol(如 BTCUSDT)转 API 格式(BTC/USDT) static String _toApiSymbol(String symbol) { String s = symbol.replaceAll('-', '/'); if (!s.contains('/')) { for (final base in ['USDT', 'BTC', 'ETH', 'BUSD']) { if (s.endsWith(base) && s.length > base.length) { return '${s.substring(0, s.length - base.length)}/$base'; } } } return s; } @override FuturesState build(String symbol) { ref.onDispose(_dispose); // 读取该交易对上次用户选择的保证金模式(跨 symbol 切换时保留) final savedModeStr = ref.read(sharedPreferencesProvider) .getString('futures_margin_mode_$symbol'); final savedMode = switch (savedModeStr) { 'split' => MarginMode.split, 'isolated' => MarginMode.isolated, _ => MarginMode.cross, }; final initial = FuturesState( symbol: symbol, lastPrice: 0, markPrice: 0, positions: const [], openOrders: const [], accountInfo: const FuturesAccountInfo.empty(), isLoading: true, amountUnit: ref.read(futuresAmountUnitPrefProvider), hideOtherSymbols: ref.read(futuresHideOtherPrefProvider), marginMode: savedMode, leverage: 20, ); Future.microtask(() => _init(symbol)); // 监听 K 线行情数据,保持与 K 线页面价格一致 // 注意:不能用 fireImmediately: true,build() 返回前 state 尚未初始化 ref.listen( marketDetailProvider(MarketDetailKey(symbol: symbol, isFutures: true)) .select((s) => s.stats), (_, stats) { if (stats == null || stats.lastPrice <= 0) return; state = state.copyWith( lastPrice: stats.lastPrice, change24h: stats.change24h, ); }, ); // 监听 WS 重连:重连成功后若合约信息未加载则重新初始化 ref.listen>( wsConnectionStateProvider, (prev, next) { final prevState = prev?.valueOrNull; final nextState = next.valueOrNull; // 任何 → connected 的转换都触发(reconnecting→connecting→connected,prev 是 connecting) if (nextState == WsConnectionState.connected && prevState != WsConnectionState.connected) { if (state.contractCoinId == 0) { _init(symbol); } } }, ); // 监听登录状态:登录后自动加载持仓;退出后清空数据并停止轮询 ref.listen(isLoggedInProvider, (prev, loggedIn) { if (loggedIn) { // 登录后刷新带单员身份(用于判断是否显示分仓选项) ref.read(copyTradingProvider.notifier).silentRefresh(); _loadWallet(state.symbol); _loadCurrentOrders(); // 在合约页时直接启动轮询,不区分子 tab if (_isFuturesTabActive(ref.read(activeBottomTabProvider))) { _startPolling(state.symbol); } } else { _stopPolling(); state = state.copyWith( positions: [], openOrders: [], accountInfo: const FuturesAccountInfo.empty(), ); } }); // 监听底部导航 tab 切换:离开合约页停止轮询,进入合约页直接启动轮询 // 注意:若子页面手动暂停了轮询(_manuallyPaused),切回 tab 时不自动恢复 ref.listen(activeBottomTabProvider, (prev, tabIndex) { if (_isFuturesTabActive(tabIndex)) { if (ref.read(isLoggedInProvider) && !_manuallyPaused) { _loadWallet(state.symbol); _loadCurrentOrders(); _startPolling(state.symbol); } } else { _stopPolling(); } }); // 监听带单员身份变化:若用户身份为带单员,强制切回全仓模式 ref.listen( copyTradingProvider.select((s) => s.isTrader), (_, isTrader) { if (isTrader && state.marginMode == MarginMode.split) { state = state.copyWith(marginMode: MarginMode.cross); } }, ); return initial; } Future _init(String symbol) async { await _loadContractInfo(symbol); _subscribeWebSocket(symbol); state = state.copyWith(isLoading: false, isTabLoading: true); if (ref.read(isLoggedInProvider)) { try { await Future.wait([_loadWallet(symbol), _loadCurrentOrders()]); } catch (_) {} if (_isFuturesTabActive(ref.read(activeBottomTabProvider))) { _startPolling(symbol); } } state = state.copyWith(isTabLoading: false); } void _startPolling(String symbol) { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (!_isFuturesTabActive(ref.read(activeBottomTabProvider))) { _stopPolling(); return; } _loadWallet(symbol); _loadCurrentOrders(); }); } void _stopPolling() { _pollTimer?.cancel(); _pollTimer = null; } /// 供页面跳转时外部调用:暂停轮询(标记手动暂停,防止 tab 切换后误恢复) void stopPolling() { _manuallyPaused = true; _stopPolling(); } /// 供页面返回时外部调用:恢复轮询(仅登录且在合约页时) void resumePolling(String symbol) { _manuallyPaused = false; if (ref.read(isLoggedInProvider) && _isFuturesTabActive(ref.read(activeBottomTabProvider))) { _startPolling(symbol); } } Future _loadContractInfo(String symbol) async { try { final svc = _service; final apiSymbol = _toApiSymbol(symbol); final info = await svc.getSymbolInfo(apiSymbol); final id = (info['id'] as num?)?.toInt() ?? 0; if (id == 0) return; final pricePrecision = ((info['coinScale'] ?? info['minScale']) as num?)?.toInt() ?? 2; const volScale = 0; final coinPrecision = (info['minScale'] as num?)?.toInt() ?? 4; final usdtPrecision = (info['baseCoinScale'] as num?)?.toInt() ?? 4; final contractSize = _toDouble( info['shareNumber'] ?? info['contractSize'] ?? info['shareSize'] ?? 1); final coinObj = info['coin'] as Map?; final rawSym = coinObj?['symbol'] as String? ?? apiSymbol; final coinSymbol = rawSym.contains('/') ? rawSym.split('/').first : rawSym; // 杠杆档位(逗号分隔字符串) final rawLeverage = info['leverage']?.toString() ?? ''; final leverageOptions = rawLeverage.isNotEmpty ? rawLeverage .split(',') .map((s) => int.tryParse(s.trim())) .whereType() .where((v) => v > 0) .toList() : []; leverageOptions.sort(); final leverageMin = leverageOptions.isNotEmpty ? leverageOptions.first : 1; final leverageMax = leverageOptions.isNotEmpty ? leverageOptions.last : 125; // leverageType: 1=固定档位,2=区间任意值 final leverageType = (info['leverageType'] as num?)?.toInt() ?? 2; final isDiscrete = leverageType == 1; final openFeeRate = _toDouble(info['openFee'] ?? info['makerFee'] ?? 0.0005); state = state.copyWith( contractCoinId: id, pricePrecision: pricePrecision, volScale: volScale.toInt(), coinPrecision: coinPrecision, usdtPrecision: usdtPrecision, contractSize: contractSize > 0 ? contractSize : 1.0, coinSymbol: coinSymbol, leverageMin: leverageMin, leverageMax: leverageMax, leverageOptions: leverageOptions, isDiscreteLeverage: isDiscrete, openFeeRate: openFeeRate > 0 ? openFeeRate : 0.0005, ); try { final leverage = await svc.getLeverage(id); state = state.copyWith(leverage: leverage.toDouble()); } catch (_) {} } catch (_) {} } Future refreshWallet() => _loadWallet(state.symbol); Future _loadWallet(String symbol) async { if (!ref.read(isLoggedInProvider)) return; final data = await _service .getWithPositions(symbol) .catchError((_) => {}); final wallet = data; final rawPositions = (data['currentPositionWithCutList'] as List? ?? []) .cast>(); List positions = []; final cs = state.contractSize > 0 ? state.contractSize : 1.0; for (final item in rawPositions) { try { positions.add(FuturesPosition.fromJson(item).withContractSize(cs)); } catch (e) { debugPrint('[FuturesPosition] parse error: $e\nitem: $item'); } } // 有匹配当前币对的持仓时,同步杠杆到下单表单 final currentSymbol = state.symbol.toUpperCase().replaceAll('-', ''); final matchedPos = positions.where((p) { final ps = p.symbol.toUpperCase().replaceAll('-', '').replaceAll('/', ''); return ps.contains(currentSymbol) || currentSymbol.contains(ps); }).toList(); if (matchedPos.isNotEmpty && matchedPos.first.leverage > 0) { state = state.copyWith( positions: positions, leverage: matchedPos.first.leverage, ); } else { state = state.copyWith(positions: positions); } _subscribePositionTickers(positions); try { if (wallet.isNotEmpty) { final totalPnl = positions.fold(0.0, (sum, p) => sum + p.unrealizedPnl); final positionsMargin = positions.fold(0.0, (sum, p) => sum + p.margin); final accountInfo = FuturesAccountInfo.fromJson( wallet, unrealizedPnl: totalPnl, positionsMargin: positionsMargin, ); // marginMode 不从服务器同步(进入合约页默认全仓,用户主动切换才变更) final isTrader = ref.read(copyTradingProvider).isTrader; final syncedMode = (isTrader && state.marginMode == MarginMode.split) ? MarginMode.cross : null; state = state.copyWith( accountInfo: accountInfo, marginMode: syncedMode ?? state.marginMode, ); } } catch (_) {} } Future _loadCurrentOrders() async { if (!ref.read(isLoggedInProvider)) return; try { final result = await _service.getCurrentOrders(pageNo: 1, pageSize: 10); final orders = _parseOrders(result.items); state = state.copyWith( openOrders: orders, ordersPage: 1, ordersHasMore: result.hasMore, ); } catch (_) {} } Future loadMoreOrders() async { if (state.ordersLoadingMore || !state.ordersHasMore) return; state = state.copyWith(ordersLoadingMore: true); try { final nextPage = state.ordersPage + 1; final result = await _service.getCurrentOrders(pageNo: nextPage, pageSize: 10); final more = _parseOrders(result.items); state = state.copyWith( openOrders: [...state.openOrders, ...more], ordersPage: nextPage, ordersHasMore: result.hasMore, ordersLoadingMore: false, ); } catch (e) { state = state.copyWith(ordersLoadingMore: false); } } static List _parseOrders(List> raw) { final orders = []; for (final item in raw) { try { orders.add(FuturesOrder.fromJson(item)); } catch (_) {} } return orders; } void _subscribeWebSocket(String symbol) { final wsSymbol = symbol.replaceAll('-', '').toLowerCase(); final ws = ref.read(wsClientProvider); ws.subscribeTicker(wsSymbol); ws.subscribeDepth(wsSymbol); ws.subscribeMark(wsSymbol); _markSub?.cancel(); _markSub = ws.markStream .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol) .listen((data) { final markPrice = (data['markPrice'] as num?)?.toDouble() ?? 0; final fundingRate = (data['fundingRate'] as num?)?.toDouble() ?? 0; final nextFundingTime = (data['nextFundingTime'] as num?)?.toInt() ?? 0; if (markPrice > 0) { state = state.copyWith(markPrice: markPrice); } // 新结算时间 > 当前记录时才更新(允许同一周期多次推送相同值) if (nextFundingTime > 0 && nextFundingTime > DateTime.now().millisecondsSinceEpoch && nextFundingTime > _nextSettleTimeMs) { _nextSettleTimeMs = nextFundingTime; _startFundingCountdown(); } state = state.copyWith(fundingRate: fundingRate); }); _tickerSub = ws.tickerStream.listen((data) { final s = (data['symbol'] as String? ?? '').toLowerCase(); final price = (data['price'] as num?)?.toDouble() ?? 0; if (price <= 0) return; final priceStr = data['priceStr'] as String? ?? ''; final now = DateTime.now().millisecondsSinceEpoch; final isCurrentSymbol = s == wsSymbol; // 非当前交易对节流 500ms if (!isCurrentSymbol) { final last = _lastTickerUpdateMs[s] ?? 0; if (now - last < 500) return; } _lastTickerUpdateMs[s] = now; final tickerApiSymbol = _toApiSymbol(s.toUpperCase()); final tSym = tickerApiSymbol.replaceAll('/', '').toUpperCase(); final hasMatch = state.positions.any( (p) => p.symbol.replaceAll('/', '').toUpperCase() == tSym); // 持仓 mark price 更新节流 1000ms,避免每次 WS 推送都重建 positions 数组 final canUpdatePositions = now - _lastPositionTickUpdateMs >= 1000; if (isCurrentSymbol) { final change = (data['change24h'] as num?)?.toDouble() ?? 0; if (hasMatch && canUpdatePositions) { _lastPositionTickUpdateMs = now; final updatedPositions = state.positions.map((p) { return p.symbol.replaceAll('/', '').toUpperCase() == tSym ? p.withMarkPrice(price) : p; }).toList(); final totalPnl = updatedPositions.fold(0.0, (sum, p) => sum + p.unrealizedPnl); final ai = state.accountInfo; state = state.copyWith( lastPrice: price, lastPriceStr: priceStr.isNotEmpty ? priceStr : null, markPrice: price, change24h: change, positions: updatedPositions, accountInfo: FuturesAccountInfo( totalBalance: ai.totalBalance, usedMargin: ai.usedMargin, availableMargin: ai.availableMargin, unrealizedPnl: totalPnl, ), ); } else { state = state.copyWith( lastPrice: price, lastPriceStr: priceStr.isNotEmpty ? priceStr : null, markPrice: price, change24h: change, ); } } else if (hasMatch && canUpdatePositions) { _lastPositionTickUpdateMs = now; final updatedPositions = state.positions.map((p) { return p.symbol.replaceAll('/', '').toUpperCase() == tSym ? p.withMarkPrice(price) : p; }).toList(); final totalPnl = updatedPositions.fold(0.0, (sum, p) => sum + p.unrealizedPnl); final ai = state.accountInfo; state = state.copyWith( positions: updatedPositions, accountInfo: FuturesAccountInfo( totalBalance: ai.totalBalance, usedMargin: ai.usedMargin, availableMargin: ai.availableMargin, unrealizedPnl: totalPnl, ), ); } }); _depthSub = ws.orderBookStream.listen((data) { final s = (data['symbol'] as String? ?? '').toLowerCase(); if (s != wsSymbol) return; // 盘口节流 120ms final now = DateTime.now().millisecondsSinceEpoch; if (now - _lastDepthUpdateMs < 120) return; _lastDepthUpdateMs = now; final rawAsks = data['asks'] as List? ?? []; final rawBids = data['bids'] as List? ?? []; _pendingAsks = rawAsks.whereType>().toList(); _pendingBids = rawBids.whereType>().toList(); if (_depthTimer == null || !_depthTimer!.isActive) { _depthTimer = Timer(Duration.zero, () { final asks = _pendingAsks; final bids = _pendingBids; _pendingAsks = null; _pendingBids = null; if (asks != null && bids != null) { state = state.copyWith(orderBookAsks: asks, orderBookBids: bids); } }); } }); } void _subscribePositionTickers(List positions) { if (positions.isEmpty) return; try { final ws = ref.read(wsClientProvider); final symbols = positions .map((p) => p.symbol.replaceAll('/', '').toLowerCase()) .toSet() .toList(); ws.subscribeTickerBatch(symbols); } catch (_) {} } void _startFundingCountdown() { _fundingTimer?.cancel(); _updateFundingCountdown(); _fundingTimer = Timer.periodic(const Duration(seconds: 1), (_) { _updateFundingCountdown(); }); } void _updateFundingCountdown() { final remaining = _nextSettleTimeMs - DateTime.now().millisecondsSinceEpoch; if (remaining <= 0) { state = state.copyWith(fundingCountdown: '00:00:00'); _fundingTimer?.cancel(); // 下次结算时间由 WS mark 推送更新,无需 HTTP 拉取 return; } final secs = remaining ~/ 1000; final h = secs ~/ 3600; final m = (secs % 3600) ~/ 60; final s = secs % 60; state = state.copyWith( fundingCountdown: '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}', ); } void _dispose() { _tickerSub?.cancel(); _tickerSub = null; _depthSub?.cancel(); _depthSub = null; _markSub?.cancel(); _markSub = null; _fundingTimer?.cancel(); _fundingTimer = null; _pollTimer?.cancel(); _pollTimer = null; _depthTimer?.cancel(); _depthTimer = null; } void setOrderSide(OrderSide side) => state = state.copyWith(orderSide: side); void setOrderType(OrderType type) => state = state.copyWith(orderType: type); void setPositionMode(PositionMode mode) { final isConditional = state.orderType == OrderType.conditionalMarket || state.orderType == OrderType.conditionalLimit; if (mode == PositionMode.close && isConditional) { // 平仓不支持计划委托:暂存类型,临时切换为市价 _savedConditionalType = state.orderType; state = state.copyWith(positionMode: mode, orderType: OrderType.market); } else if (mode == PositionMode.open && _savedConditionalType != null) { // 切回开仓:恢复之前的计划委托类型 final restored = _savedConditionalType!; _savedConditionalType = null; state = state.copyWith(positionMode: mode, orderType: restored); } else { state = state.copyWith(positionMode: mode); } } /// 切换仓位模式。 /// 全仓/分仓:必须等后端 modifyType 成功后才更新本地状态 /// 返回 null 表示成功;返回错误消息字符串表示失败(调用方负责展示) Future setMarginMode(MarginMode mode) async { // 全仓/分仓:先调后端,成功后再更新本地状态;期间禁止下单 final serverType = mode == MarginMode.split ? 1 : 0; state = state.copyWith(isSwitchingMode: true); try { await _service.modifyPositionType(serverType); state = state.copyWith( marginMode: mode, isSwitchingMode: false, ); // 持久化该交易对的保证金模式,下次切回时自动恢复 final modeStr = mode == MarginMode.split ? 'split' : mode == MarginMode.isolated ? 'isolated' : 'cross'; ref.read(sharedPreferencesProvider) .setString('futures_margin_mode_${state.symbol}', modeStr); // 切换回普通模式后,重新从服务器获取当前杠杆倍数 if (state.contractCoinId > 0) { try { final lev = await _service.getLeverage(state.contractCoinId); state = state.copyWith(leverage: lev.toDouble()); } catch (_) {} } return null; } catch (e) { // 切换失败:重新拉取真实状态,保持原有 marginMode 不变 state = state.copyWith(isSwitchingMode: false); await _loadWallet(state.symbol); final msg = e.toString(); return msg; } } void setAmountUnit(AmountUnit unit) { state = state.copyWith(amountUnit: unit); ref.read(futuresAmountUnitPrefProvider.notifier).state = unit; } void toggleHideOtherSymbols() { final next = !state.hideOtherSymbols; state = state.copyWith(hideOtherSymbols: next); ref.read(futuresHideOtherPrefProvider.notifier).state = next; } void setTab(FuturesTab tab) { state = state.copyWith(activeTab: tab); _loadWallet(state.symbol); _loadCurrentOrders(); } void setSliderPercent(double pct) => state = state.copyWith(sliderPercent: pct, isSliderInput: true); void setSliderPercentFromInput(double pct) => state = state.copyWith(sliderPercent: pct, isSliderInput: false); void toggleTpsl() => state = state.copyWith(tpslEnabled: !state.tpslEnabled); String _errMsg(Object e) { if (e is ApiException) return e.message; if (e is DioException) { final inner = e.error; if (inner is ApiException) return inner.message; final data = e.response?.data; if (data is Map) { final msg = data['message'] as String? ?? data['msg'] as String?; if (msg != null && msg.isNotEmpty) return msg; } final errStr = inner?.toString() ?? ''; if (errStr.isNotEmpty) { final m = RegExp(r'ApiException\(\d+\):\s*(.+?)[\r\n]?$', multiLine: true) .firstMatch(errStr); if (m != null) return m.group(1)!.trim(); } final dioStr = e.toString(); final dm = RegExp(r'Error:\s*ApiException\(\d+\):\s*(.+?)[\r\n]?$', multiLine: true) .firstMatch(dioStr); if (dm != null) return dm.group(1)!.trim(); if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.receiveTimeout || e.type == DioExceptionType.sendTimeout) { return 'errTimeout'; } if (e.type == DioExceptionType.connectionError) { return 'errNetworkError'; } } final s = e.toString(); final m = RegExp(r'ApiException\(\d+\):\s*(.+?)[\r\n]?$', multiLine: true) .firstMatch(s); if (m != null) return m.group(1)!.trim(); final lines = s.split('\n'); for (final line in lines.reversed) { final t = line.trim(); if (t.isEmpty || t.startsWith('Uri:') || t.startsWith('DioException')) continue; final idx = t.lastIndexOf(': '); if (idx != -1 && idx < t.length - 2) return t.substring(idx + 2).trim(); return t; } return s; } Future setLeverage(double lev) async { if (state.contractCoinId <= 0) { await _loadContractInfo(state.symbol); if (state.contractCoinId <= 0) return 'errServiceUnavailable'; } try { await _service.modifyLeverage(state.contractCoinId, lev.toInt()); state = state.copyWith(leverage: lev); return null; } catch (e) { return _errMsg(e); } } Future placeOpenOrder({ OrderSide? side, double? entrustPrice, double? triggerPrice, double? volume, double? tpPrice, double? slPrice, }) async { // 模式切换 API 尚未完成,禁止下单,防止 positionType 不一致导致仓位错误 if (state.isSwitchingMode) return 'errSwitchingMode'; if (state.contractCoinId == 0) { await _loadContractInfo(state.symbol); if (state.contractCoinId == 0) return 'errServiceUnavailable'; } final vol = volume ?? state.inputSize; if (vol <= 0) return 'errEnterVolume'; final int type; final double? ep; switch (state.orderType) { case OrderType.market: type = 0; ep = null; case OrderType.limit: type = 1; ep = entrustPrice ?? state.inputPrice; if (ep <= 0) return 'errEnterPrice'; case OrderType.conditionalMarket: type = 2; ep = null; // 计划市价不传委托价,后端以 entrustPrice=0 标识市价 if ((triggerPrice ?? 0) <= 0) return 'errEnterTriggerPrice'; case OrderType.conditionalLimit: type = 2; ep = entrustPrice; if ((triggerPrice ?? 0) <= 0) return 'errEnterTriggerPrice'; } final dir = (side ?? state.orderSide) == OrderSide.long ? 0 : 1; // 滑块模式:市价/限价传百分比给后端;计划委托不支持百分比,需换算为实际张数 if (state.isSliderInput && state.sliderPercent > 0.0001 && type != 2) { final pct = (state.sliderPercent * 100).round().clamp(1, 100); try { await _service.openOrder( contractCoinId: state.contractCoinId, type: type, direction: dir, volume: pct.toDouble(), leverage: state.leverage.toInt(), entrustPrice: ep, triggerPrice: triggerPrice, profitPrice: tpPrice ?? state.tpPrice, lossPrice: slPrice ?? state.slPrice, isPercentage: 1, positionType: state.marginMode == MarginMode.split ? 1 : 0, ); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); Future.delayed(const Duration(seconds: 2), () => _loadWallet(state.symbol)); return null; } catch (e) { return _errMsg(e); } } if (state.contractSize <= 0) return 'errContractNotReady'; // 计划市价用触发价作为换算基准,计划限价用委托价,普通单用委托价/最新价 final double effectivePrice; if (state.orderType == OrderType.conditionalMarket) { effectivePrice = (triggerPrice != null && triggerPrice > 0) ? triggerPrice : state.lastPrice; } else { effectivePrice = ep ?? (state.lastPrice > 0 ? state.lastPrice : 0); } if (effectivePrice <= 0) return 'errPriceNotReady'; final double volumeInBtc; switch (state.amountUnit) { case AmountUnit.lots: volumeInBtc = vol * state.contractSize / effectivePrice; case AmountUnit.usdt: volumeInBtc = vol / effectivePrice; case AmountUnit.btc: volumeInBtc = vol; } final factor = math.pow(10, state.coinPrecision).toDouble(); final double finalVolume = (volumeInBtc * factor).floorToDouble() / factor; if (finalVolume <= 0) return 'errVolumeInsufficient'; try { await _service.openOrder( contractCoinId: state.contractCoinId, type: type, direction: dir, volume: finalVolume, leverage: state.leverage.toInt(), entrustPrice: ep, triggerPrice: triggerPrice, profitPrice: tpPrice ?? state.tpPrice, lossPrice: slPrice ?? state.slPrice, positionType: state.marginMode == MarginMode.split ? 1 : 0, ); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); Future.delayed(const Duration(seconds: 2), () => _loadWallet(state.symbol)); return null; } catch (e) { return _errMsg(e); } } /// 市价平仓;volume 不传或 <=0 时全仓平 Future closeMarket(FuturesPosition position, {double? volume}) async { try { final vol = (volume != null && volume > 0) ? volume.clamp(0.0, position.availableSize) : position.availableSize; await _service.closeMarket( positionId: position.id, volume: vol, ); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); return null; } catch (e) { return _errMsg(e); } } /// 限价平仓;volume 不传或 <=0 时全仓平 Future closeLimit(FuturesPosition position, double price, {double? volume}) async { if (price <= 0) return 'errEnterClosePrice'; try { final vol = (volume != null && volume > 0) ? volume.clamp(0.0, position.availableSize) : position.availableSize; await _service.closeLimit( positionId: position.id, price: price, volume: vol, ); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); return null; } catch (e) { return _errMsg(e); } } Future cancelOrder(FuturesOrder order) async { final id = int.tryParse(order.id); if (id == null) return 'errInvalidOrderId'; try { await _service.cancelOrder(id); await _loadCurrentOrders(); return null; } catch (e) { return _errMsg(e); } } Future closeAllPositions() async { try { await _service.closeAllPositions(); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); return null; } catch (e) { return _errMsg(e); } } // price 为 null/0 时市价,否则限价 Future closeByDirection(OrderSide side, {double? price, double? volume}) async { final normSymbol = FuturesState._norm(_toApiSymbol(state.symbol)); final pos = state.positions.where( (p) => p.side == side && FuturesState._norm(p.symbol) == normSymbol, ).firstOrNull; if (pos == null) return side == OrderSide.long ? 'errNoLongPosition' : 'errNoShortPosition'; final closeVol = (volume != null && volume > 0) ? volume.clamp(0.0, pos.availableSize) : pos.availableSize; if (price != null && price > 0) { return _closeLimitVolume(pos, price, closeVol); } else { return _closeMarketVolume(pos, closeVol); } } Future closeConditionalByDirection( OrderSide side, { required double triggerPrice, double? volume, double entrustPrice = 0, }) async { final normSymbol = FuturesState._norm(_toApiSymbol(state.symbol)); final pos = state.positions.where( (p) => p.side == side && FuturesState._norm(p.symbol) == normSymbol, ).firstOrNull; if (pos == null) return side == OrderSide.long ? 'errNoLongPosition' : 'errNoShortPosition'; final closeVol = (volume != null && volume > 0) ? volume.clamp(0.0, pos.availableSize) : pos.availableSize; try { await _service.closeConditional( positionId: pos.id, triggerPrice: triggerPrice, volume: closeVol, entrustPrice: entrustPrice, ); await _loadCurrentOrders(); return null; } catch (e) { return _errMsg(e); } } Future _closeMarketVolume(FuturesPosition position, double volume) async { try { await _service.closeMarket(positionId: position.id, volume: volume); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); return null; } catch (e) { return _errMsg(e); } } Future _closeLimitVolume(FuturesPosition position, double price, double volume) async { if (price <= 0) return 'errEnterClosePrice'; try { await _service.closeLimit(positionId: position.id, price: price, volume: volume); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); return null; } catch (e) { return _errMsg(e); } } Future cancelAllOrders() async { final ids = state.openOrders .map((o) => int.tryParse(o.id)) .whereType() .toList(); if (ids.isEmpty) return 'errNoOrdersToCancel'; try { await _service.cancelOrdersByIds(ids); await _loadCurrentOrders(); return null; } catch (e) { await _loadCurrentOrders(); return _errMsg(e); } } Future setPositionTpsl( FuturesPosition position, { double? profitPrice, double? lossPrice, }) async { try { if (position.cutEntrustId != null) { // 已有止盈止损委托单 → 修改 await _service.modifyCutOrder( entrustId: position.cutEntrustId!, profitPrice: profitPrice, lossPrice: lossPrice, ); } else { // 首次设置,成功后刷新持仓以获取 cutEntrustId await _service.setTpsl( positionId: position.id, profitPrice: profitPrice, lossPrice: lossPrice, ); await _loadWallet(state.symbol); } return null; } catch (e) { return _errMsg(e); } } Future reversePosition(FuturesPosition position) async { try { await _service.reverseOpenPosition(position.id); await Future.wait([_loadWallet(state.symbol), _loadCurrentOrders()]); return null; } catch (e) { return _errMsg(e); } } Future refresh() async { // 下拉刷新只更新数据,不触发 isLoading(骨架屏仅用于首屏) await Future.wait([ _loadWallet(state.symbol), _loadCurrentOrders(), ]); } FuturesService get _service => FuturesService(ref.read(dioClientProvider)); } final futuresProvider = AutoDisposeNotifierProviderFamily( FuturesNotifier.new, ); final futuresAmountUnitPrefProvider = StateProvider((ref) => AmountUnit.btc); final futuresHideOtherPrefProvider = StateProvider((ref) => false); final futuresActiveSymbolProvider = StateProvider((ref) => ''); /// 当前底部导航栏选中的 tab 索引(0=首页, 1=行情, 2=交易, 3=合约, 4=跟单, 5=资产) final activeBottomTabProvider = StateProvider((ref) => 0); /// 上次访问的交易路径(现货 /spot/:sym 或合约 /futures/:sym),用于恢复交易 tab final lastTradingRouteProvider = StateProvider((ref) => '/spot/BTCUSDT'); double _toDouble(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } /// 预计强平价:直接使用后端返回值,<=0 或 -1 均视为无效(显示 '--') double _calcLiquidationPrice({required double apiValue}) { return apiValue > 0 ? apiValue : 0.0; }