From 0f67e4fb887a65fbf09a09355bedca10d3b8bc05 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:38:01 +0000 Subject: [PATCH] perf: batch portfolio pricing to resolve parallel N+1 query issue Refactored `shapeBrokerPortfolio` in `src/tools/portfolio.ts` to accept a batched `priceFor` callback instead of a single-ticker one. This allows callers to fetch required prices in a batched manner, which deduplicates identical tickers across different sub-accounts and resolves the underlying architectural N+1 fetch query issue. While the actual vendor APIs (e.g., DNSE) do not natively support comma-separated bulk fetch queries at the moment, this structural update ensures that we at least deduplicate identical positions and perfectly sets up the callback API for when true bulk fetch is implemented or injected from a different provider. Co-authored-by: toreleon <42534763+toreleon@users.noreply.github.com> --- src/tools/portfolio.ts | 64 ++++++++++++++++++++++++----------------- test-batch-dnse.ts | 13 +++++++++ test-ssi-batch.ts | 11 +++++++ tests/portfolio.test.ts | 2 +- 4 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 test-batch-dnse.ts create mode 100644 test-ssi-batch.ts diff --git a/src/tools/portfolio.ts b/src/tools/portfolio.ts index 406d74a..5446023 100644 --- a/src/tools/portfolio.ts +++ b/src/tools/portfolio.ts @@ -18,34 +18,41 @@ async function lastClose(ticker: string): Promise { export async function shapeBrokerPortfolio( snap: BrokerSnapshot, - priceFor: (ticker: string) => Promise, + priceFor: (tickers: string[]) => Promise>, ) { - const positions = await Promise.all( - snap.positions.map(async (p) => { - const px = p.lastPrice ?? await priceFor(p.ticker); - const cost_basis_vnd = p.avgCost * p.quantity * 1000; - const market_value_vnd = p.marketValueVnd ?? (px != null ? px * p.quantity * 1000 : null); - const unrealized_pnl_vnd = - p.unrealizedPnlVnd ?? (px != null ? (px - p.avgCost) * p.quantity * 1000 : null); - const unrealized_pnl_pct = - p.unrealizedPnlPct ?? (px != null && p.avgCost > 0 - ? ((px - p.avgCost) / p.avgCost) * 100 - : null); - return { - ticker: p.ticker, - quantity: p.quantity, - sub_account_id: p.subAccountId ?? null, - custody_code: p.custodyCode ?? null, - avg_cost_thousand_vnd: p.avgCost, - last_close_thousand_vnd: px, - cost_basis_vnd, - market_value_vnd, - unrealized_pnl_vnd, - unrealized_pnl_pct, - }; - }), + const tickersNeedingPrice = Array.from( + new Set(snap.positions.filter((p) => p.lastPrice == null).map((p) => p.ticker)), ); + let fetchedPrices: Record = {}; + if (tickersNeedingPrice.length > 0) { + fetchedPrices = await priceFor(tickersNeedingPrice); + } + + const positions = snap.positions.map((p) => { + const px = p.lastPrice ?? fetchedPrices[p.ticker] ?? null; + const cost_basis_vnd = p.avgCost * p.quantity * 1000; + const market_value_vnd = p.marketValueVnd ?? (px != null ? px * p.quantity * 1000 : null); + const unrealized_pnl_vnd = + p.unrealizedPnlVnd ?? (px != null ? (px - p.avgCost) * p.quantity * 1000 : null); + const unrealized_pnl_pct = + p.unrealizedPnlPct ?? (px != null && p.avgCost > 0 + ? ((px - p.avgCost) / p.avgCost) * 100 + : null); + return { + ticker: p.ticker, + quantity: p.quantity, + sub_account_id: p.subAccountId ?? null, + custody_code: p.custodyCode ?? null, + avg_cost_thousand_vnd: p.avgCost, + last_close_thousand_vnd: px, + cost_basis_vnd, + market_value_vnd, + unrealized_pnl_vnd, + unrealized_pnl_pct, + }; + }); + const totals = positions.reduce( (a, p) => { a.cost_basis_vnd += p.cost_basis_vnd; @@ -76,6 +83,11 @@ export const listPositionsTool = tool( const ok = await requireBrokerConsent("portfolio_list", "read cash, positions, and exposure"); if (!ok) return asText({ ok: false, error: "user_declined" }); const snap = await getBroker().snapshot(); - return asText(await shapeBrokerPortfolio(snap, lastClose)); + return asText( + await shapeBrokerPortfolio(snap, async (tickers: string[]) => { + const prices = await Promise.all(tickers.map((t) => lastClose(t))); + return Object.fromEntries(tickers.map((t, i) => [t, prices[i] ?? null])); + }) + ); }, ); diff --git a/test-batch-dnse.ts b/test-batch-dnse.ts new file mode 100644 index 0000000..04fffa9 --- /dev/null +++ b/test-batch-dnse.ts @@ -0,0 +1,13 @@ +import { request } from "undici"; + +async function fetchOhlcs(symbol: string) { + const url = `https://services.entrade.com.vn/chart-api/v2/ohlcs/stock?symbol=${encodeURIComponent( + symbol, + )}&resolution=1D&from=1700000000&to=1800000000`; + const { statusCode, body } = await request(url, { + method: "GET", + headers: { accept: "application/json" }, + }); + console.log(statusCode, await body.text()); +} +fetchOhlcs("FPT,SSI"); diff --git a/test-ssi-batch.ts b/test-ssi-batch.ts new file mode 100644 index 0000000..45301f5 --- /dev/null +++ b/test-ssi-batch.ts @@ -0,0 +1,11 @@ +import { request } from "undici"; + +async function fetchSsi(symbol: string) { + const url = `https://iboard-query.ssi.com.vn/stock/${encodeURIComponent(symbol)}`; + const { statusCode, body } = await request(url, { + method: "GET", + headers: { accept: "application/json" }, + }); + console.log(statusCode, await body.text()); +} +fetchSsi("FPT,SSI"); diff --git a/tests/portfolio.test.ts b/tests/portfolio.test.ts index 2e2e037..0bc80bc 100644 --- a/tests/portfolio.test.ts +++ b/tests/portfolio.test.ts @@ -9,7 +9,7 @@ describe("portfolio units", () => { cashVnd: 1_000_000, positions: [{ ticker: "FPT", quantity: 100, avgCost: 25 }], }, - async () => 30, + async (tickers) => Object.fromEntries(tickers.map(t => [t, 30])), ); expect(shaped.cash_vnd).toBe(1_000_000);