market_detail_provider.dart 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:k_chart_plus/k_chart_plus.dart';
  5. import '../core/network/dio_client.dart';
  6. import '../core/network/spot_ws_client.dart';
  7. import '../data/models/market/kline_bar.dart';
  8. import '../data/models/market/order_book_entry.dart';
  9. import 'spot_ws_provider.dart';
  10. import 'ws_provider.dart';
  11. // ── K 线内存缓存(Stale-While-Revalidate)──────────────────────
  12. // key: "f|s:<symbol>:<wsInterval>",现货/合约分开缓存。
  13. // 作用:再次打开同一行情页时立即渲染旧数据,后台静默刷新,消除 loading 闪烁。
  14. final _klineBarCache = <String, List<KlineBar>>{};
  15. const _kCacheMaxSize = 30;
  16. /// 现货 WS `event: req` 拉历史 K 默认条数(与 web `useSpotMarketWs.sendKlineReq.pageSize` 一致)
  17. const int _spotWsKlinePageSizeDefault = 300;
  18. /// 行情详情 Provider 的 family 参数:区分现货 / 永续,避免共用合约 WS 与缓存。
  19. @immutable
  20. class MarketDetailKey {
  21. const MarketDetailKey({required this.symbol, required this.isFutures});
  22. final String symbol;
  23. final bool isFutures;
  24. @override
  25. bool operator ==(Object other) =>
  26. other is MarketDetailKey &&
  27. other.symbol == symbol &&
  28. other.isFutures == isFutures;
  29. @override
  30. int get hashCode => Object.hash(symbol, isFutures);
  31. }
  32. String _klineCacheKey(String symbol, KlinePeriod period, bool isFutures) =>
  33. '${isFutures ? 'f' : 's'}:$symbol:${period.wsInterval}';
  34. /// WS / 归一化 Map 中的数值可能是 int/double/String,避免 `as num?` 丢数据或抛错。
  35. double _looseDouble(dynamic v) {
  36. if (v == null) return 0.0;
  37. if (v is num) return v.toDouble();
  38. return double.tryParse(v.toString().trim().replaceAll(',', '')) ?? 0.0;
  39. }
  40. void _putCache(String key, List<KlineBar> bars) {
  41. if (bars.isEmpty) return;
  42. if (_klineBarCache.length >= _kCacheMaxSize && !_klineBarCache.containsKey(key)) {
  43. _klineBarCache.remove(_klineBarCache.keys.first); // 移除最早的条目
  44. }
  45. _klineBarCache[key] = bars;
  46. }
  47. // ── 币种扩展信息(/api/contract/coin-ext/detail)─────────────
  48. String? _toNonEmpty(dynamic v) {
  49. if (v == null) return null;
  50. final s = v.toString().trim();
  51. return s.isEmpty ? null : s;
  52. }
  53. class CoinExtInfo {
  54. final int? id;
  55. final String symbol;
  56. final String nameCn;
  57. final String nameEn;
  58. final String? icon;
  59. final int? rank;
  60. final String? marketCap; // 后端返回格式化字符串,如 "$1.76万亿"
  61. final String? circulatingSupply; // 后端返回格式化字符串,如 "1,976.43万 BTC"
  62. final double? issuePrice;
  63. final double? athPrice;
  64. final String? athDate;
  65. final String? whitepaper;
  66. const CoinExtInfo({
  67. this.id,
  68. required this.symbol,
  69. required this.nameCn,
  70. required this.nameEn,
  71. this.icon,
  72. this.rank,
  73. this.marketCap,
  74. this.circulatingSupply,
  75. this.issuePrice,
  76. this.athPrice,
  77. this.athDate,
  78. this.whitepaper,
  79. });
  80. factory CoinExtInfo.fromJson(Map<String, dynamic> json) {
  81. double? toDouble(dynamic v) {
  82. if (v == null) return null;
  83. if (v is num) return v.toDouble();
  84. final s = v.toString().trim();
  85. return s.isEmpty ? null : double.tryParse(s);
  86. }
  87. return CoinExtInfo(
  88. id: json['id'] as int?,
  89. symbol: json['symbol']?.toString() ?? '',
  90. nameCn: json['nameCn']?.toString() ?? json['name']?.toString() ?? '',
  91. nameEn: json['nameEn']?.toString() ?? '',
  92. icon: json['icon']?.toString(),
  93. rank: json['rank'] is int ? json['rank'] : int.tryParse('${json['rank'] ?? ''}'),
  94. marketCap: _toNonEmpty(json['marketCap']),
  95. circulatingSupply: _toNonEmpty(json['circulatingSupply']),
  96. issuePrice: toDouble(json['issuePrice']),
  97. athPrice: toDouble(json['athPrice']),
  98. athDate: json['athDate']?.toString(),
  99. whitepaper: json['whitepaper']?.toString(),
  100. );
  101. }
  102. }
  103. // ── 周期 & 指标枚举 ────────────────────────────────────────
  104. enum KlinePeriod { min1, min5, min15, min30, hour1, hour4, day1, week1, month1 }
  105. extension KlinePeriodLabel on KlinePeriod {
  106. String get label {
  107. switch (this) {
  108. case KlinePeriod.min1: return '1分';
  109. case KlinePeriod.min5: return '5分';
  110. case KlinePeriod.min15: return '15分';
  111. case KlinePeriod.min30: return '30分';
  112. case KlinePeriod.hour1: return '1时';
  113. case KlinePeriod.hour4: return '4时';
  114. case KlinePeriod.day1: return '日线';
  115. case KlinePeriod.week1: return '周线';
  116. case KlinePeriod.month1: return '月线';
  117. }
  118. }
  119. /// WS 订阅的 interval 字符串
  120. String get wsInterval {
  121. switch (this) {
  122. case KlinePeriod.min1: return '1m';
  123. case KlinePeriod.min5: return '5m';
  124. case KlinePeriod.min15: return '15m';
  125. case KlinePeriod.min30: return '30m';
  126. case KlinePeriod.hour1: return '1h';
  127. case KlinePeriod.hour4: return '4h';
  128. case KlinePeriod.day1: return '1d';
  129. case KlinePeriod.week1: return '1w';
  130. case KlinePeriod.month1: return '1mon';
  131. }
  132. }
  133. /// 现货行情 WS `market_{sym}_kline_{period}` 的 period 后缀(与 market 服务 SpotConstant / spot-websocket-api 一致)
  134. String get spotWsKlinePeriod {
  135. switch (this) {
  136. case KlinePeriod.min1: return '1min';
  137. case KlinePeriod.min5: return '5min';
  138. case KlinePeriod.min15: return '15min';
  139. case KlinePeriod.min30: return '30min';
  140. case KlinePeriod.hour1: return '60min';
  141. case KlinePeriod.hour4: return '4h';
  142. case KlinePeriod.day1: return '1day';
  143. case KlinePeriod.week1: return '1week';
  144. case KlinePeriod.month1: return '1month';
  145. }
  146. }
  147. }
  148. /// K 线订阅/请求用的 interval:合约用 Huobi 风格,现货用下划线 channel 后缀
  149. String _klineChannelInterval(KlinePeriod period, bool isFutures) =>
  150. isFutures ? period.wsInterval : period.spotWsKlinePeriod;
  151. enum ChartIndicator { ma, boll, vol, macd, rsi, kdj, wr }
  152. extension ChartIndicatorLabel on ChartIndicator {
  153. String get label => name.toUpperCase();
  154. }
  155. // ── 市场概况数据 ───────────────────────────────────────────
  156. class MarketStats {
  157. final double lastPrice;
  158. final String? lastPriceStr; // WS 返回的原始价格字符串
  159. final double markPrice;
  160. final double change24h;
  161. final double high24h;
  162. final double low24h;
  163. final double volume24h;
  164. final double turnover24h;
  165. const MarketStats({
  166. required this.lastPrice,
  167. this.lastPriceStr,
  168. required this.markPrice,
  169. required this.change24h,
  170. required this.high24h,
  171. required this.low24h,
  172. required this.volume24h,
  173. required this.turnover24h,
  174. });
  175. @override
  176. bool operator ==(Object other) =>
  177. identical(this, other) ||
  178. other is MarketStats &&
  179. lastPrice == other.lastPrice &&
  180. lastPriceStr == other.lastPriceStr &&
  181. markPrice == other.markPrice &&
  182. change24h == other.change24h &&
  183. high24h == other.high24h &&
  184. low24h == other.low24h &&
  185. volume24h == other.volume24h &&
  186. turnover24h == other.turnover24h;
  187. @override
  188. int get hashCode => Object.hash(
  189. lastPrice, lastPriceStr, markPrice, change24h, high24h, low24h, volume24h, turnover24h);
  190. }
  191. // ── 最新成交数据 ────────────────────────────────────────
  192. class RecentTrade {
  193. final double price;
  194. final double quantity;
  195. final bool isBuyerMaker;
  196. final int time;
  197. final String tradeId;
  198. const RecentTrade({
  199. required this.price,
  200. required this.quantity,
  201. required this.isBuyerMaker,
  202. required this.time,
  203. this.tradeId = '',
  204. });
  205. @override
  206. bool operator ==(Object other) =>
  207. identical(this, other) ||
  208. other is RecentTrade &&
  209. price == other.price &&
  210. quantity == other.quantity &&
  211. isBuyerMaker == other.isBuyerMaker &&
  212. time == other.time &&
  213. tradeId == other.tradeId;
  214. @override
  215. int get hashCode => Object.hash(price, quantity, isBuyerMaker, time, tradeId);
  216. }
  217. // ── K 线加载类型 ──────────────────────────────────────────
  218. enum KlineLoadType { initial, loadMore, realtimeAppend }
  219. // ── UI State ──────────────────────────────────────────────
  220. class MarketDetailState {
  221. final String symbol;
  222. final bool isLoading;
  223. final String? errorMessage;
  224. final MarketStats? stats;
  225. final List<KlineBar> klines;
  226. /// k_chart_plus 用的 KLineEntity 列表,与 klines 同步,指标已计算
  227. final List<KLineEntity> entities;
  228. final OrderBook? orderBook;
  229. final KlinePeriod period;
  230. final ChartIndicator indicator;
  231. final int topTab;
  232. final int bottomTab;
  233. final List<RecentTrade> trades;
  234. final KlineLoadType lastLoadType;
  235. final bool isLoadingMore;
  236. final CoinExtInfo? coinExt;
  237. const MarketDetailState({
  238. required this.symbol,
  239. this.isLoading = false,
  240. this.errorMessage,
  241. this.stats,
  242. this.klines = const [],
  243. this.entities = const [],
  244. this.orderBook,
  245. this.period = KlinePeriod.hour1,
  246. this.indicator = ChartIndicator.ma,
  247. this.topTab = 0,
  248. this.bottomTab = 0,
  249. this.trades = const [],
  250. this.lastLoadType = KlineLoadType.initial,
  251. this.isLoadingMore = false,
  252. this.coinExt,
  253. });
  254. MarketDetailState copyWith({
  255. bool? isLoading,
  256. String? errorMessage,
  257. MarketStats? stats,
  258. List<KlineBar>? klines,
  259. List<KLineEntity>? entities,
  260. OrderBook? orderBook,
  261. KlinePeriod? period,
  262. ChartIndicator? indicator,
  263. int? topTab,
  264. int? bottomTab,
  265. List<RecentTrade>? trades,
  266. KlineLoadType? lastLoadType,
  267. bool? isLoadingMore,
  268. CoinExtInfo? coinExt,
  269. bool clearCoinExt = false,
  270. }) {
  271. return MarketDetailState(
  272. symbol: symbol,
  273. isLoading: isLoading ?? this.isLoading,
  274. errorMessage: errorMessage,
  275. stats: stats ?? this.stats,
  276. klines: klines ?? this.klines,
  277. entities: entities ?? this.entities,
  278. orderBook: orderBook ?? this.orderBook,
  279. period: period ?? this.period,
  280. indicator: indicator ?? this.indicator,
  281. topTab: topTab ?? this.topTab,
  282. bottomTab: bottomTab ?? this.bottomTab,
  283. trades: trades ?? this.trades,
  284. lastLoadType: lastLoadType ?? this.lastLoadType,
  285. isLoadingMore: isLoadingMore ?? this.isLoadingMore,
  286. coinExt: clearCoinExt ? null : (coinExt ?? this.coinExt),
  287. );
  288. }
  289. }
  290. // ── Notifier ──────────────────────────────────────────────
  291. class MarketDetailNotifier extends FamilyNotifier<MarketDetailState, MarketDetailKey> {
  292. StreamSubscription? _tickerSub;
  293. StreamSubscription? _markSub;
  294. StreamSubscription? _klineSub;
  295. StreamSubscription? _depthSub;
  296. StreamSubscription? _tradeSub;
  297. String? _currentKlineInterval;
  298. /// 与 [build] 传入的 [MarketDetailKey.isFutures] 同步,供异步方法使用
  299. bool _isFutures = true;
  300. // 节流:WS 高频更新延迟到下一个事件循环,防止 layout/semantics 重入
  301. Timer? _depthTimer;
  302. OrderBook? _pendingDepth;
  303. Timer? _tradeTimer;
  304. List<RecentTrade>? _pendingTrades;
  305. @override
  306. MarketDetailState build(MarketDetailKey key) {
  307. final symbol = key.symbol;
  308. final isFutures = key.isFutures;
  309. _isFutures = isFutures;
  310. ref.onDispose(() {
  311. _tickerSub?.cancel();
  312. _markSub?.cancel();
  313. _klineSub?.cancel();
  314. _depthSub?.cancel();
  315. _tradeSub?.cancel();
  316. _depthTimer?.cancel();
  317. _tradeTimer?.cancel();
  318. if (isFutures) {
  319. final ws = ref.read(wsClientProvider);
  320. ws.unsubscribeTicker(symbol);
  321. ws.unsubscribeMark(symbol);
  322. ws.unsubscribeDepth(symbol);
  323. ws.unsubscribeTrade(symbol);
  324. if (_currentKlineInterval != null) {
  325. ws.unsubscribeKline(symbol, _currentKlineInterval!);
  326. }
  327. } else {
  328. final sw = ref.read(spotWsClientProvider);
  329. sw.unsubscribeTicker(symbol);
  330. // 深度/成交由 [spotProvider] 订阅,此处只取消本 Notifier 使用的 ticker/K 线
  331. if (_currentKlineInterval != null) {
  332. sw.unsubscribeKline(symbol, _currentKlineInterval!);
  333. }
  334. }
  335. });
  336. if (isFutures) {
  337. ref.listen<AsyncValue<WsConnectionState>>(
  338. wsConnectionStateProvider,
  339. (prev, next) {
  340. final prevState = prev?.valueOrNull;
  341. final nextState = next.valueOrNull;
  342. if (nextState == WsConnectionState.connected &&
  343. prevState != WsConnectionState.connected) {
  344. _reloadKlines(symbol, state.period, isFutures: true);
  345. }
  346. },
  347. );
  348. } else {
  349. ref.listen<AsyncValue<SpotWsState>>(
  350. spotWsConnectionStateProvider,
  351. (prev, next) {
  352. final prevState = prev?.valueOrNull;
  353. final nextState = next.valueOrNull;
  354. if (nextState == SpotWsState.connected &&
  355. prevState != SpotWsState.connected) {
  356. _reloadKlines(symbol, state.period, isFutures: false);
  357. }
  358. },
  359. );
  360. }
  361. Future.microtask(() => _loadData(symbol, isFutures: isFutures));
  362. return MarketDetailState(symbol: symbol, isLoading: true);
  363. }
  364. // ── KlineBar ↔ KLineEntity 转换 ──────────────────────
  365. static KLineEntity _toEntity(KlineBar bar) => KLineEntity.fromCustom(
  366. open: bar.open,
  367. close: bar.close,
  368. high: bar.high,
  369. low: bar.low,
  370. vol: bar.volume,
  371. time: bar.time.millisecondsSinceEpoch,
  372. );
  373. /// 统一入口:同步 klines + entities + 指标计算
  374. /// 任何修改 klines 的地方都必须走这里,防止数据不一致
  375. void _setKlines(List<KlineBar> newKlines, KlineLoadType type) {
  376. final entities = newKlines.map(_toEntity).toList();
  377. // 全量计算所有指标:MA 计算依赖前序数据,局部窗口计算会导致早期 MA 值偏差
  378. if (entities.isNotEmpty) DataUtil.calculate(entities, [5, 10, 20]);
  379. state = state.copyWith(
  380. klines: newKlines,
  381. entities: entities,
  382. lastLoadType: type,
  383. );
  384. }
  385. // ── 数据加载 ──────────────────────────────────────────
  386. Future<void> _loadData(String symbol, {required bool isFutures}) async {
  387. final period = state.period;
  388. final interval = _klineChannelInterval(period, isFutures);
  389. final cacheKey = _klineCacheKey(symbol, period, isFutures);
  390. final cached = _klineBarCache[cacheKey];
  391. // ── 有缓存:立即渲染,无需 loading,WS 订阅先启动 ──────────
  392. if (cached != null && cached.isNotEmpty) {
  393. final price = cached.last.close;
  394. final prev = state.stats;
  395. state = state.copyWith(
  396. isLoading: false,
  397. stats: MarketStats(
  398. lastPrice: price,
  399. lastPriceStr: prev?.lastPriceStr,
  400. markPrice: prev?.markPrice ?? 0,
  401. change24h: prev?.change24h ?? 0,
  402. high24h: prev?.high24h ?? 0,
  403. low24h: prev?.low24h ?? 0,
  404. volume24h: prev?.volume24h ?? 0,
  405. turnover24h: prev?.turnover24h ?? 0,
  406. ),
  407. );
  408. _setKlines(cached, KlineLoadType.initial);
  409. _subscribeMarketStreams(symbol, interval, isFutures: isFutures);
  410. }
  411. // 现货须先订阅 kline channel,再 WS `event: req`(与 web `useSpotMarketWs` 一致)
  412. if (!isFutures && (cached == null || cached.isEmpty)) {
  413. _subscribeMarketStreams(symbol, interval, isFutures: false);
  414. }
  415. // ── 后台请求最新 K 线历史 ─────────────────────────────────
  416. final List<Map<String, dynamic>> historyData;
  417. if (isFutures) {
  418. final ws = ref.read(wsClientProvider);
  419. final now = DateTime.now().millisecondsSinceEpoch;
  420. final from = now - _periodDurationMs(period);
  421. historyData = await ws.requestKlineHistory(
  422. symbol: symbol,
  423. interval: interval,
  424. from: from,
  425. to: now,
  426. );
  427. } else {
  428. historyData = await _fetchSpotKlineHistoryRaw(
  429. symbol,
  430. period,
  431. pageSize: _spotWsKlinePageSizeDefault,
  432. );
  433. }
  434. final klines = historyData.isNotEmpty
  435. ? _parseKlineHistory(historyData, period)
  436. : (cached ?? <KlineBar>[]);
  437. _putCache(cacheKey, klines);
  438. final price = klines.isNotEmpty ? klines.last.close : 0.0;
  439. state = state.copyWith(
  440. isLoading: false,
  441. stats: MarketStats(
  442. lastPrice: price,
  443. lastPriceStr: state.stats?.lastPriceStr,
  444. markPrice: state.stats?.markPrice ?? 0,
  445. change24h: state.stats?.change24h ?? 0,
  446. high24h: state.stats?.high24h ?? 0,
  447. low24h: state.stats?.low24h ?? 0,
  448. volume24h: state.stats?.volume24h ?? 0,
  449. turnover24h: state.stats?.turnover24h ?? 0,
  450. ),
  451. );
  452. _setKlines(klines, KlineLoadType.initial);
  453. if (cached == null || cached.isEmpty) {
  454. if (isFutures) {
  455. _subscribeMarketStreams(symbol, interval, isFutures: true);
  456. }
  457. // 现货已在请求历史之前完成订阅;合约仍在此处一次性订阅。
  458. } else {
  459. _subscribeWsKline(symbol, interval, isFutures: isFutures);
  460. }
  461. }
  462. void _subscribeMarketStreams(String symbol, String interval,
  463. {required bool isFutures}) {
  464. if (isFutures) {
  465. _subscribeWsTicker(symbol);
  466. _subscribeWsMark(symbol);
  467. _subscribeWsDepth(symbol);
  468. _subscribeWsTrade(symbol);
  469. } else {
  470. _subscribeSpotTicker(symbol);
  471. }
  472. _subscribeWsKline(symbol, interval, isFutures: isFutures);
  473. }
  474. /// 等待 Spot WS 建连,`event: req` 须在 connected 状态下发送。
  475. Future<void> _ensureSpotWsConnected(SpotWsClient sw) async {
  476. if (sw.currentState == SpotWsState.connected) {
  477. return;
  478. }
  479. final deadline = DateTime.now().add(const Duration(seconds: 15));
  480. while (DateTime.now().isBefore(deadline)) {
  481. await Future<void>.delayed(const Duration(milliseconds: 100));
  482. if (sw.currentState == SpotWsState.connected) {
  483. return;
  484. }
  485. }
  486. }
  487. static int _klineOpenTimeMs(Map<String, dynamic> item) {
  488. final raw = item['beginTime'] ?? item['time'] ?? item['t'] ?? item['id'];
  489. int t = 0;
  490. if (raw is int) {
  491. t = raw;
  492. } else if (raw is num) {
  493. t = raw.toInt();
  494. } else {
  495. t = int.tryParse('$raw') ?? 0;
  496. }
  497. if (t <= 0) {
  498. return 0;
  499. }
  500. if (t < 10000000000) {
  501. t *= 1000;
  502. }
  503. return t;
  504. }
  505. /// 服务端返回顺序不保证稳定,先按开盘时间排序再交给 [_parseKlineHistory]。
  506. static void _sortSpotKlineWsRowsAscending(List<Map<String, dynamic>> rows) {
  507. rows.sort((a, b) => _klineOpenTimeMs(a).compareTo(_klineOpenTimeMs(b)));
  508. }
  509. /// 现货历史 K:通过现货 WS `event: req` 拉取(与 web `useSpotMarketWs` 同源)。
  510. /// [endIdxSec]:`SpotWsClient.requestKlineHistory.endIdx`,单位秒,`0` 表示最新一页。
  511. Future<List<Map<String, dynamic>>> _fetchSpotKlineHistoryRaw(
  512. String symbol,
  513. KlinePeriod period, {
  514. int pageSize = _spotWsKlinePageSizeDefault,
  515. int endIdxSec = 0,
  516. }) async {
  517. final sw = ref.read(spotWsClientProvider);
  518. await _ensureSpotWsConnected(sw);
  519. if (sw.currentState != SpotWsState.connected) {
  520. assert(() {
  521. debugPrint('[MarketDetail] spot klines req skipped: WS not connected');
  522. return true;
  523. }());
  524. return [];
  525. }
  526. try {
  527. final rows = await sw.requestKlineHistory(
  528. symbol: symbol,
  529. interval: period.spotWsKlinePeriod,
  530. pageSize: pageSize.clamp(1, 1000),
  531. endIdx: endIdxSec < 0 ? 0 : endIdxSec,
  532. );
  533. _sortSpotKlineWsRowsAscending(rows);
  534. return rows;
  535. } catch (e, st) {
  536. assert(() {
  537. debugPrint('[MarketDetail] Spot WS spot klines: $e\n$st');
  538. return true;
  539. }());
  540. return [];
  541. }
  542. }
  543. /// 解析 K 线历史数据,并自动修复时间戳。
  544. /// 部分交易所(如本项目)对月线/周线返回服务器处理时间而非 K 线开盘时间,
  545. /// 导致所有 bar 的时间戳集中在同一分钟内,无法正常显示时间轴。
  546. /// 修复方案:当实际时间跨度 < 期望时间跨度的 10% 时,按周期均匀重建时间戳。
  547. /// [anchorEndMs] 仅在 loadMore 时传入,作为重建时间的右锚点;
  548. /// 初始加载时传 null,使用当前时间作为锚点。
  549. static List<KlineBar> _parseKlineHistory(
  550. List<Map<String, dynamic>> data,
  551. KlinePeriod period, {
  552. int? anchorEndMs,
  553. }) {
  554. if (data.isEmpty) return [];
  555. // 提取 K 线时间(毫秒)。现货 history 用 `id` 表示开盘时间(秒);合约多为毫秒 beginTime
  556. final times = data.map((item) {
  557. final raw = item['beginTime'] ?? item['time'] ?? item['t'] ?? item['id'];
  558. int t;
  559. if (raw is int) {
  560. t = raw;
  561. } else {
  562. t = int.tryParse('$raw') ?? 0;
  563. }
  564. if (t <= 0) return 0;
  565. if (t < 10000000000) return t * 1000; // 秒 → 毫秒
  566. return t;
  567. }).toList();
  568. final actualSpan = (times.last - times.first).abs();
  569. final stepMs = _periodStepMs(period);
  570. // 实际跨度 < 期望跨度 10% → 服务端时间不可信,需重建
  571. final expectedMinSpan = (data.length - 1) * stepMs * 0.1;
  572. final needRebuild = data.length > 1 && actualSpan < expectedMinSpan;
  573. List<int> finalTimes;
  574. if (needRebuild) {
  575. // 以锚点为最后一根 K 线时间,往前均匀分布
  576. final anchor = anchorEndMs ?? DateTime.now().millisecondsSinceEpoch;
  577. finalTimes = List.generate(
  578. data.length,
  579. (i) => anchor - (data.length - 1 - i) * stepMs,
  580. );
  581. } else {
  582. finalTimes = times;
  583. }
  584. return data.asMap().entries.map((entry) {
  585. final item = entry.value;
  586. return KlineBar(
  587. time: DateTime.fromMillisecondsSinceEpoch(finalTimes[entry.key]),
  588. open: double.tryParse('${item['open']}') ?? 0,
  589. close: double.tryParse('${item['close']}') ?? 0,
  590. high: double.tryParse('${item['high']}') ?? 0,
  591. low: double.tryParse('${item['low']}') ?? 0,
  592. volume: double.tryParse('${item['volume'] ?? item['vol']}') ?? 0,
  593. );
  594. }).toList();
  595. }
  596. /// 单根 K 线的毫秒步长(用于时间戳修复)
  597. static int _periodStepMs(KlinePeriod period) {
  598. switch (period) {
  599. case KlinePeriod.min1: return 60 * 1000;
  600. case KlinePeriod.min5: return 5 * 60 * 1000;
  601. case KlinePeriod.min15: return 15 * 60 * 1000;
  602. case KlinePeriod.min30: return 30 * 60 * 1000;
  603. case KlinePeriod.hour1: return 60 * 60 * 1000;
  604. case KlinePeriod.hour4: return 4 * 60 * 60 * 1000;
  605. case KlinePeriod.day1: return 24 * 60 * 60 * 1000;
  606. case KlinePeriod.week1: return 7 * 24 * 60 * 60 * 1000;
  607. case KlinePeriod.month1: return 30 * 24 * 60 * 60 * 1000;
  608. }
  609. }
  610. static int _periodDurationMs(KlinePeriod period) {
  611. // 初始加载约 100 根 K 线
  612. const bars = 100;
  613. switch (period) {
  614. case KlinePeriod.min1: return bars * 60 * 1000;
  615. case KlinePeriod.min5: return bars * 5 * 60 * 1000;
  616. case KlinePeriod.min15: return bars * 15 * 60 * 1000;
  617. case KlinePeriod.min30: return bars * 30 * 60 * 1000;
  618. case KlinePeriod.hour1: return bars * 60 * 60 * 1000;
  619. case KlinePeriod.hour4: return bars * 4 * 60 * 60 * 1000;
  620. case KlinePeriod.day1: return bars * 24 * 60 * 60 * 1000;
  621. case KlinePeriod.week1: return bars * 7 * 24 * 60 * 60 * 1000;
  622. case KlinePeriod.month1: return bars * 30 * 24 * 60 * 60 * 1000;
  623. }
  624. }
  625. /// 将时间对齐到所在周期的起始时刻
  626. static DateTime _alignToPeriodStart(DateTime t, KlinePeriod period) {
  627. switch (period) {
  628. case KlinePeriod.min1:
  629. return DateTime(t.year, t.month, t.day, t.hour, t.minute);
  630. case KlinePeriod.min5:
  631. return DateTime(t.year, t.month, t.day, t.hour, (t.minute ~/ 5) * 5);
  632. case KlinePeriod.min15:
  633. return DateTime(t.year, t.month, t.day, t.hour, (t.minute ~/ 15) * 15);
  634. case KlinePeriod.min30:
  635. return DateTime(t.year, t.month, t.day, t.hour, (t.minute ~/ 30) * 30);
  636. case KlinePeriod.hour1:
  637. return DateTime(t.year, t.month, t.day, t.hour);
  638. case KlinePeriod.hour4:
  639. return DateTime(t.year, t.month, t.day, (t.hour ~/ 4) * 4);
  640. case KlinePeriod.day1:
  641. return DateTime(t.year, t.month, t.day);
  642. case KlinePeriod.week1:
  643. // 以周一为起始
  644. return DateTime(t.year, t.month, t.day - (t.weekday - 1));
  645. case KlinePeriod.month1:
  646. return DateTime(t.year, t.month, 1);
  647. }
  648. }
  649. /// 给定某周期起始时间,返回下一个周期的起始时间
  650. static DateTime _nextPeriodStart(DateTime periodStart, KlinePeriod period) {
  651. switch (period) {
  652. case KlinePeriod.min1:
  653. return periodStart.add(const Duration(minutes: 1));
  654. case KlinePeriod.min5:
  655. return periodStart.add(const Duration(minutes: 5));
  656. case KlinePeriod.min15:
  657. return periodStart.add(const Duration(minutes: 15));
  658. case KlinePeriod.min30:
  659. return periodStart.add(const Duration(minutes: 30));
  660. case KlinePeriod.hour1:
  661. return periodStart.add(const Duration(hours: 1));
  662. case KlinePeriod.hour4:
  663. return periodStart.add(const Duration(hours: 4));
  664. case KlinePeriod.day1:
  665. return periodStart.add(const Duration(days: 1));
  666. case KlinePeriod.week1:
  667. return periodStart.add(const Duration(days: 7));
  668. case KlinePeriod.month1:
  669. // DateTime 自动处理跨年:DateTime(2025, 13, 1) → 2026-01-01
  670. return DateTime(periodStart.year, periodStart.month + 1, 1);
  671. }
  672. }
  673. static int _loadMoreRangeMs(KlinePeriod period) {
  674. const count = 200;
  675. switch (period) {
  676. case KlinePeriod.min1: return count * 60 * 1000;
  677. case KlinePeriod.min5: return count * 5 * 60 * 1000;
  678. case KlinePeriod.min15: return count * 15 * 60 * 1000;
  679. case KlinePeriod.min30: return count * 30 * 60 * 1000;
  680. case KlinePeriod.hour1: return count * 60 * 60 * 1000;
  681. case KlinePeriod.hour4: return count * 4 * 60 * 60 * 1000;
  682. case KlinePeriod.day1: return count * 24 * 60 * 60 * 1000;
  683. case KlinePeriod.week1: return count * 7 * 24 * 60 * 60 * 1000;
  684. case KlinePeriod.month1: return count * 30 * 24 * 60 * 60 * 1000;
  685. }
  686. }
  687. /// 加载更早的 K线历史(向左滑动触发)
  688. Future<void> loadMoreKlines() async {
  689. if (state.isLoadingMore || state.klines.isEmpty) return;
  690. state = state.copyWith(isLoadingMore: true);
  691. final interval = _klineChannelInterval(state.period, _isFutures);
  692. final earliest = state.klines.first.time.millisecondsSinceEpoch;
  693. final List<Map<String, dynamic>> historyData;
  694. if (_isFutures) {
  695. final ws = ref.read(wsClientProvider);
  696. final to = earliest - 1;
  697. final from = to - _loadMoreRangeMs(state.period);
  698. historyData = await ws.requestKlineHistory(
  699. symbol: state.symbol,
  700. interval: interval,
  701. from: from,
  702. to: to,
  703. );
  704. } else {
  705. final anchorMs = earliest - 1;
  706. historyData = await _fetchSpotKlineHistoryRaw(
  707. state.symbol,
  708. state.period,
  709. pageSize: 200,
  710. endIdxSec: anchorMs < 0 ? 0 : anchorMs ~/ 1000,
  711. );
  712. }
  713. if (historyData.isEmpty) {
  714. state = state.copyWith(isLoadingMore: false);
  715. return;
  716. }
  717. final older = _parseKlineHistory(
  718. historyData,
  719. state.period,
  720. anchorEndMs: earliest - 1,
  721. );
  722. final existingTimes = state.klines.map((k) => k.time.millisecondsSinceEpoch).toSet();
  723. final dedupedOlder = older.where((k) => !existingTimes.contains(k.time.millisecondsSinceEpoch)).toList();
  724. final merged = [...dedupedOlder, ...state.klines];
  725. state = state.copyWith(isLoadingMore: false);
  726. _setKlines(merged, KlineLoadType.loadMore);
  727. }
  728. // ── WS 订阅 ────────────────────────────────────────────
  729. static String _normSym(String s) =>
  730. s.replaceAll('/', '').replaceAll('-', '').toUpperCase();
  731. void _subscribeWsTicker(String symbol) {
  732. final ws = ref.read(wsClientProvider);
  733. ws.subscribeTicker(symbol);
  734. _tickerSub?.cancel();
  735. final want = _normSym(symbol);
  736. _tickerSub = ws.tickerStream
  737. .where((data) => _normSym('${data['symbol'] ?? ''}') == want)
  738. .listen((data) {
  739. final price = (data['price'] as num?)?.toDouble();
  740. if (price == null) return;
  741. final priceStr = data['priceStr'] as String? ?? '';
  742. final change24h = (data['change24h'] as num?)?.toDouble() ?? 0;
  743. final high24h = (data['high24h'] as num?)?.toDouble() ?? 0;
  744. final low24h = (data['low24h'] as num?)?.toDouble() ?? 0;
  745. final turnover = (data['volume24h'] as num?)?.toDouble() ?? 0;
  746. final volumeCoin = (data['volumeCoin'] as num?)?.toDouble() ?? 0;
  747. final oldStats = state.stats;
  748. if (oldStats == null) return;
  749. state = state.copyWith(
  750. stats: MarketStats(
  751. lastPrice: price,
  752. lastPriceStr: priceStr.isNotEmpty ? priceStr : null,
  753. markPrice: oldStats.markPrice,
  754. change24h: change24h,
  755. high24h: high24h > 0 ? high24h : oldStats.high24h,
  756. low24h: low24h > 0 ? low24h : oldStats.low24h,
  757. volume24h: volumeCoin > 0 ? volumeCoin : oldStats.volume24h,
  758. turnover24h: turnover > 0 ? turnover : oldStats.turnover24h,
  759. ),
  760. );
  761. });
  762. }
  763. /// 现货 ticker → 与合约统一的 [MarketStats] 字段
  764. void _subscribeSpotTicker(String symbol) {
  765. final sw = ref.read(spotWsClientProvider);
  766. sw.subscribeTicker(symbol);
  767. _tickerSub?.cancel();
  768. final want = _normSym(symbol);
  769. _tickerSub = sw.tickerStream
  770. .where((data) => _normSym('${data['symbol'] ?? ''}') == want)
  771. .listen((data) {
  772. final price = _looseDouble(data['price']);
  773. if (price <= 0) return;
  774. final priceStr = data['priceStr'] as String? ?? '';
  775. double? pct(dynamic v) {
  776. if (v == null) return null;
  777. if (v is num) return v.toDouble();
  778. return double.tryParse(v.toString());
  779. }
  780. final change24h = pct(data['change24h']);
  781. final high24h = _looseDouble(data['high']);
  782. final low24h = _looseDouble(data['low']);
  783. final turnover = _looseDouble(data['turnover']);
  784. final volumeCoin = _looseDouble(data['volume']);
  785. final oldStats = state.stats;
  786. if (oldStats == null) return;
  787. state = state.copyWith(
  788. stats: MarketStats(
  789. lastPrice: price,
  790. lastPriceStr: priceStr.isNotEmpty ? priceStr : null,
  791. markPrice: oldStats.markPrice,
  792. change24h: change24h ?? oldStats.change24h,
  793. high24h: high24h > 0 ? high24h : oldStats.high24h,
  794. low24h: low24h > 0 ? low24h : oldStats.low24h,
  795. volume24h: volumeCoin > 0 ? volumeCoin : oldStats.volume24h,
  796. turnover24h: turnover > 0 ? turnover : oldStats.turnover24h,
  797. ),
  798. );
  799. });
  800. }
  801. void _subscribeWsMark(String symbol) {
  802. final ws = ref.read(wsClientProvider);
  803. ws.subscribeMark(symbol);
  804. _markSub?.cancel();
  805. _markSub = ws.markStream
  806. .where((data) => (data['symbol'] as String? ?? '').toUpperCase() == symbol.toUpperCase())
  807. .listen((data) {
  808. final markPrice = (data['markPrice'] as num?)?.toDouble() ?? 0;
  809. final oldStats = state.stats;
  810. if (oldStats == null || markPrice <= 0) return;
  811. state = state.copyWith(
  812. stats: MarketStats(
  813. lastPrice: oldStats.lastPrice,
  814. lastPriceStr: oldStats.lastPriceStr, // 保留 ticker 推送的原始字符串
  815. markPrice: markPrice,
  816. change24h: oldStats.change24h,
  817. high24h: oldStats.high24h,
  818. low24h: oldStats.low24h,
  819. volume24h: oldStats.volume24h,
  820. turnover24h: oldStats.turnover24h,
  821. ),
  822. );
  823. });
  824. }
  825. void _subscribeWsDepth(String symbol) {
  826. final ws = ref.read(wsClientProvider);
  827. ws.subscribeDepth(symbol);
  828. _depthSub?.cancel();
  829. final want = _normSym(symbol);
  830. _depthSub = ws.orderBookStream
  831. .where((d) => _normSym('${d['symbol'] ?? ''}') == want)
  832. .listen((data) {
  833. final rawBids = data['bids'] as List<dynamic>? ?? [];
  834. final rawAsks = data['asks'] as List<dynamic>? ?? [];
  835. double cumTotal = 0;
  836. List<OrderBookEntry> parse(List<dynamic> raw) {
  837. cumTotal = 0;
  838. return raw.map((e) {
  839. final m = e as Map;
  840. final amt = (m['quantity'] as num?)?.toDouble() ?? 0;
  841. cumTotal += amt;
  842. return OrderBookEntry(price: (m['price'] as num?)?.toDouble() ?? 0, amount: amt, total: cumTotal);
  843. }).toList();
  844. }
  845. _pendingDepth = OrderBook(asks: parse(rawAsks), bids: parse(rawBids));
  846. if (_depthTimer == null || !_depthTimer!.isActive) {
  847. _depthTimer = Timer(Duration.zero, () {
  848. final pending = _pendingDepth;
  849. _pendingDepth = null;
  850. if (pending != null) state = state.copyWith(orderBook: pending);
  851. });
  852. }
  853. });
  854. }
  855. void _subscribeWsTrade(String symbol) {
  856. final ws = ref.read(wsClientProvider);
  857. ws.subscribeTrade(symbol);
  858. _tradeSub?.cancel();
  859. final want = _normSym(symbol);
  860. _tradeSub = ws.tradeStream
  861. .where((d) => _normSym('${d['symbol'] ?? ''}') == want)
  862. .listen((data) {
  863. final trade = RecentTrade(
  864. price: (data['price'] as num?)?.toDouble() ?? 0,
  865. quantity: (data['quantity'] as num?)?.toDouble() ?? 0,
  866. isBuyerMaker: data['isBuyerMaker'] == true,
  867. time: data['time'] as int? ?? 0,
  868. tradeId: data['tradeId']?.toString() ?? '',
  869. );
  870. final trades = [trade, ...(state.trades)];
  871. if (trades.length > 50) trades.removeRange(50, trades.length);
  872. _pendingTrades = trades;
  873. if (_tradeTimer == null || !_tradeTimer!.isActive) {
  874. _tradeTimer = Timer(Duration.zero, () {
  875. final pending = _pendingTrades;
  876. _pendingTrades = null;
  877. if (pending != null) state = state.copyWith(trades: pending);
  878. });
  879. }
  880. });
  881. }
  882. void _subscribeWsKline(String symbol, String interval,
  883. {required bool isFutures}) {
  884. if (isFutures) {
  885. final ws = ref.read(wsClientProvider);
  886. if (_currentKlineInterval != null && _currentKlineInterval != interval) {
  887. ws.unsubscribeKline(symbol, _currentKlineInterval!);
  888. }
  889. _currentKlineInterval = interval;
  890. ws.subscribeKline(symbol, interval);
  891. _klineSub?.cancel();
  892. final want = _normSym(symbol);
  893. _klineSub = ws.klineStream
  894. .where((d) =>
  895. _normSym('${d['symbol'] ?? ''}') == want &&
  896. d['interval'] == interval)
  897. .listen((data) {
  898. _onKlineTick(data, interval);
  899. });
  900. } else {
  901. final sw = ref.read(spotWsClientProvider);
  902. if (_currentKlineInterval != null && _currentKlineInterval != interval) {
  903. sw.unsubscribeKline(symbol, _currentKlineInterval!);
  904. }
  905. _currentKlineInterval = interval;
  906. sw.subscribeKline(symbol, interval);
  907. _klineSub?.cancel();
  908. final want = _normSym(symbol);
  909. _klineSub = sw.klineStream
  910. .where((d) =>
  911. _normSym('${d['symbol'] ?? ''}') == want &&
  912. '${d['interval']}' == interval)
  913. .listen((data) {
  914. _onKlineTick(data, interval);
  915. });
  916. }
  917. }
  918. static double _dynToDouble(dynamic v) {
  919. if (v == null) return 0;
  920. if (v is num) return v.toDouble();
  921. return double.tryParse(v.toString()) ?? 0;
  922. }
  923. void _onKlineTick(Map<String, dynamic> data, String interval) {
  924. final rawT = data['time'] as int? ?? 0;
  925. if (rawT == 0) return;
  926. final time = DateTime.fromMillisecondsSinceEpoch(
  927. rawT < 10000000000 ? rawT * 1000 : rawT,
  928. );
  929. final volRaw = data['volume'] ?? data['vol'];
  930. final bar = KlineBar(
  931. time: time,
  932. open: _dynToDouble(data['open']),
  933. close: _dynToDouble(data['close']),
  934. high: _dynToDouble(data['high']),
  935. low: _dynToDouble(data['low']),
  936. volume: _dynToDouble(volRaw),
  937. );
  938. final klines = [...state.klines];
  939. if (klines.isNotEmpty) {
  940. final period = state.period;
  941. final alignedBar = _alignToPeriodStart(bar.time, period);
  942. final alignedLast = _alignToPeriodStart(klines.last.time, period);
  943. if (alignedLast == alignedBar) {
  944. klines[klines.length - 1] = KlineBar(
  945. time: klines.last.time,
  946. open: bar.open,
  947. high: bar.high,
  948. low: bar.low,
  949. close: bar.close,
  950. volume: bar.volume,
  951. );
  952. } else if (_nextPeriodStart(alignedLast, period) == alignedBar) {
  953. klines.add(KlineBar(
  954. time: alignedBar,
  955. open: bar.open,
  956. high: bar.high,
  957. low: bar.low,
  958. close: bar.close,
  959. volume: bar.volume,
  960. ));
  961. }
  962. } else {
  963. klines.add(bar);
  964. }
  965. _setKlines(klines, KlineLoadType.realtimeAppend);
  966. final oldStats = state.stats;
  967. if (oldStats != null && bar.close > 0) {
  968. state = state.copyWith(
  969. stats: MarketStats(
  970. lastPrice: bar.close,
  971. lastPriceStr: oldStats.lastPriceStr, // 保留 ticker 推送的原始字符串
  972. markPrice: oldStats.markPrice,
  973. change24h: oldStats.change24h,
  974. high24h: oldStats.high24h,
  975. low24h: oldStats.low24h,
  976. volume24h: oldStats.volume24h,
  977. turnover24h: oldStats.turnover24h,
  978. ),
  979. );
  980. }
  981. }
  982. // ── UI 操作 ────────────────────────────────────────────
  983. void setTopTab(int tab) {
  984. state = state.copyWith(topTab: tab);
  985. if (tab == 1) {
  986. // 每次切入概览 tab 都重新请求,保证数据最新
  987. _loadCoinExt(state.symbol);
  988. }
  989. }
  990. Future<void> _loadCoinExt(String symbol) async {
  991. // 提取基础币名(BTCUSDT → BTC)
  992. final baseAsset = symbol.toUpperCase().replaceAll('USDT', '').replaceAll('PERP', '');
  993. try {
  994. final dio = ref.read(dioClientProvider);
  995. final resp = await dio.get(
  996. 'contract/coin-ext/detail',
  997. queryParameters: {'symbol': baseAsset},
  998. );
  999. final data = resp.data;
  1000. Map<String, dynamic>? json;
  1001. if (data is Map) {
  1002. final body = data['data'] ?? data['result'] ?? data;
  1003. if (body is Map<String, dynamic>) {
  1004. json = body;
  1005. }
  1006. }
  1007. if (json != null) {
  1008. state = state.copyWith(coinExt: CoinExtInfo.fromJson(json));
  1009. }
  1010. } catch (_) {
  1011. // 静默失败,UI 降级到默认显示
  1012. }
  1013. }
  1014. void setPeriod(KlinePeriod period) {
  1015. if (period == state.period) return;
  1016. final cacheKey = _klineCacheKey(state.symbol, period, _isFutures);
  1017. final cached = _klineBarCache[cacheKey];
  1018. if (cached != null && cached.isNotEmpty) {
  1019. // 有缓存:立即切换显示,后台刷新
  1020. state = state.copyWith(period: period);
  1021. _setKlines(cached, KlineLoadType.initial);
  1022. } else {
  1023. // 无缓存:清空旧数据,等待加载
  1024. state = state.copyWith(period: period, klines: [], entities: []);
  1025. }
  1026. _reloadKlines(state.symbol, period, isFutures: _isFutures);
  1027. }
  1028. Future<void> _reloadKlines(String symbol, KlinePeriod period,
  1029. {required bool isFutures}) async {
  1030. final interval = _klineChannelInterval(period, isFutures);
  1031. if (!isFutures) {
  1032. _subscribeWsKline(symbol, interval, isFutures: false);
  1033. }
  1034. final List<Map<String, dynamic>> historyData;
  1035. if (isFutures) {
  1036. final ws = ref.read(wsClientProvider);
  1037. final now = DateTime.now().millisecondsSinceEpoch;
  1038. final from = now - _periodDurationMs(period);
  1039. historyData = await ws.requestKlineHistory(
  1040. symbol: symbol,
  1041. interval: interval,
  1042. from: from,
  1043. to: now,
  1044. );
  1045. } else {
  1046. historyData = await _fetchSpotKlineHistoryRaw(
  1047. symbol,
  1048. period,
  1049. pageSize: _spotWsKlinePageSizeDefault,
  1050. );
  1051. }
  1052. final cacheKey = _klineCacheKey(symbol, period, isFutures);
  1053. final klines = historyData.isNotEmpty
  1054. ? _parseKlineHistory(historyData, period)
  1055. : (_klineBarCache[cacheKey] ?? <KlineBar>[]);
  1056. _putCache(cacheKey, klines);
  1057. _setKlines(klines, KlineLoadType.initial);
  1058. if (isFutures) {
  1059. _subscribeWsKline(symbol, interval, isFutures: true);
  1060. }
  1061. }
  1062. void setIndicator(ChartIndicator indicator) => state = state.copyWith(indicator: indicator);
  1063. void setBottomTab(int tab) => state = state.copyWith(bottomTab: tab);
  1064. }
  1065. final marketDetailProvider =
  1066. NotifierProviderFamily<MarketDetailNotifier, MarketDetailState, MarketDetailKey>(
  1067. MarketDetailNotifier.new,
  1068. );