mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(intraday): 竞价图不再整幅铺满 + 手动刷新无分钟数据
- AuctionOnly: 无分钟K时竞价限左侧窄栏 (按宽度档, 默认 30%), 右侧留「暂无分时」占位, 不再整幅渲染竞价曲线让人误以为就是分时 - 增量 since 逐 symbol 计算并按 since 分组发请求: 块内混有未缓存 symbol 时不再被裁到 1 根 - 残序列自愈: 缓存不足 2 根时视为未缓存全量重取, 修复「1 根卡死 → 刷新永远只回 1 根」 - 测试: 逐 symbol since 拆分/残序列自愈回归 + 竞价窄栏布局断言
This commit is contained in:
@@ -183,7 +183,7 @@ it('盘前无分钟K但已有竞价序列: 仍渲染竞价图', () => {
|
||||
height: 80,
|
||||
auction: { price: [['09:15:00', 9.9], ['09:20:00', 10.05], ['09:25:00', 10.1]] },
|
||||
})
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价')
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价 (暂无分时)')
|
||||
expect(polylines(host)).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -292,7 +292,7 @@ it('固定轴按墙钟布点: 09:16:00 落在 15% 处而非 3 点中点 (50%)',
|
||||
expect(xs[1]).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('盘前 AuctionOnly: 价格点吸附桶中心, 前密后疏第二点 x<W×20% (旧序号=50%)', () => {
|
||||
it('盘前 AuctionOnly: 竞价占左侧窄栏不铺满, 右侧留「暂无分时」, 价格点吸附桶中心', () => {
|
||||
const host = render({
|
||||
rows: [],
|
||||
prevClose: 10,
|
||||
@@ -303,16 +303,22 @@ it('盘前 AuctionOnly: 价格点吸附桶中心, 前密后疏第二点 x<W×20%
|
||||
match: [['09:15:50', 300]],
|
||||
},
|
||||
})
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价')
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价 (暂无分时)')
|
||||
// 竞价只占窄栏 (standard 30%), 右侧空白区有提示文字
|
||||
const auctionW = Math.round((146 - 4) * 0.3)
|
||||
expect(host.textContent).toContain('暂无分时')
|
||||
const line = polylines(host)[0]
|
||||
const xs = lineXs(line)
|
||||
// W=146 → B=49: b(09:15:50)=floor(50/600×49)=4 → x=(4.5/49)×146≈13.41≈9.2% (旧序号中点=73)
|
||||
expect(xs[1]).toBeLessThan(146 * 0.2)
|
||||
expect(xs[1]).toBeGreaterThan(5)
|
||||
// 价格点全部落在窄栏内 (不再铺满整幅)
|
||||
for (const x of xs) expect(x).toBeLessThanOrEqual(auctionW + 0.01)
|
||||
// 桶中心映射: B=clamp(round(auctionW/3),8,60)
|
||||
const B = Math.max(8, Math.min(60, Math.round(auctionW / 3)))
|
||||
expect(xs[1]).toBeGreaterThan(0)
|
||||
expect(xs[1]).toBeLessThan(auctionW * 0.5)
|
||||
// 末点 09:25:00 → 末桶 clamp, 末点 circle 同映射
|
||||
const cx = Number(host.querySelector('circle')?.getAttribute('cx'))
|
||||
expect(cx).toBeCloseTo(((49 - 0.5) / 49) * 146, 6)
|
||||
// 与量柱对称: 同桶价格点 x = 柱 x
|
||||
expect(cx).toBeCloseTo(((B - 0.5) / B) * auctionW, 6)
|
||||
// 与量柱对称: 同桶价格点 x = 柱中心
|
||||
const gold = barsByFill(host, [GOLD])
|
||||
expect(gold).toHaveLength(1)
|
||||
expectNear(rectCenterX(gold[0]), xs[1])
|
||||
|
||||
@@ -158,16 +158,18 @@ function buildAuctionBars(axis: AuctionAxis, match: AuctionPt[], unmatched: Auct
|
||||
return bars
|
||||
}
|
||||
|
||||
/** 盘前无分钟K时的竞价单独渲染 (整幅宽度)。时间轴/量柱与主体窄栏共用同一套
|
||||
* (buildAuctionAxis/buildAuctionBars), 仅价格纵轴换成本图自身区间;
|
||||
* 配了量字段时同样上下分区 (价格上/量带下), 整幅无第二态, 不加窄栏底色。 */
|
||||
function AuctionOnly({ rows, prevClose, match, unmatched, width, height }: {
|
||||
/** 盘前无分钟K时的竞价渲染: 竞价只占左侧窄栏 (与有分钟K时同一宽度档),
|
||||
* 右侧连续竞价区留空 (虚线分隔 + 淡字提示), 不再整幅铺满 — 整幅铺满会让
|
||||
* 用户以为竞价曲线就是分时走势。时间轴/量柱与主体窄栏共用同一套
|
||||
* (buildAuctionAxis/buildAuctionBars), 仅价格纵轴换成本图自身区间。 */
|
||||
function AuctionOnly({ rows, prevClose, match, unmatched, width, height, auctionWidth = 'standard' }: {
|
||||
rows: AuctionPt[]
|
||||
prevClose?: number | null
|
||||
match?: [string, number][]
|
||||
unmatched?: [string, number][]
|
||||
width: number
|
||||
height: number
|
||||
auctionWidth?: AuctionBarWidth
|
||||
}) {
|
||||
const BULL = '#C74040'
|
||||
const BEAR = '#2D9B65'
|
||||
@@ -187,6 +189,10 @@ function AuctionOnly({ rows, prevClose, match, unmatched, width, height }: {
|
||||
// m/u 过滤落点 (rows 已由调用方以同一 filterAuctionPoints 过滤)
|
||||
const m = filterAuctionPoints(match)
|
||||
const u = filterAuctionPoints(unmatched)
|
||||
// 竞价窄栏宽度 (与有分钟K时同档), 右侧留给"暂无分时"占位
|
||||
const paneGap = 4
|
||||
const auctionW = Math.max(24, Math.round((W - paneGap) * (AUCTION_WIDTH_RATIOS[auctionWidth] ?? AUCTION_WIDTH_RATIOS.standard)))
|
||||
const plotX0 = auctionW + paneGap
|
||||
// 上下分区: 配了量字段时价格区止于 64%, 量带独立在底 (与主体同一比例)
|
||||
const hasVol = m.length > 0 || u.length > 0
|
||||
const priceBottom = hasVol ? Math.round(H * 0.64) : H - padY
|
||||
@@ -194,24 +200,38 @@ function AuctionOnly({ rows, prevClose, match, unmatched, width, height }: {
|
||||
const bottomY = H - padY
|
||||
const volTop = hasVol ? priceBottom + 2 : bottomY
|
||||
const volBandH = bottomY - volTop
|
||||
const axis = buildAuctionAxis([rows, m, u], W)
|
||||
const axis = buildAuctionAxis([rows, m, u], auctionW)
|
||||
const bars = buildAuctionBars(axis, m, u, { volBandH, bandTop: volTop, bottomY, gold: LINE_MATCH, bull: BULL, bear: BEAR })
|
||||
// 价格点 x = 所在桶中心 (与量柱逐点对齐; 末点 circle 同映射)
|
||||
const xAtT = (t: number) => axis.xAt(axis.bucketIndex(t))
|
||||
return (
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block" aria-label="集合竞价">
|
||||
<line x1={0} y1={y(baseline)} x2={W} y2={y(baseline)} stroke={LINE_PREV_CLOSE} strokeWidth={0.6} opacity={0.7} />
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block" aria-label="集合竞价 (暂无分时)">
|
||||
{/* 竞价窄栏底色 + 右侧留白区分两栏 */}
|
||||
<rect x={0} y={0} width={auctionW} height={H} fill={LINE_PREV_CLOSE} opacity={0.08} />
|
||||
<line
|
||||
x1={plotX0 - paneGap / 2} y1={0} x2={plotX0 - paneGap / 2} y2={H}
|
||||
stroke={LINE_PREV_CLOSE} strokeWidth={0.5} opacity={0.25}
|
||||
/>
|
||||
<line x1={0} y1={y(baseline)} x2={auctionW} y2={y(baseline)} stroke={LINE_PREV_CLOSE} strokeWidth={0.6} opacity={0.7} />
|
||||
{bars.map((b, i) => (
|
||||
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} opacity={b.opacity} />
|
||||
))}
|
||||
{hasVol && (
|
||||
<line x1={0} y1={volTop - 1} x2={W} y2={volTop - 1} stroke={LINE_PREV_CLOSE} strokeWidth={0.5} opacity={0.25} />
|
||||
<line x1={0} y1={volTop - 1} x2={auctionW} y2={volTop - 1} stroke={LINE_PREV_CLOSE} strokeWidth={0.5} opacity={0.25} />
|
||||
)}
|
||||
<polyline
|
||||
points={rows.map(p => `${xAtT(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ')}
|
||||
fill="none" stroke={color} strokeWidth={1.1} strokeLinejoin="round" strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={xAtT(rows[rows.length - 1].t)} cy={y(last)} r={1.4} fill={color} />
|
||||
{/* 右侧连续竞价区: 无分钟数据提示 (浅灰, 不抢视觉) */}
|
||||
<text
|
||||
x={plotX0 + (W - plotX0) / 2} y={H / 2}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill={LINE_PREV_CLOSE} fontSize={8} opacity={0.55}
|
||||
>
|
||||
暂无分时
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -255,7 +275,7 @@ export const MiniIntraday = memo(function MiniIntraday({ rows, prevClose, change
|
||||
if (auctionPriceRaw.length < 2) {
|
||||
return <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block" aria-label="暂无分时" />
|
||||
}
|
||||
return <AuctionOnly rows={auctionPriceRaw} prevClose={prevClose} match={auction?.match} unmatched={auction?.unmatched} width={W} height={H} />
|
||||
return <AuctionOnly rows={auctionPriceRaw} prevClose={prevClose} match={auction?.match} unmatched={auction?.unmatched} width={W} height={H} auctionWidth={auctionWidth} />
|
||||
}
|
||||
|
||||
const n = rows.length
|
||||
|
||||
@@ -123,7 +123,7 @@ it('volume 开+rows 全部 volume=0: 不画空带 (无分隔线无量柱), 价
|
||||
expect(Math.max(...priceYs(host))).toBeCloseTo(77, 5)
|
||||
})
|
||||
|
||||
it('盘前 AuctionOnly + volume 开 + 无量字段: 整幅价格, 不画空带 (R6)', () => {
|
||||
it('盘前 AuctionOnly + volume 开 + 无量字段: 窄栏价格, 不画空带 (R6)', () => {
|
||||
const host = render({
|
||||
rows: [],
|
||||
prevClose: 10,
|
||||
@@ -132,9 +132,10 @@ it('盘前 AuctionOnly + volume 开 + 无量字段: 整幅价格, 不画空带 (
|
||||
height: 80,
|
||||
auction: { price: [['09:15:00', 9.9], ['09:20:00', 10.05], ['09:25:00', 10.1]] },
|
||||
})
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价')
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价 (暂无分时)')
|
||||
expect(bandSeparator(host)).toHaveLength(0)
|
||||
expect(host.querySelectorAll('rect')).toHaveLength(0)
|
||||
// 唯一 rect 是窄栏底色 (无竞价量柱、无空量带)
|
||||
expect(host.querySelectorAll('rect')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('盘前 AuctionOnly + 竞价量数据 + volume 未传: 量带照画 (R2 渲染侧保障)', () => {
|
||||
@@ -148,7 +149,7 @@ it('盘前 AuctionOnly + 竞价量数据 + volume 未传: 量带照画 (R2 渲
|
||||
match: [['09:15:00', 100], ['09:25:00', 500]],
|
||||
},
|
||||
})
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价')
|
||||
expect(host.querySelector('svg')?.getAttribute('aria-label')).toBe('集合竞价 (暂无分时)')
|
||||
expect(bandSeparator(host)).toHaveLength(1)
|
||||
expect(host.querySelectorAll(`rect[fill="${GOLD}"]`).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
MINUTE_BATCH_CHUNK,
|
||||
MINUTE_CACHE_MAX_SYMBOLS,
|
||||
fetchMinuteBatchIncremental,
|
||||
lastBarTs,
|
||||
mergeInto,
|
||||
pruneMinuteCache,
|
||||
} from './minuteBatchIncremental'
|
||||
@@ -25,19 +24,6 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('lastBarTs', () => {
|
||||
it('取请求范围内最旧的最后一根', () => {
|
||||
const data = {
|
||||
a: bars(['09:31', '09:40']),
|
||||
b: bars(['09:31', '09:35']),
|
||||
c: bars(['09:31', '09:50']),
|
||||
}
|
||||
expect(lastBarTs(data, ['a', 'c'])).toBe('09:40')
|
||||
expect(lastBarTs(data)).toBe('09:35')
|
||||
expect(lastBarTs({}, ['a'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeInto', () => {
|
||||
it('新值按 datetime 覆盖旧值并保持时序', () => {
|
||||
const base = { s: bars(['09:31', '09:32']) }
|
||||
@@ -107,6 +93,47 @@ describe('fetchMinuteBatchIncremental', () => {
|
||||
expect(out.data.a.map(r => r.datetime)).toEqual(['09:31', '09:40', '09:41'])
|
||||
})
|
||||
|
||||
it('缓存只有 1 根残序列时自愈: 不带 since 全量重取 (手动刷新可恢复)', async () => {
|
||||
// 历史遗留的 1 根残序列若不重取, since 恒为那一根 → 每轮只回 1 根, 永远画不出分时
|
||||
const qc = new QueryClient()
|
||||
qc.setQueryData(QK.minuteBatch(), {
|
||||
data: { a: bars(['15:00']) },
|
||||
})
|
||||
const mock = vi.mocked(api.klineMinuteBatch)
|
||||
mock.mockResolvedValue({ data: { a: bars(['09:31', '09:35', '15:00']) }, incremental: false } as any)
|
||||
|
||||
const out = await fetchMinuteBatchIncremental(qc, ['a'], true)
|
||||
|
||||
expect(mock.mock.calls[0][3]).toBeUndefined() // 不带 since → 全天
|
||||
expect(out.data.a).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('已缓存与未缓存 symbol 混合时按 since 拆成两请求 — 新 symbol 不能被裁成新增 (回归)', async () => {
|
||||
// 真实 bug: 整块共用最旧末根作 since, 未缓存的新 symbol 只回 1 根
|
||||
// → 分时图空白, 且手动点刷新也永远凑不满 2 根
|
||||
const qc = new QueryClient()
|
||||
qc.setQueryData(QK.minuteBatch(), {
|
||||
data: { old: bars(['09:31', '09:40']) },
|
||||
})
|
||||
const mock = vi.mocked(api.klineMinuteBatch)
|
||||
mock.mockImplementation(async (syms: string[]) =>
|
||||
syms.includes('old')
|
||||
? { data: { old: bars(['09:41']) }, incremental: true } as any
|
||||
: { data: { fresh: bars(['09:31', '09:35']) }, incremental: false } as any)
|
||||
|
||||
const out = await fetchMinuteBatchIncremental(qc, ['old', 'fresh'], true)
|
||||
|
||||
const withSince = mock.mock.calls.filter(c => c[3] != null)
|
||||
const withoutSince = mock.mock.calls.filter(c => c[3] == null)
|
||||
expect(withSince).toHaveLength(1)
|
||||
expect(withSince[0][0]).toEqual(['old'])
|
||||
expect(withSince[0][3]).toBe('09:40')
|
||||
expect(withoutSince).toHaveLength(1)
|
||||
expect(withoutSince[0][0]).toEqual(['fresh'])
|
||||
// 新 symbol 拿到全天序列, 分时图可正常绘制
|
||||
expect(out.data.fresh.map(r => r.datetime)).toEqual(['09:31', '09:35'])
|
||||
})
|
||||
|
||||
it('无缓存时不带 since, 响应按 symbol 合并进同一份结果', async () => {
|
||||
const qc = new QueryClient()
|
||||
const mock = vi.mocked(api.klineMinuteBatch)
|
||||
|
||||
@@ -32,19 +32,28 @@ function chunk<T>(items: T[], size: number): T[][] {
|
||||
return out
|
||||
}
|
||||
|
||||
export function lastBarTs(data: MinuteBatchData, symbols?: string[]): string | null {
|
||||
// 直接取"最旧最后一根"的原始 datetime 字符串 (与服务端行同格式, 同为北京墙钟):
|
||||
// 不做任何 Date/ISO 转换 — toISOString 会变成带 Z 的 UTC, 服务端 naive 比较
|
||||
// 会 TypeError, 且换算差 8 小时。固定格式字符串的字典序即时间序。
|
||||
const scope = symbols ? new Set(symbols) : null
|
||||
let min: string | null = null
|
||||
for (const [sym, rows] of Object.entries(data)) {
|
||||
if (scope && !scope.has(sym)) continue
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) continue
|
||||
if (min === null || last.datetime < min) min = last.datetime
|
||||
/**
|
||||
* 逐 symbol 的 since (symbol → 最后已缓存 bar 时间)。
|
||||
*
|
||||
* 整块共用「最旧末根」是错的: 块里混有未缓存的新 symbol 时 (滚动进新区段、
|
||||
* 手动刷新、新增自选), 新 symbol 会被 since 裁到只剩 1 根, 永远凑不满 2 根
|
||||
* → 分时图空白且点刷新也没用。逐 symbol 精确化后, 已缓存者仍只拉新增,
|
||||
* 未缓存者不带 since 拉全天。
|
||||
*
|
||||
* 另加自愈: 缓存序列不足 MIN_CACHED_BARS 根时同样视为未缓存 (不带 since)。
|
||||
* 否则历史遗留的「1 根残序列」会因 since 恒为最后一根而永久卡死 — 每轮只
|
||||
* 补 1 根、永远画不出分时, 手动刷新也无法恢复。
|
||||
*/
|
||||
export const MIN_CACHED_BARS = 2
|
||||
|
||||
function sinceBySymbol(data: MinuteBatchData, symbols: string[]): (sym: string) => string | undefined {
|
||||
const lastOf = new Map<string, string>()
|
||||
for (const sym of symbols) {
|
||||
const rows = data[sym]
|
||||
if (!rows || rows.length < MIN_CACHED_BARS) continue // 残序列 → 全量重取
|
||||
lastOf.set(sym, rows[rows.length - 1].datetime)
|
||||
}
|
||||
return min
|
||||
return (sym: string) => lastOf.get(sym)
|
||||
}
|
||||
|
||||
export function mergeInto(base: MinuteBatchData, patch: MinuteBatchData): MinuteBatchData {
|
||||
@@ -107,10 +116,21 @@ export async function fetchMinuteBatchIncremental(
|
||||
const cacheKey = QK.minuteBatch()
|
||||
const prev = qc.getQueryData<{ data: MinuteBatchData }>(cacheKey)?.data
|
||||
let data: MinuteBatchData = prev ?? {}
|
||||
const sinceOf = sinceBySymbol(data, symbols)
|
||||
for (const part of chunk(symbols, MINUTE_BATCH_CHUNK)) {
|
||||
const since = prev ? lastBarTs(prev, part) ?? undefined : undefined
|
||||
const resp = await api.klineMinuteBatch(part, undefined, preferLocal, since)
|
||||
data = mergeInto(data, resp.data ?? {})
|
||||
// 按 since 分组: 端点单请求只收一个 since, 而块的 symbols 可能混合
|
||||
// 「已缓存(有 since)」与「新增(无 since)」。混发会把新 symbol 裁成 1 根。
|
||||
const groups = new Map<string | undefined, string[]>()
|
||||
for (const sym of part) {
|
||||
const s = sinceOf(sym)
|
||||
const list = groups.get(s)
|
||||
if (list) list.push(sym)
|
||||
else groups.set(s, [sym])
|
||||
}
|
||||
for (const [since, group] of groups) {
|
||||
const resp = await api.klineMinuteBatch(group, undefined, preferLocal, since)
|
||||
data = mergeInto(data, resp.data ?? {})
|
||||
}
|
||||
}
|
||||
for (const sym of symbols) _lastTouch.set(sym, (_touchSeq += 1))
|
||||
return { data: pruneMinuteCache(data, symbols, _lastTouch, MINUTE_CACHE_MAX_SYMBOLS) }
|
||||
|
||||
Reference in New Issue
Block a user