import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:k_chart_plus/k_chart_plus.dart'; import '../core/network/dio_client.dart'; import '../core/network/spot_ws_client.dart'; import '../data/models/market/kline_bar.dart'; import '../data/models/market/order_book_entry.dart'; import 'spot_ws_provider.dart'; import 'ws_provider.dart'; // ── K 线内存缓存(Stale-While-Revalidate)────────────────────── // key: "f|s::",现货/合约分开缓存。 // 作用:再次打开同一行情页时立即渲染旧数据,后台静默刷新,消除 loading 闪烁。 final _klineBarCache = >{}; const _kCacheMaxSize = 30; /// 现货 WS `event: req` 拉历史 K 默认条数(与 web `useSpotMarketWs.sendKlineReq.pageSize` 一致) const int _spotWsKlinePageSizeDefault = 300; /// 行情详情 Provider 的 family 参数:区分现货 / 永续,避免共用合约 WS 与缓存。 @immutable class MarketDetailKey { const MarketDetailKey({required this.symbol, required this.isFutures}); final String symbol; final bool isFutures; @override bool operator ==(Object other) => other is MarketDetailKey && other.symbol == symbol && other.isFutures == isFutures; @override int get hashCode => Object.hash(symbol, isFutures); } String _klineCacheKey(String symbol, KlinePeriod period, bool isFutures) => '${isFutures ? 'f' : 's'}:$symbol:${period.wsInterval}'; /// WS / 归一化 Map 中的数值可能是 int/double/String,避免 `as num?` 丢数据或抛错。 double _looseDouble(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString().trim().replaceAll(',', '')) ?? 0.0; } void _putCache(String key, List bars) { if (bars.isEmpty) return; if (_klineBarCache.length >= _kCacheMaxSize && !_klineBarCache.containsKey(key)) { _klineBarCache.remove(_klineBarCache.keys.first); // 移除最早的条目 } _klineBarCache[key] = bars; } // ── 币种扩展信息(/api/contract/coin-ext/detail)───────────── String? _toNonEmpty(dynamic v) { if (v == null) return null; final s = v.toString().trim(); return s.isEmpty ? null : s; } class CoinExtInfo { final int? id; final String symbol; final String nameCn; final String nameEn; final String? icon; final int? rank; final String? marketCap; // 后端返回格式化字符串,如 "$1.76万亿" final String? circulatingSupply; // 后端返回格式化字符串,如 "1,976.43万 BTC" final double? issuePrice; final double? athPrice; final String? athDate; final String? whitepaper; const CoinExtInfo({ this.id, required this.symbol, required this.nameCn, required this.nameEn, this.icon, this.rank, this.marketCap, this.circulatingSupply, this.issuePrice, this.athPrice, this.athDate, this.whitepaper, }); factory CoinExtInfo.fromJson(Map json) { double? toDouble(dynamic v) { if (v == null) return null; if (v is num) return v.toDouble(); final s = v.toString().trim(); return s.isEmpty ? null : double.tryParse(s); } return CoinExtInfo( id: json['id'] as int?, symbol: json['symbol']?.toString() ?? '', nameCn: json['nameCn']?.toString() ?? json['name']?.toString() ?? '', nameEn: json['nameEn']?.toString() ?? '', icon: json['icon']?.toString(), rank: json['rank'] is int ? json['rank'] : int.tryParse('${json['rank'] ?? ''}'), marketCap: _toNonEmpty(json['marketCap']), circulatingSupply: _toNonEmpty(json['circulatingSupply']), issuePrice: toDouble(json['issuePrice']), athPrice: toDouble(json['athPrice']), athDate: json['athDate']?.toString(), whitepaper: json['whitepaper']?.toString(), ); } } // ── 周期 & 指标枚举 ──────────────────────────────────────── enum KlinePeriod { min1, min5, min15, min30, hour1, hour4, day1, week1, month1 } extension KlinePeriodLabel on KlinePeriod { String get label { switch (this) { case KlinePeriod.min1: return '1分'; case KlinePeriod.min5: return '5分'; case KlinePeriod.min15: return '15分'; case KlinePeriod.min30: return '30分'; case KlinePeriod.hour1: return '1时'; case KlinePeriod.hour4: return '4时'; case KlinePeriod.day1: return '日线'; case KlinePeriod.week1: return '周线'; case KlinePeriod.month1: return '月线'; } } /// WS 订阅的 interval 字符串 String get wsInterval { switch (this) { case KlinePeriod.min1: return '1m'; case KlinePeriod.min5: return '5m'; case KlinePeriod.min15: return '15m'; case KlinePeriod.min30: return '30m'; case KlinePeriod.hour1: return '1h'; case KlinePeriod.hour4: return '4h'; case KlinePeriod.day1: return '1d'; case KlinePeriod.week1: return '1w'; case KlinePeriod.month1: return '1mon'; } } /// 现货行情 WS `market_{sym}_kline_{period}` 的 period 后缀(与 market 服务 SpotConstant / spot-websocket-api 一致) String get spotWsKlinePeriod { switch (this) { case KlinePeriod.min1: return '1min'; case KlinePeriod.min5: return '5min'; case KlinePeriod.min15: return '15min'; case KlinePeriod.min30: return '30min'; case KlinePeriod.hour1: return '60min'; case KlinePeriod.hour4: return '4h'; case KlinePeriod.day1: return '1day'; case KlinePeriod.week1: return '1week'; case KlinePeriod.month1: return '1month'; } } } /// K 线订阅/请求用的 interval:合约用 Huobi 风格,现货用下划线 channel 后缀 String _klineChannelInterval(KlinePeriod period, bool isFutures) => isFutures ? period.wsInterval : period.spotWsKlinePeriod; enum ChartIndicator { ma, boll, vol, macd, rsi, kdj, wr } extension ChartIndicatorLabel on ChartIndicator { String get label => name.toUpperCase(); } // ── 市场概况数据 ─────────────────────────────────────────── class MarketStats { final double lastPrice; final String? lastPriceStr; // WS 返回的原始价格字符串 final double markPrice; final double change24h; final double high24h; final double low24h; final double volume24h; final double turnover24h; const MarketStats({ required this.lastPrice, this.lastPriceStr, required this.markPrice, required this.change24h, required this.high24h, required this.low24h, required this.volume24h, required this.turnover24h, }); @override bool operator ==(Object other) => identical(this, other) || other is MarketStats && lastPrice == other.lastPrice && lastPriceStr == other.lastPriceStr && markPrice == other.markPrice && change24h == other.change24h && high24h == other.high24h && low24h == other.low24h && volume24h == other.volume24h && turnover24h == other.turnover24h; @override int get hashCode => Object.hash( lastPrice, lastPriceStr, markPrice, change24h, high24h, low24h, volume24h, turnover24h); } // ── 最新成交数据 ──────────────────────────────────────── class RecentTrade { final double price; final double quantity; final bool isBuyerMaker; final int time; final String tradeId; const RecentTrade({ required this.price, required this.quantity, required this.isBuyerMaker, required this.time, this.tradeId = '', }); @override bool operator ==(Object other) => identical(this, other) || other is RecentTrade && price == other.price && quantity == other.quantity && isBuyerMaker == other.isBuyerMaker && time == other.time && tradeId == other.tradeId; @override int get hashCode => Object.hash(price, quantity, isBuyerMaker, time, tradeId); } // ── K 线加载类型 ────────────────────────────────────────── enum KlineLoadType { initial, loadMore, realtimeAppend } // ── UI State ────────────────────────────────────────────── class MarketDetailState { final String symbol; final bool isLoading; final String? errorMessage; final MarketStats? stats; final List klines; /// k_chart_plus 用的 KLineEntity 列表,与 klines 同步,指标已计算 final List entities; final OrderBook? orderBook; final KlinePeriod period; final ChartIndicator indicator; final int topTab; final int bottomTab; final List trades; final KlineLoadType lastLoadType; final bool isLoadingMore; final CoinExtInfo? coinExt; const MarketDetailState({ required this.symbol, this.isLoading = false, this.errorMessage, this.stats, this.klines = const [], this.entities = const [], this.orderBook, this.period = KlinePeriod.hour1, this.indicator = ChartIndicator.ma, this.topTab = 0, this.bottomTab = 0, this.trades = const [], this.lastLoadType = KlineLoadType.initial, this.isLoadingMore = false, this.coinExt, }); MarketDetailState copyWith({ bool? isLoading, String? errorMessage, MarketStats? stats, List? klines, List? entities, OrderBook? orderBook, KlinePeriod? period, ChartIndicator? indicator, int? topTab, int? bottomTab, List? trades, KlineLoadType? lastLoadType, bool? isLoadingMore, CoinExtInfo? coinExt, bool clearCoinExt = false, }) { return MarketDetailState( symbol: symbol, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, stats: stats ?? this.stats, klines: klines ?? this.klines, entities: entities ?? this.entities, orderBook: orderBook ?? this.orderBook, period: period ?? this.period, indicator: indicator ?? this.indicator, topTab: topTab ?? this.topTab, bottomTab: bottomTab ?? this.bottomTab, trades: trades ?? this.trades, lastLoadType: lastLoadType ?? this.lastLoadType, isLoadingMore: isLoadingMore ?? this.isLoadingMore, coinExt: clearCoinExt ? null : (coinExt ?? this.coinExt), ); } } // ── Notifier ────────────────────────────────────────────── class MarketDetailNotifier extends FamilyNotifier { StreamSubscription? _tickerSub; StreamSubscription? _markSub; StreamSubscription? _klineSub; StreamSubscription? _depthSub; StreamSubscription? _tradeSub; String? _currentKlineInterval; /// 与 [build] 传入的 [MarketDetailKey.isFutures] 同步,供异步方法使用 bool _isFutures = true; // 节流:WS 高频更新延迟到下一个事件循环,防止 layout/semantics 重入 Timer? _depthTimer; OrderBook? _pendingDepth; Timer? _tradeTimer; List? _pendingTrades; @override MarketDetailState build(MarketDetailKey key) { final symbol = key.symbol; final isFutures = key.isFutures; _isFutures = isFutures; ref.onDispose(() { _tickerSub?.cancel(); _markSub?.cancel(); _klineSub?.cancel(); _depthSub?.cancel(); _tradeSub?.cancel(); _depthTimer?.cancel(); _tradeTimer?.cancel(); if (isFutures) { final ws = ref.read(wsClientProvider); ws.unsubscribeTicker(symbol); ws.unsubscribeMark(symbol); ws.unsubscribeDepth(symbol); ws.unsubscribeTrade(symbol); if (_currentKlineInterval != null) { ws.unsubscribeKline(symbol, _currentKlineInterval!); } } else { final sw = ref.read(spotWsClientProvider); sw.unsubscribeTicker(symbol); // 深度/成交由 [spotProvider] 订阅,此处只取消本 Notifier 使用的 ticker/K 线 if (_currentKlineInterval != null) { sw.unsubscribeKline(symbol, _currentKlineInterval!); } } }); if (isFutures) { ref.listen>( wsConnectionStateProvider, (prev, next) { final prevState = prev?.valueOrNull; final nextState = next.valueOrNull; if (nextState == WsConnectionState.connected && prevState != WsConnectionState.connected) { _reloadKlines(symbol, state.period, isFutures: true); } }, ); } else { ref.listen>( spotWsConnectionStateProvider, (prev, next) { final prevState = prev?.valueOrNull; final nextState = next.valueOrNull; if (nextState == SpotWsState.connected && prevState != SpotWsState.connected) { _reloadKlines(symbol, state.period, isFutures: false); } }, ); } Future.microtask(() => _loadData(symbol, isFutures: isFutures)); return MarketDetailState(symbol: symbol, isLoading: true); } // ── KlineBar ↔ KLineEntity 转换 ────────────────────── static KLineEntity _toEntity(KlineBar bar) => KLineEntity.fromCustom( open: bar.open, close: bar.close, high: bar.high, low: bar.low, vol: bar.volume, time: bar.time.millisecondsSinceEpoch, ); /// 统一入口:同步 klines + entities + 指标计算 /// 任何修改 klines 的地方都必须走这里,防止数据不一致 void _setKlines(List newKlines, KlineLoadType type) { final entities = newKlines.map(_toEntity).toList(); // 全量计算所有指标:MA 计算依赖前序数据,局部窗口计算会导致早期 MA 值偏差 if (entities.isNotEmpty) DataUtil.calculate(entities, [5, 10, 20]); state = state.copyWith( klines: newKlines, entities: entities, lastLoadType: type, ); } // ── 数据加载 ────────────────────────────────────────── Future _loadData(String symbol, {required bool isFutures}) async { final period = state.period; final interval = _klineChannelInterval(period, isFutures); final cacheKey = _klineCacheKey(symbol, period, isFutures); final cached = _klineBarCache[cacheKey]; // ── 有缓存:立即渲染,无需 loading,WS 订阅先启动 ────────── if (cached != null && cached.isNotEmpty) { final price = cached.last.close; final prev = state.stats; state = state.copyWith( isLoading: false, stats: MarketStats( lastPrice: price, lastPriceStr: prev?.lastPriceStr, markPrice: prev?.markPrice ?? 0, change24h: prev?.change24h ?? 0, high24h: prev?.high24h ?? 0, low24h: prev?.low24h ?? 0, volume24h: prev?.volume24h ?? 0, turnover24h: prev?.turnover24h ?? 0, ), ); _setKlines(cached, KlineLoadType.initial); _subscribeMarketStreams(symbol, interval, isFutures: isFutures); } // 现货须先订阅 kline channel,再 WS `event: req`(与 web `useSpotMarketWs` 一致) if (!isFutures && (cached == null || cached.isEmpty)) { _subscribeMarketStreams(symbol, interval, isFutures: false); } // ── 后台请求最新 K 线历史 ───────────────────────────────── final List> historyData; if (isFutures) { final ws = ref.read(wsClientProvider); final now = DateTime.now().millisecondsSinceEpoch; final from = now - _periodDurationMs(period); historyData = await ws.requestKlineHistory( symbol: symbol, interval: interval, from: from, to: now, ); } else { historyData = await _fetchSpotKlineHistoryRaw( symbol, period, pageSize: _spotWsKlinePageSizeDefault, ); } final klines = historyData.isNotEmpty ? _parseKlineHistory(historyData, period) : (cached ?? []); _putCache(cacheKey, klines); final price = klines.isNotEmpty ? klines.last.close : 0.0; state = state.copyWith( isLoading: false, stats: MarketStats( lastPrice: price, lastPriceStr: state.stats?.lastPriceStr, markPrice: state.stats?.markPrice ?? 0, change24h: state.stats?.change24h ?? 0, high24h: state.stats?.high24h ?? 0, low24h: state.stats?.low24h ?? 0, volume24h: state.stats?.volume24h ?? 0, turnover24h: state.stats?.turnover24h ?? 0, ), ); _setKlines(klines, KlineLoadType.initial); if (cached == null || cached.isEmpty) { if (isFutures) { _subscribeMarketStreams(symbol, interval, isFutures: true); } // 现货已在请求历史之前完成订阅;合约仍在此处一次性订阅。 } else { _subscribeWsKline(symbol, interval, isFutures: isFutures); } } void _subscribeMarketStreams(String symbol, String interval, {required bool isFutures}) { if (isFutures) { _subscribeWsTicker(symbol); _subscribeWsMark(symbol); _subscribeWsDepth(symbol); _subscribeWsTrade(symbol); } else { _subscribeSpotTicker(symbol); } _subscribeWsKline(symbol, interval, isFutures: isFutures); } /// 等待 Spot WS 建连,`event: req` 须在 connected 状态下发送。 Future _ensureSpotWsConnected(SpotWsClient sw) async { if (sw.currentState == SpotWsState.connected) { return; } final deadline = DateTime.now().add(const Duration(seconds: 15)); while (DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 100)); if (sw.currentState == SpotWsState.connected) { return; } } } static int _klineOpenTimeMs(Map item) { final raw = item['beginTime'] ?? item['time'] ?? item['t'] ?? item['id']; int t = 0; if (raw is int) { t = raw; } else if (raw is num) { t = raw.toInt(); } else { t = int.tryParse('$raw') ?? 0; } if (t <= 0) { return 0; } if (t < 10000000000) { t *= 1000; } return t; } /// 服务端返回顺序不保证稳定,先按开盘时间排序再交给 [_parseKlineHistory]。 static void _sortSpotKlineWsRowsAscending(List> rows) { rows.sort((a, b) => _klineOpenTimeMs(a).compareTo(_klineOpenTimeMs(b))); } /// 现货历史 K:通过现货 WS `event: req` 拉取(与 web `useSpotMarketWs` 同源)。 /// [endIdxSec]:`SpotWsClient.requestKlineHistory.endIdx`,单位秒,`0` 表示最新一页。 Future>> _fetchSpotKlineHistoryRaw( String symbol, KlinePeriod period, { int pageSize = _spotWsKlinePageSizeDefault, int endIdxSec = 0, }) async { final sw = ref.read(spotWsClientProvider); await _ensureSpotWsConnected(sw); if (sw.currentState != SpotWsState.connected) { assert(() { debugPrint('[MarketDetail] spot klines req skipped: WS not connected'); return true; }()); return []; } try { final rows = await sw.requestKlineHistory( symbol: symbol, interval: period.spotWsKlinePeriod, pageSize: pageSize.clamp(1, 1000), endIdx: endIdxSec < 0 ? 0 : endIdxSec, ); _sortSpotKlineWsRowsAscending(rows); return rows; } catch (e, st) { assert(() { debugPrint('[MarketDetail] Spot WS spot klines: $e\n$st'); return true; }()); return []; } } /// 解析 K 线历史数据,并自动修复时间戳。 /// 部分交易所(如本项目)对月线/周线返回服务器处理时间而非 K 线开盘时间, /// 导致所有 bar 的时间戳集中在同一分钟内,无法正常显示时间轴。 /// 修复方案:当实际时间跨度 < 期望时间跨度的 10% 时,按周期均匀重建时间戳。 /// [anchorEndMs] 仅在 loadMore 时传入,作为重建时间的右锚点; /// 初始加载时传 null,使用当前时间作为锚点。 static List _parseKlineHistory( List> data, KlinePeriod period, { int? anchorEndMs, }) { if (data.isEmpty) return []; // 提取 K 线时间(毫秒)。现货 history 用 `id` 表示开盘时间(秒);合约多为毫秒 beginTime final times = data.map((item) { final raw = item['beginTime'] ?? item['time'] ?? item['t'] ?? item['id']; int t; if (raw is int) { t = raw; } else { t = int.tryParse('$raw') ?? 0; } if (t <= 0) return 0; if (t < 10000000000) return t * 1000; // 秒 → 毫秒 return t; }).toList(); final actualSpan = (times.last - times.first).abs(); final stepMs = _periodStepMs(period); // 实际跨度 < 期望跨度 10% → 服务端时间不可信,需重建 final expectedMinSpan = (data.length - 1) * stepMs * 0.1; final needRebuild = data.length > 1 && actualSpan < expectedMinSpan; List finalTimes; if (needRebuild) { // 以锚点为最后一根 K 线时间,往前均匀分布 final anchor = anchorEndMs ?? DateTime.now().millisecondsSinceEpoch; finalTimes = List.generate( data.length, (i) => anchor - (data.length - 1 - i) * stepMs, ); } else { finalTimes = times; } return data.asMap().entries.map((entry) { final item = entry.value; return KlineBar( time: DateTime.fromMillisecondsSinceEpoch(finalTimes[entry.key]), open: double.tryParse('${item['open']}') ?? 0, close: double.tryParse('${item['close']}') ?? 0, high: double.tryParse('${item['high']}') ?? 0, low: double.tryParse('${item['low']}') ?? 0, volume: double.tryParse('${item['volume'] ?? item['vol']}') ?? 0, ); }).toList(); } /// 单根 K 线的毫秒步长(用于时间戳修复) static int _periodStepMs(KlinePeriod period) { switch (period) { case KlinePeriod.min1: return 60 * 1000; case KlinePeriod.min5: return 5 * 60 * 1000; case KlinePeriod.min15: return 15 * 60 * 1000; case KlinePeriod.min30: return 30 * 60 * 1000; case KlinePeriod.hour1: return 60 * 60 * 1000; case KlinePeriod.hour4: return 4 * 60 * 60 * 1000; case KlinePeriod.day1: return 24 * 60 * 60 * 1000; case KlinePeriod.week1: return 7 * 24 * 60 * 60 * 1000; case KlinePeriod.month1: return 30 * 24 * 60 * 60 * 1000; } } static int _periodDurationMs(KlinePeriod period) { // 初始加载约 100 根 K 线 const bars = 100; switch (period) { case KlinePeriod.min1: return bars * 60 * 1000; case KlinePeriod.min5: return bars * 5 * 60 * 1000; case KlinePeriod.min15: return bars * 15 * 60 * 1000; case KlinePeriod.min30: return bars * 30 * 60 * 1000; case KlinePeriod.hour1: return bars * 60 * 60 * 1000; case KlinePeriod.hour4: return bars * 4 * 60 * 60 * 1000; case KlinePeriod.day1: return bars * 24 * 60 * 60 * 1000; case KlinePeriod.week1: return bars * 7 * 24 * 60 * 60 * 1000; case KlinePeriod.month1: return bars * 30 * 24 * 60 * 60 * 1000; } } /// 将时间对齐到所在周期的起始时刻 static DateTime _alignToPeriodStart(DateTime t, KlinePeriod period) { switch (period) { case KlinePeriod.min1: return DateTime(t.year, t.month, t.day, t.hour, t.minute); case KlinePeriod.min5: return DateTime(t.year, t.month, t.day, t.hour, (t.minute ~/ 5) * 5); case KlinePeriod.min15: return DateTime(t.year, t.month, t.day, t.hour, (t.minute ~/ 15) * 15); case KlinePeriod.min30: return DateTime(t.year, t.month, t.day, t.hour, (t.minute ~/ 30) * 30); case KlinePeriod.hour1: return DateTime(t.year, t.month, t.day, t.hour); case KlinePeriod.hour4: return DateTime(t.year, t.month, t.day, (t.hour ~/ 4) * 4); case KlinePeriod.day1: return DateTime(t.year, t.month, t.day); case KlinePeriod.week1: // 以周一为起始 return DateTime(t.year, t.month, t.day - (t.weekday - 1)); case KlinePeriod.month1: return DateTime(t.year, t.month, 1); } } /// 给定某周期起始时间,返回下一个周期的起始时间 static DateTime _nextPeriodStart(DateTime periodStart, KlinePeriod period) { switch (period) { case KlinePeriod.min1: return periodStart.add(const Duration(minutes: 1)); case KlinePeriod.min5: return periodStart.add(const Duration(minutes: 5)); case KlinePeriod.min15: return periodStart.add(const Duration(minutes: 15)); case KlinePeriod.min30: return periodStart.add(const Duration(minutes: 30)); case KlinePeriod.hour1: return periodStart.add(const Duration(hours: 1)); case KlinePeriod.hour4: return periodStart.add(const Duration(hours: 4)); case KlinePeriod.day1: return periodStart.add(const Duration(days: 1)); case KlinePeriod.week1: return periodStart.add(const Duration(days: 7)); case KlinePeriod.month1: // DateTime 自动处理跨年:DateTime(2025, 13, 1) → 2026-01-01 return DateTime(periodStart.year, periodStart.month + 1, 1); } } static int _loadMoreRangeMs(KlinePeriod period) { const count = 200; switch (period) { case KlinePeriod.min1: return count * 60 * 1000; case KlinePeriod.min5: return count * 5 * 60 * 1000; case KlinePeriod.min15: return count * 15 * 60 * 1000; case KlinePeriod.min30: return count * 30 * 60 * 1000; case KlinePeriod.hour1: return count * 60 * 60 * 1000; case KlinePeriod.hour4: return count * 4 * 60 * 60 * 1000; case KlinePeriod.day1: return count * 24 * 60 * 60 * 1000; case KlinePeriod.week1: return count * 7 * 24 * 60 * 60 * 1000; case KlinePeriod.month1: return count * 30 * 24 * 60 * 60 * 1000; } } /// 加载更早的 K线历史(向左滑动触发) Future loadMoreKlines() async { if (state.isLoadingMore || state.klines.isEmpty) return; state = state.copyWith(isLoadingMore: true); final interval = _klineChannelInterval(state.period, _isFutures); final earliest = state.klines.first.time.millisecondsSinceEpoch; final List> historyData; if (_isFutures) { final ws = ref.read(wsClientProvider); final to = earliest - 1; final from = to - _loadMoreRangeMs(state.period); historyData = await ws.requestKlineHistory( symbol: state.symbol, interval: interval, from: from, to: to, ); } else { final anchorMs = earliest - 1; historyData = await _fetchSpotKlineHistoryRaw( state.symbol, state.period, pageSize: 200, endIdxSec: anchorMs < 0 ? 0 : anchorMs ~/ 1000, ); } if (historyData.isEmpty) { state = state.copyWith(isLoadingMore: false); return; } final older = _parseKlineHistory( historyData, state.period, anchorEndMs: earliest - 1, ); final existingTimes = state.klines.map((k) => k.time.millisecondsSinceEpoch).toSet(); final dedupedOlder = older.where((k) => !existingTimes.contains(k.time.millisecondsSinceEpoch)).toList(); final merged = [...dedupedOlder, ...state.klines]; state = state.copyWith(isLoadingMore: false); _setKlines(merged, KlineLoadType.loadMore); } // ── WS 订阅 ──────────────────────────────────────────── static String _normSym(String s) => s.replaceAll('/', '').replaceAll('-', '').toUpperCase(); void _subscribeWsTicker(String symbol) { final ws = ref.read(wsClientProvider); ws.subscribeTicker(symbol); _tickerSub?.cancel(); final want = _normSym(symbol); _tickerSub = ws.tickerStream .where((data) => _normSym('${data['symbol'] ?? ''}') == want) .listen((data) { final price = (data['price'] as num?)?.toDouble(); if (price == null) return; final priceStr = data['priceStr'] as String? ?? ''; final change24h = (data['change24h'] as num?)?.toDouble() ?? 0; final high24h = (data['high24h'] as num?)?.toDouble() ?? 0; final low24h = (data['low24h'] as num?)?.toDouble() ?? 0; final turnover = (data['volume24h'] as num?)?.toDouble() ?? 0; final volumeCoin = (data['volumeCoin'] as num?)?.toDouble() ?? 0; final oldStats = state.stats; if (oldStats == null) return; state = state.copyWith( stats: MarketStats( lastPrice: price, lastPriceStr: priceStr.isNotEmpty ? priceStr : null, markPrice: oldStats.markPrice, change24h: change24h, high24h: high24h > 0 ? high24h : oldStats.high24h, low24h: low24h > 0 ? low24h : oldStats.low24h, volume24h: volumeCoin > 0 ? volumeCoin : oldStats.volume24h, turnover24h: turnover > 0 ? turnover : oldStats.turnover24h, ), ); }); } /// 现货 ticker → 与合约统一的 [MarketStats] 字段 void _subscribeSpotTicker(String symbol) { final sw = ref.read(spotWsClientProvider); sw.subscribeTicker(symbol); _tickerSub?.cancel(); final want = _normSym(symbol); _tickerSub = sw.tickerStream .where((data) => _normSym('${data['symbol'] ?? ''}') == want) .listen((data) { final price = _looseDouble(data['price']); if (price <= 0) return; final priceStr = data['priceStr'] as String? ?? ''; double? pct(dynamic v) { if (v == null) return null; if (v is num) return v.toDouble(); return double.tryParse(v.toString()); } final change24h = pct(data['change24h']); final high24h = _looseDouble(data['high']); final low24h = _looseDouble(data['low']); final turnover = _looseDouble(data['turnover']); final volumeCoin = _looseDouble(data['volume']); final oldStats = state.stats; if (oldStats == null) return; state = state.copyWith( stats: MarketStats( lastPrice: price, lastPriceStr: priceStr.isNotEmpty ? priceStr : null, markPrice: oldStats.markPrice, change24h: change24h ?? oldStats.change24h, high24h: high24h > 0 ? high24h : oldStats.high24h, low24h: low24h > 0 ? low24h : oldStats.low24h, volume24h: volumeCoin > 0 ? volumeCoin : oldStats.volume24h, turnover24h: turnover > 0 ? turnover : oldStats.turnover24h, ), ); }); } void _subscribeWsMark(String symbol) { final ws = ref.read(wsClientProvider); ws.subscribeMark(symbol); _markSub?.cancel(); _markSub = ws.markStream .where((data) => (data['symbol'] as String? ?? '').toUpperCase() == symbol.toUpperCase()) .listen((data) { final markPrice = (data['markPrice'] as num?)?.toDouble() ?? 0; final oldStats = state.stats; if (oldStats == null || markPrice <= 0) return; state = state.copyWith( stats: MarketStats( lastPrice: oldStats.lastPrice, lastPriceStr: oldStats.lastPriceStr, // 保留 ticker 推送的原始字符串 markPrice: markPrice, change24h: oldStats.change24h, high24h: oldStats.high24h, low24h: oldStats.low24h, volume24h: oldStats.volume24h, turnover24h: oldStats.turnover24h, ), ); }); } void _subscribeWsDepth(String symbol) { final ws = ref.read(wsClientProvider); ws.subscribeDepth(symbol); _depthSub?.cancel(); final want = _normSym(symbol); _depthSub = ws.orderBookStream .where((d) => _normSym('${d['symbol'] ?? ''}') == want) .listen((data) { final rawBids = data['bids'] as List? ?? []; final rawAsks = data['asks'] as List? ?? []; double cumTotal = 0; List parse(List raw) { cumTotal = 0; return raw.map((e) { final m = e as Map; final amt = (m['quantity'] as num?)?.toDouble() ?? 0; cumTotal += amt; return OrderBookEntry(price: (m['price'] as num?)?.toDouble() ?? 0, amount: amt, total: cumTotal); }).toList(); } _pendingDepth = OrderBook(asks: parse(rawAsks), bids: parse(rawBids)); if (_depthTimer == null || !_depthTimer!.isActive) { _depthTimer = Timer(Duration.zero, () { final pending = _pendingDepth; _pendingDepth = null; if (pending != null) state = state.copyWith(orderBook: pending); }); } }); } void _subscribeWsTrade(String symbol) { final ws = ref.read(wsClientProvider); ws.subscribeTrade(symbol); _tradeSub?.cancel(); final want = _normSym(symbol); _tradeSub = ws.tradeStream .where((d) => _normSym('${d['symbol'] ?? ''}') == want) .listen((data) { final trade = RecentTrade( price: (data['price'] as num?)?.toDouble() ?? 0, quantity: (data['quantity'] as num?)?.toDouble() ?? 0, isBuyerMaker: data['isBuyerMaker'] == true, time: data['time'] as int? ?? 0, tradeId: data['tradeId']?.toString() ?? '', ); final trades = [trade, ...(state.trades)]; if (trades.length > 50) trades.removeRange(50, trades.length); _pendingTrades = trades; if (_tradeTimer == null || !_tradeTimer!.isActive) { _tradeTimer = Timer(Duration.zero, () { final pending = _pendingTrades; _pendingTrades = null; if (pending != null) state = state.copyWith(trades: pending); }); } }); } void _subscribeWsKline(String symbol, String interval, {required bool isFutures}) { if (isFutures) { final ws = ref.read(wsClientProvider); if (_currentKlineInterval != null && _currentKlineInterval != interval) { ws.unsubscribeKline(symbol, _currentKlineInterval!); } _currentKlineInterval = interval; ws.subscribeKline(symbol, interval); _klineSub?.cancel(); final want = _normSym(symbol); _klineSub = ws.klineStream .where((d) => _normSym('${d['symbol'] ?? ''}') == want && d['interval'] == interval) .listen((data) { _onKlineTick(data, interval); }); } else { final sw = ref.read(spotWsClientProvider); if (_currentKlineInterval != null && _currentKlineInterval != interval) { sw.unsubscribeKline(symbol, _currentKlineInterval!); } _currentKlineInterval = interval; sw.subscribeKline(symbol, interval); _klineSub?.cancel(); final want = _normSym(symbol); _klineSub = sw.klineStream .where((d) => _normSym('${d['symbol'] ?? ''}') == want && '${d['interval']}' == interval) .listen((data) { _onKlineTick(data, interval); }); } } static double _dynToDouble(dynamic v) { if (v == null) return 0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0; } void _onKlineTick(Map data, String interval) { final rawT = data['time'] as int? ?? 0; if (rawT == 0) return; final time = DateTime.fromMillisecondsSinceEpoch( rawT < 10000000000 ? rawT * 1000 : rawT, ); final volRaw = data['volume'] ?? data['vol']; final bar = KlineBar( time: time, open: _dynToDouble(data['open']), close: _dynToDouble(data['close']), high: _dynToDouble(data['high']), low: _dynToDouble(data['low']), volume: _dynToDouble(volRaw), ); final klines = [...state.klines]; if (klines.isNotEmpty) { final period = state.period; final alignedBar = _alignToPeriodStart(bar.time, period); final alignedLast = _alignToPeriodStart(klines.last.time, period); if (alignedLast == alignedBar) { klines[klines.length - 1] = KlineBar( time: klines.last.time, open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume, ); } else if (_nextPeriodStart(alignedLast, period) == alignedBar) { klines.add(KlineBar( time: alignedBar, open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume, )); } } else { klines.add(bar); } _setKlines(klines, KlineLoadType.realtimeAppend); final oldStats = state.stats; if (oldStats != null && bar.close > 0) { state = state.copyWith( stats: MarketStats( lastPrice: bar.close, lastPriceStr: oldStats.lastPriceStr, // 保留 ticker 推送的原始字符串 markPrice: oldStats.markPrice, change24h: oldStats.change24h, high24h: oldStats.high24h, low24h: oldStats.low24h, volume24h: oldStats.volume24h, turnover24h: oldStats.turnover24h, ), ); } } // ── UI 操作 ──────────────────────────────────────────── void setTopTab(int tab) { state = state.copyWith(topTab: tab); if (tab == 1) { // 每次切入概览 tab 都重新请求,保证数据最新 _loadCoinExt(state.symbol); } } Future _loadCoinExt(String symbol) async { // 提取基础币名(BTCUSDT → BTC) final baseAsset = symbol.toUpperCase().replaceAll('USDT', '').replaceAll('PERP', ''); try { final dio = ref.read(dioClientProvider); final resp = await dio.get( 'contract/coin-ext/detail', queryParameters: {'symbol': baseAsset}, ); final data = resp.data; Map? json; if (data is Map) { final body = data['data'] ?? data['result'] ?? data; if (body is Map) { json = body; } } if (json != null) { state = state.copyWith(coinExt: CoinExtInfo.fromJson(json)); } } catch (_) { // 静默失败,UI 降级到默认显示 } } void setPeriod(KlinePeriod period) { if (period == state.period) return; final cacheKey = _klineCacheKey(state.symbol, period, _isFutures); final cached = _klineBarCache[cacheKey]; if (cached != null && cached.isNotEmpty) { // 有缓存:立即切换显示,后台刷新 state = state.copyWith(period: period); _setKlines(cached, KlineLoadType.initial); } else { // 无缓存:清空旧数据,等待加载 state = state.copyWith(period: period, klines: [], entities: []); } _reloadKlines(state.symbol, period, isFutures: _isFutures); } Future _reloadKlines(String symbol, KlinePeriod period, {required bool isFutures}) async { final interval = _klineChannelInterval(period, isFutures); if (!isFutures) { _subscribeWsKline(symbol, interval, isFutures: false); } final List> historyData; if (isFutures) { final ws = ref.read(wsClientProvider); final now = DateTime.now().millisecondsSinceEpoch; final from = now - _periodDurationMs(period); historyData = await ws.requestKlineHistory( symbol: symbol, interval: interval, from: from, to: now, ); } else { historyData = await _fetchSpotKlineHistoryRaw( symbol, period, pageSize: _spotWsKlinePageSizeDefault, ); } final cacheKey = _klineCacheKey(symbol, period, isFutures); final klines = historyData.isNotEmpty ? _parseKlineHistory(historyData, period) : (_klineBarCache[cacheKey] ?? []); _putCache(cacheKey, klines); _setKlines(klines, KlineLoadType.initial); if (isFutures) { _subscribeWsKline(symbol, interval, isFutures: true); } } void setIndicator(ChartIndicator indicator) => state = state.copyWith(indicator: indicator); void setBottomTab(int tab) => state = state.copyWith(bottomTab: tab); } final marketDetailProvider = NotifierProviderFamily( MarketDetailNotifier.new, );