SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

web: /charts — e2e Playwright (fixtures OHLC synthétiques, 12 scénarios), correctifs mobile/échelle, stub moteur respecte le positionnement CSS

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent e137c2c

5 changed files +298 −4

added hfmarketdata/web/e2e/charts.spec.js +214 −0
@@ -0,0 +1,214 @@
1 +// /charts — offline E2E against synthetic OHLC fixtures (e2e/fixtures/charts.js). The chart object is exposed as
2 +// window.__hfmdChart only with ?debug=1 (or in dev), which the pagination test uses to move the viewport.
3 +import { expect, test } from '@playwright/test'
4 +import { mockAnon } from './mocks.js'
5 +import { chartsHandler } from './fixtures/charts.js'
6 +
7 +async function setup(page, state = {}) {
8 + const handler = chartsHandler(state)
9 + await mockAnon(page, url => handler(url))
10 + return state
11 +}
12 +const legend = page => page.getByTestId('ch-legend')
13 +const barsCalls = (state, pred = () => true) => state.calls.filter(pred)
14 +
15 +test.describe('charts', () => {
16 + test('loads AAPL 1D by default: one bars request, legend, status, URL', async ({ page }) => {
17 + const state = await setup(page)
18 + await page.goto('/charts')
19 + await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL')
20 + await expect(page.getByTestId('ch-legend-ohlc')).toContainText('O')
21 + await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/)
22 + await expect(page).toHaveURL(/\/charts\?s=AAPL$/)
23 + expect(barsCalls(state)).toHaveLength(1)
24 + expect(state.calls[0]).toMatchObject({ asset: 'stock', ticker: 'AAPL', tf: '1day' })
25 + expect(state.calls[0].search).toContain('order=desc')
26 + expect(state.calls[0].search).toContain('limit=1500')
27 + await expect(page.getByTestId('ch-status-quota')).toContainText('req left')
28 + await expect(page.getByTestId('ch-status-quota').getByRole('link', { name: /Sign in/ })).toBeVisible()
29 + await expect(page).toHaveTitle(/AAPL 1D · Charts/)
30 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-desktop.png' })
31 + })
32 +
33 + test('timeframe switch keeps the previous render (no skeleton), updates URL, legend and request', async ({ page }) => {
34 + const state = await setup(page)
35 + await page.goto('/charts')
36 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
37 + await page.getByTestId('ch-tf-1min').click()
38 + await expect(page.getByTestId('ch-skeleton')).toHaveCount(0)
39 + await expect(page.getByTestId('ch-tf-1min')).toHaveAttribute('aria-pressed', 'true')
40 + await expect(page).toHaveURL(/tf=1min/)
41 + await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1m')
42 + await expect.poll(() => barsCalls(state, c => c.tf === '1min').length).toBe(1)
43 + expect(state.calls[1].search).toContain('limit=3000')
44 + await expect(page.getByTestId('ch-legend-ohlc')).toContainText('ET')
45 + })
46 +
47 + test('URL state restores symbol, timeframe, type, indicators, scale and volume', async ({ page }) => {
48 + await setup(page)
49 + await page.goto('/charts?s=MSFT&tf=1hour&type=line&ind=sma:20,rsi:14&scale=log&vol=0&debug=1')
50 + await expect(legend(page).locator('.ch-legend-sym')).toHaveText('MSFT')
51 + await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1h')
52 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(2)
53 + await expect(page.getByTestId('ch-legend-ind').nth(0)).toContainText('SMA 20')
54 + await expect(page.getByTestId('ch-legend-ind').nth(1)).toContainText('RSI 14')
55 + await expect(page.getByTestId('ch-type-menu')).toContainText('Line')
56 + await expect(page.getByTestId('ch-scale-menu')).toContainText('Log')
57 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane}`))).toEqual(['sma:20:main', 'rsi:14:new'])
58 + await expect(page).toHaveTitle(/MSFT 1h · Charts/)
59 + })
60 +
61 + test('indicators: add from the searchable menu, edit a parameter inline, remove', async ({ page }) => {
62 + await setup(page)
63 + await page.goto('/charts?debug=1')
64 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
65 + await page.getByTestId('ch-ind-menu').click()
66 + await page.getByTestId('ch-ind-search').fill('expo')
67 + await expect(page.getByTestId('ch-ind-ema')).toBeVisible()
68 + await expect(page.getByTestId('ch-ind-sma')).toHaveCount(0)
69 + await page.getByTestId('ch-ind-ema').click()
70 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1)
71 + await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 20')
72 + await expect(page).toHaveURL(/ind=ema:20/)
73 + await page.getByTestId('ch-legend-ind').getByRole('button', { name: 'EMA 20', exact: true }).click()
74 + const editor = page.getByTestId('ch-ind-editor')
75 + await editor.getByRole('spinbutton').fill('50')
76 + await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 50')
77 + await expect(page).toHaveURL(/ind=ema:50/)
78 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators()[0].params.length)).toBe(50)
79 + await editor.getByRole('button', { name: 'Done' }).click()
80 + await page.getByRole('button', { name: 'Remove EMA 50' }).click()
81 + await expect(page.getByTestId('ch-legend-ind')).toHaveCount(0)
82 + await expect(page).not.toHaveURL(/ind=/)
83 + })
84 +
85 + test('compare: adds a % overlay, chip + legend row, one request, URL cmp=', async ({ page }) => {
86 + const state = await setup(page)
87 + await page.goto('/charts')
88 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
89 + await page.getByTestId('ch-cmp-menu').click()
90 + const input = page.locator('#ch-compare-input')
91 + await input.fill('MSF')
92 + await page.getByTestId('ch-symbol-list').getByRole('option', { name: /MSFT/ }).click()
93 + await expect(page.getByTestId('ch-cmp-chip')).toHaveText(/MSFT/)
94 + await page.keyboard.press('Escape')
95 + await expect(page.getByTestId('ch-legend-cmp')).toContainText('MSFT')
96 + await expect(page).toHaveURL(/cmp=MSFT/)
97 + await expect.poll(() => barsCalls(state, c => c.ticker === 'MSFT').length).toBe(1)
98 + await expect(page.getByTestId('ch-scale-menu')).toContainText('%')
99 + await page.getByRole('button', { name: 'Remove comparison MSFT' }).click()
100 + await expect(page.getByTestId('ch-legend-cmp')).toHaveCount(0)
101 + })
102 +
103 + test('infinite history: needMoreLeft loads older bars once, then "start of history"', async ({ page }) => {
104 + const state = await setup(page, { total: 2000 })
105 + await page.goto('/charts?debug=1')
106 + await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/)
107 + await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 120 }))
108 + await expect(page.getByTestId('ch-status-count')).toContainText(/2,000 bars/)
109 + await expect(page.getByTestId('ch-status-start')).toHaveText('start of history')
110 + expect(barsCalls(state)).toHaveLength(2)
111 + expect(state.calls[1].search).toMatch(/end=\d{4}-\d{2}-\d{2}/)
112 + const data = await page.evaluate(() => { const d = window.__hfmdChart.getData(); return { n: d.length, sorted: d.every((b, i) => i === 0 || d[i - 1].t < b.t) } })
113 + expect(data).toEqual({ n: 2000, sorted: true })
114 + // no further request once the start is reached
115 + await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 80 }))
116 + await page.waitForTimeout(300)
117 + expect(barsCalls(state)).toHaveLength(2)
118 + })
119 +
120 + test('429 shows "rate limited — not down" with countdown and free-account CTA', async ({ page }) => {
121 + await setup(page, { rateLimit: true })
122 + await page.goto('/charts')
123 + const err = page.getByTestId('ch-error').locator('.error-state.is-429')
124 + await expect(err).toBeVisible()
125 + await expect(err).toContainText('Rate limited — not down')
126 + await expect(err.locator('.countdown')).toContainText(/min|s/)
127 + await expect(err.getByRole('link', { name: /Create free account/ })).toBeVisible()
128 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-429.png' })
129 + })
130 +
131 + test('unknown symbol: 404 state with suggestions, picking one recovers', async ({ page }) => {
132 + await setup(page)
133 + await page.goto('/charts?s=AAPX')
134 + const err = page.getByTestId('ch-error')
135 + await expect(err).toContainText('AAPX not found')
136 + await expect(page.getByTestId('ch-suggestions')).toContainText('AAPL')
137 + await page.getByTestId('ch-suggestions').getByRole('button', { name: /^AAPL/ }).click()
138 + await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL')
139 + await expect(page.getByTestId('ch-error')).toHaveCount(0)
140 + })
141 +
142 + test('symbol search: groups by asset, keyboard pick, recents; `/` focuses it', async ({ page }) => {
143 + await setup(page)
144 + await page.goto('/charts')
145 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
146 + await page.keyboard.press('/')
147 + await expect(page.getByTestId('ch-symbol-input')).toBeFocused()
148 + await expect(page.getByTestId('search-dialog')).toHaveCount(0)
149 + await page.keyboard.type('ES')
150 + const list = page.getByTestId('ch-symbol-list')
151 + await expect(list).toContainText('Futures (continuous)')
152 + await expect(list).toContainText('E-mini S&P 500')
153 + await list.getByRole('option', { name: /^ES E-mini/ }).click()
154 + await expect(legend(page).locator('.ch-legend-sym')).toHaveText('ES')
155 + await expect(page).toHaveURL(/s=ES&asset=futures/)
156 + await expect(page.getByTestId('ch-adj-menu')).toBeVisible()
157 + await page.getByTestId('ch-symbol-input').focus()
158 + await expect(list).toContainText('Recent')
159 + })
160 +
161 + test('drawing tools: keyboard shortcuts toggle tools, table view lists visible bars with CSV export', async ({ page }) => {
162 + await setup(page)
163 + await page.goto('/charts')
164 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
165 + await page.keyboard.press('t')
166 + await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'true')
167 + await page.keyboard.press('Escape')
168 + await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'false')
169 + await page.getByTestId('ch-drawbar').getByRole('button', { name: 'Fibonacci retracement' }).click()
170 + await expect(page.getByTestId('ch-drawbar').locator('[data-tool="fib"]')).toHaveAttribute('aria-pressed', 'true')
171 + await page.getByTestId('ch-table-btn').click()
172 + const table = page.getByTestId('ch-table')
173 + await expect(table).toBeVisible()
174 + await expect(table.locator('tbody tr').first()).toContainText('2024-06-28')
175 + await expect(table.getByTestId('ch-table-csv')).toBeEnabled()
176 + await expect(page.getByTestId('ch-legend-ohlc')).toBeVisible()
177 + await page.getByRole('button', { name: 'Close table' }).click()
178 + await expect(table).toHaveCount(0)
179 + })
180 +
181 + test('share copies the current URL; screenshot produces a PNG', async ({ page }) => {
182 + await setup(page)
183 + await page.goto('/charts?s=MSFT&tf=1hour')
184 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
185 + const blobSize = await page.evaluate(async () => { const b = await window.__hfmdChart?.toPNG?.(); return b ? b.size : -1 })
186 + expect(blobSize).toBe(-1) // not exposed without ?debug=1
187 + await page.getByTestId('ch-share').click()
188 + await expect(page.getByTestId('ch-share')).toHaveAttribute('aria-label', 'Link copied')
189 + })
190 +})
191 +
192 +test.describe('charts mobile', () => {
193 + test.use({ viewport: { width: 393, height: 851 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 })
194 +
195 + test('condensed toolbar, bottom sheet with the rest, no horizontal overflow, 44 px targets', async ({ page }) => {
196 + await setup(page)
197 + await page.goto('/charts')
198 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
199 + await expect(page.getByTestId('ch-type-menu')).toHaveCount(0)
200 + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
201 + const small = await page.evaluate(() => [...document.querySelectorAll('[data-testid="ch-toolbar"] button, [data-testid="ch-toolbar"] input')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 44 }).length)
202 + expect(small).toBe(0)
203 + const pageBox = await page.getByTestId('ch-page').boundingBox()
204 + expect(pageBox.height).toBeLessThanOrEqual(851 - 56 + 1)
205 + await page.getByTestId('ch-more').click()
206 + const sheet = page.getByTestId('ch-sheet')
207 + await expect(sheet).toBeVisible()
208 + await sheet.getByTestId('ch-sheet-indicators').click()
209 + await page.getByTestId('ch-ind-rsi').click()
210 + await expect(page.getByTestId('ch-legend-ind')).toContainText('RSI 14')
211 + await expect(page.getByTestId('ch-sheet')).toHaveCount(0)
212 + await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile.png' })
213 + })
214 +})
added hfmarketdata/web/e2e/fixtures/charts.js +77 −0
@@ -0,0 +1,77 @@
1 +// Synthetic OHLCV fixtures + route handler for the /charts E2E spec. Mirrors the real shapes:
2 +// GET /v1/{asset}/tickers → { asset, timeframe, adjustment, count, tickers } · GET /v1/bars/{asset}/{ticker} (legacy
3 +// envelope { count, data }, `order=desc`, `end` inclusive, `limit`) · GET /v1/futures/roots → { data, meta } ·
4 +// GET /v1/limits → { data: { tiers, principal } }.
5 +import { RATE } from '../mocks.js'
6 +
7 +const pad2 = n => String(n).padStart(2, '0')
8 +const stamp = (ms, daily) => {
9 + const d = new Date(ms)
10 + const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
11 + return daily ? date : `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
12 +}
13 +const parse = s => { const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0)) : NaN }
14 +
15 +/** Deterministic random walk: `n` bars ending at 2024-06-28 (daily: trading days only; intraday: 09:30–16:00 ET). */
16 +export function makeSeries(ticker, timeframe, n, seed = 7) {
17 + let x = seed
18 + const rnd = () => { x = (x * 1103515245 + 12345) & 0x7fffffff; return x / 0x7fffffff }
19 + const daily = timeframe === '1day'
20 + const step = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 }[timeframe]
21 + const out = []
22 + let t = daily ? Date.UTC(2024, 5, 28) : Date.UTC(2024, 5, 28, 15, 59)
23 + let px = ticker === 'MSFT' ? 420 : ticker === 'ES' ? 5400 : 190
24 + for (let i = 0; i < n; i++) {
25 + const d = new Date(t)
26 + const dow = d.getUTCDay()
27 + if (dow === 0 || dow === 6) { t -= daily ? step : (dow === 0 ? 2 : 1) * 86_400_000; i--; continue }
28 + if (!daily) { const mins = d.getUTCHours() * 60 + d.getUTCMinutes(); if (mins < 570) { t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 1, 15, 59); i--; continue } if (mins >= 960) { t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 15, 59); i--; continue } }
29 + const o = px, c = +(px * (1 + (rnd() - 0.5) * 0.02)).toFixed(2)
30 + const h = +(Math.max(o, c) * (1 + rnd() * 0.005)).toFixed(2), l = +(Math.min(o, c) * (1 - rnd() * 0.005)).toFixed(2)
31 + out.push({ ticker, datetime: stamp(t, daily), open: +o.toFixed(2), high: h, low: l, close: c, volume: Math.round(1e6 + rnd() * 5e6) })
32 + px = c
33 + t -= step
34 + }
35 + return out.reverse()
36 +}
37 +
38 +const TICKERS = { stock: ['AAPL', 'AAPG', 'AAP', 'MSFT', 'TSLA', 'NVDA', 'AMZN'], etf: ['SPY', 'QQQ', 'IWM'], index: ['SPX', 'NDX', 'VIX'], crypto: ['BTC', 'ETH'], fx: ['EURUSD', 'USDJPY'], futures: ['ES', 'CL', 'GC'] }
39 +const ROOTS = [{ root: 'ES', name: 'E-mini S&P 500', exchange: 'CME', tick_size: 0.25, tick_value: 12.5 }, { root: 'CL', name: 'Crude Oil WTI', exchange: 'NYMEX', tick_size: 0.01, tick_value: 10 }, { root: 'GC', name: 'Gold', exchange: 'COMEX', tick_size: 0.1, tick_value: 10 }]
40 +const LIMITS = { data: { tiers: {}, principal: { principal: 'ip:x', kind: 'keyless', tier: 'keyless', window_seconds: 3600, max_rows_per_request: 5000, requests: { limit: 30, remaining: 27, reset: Math.floor(Date.now() / 1000) + 1800 }, rows: { limit: 100000, remaining: 99000, reset: Math.floor(Date.now() / 1000) + 1800 } } }, meta: { count: 1 } }
41 +
42 +const json = (body, status = 200, headers = {}) => ({ status, contentType: 'application/json', headers: { ...RATE, 'X-Row-Count': String(Array.isArray(body?.data) ? body.data.length : 0), ...headers }, body: JSON.stringify(body) })
43 +const err = (code, message, status, headers = {}) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code.toLowerCase()}` }, detail: message }, status, headers)
44 +
45 +/**
46 + * Route handler factory. `state.calls` collects every bars request; `state.total` = history depth (bars available);
47 + * `state.rateLimit` = true → every bars call answers 429 with Retry-After.
48 + */
49 +export function chartsHandler(state = {}) {
50 + const series = new Map()
51 + const seriesFor = (ticker, tf) => { const k = `${ticker}|${tf}`; if (!series.has(k)) series.set(k, makeSeries(ticker, tf, state.total ?? 4200, ticker.length * 13)); return series.get(k) }
52 + state.calls = state.calls || []
53 + return (url) => {
54 + const p = url.pathname
55 + if (p === '/v1/limits') return json(LIMITS)
56 + if (p === '/v1/futures/roots') return json({ data: ROOTS, meta: { count: ROOTS.length } })
57 + let m = /^\/v1\/(stock|etf|index|crypto|fx|futures)\/tickers$/.exec(p)
58 + if (m) { const q = (url.searchParams.get('search') || '').toUpperCase(); const list = (TICKERS[m[1]] || []).filter(t => !q || t.includes(q)); return json({ asset: m[1], timeframe: '1day', adjustment: 'none', count: list.length, tickers: list }) }
59 + m = /^\/v1\/bars\/(\w+)\/([A-Z0-9.]+)$/.exec(p)
60 + if (m) {
61 + const [, asset, ticker] = m
62 + const tf = url.searchParams.get('timeframe') || '1day'
63 + state.calls.push({ asset, ticker, tf, search: url.search })
64 + if (state.rateLimit) return err('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded: 30 requests per hour (keyless).', 429, { 'Retry-After': '90', 'X-RateLimit-Remaining-Requests': '0' })
65 + if (!(TICKERS[asset] || []).includes(ticker)) return err('TICKER_NOT_FOUND', `Ticker '${ticker}' not found in ${asset}/${tf}`, 404)
66 + let rows = seriesFor(ticker, tf)
67 + const end = url.searchParams.get('end'), start = url.searchParams.get('start')
68 + if (end) { const e = parse(end) + (tf === '1day' ? 0 : 0); rows = rows.filter(r => parse(r.datetime) <= e) }
69 + if (start) rows = rows.filter(r => parse(r.datetime) >= parse(start))
70 + const limit = Math.min(Number(url.searchParams.get('limit') || 5000), 5000)
71 + if (url.searchParams.get('order') === 'desc') rows = rows.slice().reverse()
72 + rows = rows.slice(0, limit)
73 + return json({ count: rows.length, data: rows }, 200, { 'X-Row-Count': String(rows.length) })
74 + }
75 + return null
76 + }
77 +}
modified hfmarketdata/web/src/charts/engine/index.js +1 −1
@@ -43,7 +43,7 @@ export function createChart(container, options = {}) {
43 43 let raf = 0
44 44 let hover = null
45 45
46 − container.style.position = container.style.position || 'relative'
46 + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'
47 47 const main = document.createElement('canvas')
48 48 const overlay = document.createElement('canvas')
49 49 for (const c of [main, overlay]) { c.style.position = 'absolute'; c.style.inset = '0'; c.style.width = '100%'; c.style.height = '100%'; container.appendChild(c) }
modified hfmarketdata/web/src/pages/charts/Toolbar.jsx +3 −2
@@ -14,8 +14,9 @@ import { AutoIcon, CalendarIcon, CameraIcon, CompareIcon, DotsIcon, ExpandIcon,
14 14 export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullscreen, table, drawbarOpen, symbolRef, isMobile }) {
15 15 const TypeIcon = SERIES_ICONS[state.type] || SERIES_ICONS.candles
16 16 const typeLabel = SERIES_TYPES.find(([id]) => id === state.type)?.[1] || 'Candles'
17 − const scaleLabel = state.scale === 'log' ? 'Log' : state.scale === 'percent' ? '%' : 'Lin'
18 17 const hasCompare = state.compares.length > 0
18 + const scale = hasCompare ? 'percent' : state.scale // comparisons force the percent scale (engine contract)
19 + const scaleLabel = scale === 'log' ? 'Log' : scale === 'percent' ? '%' : 'Lin'
19 20 const shareUrl = () => window.location.href
20 21 return (
21 22 <div className="ch-toolbar" role="toolbar" aria-label="Chart tools" data-testid="ch-toolbar">
@@ -46,7 +47,7 @@ export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullsc
46 47 )}
47 48 <span className="ch-tb-spacer" />
48 49 <Menu label="Price scale" value={scaleLabel} icon={<ScaleIcon />} align="end" testId="ch-scale-menu">
49 − <ScalePanel scale={state.scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={hasCompare} />
50 + <ScalePanel scale={scale} onScale={a.setScale} auto={prefs.autoScale} onAuto={v => a.setPref('autoScale', v)} hasCompare={hasCompare} />
50 51 </Menu>
51 52 <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={prefs.autoScale} aria-label="Auto-scale" title="Auto-scale price axis" onClick={() => a.setPref('autoScale', !prefs.autoScale)}><AutoIcon /></button>
52 53 <button type="button" className="ch-tb-btn ch-tb-icon" aria-pressed={prefs.magnet} aria-label="Magnet mode" title="Magnet: snap crosshair and drawings to OHLC" onClick={() => a.setPref('magnet', !prefs.magnet)} data-testid="ch-magnet"><MagnetIcon /></button>
modified hfmarketdata/web/src/pages/charts/charts.css +3 −1
@@ -26,7 +26,7 @@
26 26 .ch-symbol { position: relative; display: flex; align-items: center; gap: 6px; width: 230px; min-width: 150px; height: var(--tap); padding: 0 8px; border: 1px solid var(--line-2); border-radius: var(--r-sm); background: var(--bg-1); flex: none; }
27 27 .ch-symbol:focus-within { border-color: var(--accent); }
28 28 .ch-symbol-icon { width: 16px; height: 16px; color: var(--fg-3); flex: none; }
29 −.ch-symbol-input { flex: 1; min-width: 0; height: 100%; min-height: 0; padding: 0; border: 0; background: transparent; color: var(--fg); font-size: var(--fs-2); font-weight: 600; }
29 +.ch-symbol-input { flex: 1; min-width: 0; height: var(--tap); min-height: var(--tap); padding: 0; border: 0; background: transparent; color: var(--fg); font-size: var(--fs-2); font-weight: 600; }
30 30 .ch-symbol-input:focus-visible { outline: none; border-radius: 0; }
31 31 .ch-symbol-current { position: absolute; left: 30px; top: 50%; transform: translateY(-50%); font-weight: 600; font-size: var(--fs-2); color: var(--fg); pointer-events: none; }
32 32 .ch-symbol:focus-within .ch-symbol-current { display: none; }
@@ -161,6 +161,8 @@ button.ch-legend-btn:hover, .ch-legend-btn[aria-expanded="true"] { background: v
161 161 }
162 162 @media (max-width: 960px) {
163 163 .ch-toolbar { height: 52px; gap: 2px; padding: 0 6px; }
164 + .ch-seg { padding: 0; }
165 + .ch-seg-btn { height: var(--tap); min-width: 38px; }
164 166 .ch-table { width: 100%; max-width: none; position: absolute; inset: 0; z-index: 4; }
165 167 .ch-body { position: relative; }
166 168 .ch-tool { width: 40px; height: 40px; }
167 169