From 7f4779973302bbb365f135104d19db0d3f4ce628 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 21 Apr 2026 13:33:08 +0700 Subject: [PATCH] fix(trading): swap dead TCBS endpoint for KBS data_day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TCBS `apipubaws.tcbs.com.vn` host returns HTTP 404/500 for every request, so every ticker resolved as "Unknown stock ticker" and /trade_buy was unusable. Switch price + symbol resolution to the KBS public endpoint that vnstock currently defaults to (`kbbuddywts.kbsec.com.vn/iis-server/ investment/stocks/{TICKER}/data_day`). KBS needs no auth, returns JSON, and is Worker-compatible. - `prices.fetchStockPrice` now queries KBS with a 14-day lookback window (covers weekends/holidays) and drops the TCBS-specific ×1000 scaling; KBS returns real VND. - `symbols.resolveSymbol` delegates to `fetchStockPrice` for existence checks — empty `data_day` means unknown ticker. - Update test fetch stubs to match the `kbsec` host and KBS response shape (`{ symbol, data_day: [{ c }] }`). --- src/modules/trading/README.md | 14 ++++++------ src/modules/trading/prices.js | 30 ++++++++++++++++++-------- src/modules/trading/stats-handler.js | 2 +- src/modules/trading/symbols.js | 20 +++++++---------- tests/modules/trading/handlers.test.js | 17 ++++++++++----- tests/modules/trading/history.test.js | 8 +++++-- tests/modules/trading/symbols.test.js | 11 +++++++--- 7 files changed, 63 insertions(+), 39 deletions(-) diff --git a/src/modules/trading/README.md b/src/modules/trading/README.md index 391b749..5e9c4a2 100644 --- a/src/modules/trading/README.md +++ b/src/modules/trading/README.md @@ -17,10 +17,10 @@ Paper-trading system where each Telegram user manages a virtual portfolio. Curre Symbols are **resolved dynamically** — no hardcoded registry. When a user buys a ticker: 1. Check KV cache (`sym:`) → if cached, use it -2. Query TCBS API to verify the ticker exists and has price data +2. Query KBS API to verify the ticker exists and has price data 3. Cache the resolution permanently in KV -Any valid VN stock ticker on TCBS "just works" without code changes. +Any valid VN stock ticker listed on KBS "just works" without code changes. ## Database @@ -53,25 +53,25 @@ KV namespace prefix: `trading:` { "symbol": "TCB", "category": "stock", "label": "TCB" } ``` -Cached permanently after first successful TCBS lookup. +Cached permanently after first successful KBS lookup. ## Price Source | API | Purpose | Auth | |-----|---------|------| -| TCBS `/stock-insight/v1/stock/bars-long-term` | VN stock close price (× 1000) | None | +| KBS `/iis-server/investment/stocks/{TICKER}/data_day` | VN stock daily close (VND, unscaled) | None | -Prices are fetched on demand per symbol (not batch-cached), since any ticker can be queried dynamically. +Prices are fetched on demand per symbol (not batch-cached), since any ticker can be queried dynamically. KBS returns a multi-day OHLCV window; we take the latest bar's close. ## File Layout ``` src/modules/trading/ ├── index.js — module entry, wires handlers to commands -├── symbols.js — dynamic symbol resolution via TCBS + KV cache +├── symbols.js — dynamic symbol resolution via KBS + KV cache ├── format.js — VND/stock number formatters ├── portfolio.js — per-user KV read/write, flat assets map -├── prices.js — TCBS stock price fetch + BIDV forex (for future use) +├── prices.js — KBS stock price fetch + BIDV forex (for future use) ├── handlers.js — topup/buy/sell/convert handlers └── stats-handler.js — stats/P&L breakdown handler ``` diff --git a/src/modules/trading/prices.js b/src/modules/trading/prices.js index 8296f3f..1248f31 100644 --- a/src/modules/trading/prices.js +++ b/src/modules/trading/prices.js @@ -1,27 +1,39 @@ /** - * @file Price fetching — TCBS (VN stocks) + BIDV (forex). + * @file Price fetching — KBS (VN stocks) + BIDV (forex). * Single-stock price fetch on demand. Forex rates cached for 60s. */ const FOREX_CACHE_KEY = "forex:latest"; const CACHE_TTL_MS = 60_000; const STALE_LIMIT_MS = 300_000; +const KBS_LOOKBACK_DAYS = 14; // window wide enough to cover weekends & holidays + +function kbsDate(d) { + const dd = String(d.getUTCDate()).padStart(2, "0"); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const yyyy = d.getUTCFullYear(); + return `${dd}-${mm}-${yyyy}`; +} /** - * Fetch current VND price for a VN stock ticker via TCBS. - * Returns close price * 1000 (TCBS convention). + * Fetch current VND price for a VN stock ticker via KBS. + * Returns the most recent daily close (already in VND — no scaling). + * Also doubles as ticker validation: null means the symbol has no KBS data. * @param {string} ticker — uppercase, e.g. "TCB" * @returns {Promise} */ export async function fetchStockPrice(ticker) { - const to = Math.floor(Date.now() / 1000); - const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${encodeURIComponent(ticker)}&type=stock&resolution=D&countBack=1&to=${to}`; - const res = await fetch(url); + const now = new Date(); + const edate = kbsDate(now); + const sdate = kbsDate(new Date(now.getTime() - KBS_LOOKBACK_DAYS * 86_400_000)); + const url = `https://kbbuddywts.kbsec.com.vn/iis-server/investment/stocks/${encodeURIComponent(ticker)}/data_day?sdate=${sdate}&edate=${edate}`; + const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } }); if (!res.ok) return null; const json = await res.json(); - const close = json?.data?.[0]?.close; - if (close == null) return null; - return close * 1000; + const bars = json?.data_day; + if (!Array.isArray(bars) || bars.length === 0) return null; + const close = bars[0]?.c; + return typeof close === "number" && Number.isFinite(close) ? close : null; } /** Forex rates via BIDV public API — returns real buy/sell rates */ diff --git a/src/modules/trading/stats-handler.js b/src/modules/trading/stats-handler.js index 8db8a06..b93b658 100644 --- a/src/modules/trading/stats-handler.js +++ b/src/modules/trading/stats-handler.js @@ -3,7 +3,7 @@ * * Price fetches are issued in parallel with Promise.allSettled so a portfolio * holding N stocks only waits for the slowest fetch, not the sum. Without this - * 10+ symbols would serially stack TCBS latency and can blow Cloudflare's + * 10+ symbols would serially stack KBS latency and can blow Cloudflare's * subrequest budget. */ diff --git a/src/modules/trading/symbols.js b/src/modules/trading/symbols.js index 8735cd0..4f32702 100644 --- a/src/modules/trading/symbols.js +++ b/src/modules/trading/symbols.js @@ -1,9 +1,11 @@ /** - * @file Symbol resolution — dynamically resolves stock tickers via TCBS API. + * @file Symbol resolution — dynamically resolves stock tickers via KBS. * Resolved symbols are cached in KV permanently to avoid repeated lookups. * Currently only supports VN stocks. Crypto, gold, forex coming later. */ +import { fetchStockPrice } from "./prices.js"; + const COMING_SOON = "Crypto, gold & currency exchange coming soon!"; /** @@ -14,28 +16,22 @@ const COMING_SOON = "Crypto, gold & currency exchange coming soon!"; */ /** - * Resolve a ticker to a symbol entry. Checks KV cache first, then queries TCBS. + * Resolve a ticker to a symbol entry. Checks KV cache first, then queries KBS. + * Validation reuses the KBS price endpoint — if it returns a bar, the ticker is real. * @param {import("../../db/kv-store-interface.js").KVStore} db * @param {string} ticker — user input, case-insensitive - * @returns {Promise} null if not found on TCBS + * @returns {Promise} null if KBS has no data for this ticker */ export async function resolveSymbol(db, ticker) { if (!ticker) return null; const symbol = ticker.toUpperCase(); const cacheKey = `sym:${symbol}`; - // check KV cache const cached = await db.getJSON(cacheKey); if (cached) return cached; - // query TCBS to verify this is a real VN stock - const to = Math.floor(Date.now() / 1000); - const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${encodeURIComponent(symbol)}&type=stock&resolution=D&countBack=1&to=${to}`; - const res = await fetch(url); - if (!res.ok) return null; - const json = await res.json(); - const close = json?.data?.[0]?.close; - if (close == null) return null; + const price = await fetchStockPrice(symbol); + if (price == null) return null; const entry = { symbol, category: "stock", label: symbol }; // cache permanently — stock tickers don't change diff --git a/tests/modules/trading/handlers.test.js b/tests/modules/trading/handlers.test.js index 06b791d..5579dd4 100644 --- a/tests/modules/trading/handlers.test.js +++ b/tests/modules/trading/handlers.test.js @@ -20,13 +20,17 @@ function makeCtx(match = "", userId = 42) { }; } -/** Stub fetch — TCBS returns stock data, BIDV returns forex */ +/** Stub fetch — KBS returns stock data, BIDV returns forex */ function stubFetch() { global.fetch = vi.fn((url) => { - if (url.includes("tcbs")) { + if (url.includes("kbsec")) { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ data: [{ close: 25 }] }), + json: () => + Promise.resolve({ + symbol: "TCB", + data_day: [{ t: "2026-04-21 07:00", c: 25000 }], + }), }); } if (url.includes("bidv")) { @@ -105,9 +109,12 @@ describe("trading/handlers", () => { }); it("rejects unknown ticker", async () => { - // stub TCBS to return empty data for unknown ticker + // stub KBS to return empty data_day for unknown ticker global.fetch = vi.fn(() => - Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [] }) }), + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ symbol: "NOPE", data_day: [] }), + }), ); const ctx = makeCtx("10 NOPE"); await handleBuy(ctx, db); diff --git a/tests/modules/trading/history.test.js b/tests/modules/trading/history.test.js index af21db0..e524151 100644 --- a/tests/modules/trading/history.test.js +++ b/tests/modules/trading/history.test.js @@ -239,10 +239,14 @@ describe("buy/sell handlers → recordTrade integration", () => { function stubFetch() { global.fetch = vi.fn((url) => { - if (url.includes("tcbs")) { + if (url.includes("kbsec")) { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ data: [{ close: 25 }] }), + json: () => + Promise.resolve({ + symbol: "TCB", + data_day: [{ t: "2026-04-21 07:00", c: 25000 }], + }), }); } if (url.includes("bidv")) { diff --git a/tests/modules/trading/symbols.test.js b/tests/modules/trading/symbols.test.js index 08edece..a8bcf61 100644 --- a/tests/modules/trading/symbols.test.js +++ b/tests/modules/trading/symbols.test.js @@ -3,12 +3,17 @@ import { createStore } from "../../../src/db/create-store.js"; import { comingSoonMessage, resolveSymbol } from "../../../src/modules/trading/symbols.js"; import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; -/** Stub global.fetch to return TCBS-like response */ +/** Stub global.fetch to return KBS-like response */ function stubFetch(hasData = true) { global.fetch = vi.fn(() => Promise.resolve({ ok: true, - json: () => Promise.resolve(hasData ? { data: [{ close: 25 }] } : { data: [] }), + json: () => + Promise.resolve( + hasData + ? { symbol: "TCB", data_day: [{ t: "2026-04-21 07:00", c: 25000 }] } + : { symbol: "NOPE", data_day: [] }, + ), }), ); } @@ -21,7 +26,7 @@ describe("trading/symbols", () => { vi.restoreAllMocks(); }); - it("resolves a valid VN stock ticker via TCBS", async () => { + it("resolves a valid VN stock ticker via KBS", async () => { stubFetch(); const result = await resolveSymbol(db, "TCB"); expect(result).toEqual({ symbol: "TCB", category: "stock", label: "TCB" });