| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191 |
- 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:<symbol>:<wsInterval>",现货/合约分开缓存。
- // 作用:再次打开同一行情页时立即渲染旧数据,后台静默刷新,消除 loading 闪烁。
- final _klineBarCache = <String, List<KlineBar>>{};
- 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<KlineBar> 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<String, dynamic> 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<KlineBar> klines;
- /// k_chart_plus 用的 KLineEntity 列表,与 klines 同步,指标已计算
- final List<KLineEntity> entities;
- final OrderBook? orderBook;
- final KlinePeriod period;
- final ChartIndicator indicator;
- final int topTab;
- final int bottomTab;
- final List<RecentTrade> 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<KlineBar>? klines,
- List<KLineEntity>? entities,
- OrderBook? orderBook,
- KlinePeriod? period,
- ChartIndicator? indicator,
- int? topTab,
- int? bottomTab,
- List<RecentTrade>? 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<MarketDetailState, MarketDetailKey> {
- 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<RecentTrade>? _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<AsyncValue<WsConnectionState>>(
- 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<AsyncValue<SpotWsState>>(
- 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<KlineBar> 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<void> _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<Map<String, dynamic>> 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 ?? <KlineBar>[]);
- _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<void> _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<void>.delayed(const Duration(milliseconds: 100));
- if (sw.currentState == SpotWsState.connected) {
- return;
- }
- }
- }
- static int _klineOpenTimeMs(Map<String, dynamic> 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<Map<String, dynamic>> rows) {
- rows.sort((a, b) => _klineOpenTimeMs(a).compareTo(_klineOpenTimeMs(b)));
- }
- /// 现货历史 K:通过现货 WS `event: req` 拉取(与 web `useSpotMarketWs` 同源)。
- /// [endIdxSec]:`SpotWsClient.requestKlineHistory.endIdx`,单位秒,`0` 表示最新一页。
- Future<List<Map<String, dynamic>>> _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<KlineBar> _parseKlineHistory(
- List<Map<String, dynamic>> 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<int> 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<void> 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<Map<String, dynamic>> 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<dynamic>? ?? [];
- final rawAsks = data['asks'] as List<dynamic>? ?? [];
- double cumTotal = 0;
- List<OrderBookEntry> parse(List<dynamic> 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<String, dynamic> 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<void> _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<String, dynamic>? json;
- if (data is Map) {
- final body = data['data'] ?? data['result'] ?? data;
- if (body is Map<String, dynamic>) {
- 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<void> _reloadKlines(String symbol, KlinePeriod period,
- {required bool isFutures}) async {
- final interval = _klineChannelInterval(period, isFutures);
- if (!isFutures) {
- _subscribeWsKline(symbol, interval, isFutures: false);
- }
- final List<Map<String, dynamic>> 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] ?? <KlineBar>[]);
- _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, MarketDetailState, MarketDetailKey>(
- MarketDetailNotifier.new,
- );
|