Commit
web: /charts — e2e Playwright (fixtures OHLC synthétiques, 12 scénarios), correctifs mobile/échelle, stub moteur respecte le positionnement CSS
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 | let raf = 0 | 43 | let raf = 0 |
| 44 | let hover = null | 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 | const main = document.createElement('canvas') | 47 | const main = document.createElement('canvas') |
| 48 | const overlay = document.createElement('canvas') | 48 | const overlay = document.createElement('canvas') |
| 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) } | 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 | export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullscreen, table, drawbarOpen, symbolRef, isMobile }) { | 14 | export default function Toolbar({ state, prefs, a, apiKey, authenticated, fullscreen, table, drawbarOpen, symbolRef, isMobile }) { |
| 15 | const TypeIcon = SERIES_ICONS[state.type] || SERIES_ICONS.candles | 15 | const TypeIcon = SERIES_ICONS[state.type] || SERIES_ICONS.candles |
| 16 | const typeLabel = SERIES_TYPES.find(([id]) => id === state.type)?.[1] || 'Candles' | 16 | const typeLabel = SERIES_TYPES.find(([id]) => id === state.type)?.[1] || 'Candles' |
| 17 | − const scaleLabel = state.scale === 'log' ? 'Log' : state.scale === 'percent' ? '%' : 'Lin' | ||
| 18 | const hasCompare = state.compares.length > 0 | 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 | const shareUrl = () => window.location.href | 20 | const shareUrl = () => window.location.href |
| 20 | return ( | 21 | return ( |
| 21 | <div className="ch-toolbar" role="toolbar" aria-label="Chart tools" data-testid="ch-toolbar"> | 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 | <span className="ch-tb-spacer" /> | 48 | <span className="ch-tb-spacer" /> |
| 48 | <Menu label="Price scale" value={scaleLabel} icon={<ScaleIcon />} align="end" testId="ch-scale-menu"> | 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 | </Menu> | 51 | </Menu> |
| 51 | <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 | <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 | <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> | 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 | .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; } | 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 | .ch-symbol:focus-within { border-color: var(--accent); } | 27 | .ch-symbol:focus-within { border-color: var(--accent); } |
| 28 | .ch-symbol-icon { width: 16px; height: 16px; color: var(--fg-3); flex: none; } | 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 | .ch-symbol-input:focus-visible { outline: none; border-radius: 0; } | 30 | .ch-symbol-input:focus-visible { outline: none; border-radius: 0; } |
| 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; } | 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 | .ch-symbol:focus-within .ch-symbol-current { display: none; } | 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 | @media (max-width: 960px) { | 162 | @media (max-width: 960px) { |
| 163 | .ch-toolbar { height: 52px; gap: 2px; padding: 0 6px; } | 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 | .ch-table { width: 100%; max-width: none; position: absolute; inset: 0; z-index: 4; } | 166 | .ch-table { width: 100%; max-width: none; position: absolute; inset: 0; z-index: 4; } |
| 165 | .ch-body { position: relative; } | 167 | .ch-body { position: relative; } |
| 166 | .ch-tool { width: 40px; height: 40px; } | 168 | .ch-tool { width: 40px; height: 40px; } |
| 167 | 169 | ||