diff --git a/src/api/cryptoChart.ts b/src/api/cryptoChart.ts index 21cdabb..6bb7b9d 100644 --- a/src/api/cryptoChart.ts +++ b/src/api/cryptoChart.ts @@ -191,9 +191,10 @@ export interface FetchCryptoChartResponse { */ async function fetchBinanceKlines( binanceSymbol: string, - limit: number + limit: number, + interval: '1s' | '1m' = '1m' ): Promise { - const url = `${BINANCE_REST}/klines?symbol=${binanceSymbol}&interval=1m&limit=${limit}` + const url = `${BINANCE_REST}/klines?symbol=${binanceSymbol}&interval=${interval}&limit=${limit}` const res = await fetch(url) if (!res.ok) throw new Error(`Binance API: ${res.status}`) const raw = (await res.json()) as [number, string, string, string, string, number, ...unknown[]][] @@ -223,9 +224,41 @@ async function fetchCoinGeckoChart( /** * 拉取加密货币价格历史 - * 优先 Binance(1 分钟粒度),不支持时回退 CoinGecko + * 优先 Binance(1 分钟粒度;30S 场景为 1 秒粒度以铺满视窗),不支持时回退 CoinGecko */ -const THIRTY_SEC_MS = 30 * 1000 +/** + * 30S 细粒度模式下内存中保留的历史跨度(毫秒)。 + * 需 ≥ 图表 timeScale 实际展示的时间宽度;仅用「最近 30s」裁剪时会把已拉取的 1s K 线丢掉大半,屏内左侧会变空。 + */ +export const CRYPTO_30S_RETENTION_MS = 120 * 1000 + +/** 30S 初始:拉取的 1s K 线条数,应略大于 {@link CRYPTO_30S_RETENTION_MS}(秒)以抵消网络延迟与 K 线闭合同步 */ +const CRYPTO_30S_INITIAL_1S_LIMIT = 150 + +/** 加密货币分时折线图 X 轴 / 补点统一时间步长(ms),与图表 timeScale 一致 */ +export const CRYPTO_CHART_TIME_STEP_MS = 100 + +/** 将毫秒时间戳对齐到 {@link CRYPTO_CHART_TIME_STEP_MS} 网格(向下取整,单调递增友好) */ +export function alignCryptoChartTimeMs(tsMs: number): number { + const step = CRYPTO_CHART_TIME_STEP_MS + return Math.floor(tsMs / step) * step +} + +/** 同一时间对齐戳保留最后一条,避免与 realtime 合并时重复键 */ +function dedupeLatestByTime(points: CryptoChartPoint[]): CryptoChartPoint[] { + if (points.length <= 1) return points + const sorted = [...points].sort((a, b) => a[0] - b[0]) + const out: CryptoChartPoint[] = [] + for (const pt of sorted) { + const last = out[out.length - 1] + if (last && last[0] === pt[0]) { + out[out.length - 1] = pt + } else { + out.push(pt) + } + } + return out +} export async function fetchCryptoChart( params: FetchCryptoChartParams @@ -243,10 +276,21 @@ export async function fetchCryptoChart( try { if (useBinance) { - let points = await fetchBinanceKlines(binanceSymbol, limit) + let points: CryptoChartPoint[] if (is30S) { - const cutoff = Date.now() - THIRTY_SEC_MS - points = points.filter(([ts]) => ts >= cutoff) + points = await fetchBinanceKlines( + binanceSymbol, + Math.min(CRYPTO_30S_INITIAL_1S_LIMIT, 1000), + '1s' + ) + const cutoff = Date.now() - CRYPTO_30S_RETENTION_MS + points = dedupeLatestByTime( + points + .map(([ts, p]) => [alignCryptoChartTimeMs(ts), p] as CryptoChartPoint) + .filter(([ts]) => ts >= cutoff) + ) + } else { + points = await fetchBinanceKlines(binanceSymbol, limit, '1m') } return { code: 0, data: points, msg: 'ok' } } @@ -254,7 +298,7 @@ export async function fetchCryptoChart( const days = RANGE_TO_DAYS[fetchRange] ?? 7 let points = await fetchCoinGeckoChart(coinId, days) if (is30S) { - const cutoff = Date.now() - THIRTY_SEC_MS + const cutoff = Date.now() - CRYPTO_30S_RETENTION_MS points = points.filter(([ts]) => ts >= cutoff) } return { code: 0, data: points, msg: 'ok' } @@ -278,15 +322,6 @@ interface BinanceAggTradeMsg { const CRYPTO_UPDATE_THROTTLE_MS = 80 -/** 加密货币分时折线图 X 轴 / 补点统一时间步长(ms),与图表 timeScale 一致 */ -export const CRYPTO_CHART_TIME_STEP_MS = 100 - -/** 将毫秒时间戳对齐到 {@link CRYPTO_CHART_TIME_STEP_MS} 网格(向下取整,单调递增友好) */ -export function alignCryptoChartTimeMs(tsMs: number): number { - const step = CRYPTO_CHART_TIME_STEP_MS - return Math.floor(tsMs / step) * step -} - /** * 订阅 Binance 归集交易 WebSocket(aggTrade),实现更丝滑的实时走势 * 参考:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易 diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue index 32845f0..6d4940f 100644 --- a/src/views/TradeDetail.vue +++ b/src/views/TradeDetail.vue @@ -414,6 +414,7 @@ import { subscribeCryptoRealtime, alignCryptoChartTimeMs, CRYPTO_CHART_TIME_STEP_MS, + CRYPTO_30S_RETENTION_MS, } from '../api/cryptoChart' const { t } = useI18n() @@ -1225,7 +1226,7 @@ const lineColor = '#2563eb' /** 叠加价格轴 ID:曲线绑定在 overlay scale,主图占满宽;Y 轴数字由 InPanePriceScalePrimitive 画在图内 */ const CHART_OVERLAY_PRICE_SCALE_ID = 'overlay-price' /** 右侧留白(px):fitContent 时压缩可用宽度,线头略离开物理右缘,lastPrice 水波能露出一半且不依赖超大 rightOffset(bars) */ -const CHART_RIPPLE_RIGHT_PAD_PX = 28 +const CHART_RIPPLE_RIGHT_PAD_PX = 0 function ensureChartSeries() { if (!chartInstance || !chartContainerRef.value) return @@ -1392,7 +1393,7 @@ function applyCryptoRealtimePoint(point: [number, number]) { let updatePoint: [number, number] | null = null if (range === '30S') { - const cutoff = Date.now() - 30 * 1000 + const cutoff = Date.now() - CRYPTO_30S_RETENTION_MS if (prevLastT != null && ts === prevLastT) { list[list.length - 1] = [ts, price] } else {