From 9f1916209da05b4eb7900399960c9ad66ea4db56 Mon Sep 17 00:00:00 2001 From: Roberto Date: Tue, 5 May 2026 14:42:21 -0300 Subject: [PATCH 1/5] Address post-merge chart review feedback --- src/findata/web/static/chart-explorer.js | 61 +++++++++++++++++------- tests/test_api.py | 4 ++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/findata/web/static/chart-explorer.js b/src/findata/web/static/chart-explorer.js index e7679e0..4061390 100644 --- a/src/findata/web/static/chart-explorer.js +++ b/src/findata/web/static/chart-explorer.js @@ -198,25 +198,38 @@ return Math.floor(date.getTime() / 1000); }; + const parseCompactPeriod = (text) => { + let match = text.match(/^(\d{4})(\d{2})(\d{2})$/); + if (match) return `${match[1]}-${match[2]}-${match[3]}`; + + match = text.match(/^(\d{4})(\d{2})$/); + if (match) return `${match[1]}-${match[2]}-01`; + + return null; + }; + + const parseUnixTimestamp = (text) => { + if (!/^\d{10,13}$/.test(text)) return null; + const timestamp = Number(text); + const date = new Date(timestamp > 1e11 ? timestamp : timestamp * 1000); + return timestampFromDate(date); + }; + const parseTime = (value) => { + const text = String(value).trim(); + const compactPeriod = parseCompactPeriod(text); + if (compactPeriod) return compactPeriod; + if (typeof value === "number") { - const date = new Date(value > 1e11 ? value : value * 1000); - return timestampFromDate(date); + return parseUnixTimestamp(text); } if (typeof value !== "string") return null; - const text = String(value).trim(); - if (/^\d{10,13}$/.test(text)) { - const timestamp = Number(text); - const date = new Date(timestamp > 1e11 ? timestamp : timestamp * 1000); - return timestampFromDate(date); - } + const unixTimestamp = parseUnixTimestamp(text); + if (unixTimestamp) return unixTimestamp; let match = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); if (match) return `${match[3]}-${match[2]}-${match[1]}`; - match = text.match(/^(\d{4})(\d{2})$/); - if (match) return `${match[1]}-${match[2]}-01`; - match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (match) return `${match[1]}-${match[2]}-${match[3]}`; @@ -252,6 +265,22 @@ const hasOhlc = (record) => ["open", "high", "low", "close"].every((key) => record[key] !== undefined); + const timeSortValue = (time) => { + if (typeof time === "number") return time; + return timestampFromDate(new Date(`${time}T00:00:00Z`)) ?? 0; + }; + + const normalizeMixedTimes = (data) => { + const hasIntraday = data.some((point) => typeof point.time === "number"); + if (!hasIntraday) return { data, hasIntraday }; + return { + hasIntraday, + data: data.map((point) => ( + typeof point.time === "number" ? point : { ...point, time: timeSortValue(point.time) } + )), + }; + }; + const normalizeData = (payload, options) => { const records = recordsFrom(payload); if (!records || !records.length) { @@ -288,12 +317,8 @@ if (value !== null) deduped.set(time, { time, value }); } - const data = Array.from(deduped.values()).sort((a, b) => { - if (typeof a.time === "number" && typeof b.time === "number") { - return a.time - b.time; - } - return String(a.time).localeCompare(String(b.time)); - }); + const normalizedTime = normalizeMixedTimes(Array.from(deduped.values())); + const data = normalizedTime.data.sort((a, b) => timeSortValue(a.time) - timeSortValue(b.time)); if (!data.length) throw new Error("Nenhum ponto com data e valor numérico foi encontrado."); if (data.length > MAX_POINTS) { throw new Error(`Endpoint retornou ${data.length} pontos; use um recorte menor que ${MAX_POINTS}.`); @@ -304,7 +329,7 @@ kind: shouldUseCandles ? "candlestick" : "line", valueKey, dateKey, - hasIntraday: data.some((point) => typeof point.time === "number"), + hasIntraday: normalizedTime.hasIntraday, }; }; diff --git a/tests/test_api.py b/tests/test_api.py index 5cd1673..cf72892 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -116,6 +116,10 @@ def test_chart_explorer_asset(client: TestClient) -> None: assert "/tesouro/bonds/history" not in r.text assert 'options.type === "candlestick" || (!options.field && hasOhlc(firstRecord))' in r.text assert "timestampFromDate" in r.text + assert "parseCompactPeriod" in r.text + assert "parseUnixTimestamp" in r.text + assert "normalizeMixedTimes" in r.text + assert "timeSortValue(a.time) - timeSortValue(b.time)" in r.text assert "timeVisible: normalized.hasIntraday" in r.text assert "Yahoo Finance" not in r.text From 713d6ce2cf5c82fbeac0fbea59aa4c4e168eca25 Mon Sep 17 00:00:00 2001 From: Roberto Date: Tue, 5 May 2026 14:45:13 -0300 Subject: [PATCH 2/5] Optimize chart time sorting --- src/findata/web/static/chart-explorer.js | 4 +++- tests/test_api.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/findata/web/static/chart-explorer.js b/src/findata/web/static/chart-explorer.js index 4061390..00d9da7 100644 --- a/src/findata/web/static/chart-explorer.js +++ b/src/findata/web/static/chart-explorer.js @@ -318,7 +318,9 @@ } const normalizedTime = normalizeMixedTimes(Array.from(deduped.values())); - const data = normalizedTime.data.sort((a, b) => timeSortValue(a.time) - timeSortValue(b.time)); + const data = normalizedTime.data.sort((a, b) => ( + normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time) + )); if (!data.length) throw new Error("Nenhum ponto com data e valor numérico foi encontrado."); if (data.length > MAX_POINTS) { throw new Error(`Endpoint retornou ${data.length} pontos; use um recorte menor que ${MAX_POINTS}.`); diff --git a/tests/test_api.py b/tests/test_api.py index cf72892..c72da1d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -119,7 +119,7 @@ def test_chart_explorer_asset(client: TestClient) -> None: assert "parseCompactPeriod" in r.text assert "parseUnixTimestamp" in r.text assert "normalizeMixedTimes" in r.text - assert "timeSortValue(a.time) - timeSortValue(b.time)" in r.text + assert "normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time)" in r.text assert "timeVisible: normalized.hasIntraday" in r.text assert "Yahoo Finance" not in r.text From 2f45f89335d5d16e86f0e42ced521297a50400e6 Mon Sep 17 00:00:00 2001 From: Roberto Date: Tue, 5 May 2026 14:47:57 -0300 Subject: [PATCH 3/5] Handle short Unix timestamps in chart explorer --- src/findata/web/static/chart-explorer.js | 14 +++++++++----- tests/test_api.py | 3 +++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/findata/web/static/chart-explorer.js b/src/findata/web/static/chart-explorer.js index 00d9da7..6963fb1 100644 --- a/src/findata/web/static/chart-explorer.js +++ b/src/findata/web/static/chart-explorer.js @@ -208,10 +208,14 @@ return null; }; - const parseUnixTimestamp = (text) => { - if (!/^\d{10,13}$/.test(text)) return null; + const parseUnixTimestamp = (text, { allowShortSeconds = false } = {}) => { + if (!/^\d+$/.test(text)) return null; + const isSeconds = text.length === 10 || (allowShortSeconds && (text === "0" || text.length <= 9)); + const isMilliseconds = text.length >= 12 && text.length <= 13; + if (!isSeconds && !isMilliseconds) return null; const timestamp = Number(text); - const date = new Date(timestamp > 1e11 ? timestamp : timestamp * 1000); + if (!Number.isSafeInteger(timestamp)) return null; + const date = new Date(isMilliseconds ? timestamp : timestamp * 1000); return timestampFromDate(date); }; @@ -221,11 +225,11 @@ if (compactPeriod) return compactPeriod; if (typeof value === "number") { - return parseUnixTimestamp(text); + return parseUnixTimestamp(text, { allowShortSeconds: true }); } if (typeof value !== "string") return null; const unixTimestamp = parseUnixTimestamp(text); - if (unixTimestamp) return unixTimestamp; + if (unixTimestamp !== null) return unixTimestamp; let match = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); if (match) return `${match[3]}-${match[2]}-${match[1]}`; diff --git a/tests/test_api.py b/tests/test_api.py index c72da1d..4b1b526 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -118,6 +118,9 @@ def test_chart_explorer_asset(client: TestClient) -> None: assert "timestampFromDate" in r.text assert "parseCompactPeriod" in r.text assert "parseUnixTimestamp" in r.text + assert "allowShortSeconds" in r.text + assert "parseUnixTimestamp(text, { allowShortSeconds: true })" in r.text + assert "unixTimestamp !== null" in r.text assert "normalizeMixedTimes" in r.text assert "normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time)" in r.text assert "timeVisible: normalized.hasIntraday" in r.text From faca4e0158a6b63a5f8703d6bc58ffb1ed382641 Mon Sep 17 00:00:00 2001 From: Roberto Date: Tue, 5 May 2026 15:58:18 -0300 Subject: [PATCH 4/5] Validate compact chart periods --- src/findata/web/static/chart-explorer.js | 15 +++++++++++++-- tests/test_api.py | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/findata/web/static/chart-explorer.js b/src/findata/web/static/chart-explorer.js index 6963fb1..e96f299 100644 --- a/src/findata/web/static/chart-explorer.js +++ b/src/findata/web/static/chart-explorer.js @@ -198,12 +198,23 @@ return Math.floor(date.getTime() / 1000); }; + const isValidDateParts = (year, month, day) => { + const y = Number(year); + const m = Number(month); + const d = Number(day); + if (y < 1900 || y > 2200 || m < 1 || m > 12 || d < 1 || d > 31) return false; + const date = new Date(Date.UTC(y, m - 1, d)); + return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d; + }; + const parseCompactPeriod = (text) => { let match = text.match(/^(\d{4})(\d{2})(\d{2})$/); - if (match) return `${match[1]}-${match[2]}-${match[3]}`; + if (match && isValidDateParts(match[1], match[2], match[3])) { + return `${match[1]}-${match[2]}-${match[3]}`; + } match = text.match(/^(\d{4})(\d{2})$/); - if (match) return `${match[1]}-${match[2]}-01`; + if (match && isValidDateParts(match[1], match[2], "01")) return `${match[1]}-${match[2]}-01`; return null; }; diff --git a/tests/test_api.py b/tests/test_api.py index 4b1b526..1c43d0d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -116,6 +116,7 @@ def test_chart_explorer_asset(client: TestClient) -> None: assert "/tesouro/bonds/history" not in r.text assert 'options.type === "candlestick" || (!options.field && hasOhlc(firstRecord))' in r.text assert "timestampFromDate" in r.text + assert "isValidDateParts" in r.text assert "parseCompactPeriod" in r.text assert "parseUnixTimestamp" in r.text assert "allowShortSeconds" in r.text From d90d99d6c0dca1c0bec78496185310518bef1165 Mon Sep 17 00:00:00 2001 From: Roberto Date: Tue, 5 May 2026 16:06:46 -0300 Subject: [PATCH 5/5] Normalize chart times before dedupe --- src/findata/web/static/chart-explorer.js | 29 +++++++++++++++++------- tests/test_api.py | 2 ++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/findata/web/static/chart-explorer.js b/src/findata/web/static/chart-explorer.js index e96f299..a280936 100644 --- a/src/findata/web/static/chart-explorer.js +++ b/src/findata/web/static/chart-explorer.js @@ -243,13 +243,13 @@ if (unixTimestamp !== null) return unixTimestamp; let match = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); - if (match) return `${match[3]}-${match[2]}-${match[1]}`; + if (match && isValidDateParts(match[3], match[2], match[1])) return `${match[3]}-${match[2]}-${match[1]}`; match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (match) return `${match[1]}-${match[2]}-${match[3]}`; + if (match && isValidDateParts(match[1], match[2], match[3])) return `${match[1]}-${match[2]}-${match[3]}`; match = text.match(/^(\d{4})-(\d{2})-(\d{2})T00:00:00/); - if (match) return `${match[1]}-${match[2]}-${match[3]}`; + if (match && isValidDateParts(match[1], match[2], match[3])) return `${match[1]}-${match[2]}-${match[3]}`; const parsed = new Date(text); return timestampFromDate(parsed); @@ -282,17 +282,30 @@ const timeSortValue = (time) => { if (typeof time === "number") return time; - return timestampFromDate(new Date(`${time}T00:00:00Z`)) ?? 0; + return timestampFromDate(new Date(`${time}T00:00:00Z`)); + }; + + const dedupeByTime = (data) => { + const deduped = new Map(); + for (const point of data) deduped.set(point.time, point); + return Array.from(deduped.values()); }; const normalizeMixedTimes = (data) => { const hasIntraday = data.some((point) => typeof point.time === "number"); if (!hasIntraday) return { data, hasIntraday }; + const normalizedData = []; + for (const point of data) { + if (typeof point.time === "number") { + normalizedData.push(point); + continue; + } + const time = timeSortValue(point.time); + if (time !== null) normalizedData.push({ ...point, time }); + } return { hasIntraday, - data: data.map((point) => ( - typeof point.time === "number" ? point : { ...point, time: timeSortValue(point.time) } - )), + data: normalizedData, }; }; @@ -333,7 +346,7 @@ } const normalizedTime = normalizeMixedTimes(Array.from(deduped.values())); - const data = normalizedTime.data.sort((a, b) => ( + const data = dedupeByTime(normalizedTime.data).sort((a, b) => ( normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time) )); if (!data.length) throw new Error("Nenhum ponto com data e valor numérico foi encontrado."); diff --git a/tests/test_api.py b/tests/test_api.py index 1c43d0d..fe0ff40 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -122,7 +122,9 @@ def test_chart_explorer_asset(client: TestClient) -> None: assert "allowShortSeconds" in r.text assert "parseUnixTimestamp(text, { allowShortSeconds: true })" in r.text assert "unixTimestamp !== null" in r.text + assert "dedupeByTime(normalizedTime.data)" in r.text assert "normalizeMixedTimes" in r.text + assert "if (time !== null)" in r.text assert "normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time)" in r.text assert "timeVisible: normalized.hasIntraday" in r.text assert "Yahoo Finance" not in r.text