mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-18 08:25:12 +00:00
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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user