Merge pull request #11 from tiennm99/fix/trading-kbs-endpoint

fix(trading): swap dead TCBS endpoint for KBS data_day
This commit is contained in:
2026-04-21 14:39:30 +07:00
committed by GitHub
7 changed files with 63 additions and 39 deletions
+7 -7
View File
@@ -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:<TICKER>`) → 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
```
+21 -9
View File
@@ -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<number|null>}
*/
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 */
+1 -1
View File
@@ -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.
*/
+8 -12
View File
@@ -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<ResolvedSymbol|null>} null if not found on TCBS
* @returns {Promise<ResolvedSymbol|null>} 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
+12 -5
View File
@@ -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);
+6 -2
View File
@@ -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")) {
+8 -3
View File
@@ -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" });