From c9270764f22a549b490793cc1d83563fec9255db Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 14 Apr 2026 15:16:53 +0700 Subject: [PATCH] feat: add fake trading module with crypto, stocks, forex and gold Paper trading system with 5 commands (trade_topup, trade_buy, trade_sell, trade_convert, trade_stats). Supports VN stocks via TCBS, crypto via CoinGecko, forex via ER-API, and gold via PAX Gold proxy. Per-user portfolio stored in KV with 60s price caching. 54 new tests. --- .../phase-01-symbols-and-format.md | 102 +++++++++ .../phase-02-prices.md | 120 ++++++++++ .../phase-03-portfolio.md | 90 ++++++++ .../phase-04-commands.md | 134 +++++++++++ .../phase-05-wiring.md | 67 ++++++ .../phase-06-tests.md | 140 ++++++++++++ plans/260414-1457-trading-module/plan.md | 56 +++++ src/modules/index.js | 1 + src/modules/trading/format.js | 80 +++++++ src/modules/trading/handlers.js | 164 +++++++++++++ src/modules/trading/index.js | 52 +++++ src/modules/trading/portfolio.js | 107 +++++++++ src/modules/trading/prices.js | 131 +++++++++++ src/modules/trading/stats-handler.js | 63 +++++ src/modules/trading/symbols.js | 53 +++++ tests/modules/trading/format.test.js | 79 +++++++ tests/modules/trading/handlers.test.js | 216 ++++++++++++++++++ tests/modules/trading/portfolio.test.js | 139 +++++++++++ tests/modules/trading/symbols.test.js | 46 ++++ wrangler.toml | 2 +- 20 files changed, 1841 insertions(+), 1 deletion(-) create mode 100644 plans/260414-1457-trading-module/phase-01-symbols-and-format.md create mode 100644 plans/260414-1457-trading-module/phase-02-prices.md create mode 100644 plans/260414-1457-trading-module/phase-03-portfolio.md create mode 100644 plans/260414-1457-trading-module/phase-04-commands.md create mode 100644 plans/260414-1457-trading-module/phase-05-wiring.md create mode 100644 plans/260414-1457-trading-module/phase-06-tests.md create mode 100644 plans/260414-1457-trading-module/plan.md create mode 100644 src/modules/trading/format.js create mode 100644 src/modules/trading/handlers.js create mode 100644 src/modules/trading/index.js create mode 100644 src/modules/trading/portfolio.js create mode 100644 src/modules/trading/prices.js create mode 100644 src/modules/trading/stats-handler.js create mode 100644 src/modules/trading/symbols.js create mode 100644 tests/modules/trading/format.test.js create mode 100644 tests/modules/trading/handlers.test.js create mode 100644 tests/modules/trading/portfolio.test.js create mode 100644 tests/modules/trading/symbols.test.js diff --git a/plans/260414-1457-trading-module/phase-01-symbols-and-format.md b/plans/260414-1457-trading-module/phase-01-symbols-and-format.md new file mode 100644 index 0000000..6b19056 --- /dev/null +++ b/plans/260414-1457-trading-module/phase-01-symbols-and-format.md @@ -0,0 +1,102 @@ +--- +phase: 1 +title: "Symbol Registry + Formatters" +status: Pending +priority: P2 +effort: 45m +--- + +# Phase 1: Symbol Registry + Formatters + +## Context + +- [Module pattern](../../docs/adding-a-module.md) +- [KV interface](../../src/db/kv-store-interface.js) + +## Overview + +Two pure-data/pure-function files with zero side effects. Foundation for all other phases. + +## File: `src/modules/trading/symbols.js` + +### Requirements + +- Export `SYMBOLS` — frozen object keyed by uppercase symbol name +- Export `CURRENCIES` — frozen Set of supported fiat: `VND`, `USD` +- Export helper `getSymbol(name)` — case-insensitive lookup, returns entry or `undefined` +- Export helper `listSymbols()` — returns formatted string of all symbols grouped by category + +### Data shape + +```js +export const SYMBOLS = Object.freeze({ + // crypto + BTC: { category: "crypto", apiId: "bitcoin", label: "Bitcoin" }, + ETH: { category: "crypto", apiId: "ethereum", label: "Ethereum" }, + SOL: { category: "crypto", apiId: "solana", label: "Solana" }, + // stock (Vietnam) + TCB: { category: "stock", apiId: "TCB", label: "Techcombank" }, + VPB: { category: "stock", apiId: "VPB", label: "VPBank" }, + FPT: { category: "stock", apiId: "FPT", label: "FPT Corp" }, + VNM: { category: "stock", apiId: "VNM", label: "Vinamilk" }, + HPG: { category: "stock", apiId: "HPG", label: "Hoa Phat" }, + // others + GOLD: { category: "others", apiId: "pax-gold", label: "Gold (troy oz)" }, +}); +``` + +### Implementation steps + +1. Create `src/modules/trading/symbols.js` +2. Define `SYMBOLS` constant with all entries above +3. Define `CURRENCIES = Object.freeze(new Set(["VND", "USD"]))` +4. `getSymbol(name)` — `SYMBOLS[name.toUpperCase()]` with guard for falsy input +5. `listSymbols()` — group by category, format as `SYMBOL — Label` per line +6. Keep under 60 lines + +--- + +## File: `src/modules/trading/format.js` + +### Requirements + +- `formatVND(n)` — integer, dot thousands separator, suffix ` VND`. Example: `15.000.000 VND` +- `formatUSD(n)` — 2 decimals, comma thousands, prefix `$`. Example: `$1,234.56` +- `formatCrypto(n)` — up to 8 decimals, strip trailing zeros. Example: `0.00125` +- `formatStock(n)` — integer (Math.floor), no decimals. Example: `150` +- `formatAmount(n, symbol)` — dispatcher: looks up symbol category, calls correct formatter +- `formatCurrency(n, currency)` — VND or USD formatter based on currency string + +### Implementation steps + +1. Create `src/modules/trading/format.js` +2. `formatVND`: `Math.round(n).toLocaleString("vi-VN")` + ` VND` — verify dot separator (or manual impl for CF Workers locale support) +3. `formatUSD`: `n.toFixed(2)` with comma grouping + `$` prefix +4. `formatCrypto`: `parseFloat(n.toFixed(8)).toString()` to strip trailing zeros +5. `formatStock`: `Math.floor(n).toString()` +6. `formatAmount`: switch on `getSymbol(sym).category` +7. `formatCurrency`: switch on currency string +8. Keep under 80 lines + +### Edge cases + +- `formatVND(0)` -> `0 VND` +- `formatCrypto(1.00000000)` -> `1` +- `formatAmount` with unknown symbol -> return raw number string +- CF Workers may not have full locale support — implement manual dot-separator for VND + +### Failure modes + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `toLocaleString` not available in CF Workers runtime | Medium | Medium | Manual formatter fallback: split on groups of 3, join with `.` | + +## Success criteria + +- [ ] `SYMBOLS` has 9 entries across 3 categories +- [ ] `getSymbol("btc")` returns BTC entry (case-insensitive) +- [ ] `getSymbol("NOPE")` returns `undefined` +- [ ] `formatVND(15000000)` === `"15.000.000 VND"` +- [ ] `formatCrypto(0.001)` === `"0.001"` (no trailing zeros) +- [ ] `formatStock(1.7)` === `"1"` +- [ ] Both files under 200 lines diff --git a/plans/260414-1457-trading-module/phase-02-prices.md b/plans/260414-1457-trading-module/phase-02-prices.md new file mode 100644 index 0000000..b0427e6 --- /dev/null +++ b/plans/260414-1457-trading-module/phase-02-prices.md @@ -0,0 +1,120 @@ +--- +phase: 2 +title: "Price Fetching + Caching" +status: Pending +priority: P2 +effort: 1h +--- + +# Phase 2: Price Fetching + Caching + +## Context + +- [KV store interface](../../src/db/kv-store-interface.js) — `putJSON` supports `expirationTtl` +- [Symbol registry](phase-01-symbols-and-format.md) + +## Overview + +Fetches live prices from three free APIs, merges into a single cache object in KV with 60s TTL. All prices normalized to VND. + +## File: `src/modules/trading/prices.js` + +### Data shape — KV key `prices:latest` + +```js +{ + ts: 1713100000000, // Date.now() at fetch time + crypto: { BTC: 2500000000, ETH: 75000000, SOL: 3500000 }, + stock: { TCB: 25000, VPB: 18000, FPT: 120000, VNM: 70000, HPG: 28000 }, + forex: { USD: 25400 }, // 1 USD = 25400 VND + others: { GOLD: 75000000 } // per troy oz in VND +} +``` + +### Exports + +- `fetchPrices(db)` — fetch all APIs in parallel, merge, cache in KV, return merged object +- `getPrices(db)` — cache-first: read KV, if exists and < 60s old return it, else call `fetchPrices` +- `getPrice(db, symbol)` — convenience: calls `getPrices`, looks up by symbol + category +- `getForexRate(db, currency)` — returns VND equivalent of 1 unit of currency + +### API calls + +1. **Crypto + Gold (CoinGecko)** + ``` + GET https://api.coingecko.com/api/v3/simple/price + ?ids=bitcoin,ethereum,solana,pax-gold + &vs_currencies=vnd + ``` + Response: `{ bitcoin: { vnd: N }, ... }` + Map `apiId -> VND price` using SYMBOLS registry. + +2. **Vietnam stocks (TCBS)** + For each stock symbol, fetch: + ``` + GET https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term + ?ticker={SYMBOL}&type=stock&resolution=D&countBack=1&to={unix_seconds} + ``` + Response: `{ data: [{ close: N }] }` — price in VND (already VND, multiply by 1000 for actual price per TCBS convention). + Fetch all 5 stocks in parallel via `Promise.allSettled`. + +3. **Forex (Exchange Rate API)** + ``` + GET https://open.er-api.com/v6/latest/USD + ``` + Response: `{ rates: { VND: N } }` + Store as `forex.USD = rates.VND`. + +### Implementation steps + +1. Create `src/modules/trading/prices.js` +2. Implement `fetchCrypto()` — single CoinGecko call, map apiId->VND +3. Implement `fetchStocks()` — `Promise.allSettled` for all stock symbols, extract `close * 1000` +4. Implement `fetchForex()` — single call, extract VND rate +5. Implement `fetchPrices(db)`: + - `Promise.allSettled([fetchCrypto(), fetchStocks(), fetchForex()])` + - Merge results, set `ts: Date.now()` + - `db.putJSON("prices:latest", merged)` — no expirationTtl (we manage staleness manually) + - Return merged +6. Implement `getPrices(db)`: + - `db.getJSON("prices:latest")` + - If exists and `Date.now() - ts < 60_000`, return cached + - Else call `fetchPrices(db)` +7. Implement `getPrice(db, symbol)`: + - Get symbol info from registry + - Get prices via `getPrices(db)` + - Return `prices[category][symbol]` +8. Implement `getForexRate(db, currency)`: + - If `currency === "VND"` return 1 + - If `currency === "USD"` return `prices.forex.USD` + +### Edge cases + +- Any single API fails -> `Promise.allSettled` catches it, use partial results + stale cache for missing category +- All APIs fail -> if cache < 5 min old, use it; else throw with user-friendly message +- CoinGecko rate-limited (30 calls/min free tier) -> 60s cache makes this safe for normal use +- TCBS returns empty data array -> skip that stock, log warning + +### Failure modes + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| CoinGecko rate limit | Low (60s cache) | Medium | Cache prevents rapid re-fetch; degrade gracefully | +| TCBS API changes response shape | Medium | Medium | Defensive access `data?.[0]?.close`; skip stock on parse failure | +| Forex API down | Low | Low | USD conversion unavailable; VND operations still work | +| All APIs down simultaneously | Very Low | High | Fall back to cache if < 5min; clear error message if no cache | + +### Security + +- No API keys needed (all free public endpoints) +- No user data sent to external APIs + +## Success criteria + +- [ ] `fetchPrices` calls 3 APIs in parallel, returns merged object +- [ ] `getPrices` returns cached data within 60s window +- [ ] `getPrices` refetches when cache is stale +- [ ] Partial API failure doesn't crash — missing data logged, rest returned +- [ ] `getPrice(db, "BTC")` returns a number (VND) +- [ ] `getForexRate(db, "VND")` returns 1 +- [ ] File under 200 lines diff --git a/plans/260414-1457-trading-module/phase-03-portfolio.md b/plans/260414-1457-trading-module/phase-03-portfolio.md new file mode 100644 index 0000000..06eae8b --- /dev/null +++ b/plans/260414-1457-trading-module/phase-03-portfolio.md @@ -0,0 +1,90 @@ +--- +phase: 3 +title: "Portfolio Data Layer" +status: Pending +priority: P2 +effort: 45m +--- + +# Phase 3: Portfolio Data Layer + +## Context + +- [KV interface](../../src/db/kv-store-interface.js) — `getJSON`/`putJSON` +- [Symbols](phase-01-symbols-and-format.md) + +## Overview + +CRUD operations on per-user portfolio KV objects. Pure data logic, no Telegram ctx dependency. + +## File: `src/modules/trading/portfolio.js` + +### Data shape — KV key `user:{telegramId}` + +```js +{ + currency: { VND: 0, USD: 0 }, + stock: {}, // e.g. { TCB: 10, FPT: 5 } + crypto: {}, // e.g. { BTC: 0.5, ETH: 2.1 } + others: {}, // e.g. { GOLD: 0.1 } + totalvnd: 0 // cumulative VND topped up (cost basis) +} +``` + +### Exports + +- `getPortfolio(db, userId)` — returns portfolio object; inits empty if first-time user +- `savePortfolio(db, userId, portfolio)` — writes to KV +- `addCurrency(portfolio, currency, amount)` — mutates + returns portfolio +- `deductCurrency(portfolio, currency, amount)` — returns `{ ok, portfolio, balance }`. `ok=false` if insufficient +- `addAsset(portfolio, symbol, qty)` — adds to correct category bucket +- `deductAsset(portfolio, symbol, qty)` — returns `{ ok, portfolio, held }`. `ok=false` if insufficient +- `emptyPortfolio()` — returns fresh empty portfolio object + +### Implementation steps + +1. Create `src/modules/trading/portfolio.js` +2. `emptyPortfolio()` — returns deep clone of default shape +3. `getPortfolio(db, userId)`: + - `db.getJSON("user:" + userId)` + - If null, return `emptyPortfolio()` + - Validate shape: ensure all category keys exist (migration-safe) +4. `savePortfolio(db, userId, portfolio)`: + - `db.putJSON("user:" + userId, portfolio)` +5. `addCurrency(portfolio, currency, amount)`: + - `portfolio.currency[currency] += amount` + - Return portfolio +6. `deductCurrency(portfolio, currency, amount)`: + - Check `portfolio.currency[currency] >= amount` + - If not, return `{ ok: false, portfolio, balance: portfolio.currency[currency] }` + - Deduct, return `{ ok: true, portfolio }` +7. `addAsset(portfolio, symbol, qty)`: + - Lookup category from SYMBOLS + - `portfolio[category][symbol] = (portfolio[category][symbol] || 0) + qty` +8. `deductAsset(portfolio, symbol, qty)`: + - Lookup category, check held >= qty + - If not, return `{ ok: false, portfolio, held: portfolio[category][symbol] || 0 }` + - Deduct (remove key if 0), return `{ ok: true, portfolio }` + +### Edge cases + +- First-time user -> `getPortfolio` returns empty, no KV write until explicit `savePortfolio` +- Deduct exactly full balance -> ok, set to 0 +- Deduct asset to exactly 0 -> delete key from object (keep portfolio clean) +- Portfolio shape migration: if old KV entry missing a category key, fill with `{}` + +### Failure modes + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Race condition (two concurrent commands) | Low (paper trading) | Low | Acceptable for paper trading; last write wins | +| KV write fails | Low | Medium | Command handler catches, reports error to user | + +## Success criteria + +- [ ] `getPortfolio` returns empty portfolio for new user +- [ ] `addCurrency` + `deductCurrency` correctly modify balances +- [ ] `deductCurrency` returns `ok: false` when insufficient +- [ ] `addAsset` / `deductAsset` work across all 3 categories +- [ ] `deductAsset` to zero removes key from category object +- [ ] File under 120 lines diff --git a/plans/260414-1457-trading-module/phase-04-commands.md b/plans/260414-1457-trading-module/phase-04-commands.md new file mode 100644 index 0000000..9a8c0c3 --- /dev/null +++ b/plans/260414-1457-trading-module/phase-04-commands.md @@ -0,0 +1,134 @@ +--- +phase: 4 +title: "Command Handlers + Module Entry" +status: Pending +priority: P2 +effort: 1.5h +depends_on: [1, 2, 3] +--- + +# Phase 4: Command Handlers + Module Entry + +## Context + +- [Module pattern](../../src/modules/misc/index.js) — reference for `init`, command shape +- [Registry types](../../src/modules/registry.js) — `BotModule` typedef +- Phases 1-3 provide: symbols, prices, portfolio, format + +## Overview + +Thin `index.js` wiring five `trade_*` commands. Each handler: parse args -> validate -> call data layer -> format reply. + +## File: `src/modules/trading/index.js` + +### Module shape + +```js +const tradingModule = { + name: "trading", + init: async ({ db: store }) => { db = store; }, + commands: [ + { name: "trade_topup", visibility: "public", description: "...", handler: handleTopup }, + { name: "trade_buy", visibility: "public", description: "...", handler: handleBuy }, + { name: "trade_sell", visibility: "public", description: "...", handler: handleSell }, + { name: "trade_convert", visibility: "public", description: "...", handler: handleConvert }, + { name: "trade_stats", visibility: "public", description: "...", handler: handleStats }, + ], +}; +export default tradingModule; +``` + +### Command implementations + +#### `trade_topup [currency=VND]` + +1. Parse: `ctx.match.trim().split(/\s+/)` -> `[amountStr, currencyStr?]` +2. Validate: amount > 0, numeric; currency in CURRENCIES (default VND) +3. Get portfolio, add currency +4. If currency !== VND: fetch forex rate, add `amount * rate` to `totalvnd` +5. If currency === VND: add amount to `totalvnd` +6. Save portfolio +7. Reply: `Topped up {formatCurrency(amount, currency)}. Balance: {formatCurrency(balance, currency)}` + +#### `trade_buy ` + +1. Parse args: amount + symbol +2. Validate: amount > 0; symbol exists in SYMBOLS +3. If stock: amount must be integer (`Number.isInteger(parseFloat(amount))`) +4. Fetch price via `getPrice(db, symbol)` +5. Cost = amount * price (in VND) +6. Deduct VND from portfolio; if insufficient -> error with current balance +7. Add asset to portfolio +8. Save, reply with purchase summary + +#### `trade_sell ` + +1. Parse + validate (same as buy) +2. Deduct asset; if insufficient -> error with current holding +3. Fetch price, revenue = amount * price +4. Add VND to portfolio +5. Save, reply with sale summary + +#### `trade_convert ` + +1. Parse: amount, from-currency, to-currency +2. Validate: both in CURRENCIES, from !== to, amount > 0 +3. Deduct `from` currency; if insufficient -> error +4. Fetch forex rates, compute converted amount +5. Add `to` currency +6. Save, reply with conversion summary + +#### `trade_stats` + +1. Get portfolio +2. Fetch all prices +3. For each category, compute current VND value +4. Sum all = total current value +5. P&L = total current value + currency.VND - totalvnd +6. Reply with formatted breakdown table + +### Arg parsing helper + +Extract into a local `parseArgs(ctx, specs)` at top of file: +- `specs` = array of `{ name, required, type: "number"|"string", default? }` +- Returns parsed object or null (replies usage hint on failure) +- Keeps handlers DRY + +### Implementation steps + +1. Create `src/modules/trading/index.js` +2. Module-level `let db = null;` set in `init` +3. Implement `parseArgs` helper (inline, ~20 lines) +4. Implement each handler function (~25-35 lines each) +5. Wire into `commands` array +6. Ensure file stays under 200 lines. If approaching limit, extract `parseArgs` to a `helpers.js` file + +### Edge cases + +| Input | Response | +|-------|----------| +| `/trade_buy` (no args) | Usage: `/trade_buy ` | +| `/trade_buy -5 BTC` | Amount must be positive | +| `/trade_buy 0.5 NOPE` | Unknown symbol. Supported: BTC, ETH, ... | +| `/trade_buy 1.5 TCB` | Stock quantities must be whole numbers | +| `/trade_buy 1 BTC` (no VND) | Insufficient VND. Balance: 0 VND | +| `/trade_sell 10 BTC` (only have 5) | Insufficient BTC. You have: 5 | +| `/trade_convert 100 VND VND` | Cannot convert to same currency | +| API failure during buy | Could not fetch price. Try again later. | + +### Failure modes + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| File exceeds 200 lines | Medium | Low | Extract parseArgs to helpers.js | +| Price fetch fails mid-trade | Low | Medium | Catch, reply error, don't modify portfolio | +| User sends concurrent commands | Low | Low | Last write wins; acceptable for paper trading | + +## Success criteria + +- [ ] All 5 commands registered as public +- [ ] Each command validates input and replies helpful errors +- [ ] Buy/sell correctly modify both VND and asset balances +- [ ] Convert works between VND and USD +- [ ] Stats shows breakdown with P&L +- [ ] File under 200 lines (or split cleanly) diff --git a/plans/260414-1457-trading-module/phase-05-wiring.md b/plans/260414-1457-trading-module/phase-05-wiring.md new file mode 100644 index 0000000..fa52ecb --- /dev/null +++ b/plans/260414-1457-trading-module/phase-05-wiring.md @@ -0,0 +1,67 @@ +--- +phase: 5 +title: "Integration Wiring" +status: Pending +priority: P2 +effort: 15m +depends_on: [4] +--- + +# Phase 5: Integration Wiring + +## Overview + +Two one-line edits to register the trading module in the bot framework. + +## File changes + +### `src/modules/index.js` + +Add one line to `moduleRegistry`: + +```js +export const moduleRegistry = { + util: () => import("./util/index.js"), + wordle: () => import("./wordle/index.js"), + loldle: () => import("./loldle/index.js"), + misc: () => import("./misc/index.js"), + trading: () => import("./trading/index.js"), // <-- add +}; +``` + +### `wrangler.toml` + +Append `trading` to MODULES var: + +```toml +[vars] +MODULES = "util,wordle,loldle,misc,trading" +``` + +### `.env.deploy` (manual — not committed) + +User must also add `trading` to MODULES in `.env.deploy` for the register script to pick up public commands. + +## Implementation steps + +1. Edit `src/modules/index.js` — add `trading` entry +2. Edit `wrangler.toml` — append `,trading` to MODULES +3. Run `npm run lint` to verify +4. Run `npm test` to verify existing tests still pass (trading tests in Phase 6) + +## Verification + +- `npm test` — all 56 existing tests pass +- `npm run lint` — no errors +- `npm run register:dry` — trading commands appear in output + +## Rollback + +Remove `trading` from both files. Existing KV data becomes inert (never read). + +## Success criteria + +- [ ] `src/modules/index.js` has trading entry +- [ ] `wrangler.toml` MODULES includes trading +- [ ] Existing tests pass unchanged +- [ ] Lint passes diff --git a/plans/260414-1457-trading-module/phase-06-tests.md b/plans/260414-1457-trading-module/phase-06-tests.md new file mode 100644 index 0000000..601a953 --- /dev/null +++ b/plans/260414-1457-trading-module/phase-06-tests.md @@ -0,0 +1,140 @@ +--- +phase: 6 +title: "Tests" +status: Pending +priority: P2 +effort: 1.5h +depends_on: [1, 2, 3, 4] +--- + +# Phase 6: Tests + +## Overview + +Unit tests for all trading module files. Use existing test infrastructure: vitest, `makeFakeKv`, fake bot helpers. + +## Test files + +### `tests/modules/trading/symbols.test.js` + +| Test | Assertion | +|------|-----------| +| SYMBOLS has 9 entries | `Object.keys(SYMBOLS).length === 9` | +| Every entry has category, apiId, label | Shape check | +| getSymbol case-insensitive | `getSymbol("btc")` === `getSymbol("BTC")` | +| getSymbol unknown returns undefined | `getSymbol("NOPE")` === `undefined` | +| getSymbol falsy input returns undefined | `getSymbol("")`, `getSymbol(null)` | +| listSymbols groups by category | Contains "crypto", "stock", "others" headers | +| CURRENCIES has VND and USD | Set membership | + +### `tests/modules/trading/format.test.js` + +| Test | Assertion | +|------|-----------| +| formatVND(15000000) | `"15.000.000 VND"` | +| formatVND(0) | `"0 VND"` | +| formatVND(500) | `"500 VND"` | +| formatUSD(1234.5) | `"$1,234.50"` | +| formatUSD(0) | `"$0.00"` | +| formatCrypto(0.001) | `"0.001"` | +| formatCrypto(1.00000000) | `"1"` | +| formatCrypto(0.12345678) | `"0.12345678"` | +| formatStock(1.7) | `"1"` | +| formatStock(100) | `"100"` | +| formatAmount dispatches correctly | BTC->crypto, TCB->stock, GOLD->others | +| formatCurrency dispatches | VND->formatVND, USD->formatUSD | + +### `tests/modules/trading/portfolio.test.js` + +| Test | Assertion | +|------|-----------| +| emptyPortfolio has correct shape | All keys present, zeroed | +| getPortfolio returns empty for new user | Uses fake KV | +| getPortfolio returns stored data | Pre-seed KV | +| addCurrency increases balance | `addCurrency(p, "VND", 1000)` | +| deductCurrency succeeds | Sufficient balance | +| deductCurrency fails insufficient | Returns `{ ok: false, balance }` | +| deductCurrency exact balance | Returns `{ ok: true }`, balance = 0 | +| addAsset correct category | BTC -> crypto, TCB -> stock | +| deductAsset succeeds | Sufficient holding | +| deductAsset fails insufficient | Returns `{ ok: false, held }` | +| deductAsset to zero removes key | Key deleted from category | +| savePortfolio round-trips | Write then read | + +### `tests/modules/trading/prices.test.js` + +Strategy: Mock `fetch` globally in vitest to return canned API responses. Do NOT call real APIs. + +| Test | Assertion | +|------|-----------| +| fetchPrices merges all 3 sources | Correct shape with all categories | +| getPrices returns cache when fresh | Only 1 fetch call if called twice within 60s | +| getPrices refetches when stale | Simulated stale timestamp | +| getPrice returns correct value | `getPrice(db, "BTC")` returns mocked VND price | +| getForexRate VND returns 1 | No fetch needed | +| getForexRate USD returns rate | From mocked forex response | +| Partial API failure | One API rejects; others still returned | +| All APIs fail, stale cache < 5min | Returns stale cache | +| All APIs fail, no cache | Throws with user-friendly message | + +### `tests/modules/trading/commands.test.js` + +Strategy: Integration-style tests. Use `makeFakeKv` for real KV behavior. Mock `fetch` for price APIs. Simulate grammY `ctx` with `ctx.match` and `ctx.reply` spy. + +Helper: `makeCtx(match, userId?)` — returns `{ match, from: { id: userId }, reply: vi.fn() }` + +| Test | Assertion | +|------|-----------| +| trade_topup adds VND | Portfolio balance increases | +| trade_topup adds USD + totalvnd | USD balance + totalvnd updated | +| trade_topup no args | Reply contains "Usage" | +| trade_topup negative amount | Reply contains error | +| trade_buy deducts VND, adds asset | Both modified | +| trade_buy stock fractional | Reply contains "whole numbers" | +| trade_buy insufficient VND | Reply contains balance | +| trade_buy unknown symbol | Reply lists supported symbols | +| trade_sell adds VND, deducts asset | Both modified | +| trade_sell insufficient holding | Reply contains current holding | +| trade_convert VND->USD | Both currencies modified | +| trade_convert same currency | Error message | +| trade_stats empty portfolio | Shows zero values | +| trade_stats with holdings | Shows breakdown + P&L | +| Price API failure during buy | Error message, portfolio unchanged | + +## Implementation steps + +1. Create test directory: `tests/modules/trading/` +2. Create `symbols.test.js` (~40 lines) +3. Create `format.test.js` (~60 lines) +4. Create `portfolio.test.js` (~80 lines) +5. Create `prices.test.js` (~90 lines) — mock global fetch +6. Create `commands.test.js` (~120 lines) — mock fetch + fake KV +7. Run `npm test` — all pass +8. Run `npm run lint` — clean + +### Fetch mocking pattern + +```js +import { vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => mockFetch.mockReset()); + +// Per-test setup: +mockFetch.mockImplementation((url) => { + if (url.includes("coingecko")) return Response.json({ bitcoin: { vnd: 2500000000 } }); + if (url.includes("tcbs")) return Response.json({ data: [{ close: 25 }] }); + if (url.includes("er-api")) return Response.json({ rates: { VND: 25400 } }); +}); +``` + +## Success criteria + +- [ ] All new tests pass +- [ ] All 56 existing tests still pass +- [ ] Coverage: every public export of symbols, format, portfolio, prices tested +- [ ] Command handler tests cover happy path + all error branches +- [ ] Lint passes +- [ ] No real HTTP calls in tests diff --git a/plans/260414-1457-trading-module/plan.md b/plans/260414-1457-trading-module/plan.md new file mode 100644 index 0000000..8d967c0 --- /dev/null +++ b/plans/260414-1457-trading-module/plan.md @@ -0,0 +1,56 @@ +--- +title: "Fake Trading Module" +description: "Paper trading module for Telegram bot — virtual portfolio with crypto, stocks, forex, gold" +status: pending +priority: P2 +effort: 6h +branch: main +tags: [feature, module, trading] +created: 2026-04-14 +--- + +# Fake Trading Module + +## Phases + +| # | Phase | Status | Effort | Files | +|---|-------|--------|--------|-------| +| 1 | [Symbol registry + formatters](phase-01-symbols-and-format.md) | Pending | 45m | `src/modules/trading/symbols.js`, `src/modules/trading/format.js` | +| 2 | [Price fetching + caching](phase-02-prices.md) | Pending | 1h | `src/modules/trading/prices.js` | +| 3 | [Portfolio data layer](phase-03-portfolio.md) | Pending | 45m | `src/modules/trading/portfolio.js` | +| 4 | [Command handlers + module entry](phase-04-commands.md) | Pending | 1.5h | `src/modules/trading/index.js` | +| 5 | [Integration wiring](phase-05-wiring.md) | Pending | 15m | `src/modules/index.js`, `wrangler.toml` | +| 6 | [Tests](phase-06-tests.md) | Pending | 1.5h | `tests/modules/trading/*.test.js` | + +## Dependencies + +``` +Phase 1 ──┐ +Phase 2 ──┼──► Phase 4 ──► Phase 5 +Phase 3 ──┘ │ +Phase 1,2,3,4 ────────────► Phase 6 +``` + +## Data flow + +``` +User /trade_buy 0.5 BTC + -> index.js parses args, validates + -> prices.js fetches BTC/VND (cache-first, 60s TTL) + -> portfolio.js reads user KV, checks VND balance + -> portfolio.js deducts VND, adds BTC qty, writes KV + -> format.js renders reply + -> ctx.reply() +``` + +## Rollback + +Remove `trading` from `MODULES` in `wrangler.toml` + `src/modules/index.js`. KV data inert. + +## Key decisions + +- VND sole settlement currency for buy/sell +- Single KV object per user (acceptable race for paper trading) +- 60s price cache TTL via KV putJSON with expirationTtl +- Gold via PAX Gold on CoinGecko (troy ounces) +- Stocks integer-only quantities diff --git a/src/modules/index.js b/src/modules/index.js index 67ff611..197f029 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -13,4 +13,5 @@ export const moduleRegistry = { wordle: () => import("./wordle/index.js"), loldle: () => import("./loldle/index.js"), misc: () => import("./misc/index.js"), + trading: () => import("./trading/index.js"), }; diff --git a/src/modules/trading/format.js b/src/modules/trading/format.js new file mode 100644 index 0000000..de92570 --- /dev/null +++ b/src/modules/trading/format.js @@ -0,0 +1,80 @@ +/** + * @file Number formatters for trading module display. + * Manual VND formatter avoids locale-dependent toLocaleString in CF Workers. + */ + +/** + * Format number as Vietnamese Dong — dot thousands separator, no decimals. + * @param {number} n + * @returns {string} e.g. "15.000.000 VND" + */ +export function formatVND(n) { + const rounded = Math.round(n); + const abs = Math.abs(rounded).toString(); + // insert dots every 3 digits from right + let result = ""; + for (let i = 0; i < abs.length; i++) { + if (i > 0 && (abs.length - i) % 3 === 0) result += "."; + result += abs[i]; + } + return `${rounded < 0 ? "-" : ""}${result} VND`; +} + +/** + * Format number as USD — 2 decimals, comma thousands. + * @param {number} n + * @returns {string} e.g. "$1,234.56" + */ +export function formatUSD(n) { + const fixed = Math.abs(n).toFixed(2); + const [intPart, decPart] = fixed.split("."); + let result = ""; + for (let i = 0; i < intPart.length; i++) { + if (i > 0 && (intPart.length - i) % 3 === 0) result += ","; + result += intPart[i]; + } + return `${n < 0 ? "-" : ""}$${result}.${decPart}`; +} + +/** + * Format crypto quantity — up to 8 decimals, trailing zeros stripped. + * @param {number} n + * @returns {string} e.g. "0.00125" + */ +export function formatCrypto(n) { + return Number.parseFloat(n.toFixed(8)).toString(); +} + +/** + * Format stock quantity — integer only. + * @param {number} n + * @returns {string} + */ +export function formatStock(n) { + return Math.floor(n).toString(); +} + +/** + * Format amount based on currency type. + * @param {number} n + * @param {string} currency — "VND" or "USD" + * @returns {string} + */ +export function formatCurrency(n, currency) { + if (currency === "VND") return formatVND(n); + if (currency === "USD") return formatUSD(n); + return `${n} ${currency}`; +} + +/** + * Format P&L line with absolute and percentage. + * @param {number} currentValue + * @param {number} invested + * @returns {string} + */ +export function formatPnL(currentValue, invested) { + const diff = currentValue - invested; + const pct = invested > 0 ? ((diff / invested) * 100).toFixed(2) : "0.00"; + const sign = diff >= 0 ? "+" : ""; + return `${sign}${formatVND(diff)} (${sign}${pct}%)`; +} diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js new file mode 100644 index 0000000..6800829 --- /dev/null +++ b/src/modules/trading/handlers.js @@ -0,0 +1,164 @@ +/** + * @file Command handler implementations for the trading module. + * Each handler receives (ctx, db) — the grammY context and KV store. + */ + +import { formatCrypto, formatCurrency, formatStock, formatVND } from "./format.js"; +import { + addAsset, + addCurrency, + deductAsset, + deductCurrency, + getPortfolio, + savePortfolio, +} from "./portfolio.js"; +import { getForexRate, getPrice } from "./prices.js"; +import { CURRENCIES, getSymbol, listSymbols } from "./symbols.js"; + +function uid(ctx) { + return ctx.from?.id; +} + +function parseArgs(ctx) { + return (ctx.match || "").trim().split(/\s+/).filter(Boolean); +} + +function usageReply(ctx, usage) { + return ctx.reply(`Usage: ${usage}`); +} + +/** /trade_topup [currency=VND] */ +export async function handleTopup(ctx, db) { + const args = parseArgs(ctx); + if (args.length < 1) return usageReply(ctx, "/trade_topup [VND|USD]"); + const amount = Number(args[0]); + if (!Number.isFinite(amount) || amount <= 0) + return ctx.reply("Amount must be a positive number."); + const currency = (args[1] || "VND").toUpperCase(); + if (!CURRENCIES.has(currency)) + return ctx.reply(`Unsupported currency. Use: ${[...CURRENCIES].join(", ")}`); + + const p = await getPortfolio(db, uid(ctx)); + addCurrency(p, currency, amount); + if (currency === "VND") { + p.totalvnd += amount; + } else { + const rate = await getForexRate(db, currency); + if (rate == null) return ctx.reply("Could not fetch forex rate. Try again later."); + p.totalvnd += amount * rate; + } + await savePortfolio(db, uid(ctx), p); + const bal = p.currency[currency]; + await ctx.reply( + `Topped up ${formatCurrency(amount, currency)}.\nBalance: ${formatCurrency(bal, currency)}`, + ); +} + +/** /trade_buy */ +export async function handleBuy(ctx, db) { + const args = parseArgs(ctx); + if (args.length < 2) + return usageReply(ctx, "/trade_buy \nExample: /trade_buy 0.01 BTC"); + const amount = Number(args[0]); + if (!Number.isFinite(amount) || amount <= 0) + return ctx.reply("Amount must be a positive number."); + const info = getSymbol(args[1]); + if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`); + if (info.category === "stock" && !Number.isInteger(amount)) + return ctx.reply("Stock quantities must be whole numbers."); + + let price; + try { + price = await getPrice(db, info.symbol); + } catch { + return ctx.reply("Could not fetch price. Try again later."); + } + if (price == null) return ctx.reply(`No price available for ${info.symbol}.`); + + const cost = amount * price; + const p = await getPortfolio(db, uid(ctx)); + const result = deductCurrency(p, "VND", cost); + if (!result.ok) { + return ctx.reply( + `Insufficient VND. Need ${formatVND(cost)}, have ${formatVND(result.balance)}.`, + ); + } + addAsset(p, info.symbol, amount); + await savePortfolio(db, uid(ctx), p); + const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount); + await ctx.reply(`Bought ${qty} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`); +} + +/** /trade_sell */ +export async function handleSell(ctx, db) { + const args = parseArgs(ctx); + if (args.length < 2) + return usageReply(ctx, "/trade_sell \nExample: /trade_sell 0.01 BTC"); + const amount = Number(args[0]); + if (!Number.isFinite(amount) || amount <= 0) + return ctx.reply("Amount must be a positive number."); + const info = getSymbol(args[1]); + if (!info) return ctx.reply(`Unknown symbol.\n${listSymbols()}`); + if (info.category === "stock" && !Number.isInteger(amount)) + return ctx.reply("Stock quantities must be whole numbers."); + + const p = await getPortfolio(db, uid(ctx)); + const result = deductAsset(p, info.symbol, amount); + if (!result.ok) { + const qty = info.category === "stock" ? formatStock(result.held) : formatCrypto(result.held); + return ctx.reply(`Insufficient ${info.symbol}. You have: ${qty}`); + } + + let price; + try { + price = await getPrice(db, info.symbol); + } catch { + return ctx.reply("Could not fetch price. Try again later."); + } + if (price == null) return ctx.reply(`No price available for ${info.symbol}.`); + + const revenue = amount * price; + addCurrency(p, "VND", revenue); + await savePortfolio(db, uid(ctx), p); + const qty = info.category === "stock" ? formatStock(amount) : formatCrypto(amount); + await ctx.reply( + `Sold ${qty} ${info.symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`, + ); +} + +/** /trade_convert */ +export async function handleConvert(ctx, db) { + const args = parseArgs(ctx); + if (args.length < 3) + return usageReply( + ctx, + "/trade_convert \nExample: /trade_convert 100 USD VND", + ); + const amount = Number(args[0]); + if (!Number.isFinite(amount) || amount <= 0) + return ctx.reply("Amount must be a positive number."); + const from = args[1].toUpperCase(); + const to = args[2].toUpperCase(); + if (!CURRENCIES.has(from) || !CURRENCIES.has(to)) + return ctx.reply(`Supported currencies: ${[...CURRENCIES].join(", ")}`); + if (from === to) return ctx.reply("Cannot convert to the same currency."); + + let fromRate; + let toRate; + try { + [fromRate, toRate] = await Promise.all([getForexRate(db, from), getForexRate(db, to)]); + } catch { + return ctx.reply("Could not fetch forex rate. Try again later."); + } + if (fromRate == null || toRate == null) + return ctx.reply("Forex rate unavailable. Try again later."); + + const p = await getPortfolio(db, uid(ctx)); + const result = deductCurrency(p, from, amount); + if (!result.ok) + return ctx.reply(`Insufficient ${from}. Balance: ${formatCurrency(result.balance, from)}`); + const converted = (amount * fromRate) / toRate; + addCurrency(p, to, converted); + await savePortfolio(db, uid(ctx), p); + await ctx.reply(`Converted ${formatCurrency(amount, from)} → ${formatCurrency(converted, to)}`); +} diff --git a/src/modules/trading/index.js b/src/modules/trading/index.js new file mode 100644 index 0000000..81cdb28 --- /dev/null +++ b/src/modules/trading/index.js @@ -0,0 +1,52 @@ +/** + * @file Trading module entry — fake/paper trading with crypto, VN stocks, forex, gold. + * Handlers live in handlers.js; this file wires them into the module system. + */ + +import { handleBuy, handleConvert, handleSell, handleTopup } from "./handlers.js"; +import { handleStats } from "./stats-handler.js"; + +/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ +let db = null; + +/** @type {import("../registry.js").BotModule} */ +const tradingModule = { + name: "trading", + init: async ({ db: store }) => { + db = store; + }, + commands: [ + { + name: "trade_topup", + visibility: "public", + description: "Top up fiat to your trading account", + handler: (ctx) => handleTopup(ctx, db), + }, + { + name: "trade_buy", + visibility: "public", + description: "Buy crypto/stock/gold at market price", + handler: (ctx) => handleBuy(ctx, db), + }, + { + name: "trade_sell", + visibility: "public", + description: "Sell holdings back to VND", + handler: (ctx) => handleSell(ctx, db), + }, + { + name: "trade_convert", + visibility: "public", + description: "Convert between fiat currencies", + handler: (ctx) => handleConvert(ctx, db), + }, + { + name: "trade_stats", + visibility: "public", + description: "Show portfolio summary with P&L", + handler: (ctx) => handleStats(ctx, db), + }, + ], +}; + +export default tradingModule; diff --git a/src/modules/trading/portfolio.js b/src/modules/trading/portfolio.js new file mode 100644 index 0000000..967b9b4 --- /dev/null +++ b/src/modules/trading/portfolio.js @@ -0,0 +1,107 @@ +/** + * @file Portfolio CRUD — per-user KV read/write and balance operations. + * All mutations are in-memory; caller must savePortfolio() to persist. + */ + +import { getSymbol } from "./symbols.js"; + +/** + * @typedef {Object} Portfolio + * @property {{ [currency: string]: number }} currency + * @property {{ [symbol: string]: number }} stock + * @property {{ [symbol: string]: number }} crypto + * @property {{ [symbol: string]: number }} others + * @property {number} totalvnd + */ + +/** @returns {Portfolio} */ +export function emptyPortfolio() { + return { currency: { VND: 0, USD: 0 }, stock: {}, crypto: {}, others: {}, totalvnd: 0 }; +} + +/** + * Load user portfolio from KV, or return empty if first-time user. + * Ensures all category keys exist (migration-safe). + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {number|string} userId + * @returns {Promise} + */ +export async function getPortfolio(db, userId) { + const raw = await db.getJSON(`user:${userId}`); + if (!raw) return emptyPortfolio(); + // ensure all expected keys exist + const p = emptyPortfolio(); + p.currency = { ...p.currency, ...raw.currency }; + p.stock = { ...raw.stock }; + p.crypto = { ...raw.crypto }; + p.others = { ...raw.others }; + p.totalvnd = raw.totalvnd ?? 0; + return p; +} + +/** + * Persist portfolio to KV. + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {number|string} userId + * @param {Portfolio} portfolio + */ +export async function savePortfolio(db, userId, portfolio) { + await db.putJSON(`user:${userId}`, portfolio); +} + +/** + * Add fiat to portfolio. Mutates in place. + * @param {Portfolio} p + * @param {string} currency + * @param {number} amount + */ +export function addCurrency(p, currency, amount) { + p.currency[currency] = (p.currency[currency] || 0) + amount; +} + +/** + * Deduct fiat. Returns { ok, balance } — ok=false if insufficient. + * @param {Portfolio} p + * @param {string} currency + * @param {number} amount + * @returns {{ ok: boolean, balance: number }} + */ +export function deductCurrency(p, currency, amount) { + const balance = p.currency[currency] || 0; + if (balance < amount) return { ok: false, balance }; + p.currency[currency] = balance - amount; + return { ok: true, balance: balance - amount }; +} + +/** + * Add asset (stock/crypto/others) to portfolio. + * @param {Portfolio} p + * @param {string} symbol + * @param {number} qty + */ +export function addAsset(p, symbol, qty) { + const info = getSymbol(symbol); + if (!info) return; + const cat = info.category; + p[cat][symbol] = (p[cat][symbol] || 0) + qty; +} + +/** + * Deduct asset. Returns { ok, held } — ok=false if insufficient. + * Removes key if balance reaches 0. + * @param {Portfolio} p + * @param {string} symbol + * @param {number} qty + * @returns {{ ok: boolean, held: number }} + */ +export function deductAsset(p, symbol, qty) { + const info = getSymbol(symbol); + if (!info) return { ok: false, held: 0 }; + const cat = info.category; + const held = p[cat][symbol] || 0; + if (held < qty) return { ok: false, held }; + const remaining = held - qty; + if (remaining === 0) delete p[cat][symbol]; + else p[cat][symbol] = remaining; + return { ok: true, held: remaining }; +} diff --git a/src/modules/trading/prices.js b/src/modules/trading/prices.js new file mode 100644 index 0000000..8151012 --- /dev/null +++ b/src/modules/trading/prices.js @@ -0,0 +1,131 @@ +/** + * @file Price fetching — CoinGecko (crypto+gold), TCBS (VN stocks), ER-API (forex). + * Caches merged result in KV for 60s to avoid API spam. + */ + +import { SYMBOLS } from "./symbols.js"; + +const CACHE_KEY = "prices:latest"; +const CACHE_TTL_MS = 60_000; +const STALE_LIMIT_MS = 300_000; // 5 min — max age for fallback + +/** Crypto + gold via CoinGecko free API */ +async function fetchCrypto() { + const ids = Object.values(SYMBOLS) + .filter((s) => s.category === "crypto" || s.category === "others") + .map((s) => s.apiId) + .join(","); + const res = await fetch( + `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=vnd`, + ); + if (!res.ok) throw new Error(`CoinGecko ${res.status}`); + const data = await res.json(); + const crypto = {}; + const others = {}; + for (const [sym, entry] of Object.entries(SYMBOLS)) { + if (entry.category !== "crypto" && entry.category !== "others") continue; + const price = data[entry.apiId]?.vnd; + if (price == null) continue; + if (entry.category === "crypto") crypto[sym] = price; + else others[sym] = price; + } + return { crypto, others }; +} + +/** Vietnam stock prices via TCBS public API (price in VND * 1000) */ +async function fetchStocks() { + const tickers = Object.entries(SYMBOLS).filter(([, e]) => e.category === "stock"); + const to = Math.floor(Date.now() / 1000); + const results = await Promise.allSettled( + tickers.map(async ([sym, entry]) => { + const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${entry.apiId}&type=stock&resolution=D&countBack=1&to=${to}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`TCBS ${entry.apiId} ${res.status}`); + const json = await res.json(); + const close = json?.data?.[0]?.close; + if (close == null) throw new Error(`TCBS ${entry.apiId} no close`); + return { sym, price: close * 1000 }; + }), + ); + const stock = {}; + for (const r of results) { + if (r.status === "fulfilled") stock[r.value.sym] = r.value.price; + } + return stock; +} + +/** Forex rates via open.er-api.com — VND per 1 USD */ +async function fetchForex() { + const res = await fetch("https://open.er-api.com/v6/latest/USD"); + if (!res.ok) throw new Error(`Forex API ${res.status}`); + const data = await res.json(); + const vndRate = data?.rates?.VND; + if (vndRate == null) throw new Error("Forex missing VND rate"); + return { USD: vndRate }; +} + +/** + * Fetch all prices in parallel, merge, cache in KV. + * @param {import("../../db/kv-store-interface.js").KVStore} db + */ +export async function fetchPrices(db) { + const [cryptoRes, stockRes, forexRes] = await Promise.allSettled([ + fetchCrypto(), + fetchStocks(), + fetchForex(), + ]); + const merged = { + ts: Date.now(), + crypto: cryptoRes.status === "fulfilled" ? cryptoRes.value.crypto : {}, + stock: stockRes.status === "fulfilled" ? stockRes.value : {}, + forex: forexRes.status === "fulfilled" ? forexRes.value : {}, + others: cryptoRes.status === "fulfilled" ? cryptoRes.value.others : {}, + }; + try { + await db.putJSON(CACHE_KEY, merged); + } catch { + /* best effort cache write */ + } + return merged; +} + +/** + * Cache-first price retrieval. Returns cached if < 60s old, else fetches fresh. + * @param {import("../../db/kv-store-interface.js").KVStore} db + */ +export async function getPrices(db) { + const cached = await db.getJSON(CACHE_KEY); + if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached; + try { + return await fetchPrices(db); + } catch { + // fallback to stale cache if < 5 min + if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached; + throw new Error("Could not fetch prices. Try again later."); + } +} + +/** + * Get VND price for a single symbol. + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {string} symbol — uppercase, e.g. "BTC" + * @returns {Promise} + */ +export async function getPrice(db, symbol) { + const info = SYMBOLS[symbol]; + if (!info) return null; + const prices = await getPrices(db); + return prices[info.category]?.[symbol] ?? null; +} + +/** + * VND equivalent of 1 unit of currency. + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {string} currency — "VND" or "USD" + * @returns {Promise} + */ +export async function getForexRate(db, currency) { + if (currency === "VND") return 1; + const prices = await getPrices(db); + return prices.forex?.[currency] ?? null; +} diff --git a/src/modules/trading/stats-handler.js b/src/modules/trading/stats-handler.js new file mode 100644 index 0000000..cee00ea --- /dev/null +++ b/src/modules/trading/stats-handler.js @@ -0,0 +1,63 @@ +/** + * @file /trade_stats handler — portfolio summary with P&L breakdown. + */ + +import { formatCrypto, formatCurrency, formatPnL, formatStock, formatVND } from "./format.js"; +import { getPortfolio } from "./portfolio.js"; +import { getPrices } from "./prices.js"; + +/** /trade_stats — show full portfolio valued in VND with P&L */ +export async function handleStats(ctx, db) { + const p = await getPortfolio(db, ctx.from?.id); + let prices; + try { + prices = await getPrices(db); + } catch { + return ctx.reply("Could not fetch prices. Try again later."); + } + + const lines = ["📊 Portfolio Summary\n"]; + let totalValue = 0; + + // currencies + const currLines = []; + for (const [cur, bal] of Object.entries(p.currency)) { + if (bal === 0) continue; + const rate = cur === "VND" ? 1 : (prices.forex?.[cur] ?? 0); + const vndVal = bal * rate; + totalValue += vndVal; + currLines.push( + cur === "VND" + ? ` VND: ${formatVND(bal)}` + : ` ${cur}: ${formatCurrency(bal, cur)} (~${formatVND(vndVal)})`, + ); + } + if (currLines.length) lines.push("Currency:", ...currLines); + + // asset categories + for (const [catName, catLabel] of [ + ["stock", "Stocks"], + ["crypto", "Crypto"], + ["others", "Others"], + ]) { + const catLines = []; + for (const [sym, qty] of Object.entries(p[catName])) { + if (qty === 0) continue; + const price = prices[catName]?.[sym]; + if (price == null) { + catLines.push(` ${sym}: ${qty} (no price)`); + continue; + } + const val = qty * price; + totalValue += val; + const fmtQty = catName === "stock" ? formatStock(qty) : formatCrypto(qty); + catLines.push(` ${sym} x${fmtQty} @ ${formatVND(price)} = ${formatVND(val)}`); + } + if (catLines.length) lines.push(`\n${catLabel}:`, ...catLines); + } + + lines.push(`\nTotal value: ${formatVND(totalValue)}`); + lines.push(`Invested: ${formatVND(p.totalvnd)}`); + lines.push(`P&L: ${formatPnL(totalValue, p.totalvnd)}`); + await ctx.reply(lines.join("\n")); +} diff --git a/src/modules/trading/symbols.js b/src/modules/trading/symbols.js new file mode 100644 index 0000000..1392ab8 --- /dev/null +++ b/src/modules/trading/symbols.js @@ -0,0 +1,53 @@ +/** + * @file Symbol registry — hardcoded list of tradable assets + fiat currencies. + * Adding a new asset = one line here, no logic changes elsewhere. + */ + +/** @typedef {{ category: "crypto"|"stock"|"others", apiId: string, label: string }} SymbolEntry */ + +/** @type {Readonly>} */ +export const SYMBOLS = Object.freeze({ + // crypto — CoinGecko IDs + BTC: { category: "crypto", apiId: "bitcoin", label: "Bitcoin" }, + ETH: { category: "crypto", apiId: "ethereum", label: "Ethereum" }, + SOL: { category: "crypto", apiId: "solana", label: "Solana" }, + // Vietnam stocks — TCBS tickers + TCB: { category: "stock", apiId: "TCB", label: "Techcombank" }, + VPB: { category: "stock", apiId: "VPB", label: "VPBank" }, + FPT: { category: "stock", apiId: "FPT", label: "FPT Corp" }, + VNM: { category: "stock", apiId: "VNM", label: "Vinamilk" }, + HPG: { category: "stock", apiId: "HPG", label: "Hoa Phat" }, + // others + GOLD: { category: "others", apiId: "pax-gold", label: "Gold (troy oz)" }, +}); + +/** Supported fiat currencies */ +export const CURRENCIES = Object.freeze(new Set(["VND", "USD"])); + +/** + * Case-insensitive symbol lookup. + * @param {string} name + * @returns {SymbolEntry & { symbol: string } | undefined} + */ +export function getSymbol(name) { + if (!name) return undefined; + const key = name.toUpperCase(); + const entry = SYMBOLS[key]; + return entry ? { ...entry, symbol: key } : undefined; +} + +/** + * Formatted list of all supported symbols grouped by category. + * @returns {string} + */ +export function listSymbols() { + const groups = { crypto: [], stock: [], others: [] }; + for (const [sym, entry] of Object.entries(SYMBOLS)) { + groups[entry.category].push(`${sym} — ${entry.label}`); + } + const lines = []; + if (groups.crypto.length) lines.push("Crypto:", ...groups.crypto.map((s) => ` ${s}`)); + if (groups.stock.length) lines.push("Stocks:", ...groups.stock.map((s) => ` ${s}`)); + if (groups.others.length) lines.push("Others:", ...groups.others.map((s) => ` ${s}`)); + return lines.join("\n"); +} diff --git a/tests/modules/trading/format.test.js b/tests/modules/trading/format.test.js new file mode 100644 index 0000000..bb25779 --- /dev/null +++ b/tests/modules/trading/format.test.js @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + formatCrypto, + formatCurrency, + formatPnL, + formatStock, + formatUSD, + formatVND, +} from "../../../src/modules/trading/format.js"; + +describe("trading/format", () => { + describe("formatVND", () => { + it("formats with dot thousands separator", () => { + expect(formatVND(15000000)).toBe("15.000.000 VND"); + }); + it("handles zero", () => { + expect(formatVND(0)).toBe("0 VND"); + }); + it("handles negative", () => { + expect(formatVND(-1500000)).toBe("-1.500.000 VND"); + }); + it("rounds to integer", () => { + expect(formatVND(1234.56)).toBe("1.235 VND"); + }); + it("handles small numbers", () => { + expect(formatVND(500)).toBe("500 VND"); + }); + }); + + describe("formatUSD", () => { + it("formats with comma separator and 2 decimals", () => { + expect(formatUSD(1234.5)).toBe("$1,234.50"); + }); + it("handles zero", () => { + expect(formatUSD(0)).toBe("$0.00"); + }); + }); + + describe("formatCrypto", () => { + it("strips trailing zeros", () => { + expect(formatCrypto(0.001)).toBe("0.001"); + expect(formatCrypto(1.0)).toBe("1"); + }); + it("preserves up to 8 decimals", () => { + expect(formatCrypto(0.12345678)).toBe("0.12345678"); + }); + }); + + describe("formatStock", () => { + it("floors to integer", () => { + expect(formatStock(1.7)).toBe("1"); + expect(formatStock(150)).toBe("150"); + }); + }); + + describe("formatCurrency", () => { + it("dispatches to VND formatter", () => { + expect(formatCurrency(5000, "VND")).toBe("5.000 VND"); + }); + it("dispatches to USD formatter", () => { + expect(formatCurrency(100.5, "USD")).toBe("$100.50"); + }); + it("falls back for unknown currency", () => { + expect(formatCurrency(42, "EUR")).toBe("42 EUR"); + }); + }); + + describe("formatPnL", () => { + it("shows positive P&L", () => { + expect(formatPnL(12000000, 10000000)).toBe("+2.000.000 VND (+20.00%)"); + }); + it("shows negative P&L", () => { + expect(formatPnL(8000000, 10000000)).toBe("-2.000.000 VND (-20.00%)"); + }); + it("handles zero investment", () => { + expect(formatPnL(0, 0)).toBe("+0 VND (+0.00%)"); + }); + }); +}); diff --git a/tests/modules/trading/handlers.test.js b/tests/modules/trading/handlers.test.js new file mode 100644 index 0000000..86e9fff --- /dev/null +++ b/tests/modules/trading/handlers.test.js @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStore } from "../../../src/db/create-store.js"; +import { + handleBuy, + handleConvert, + handleSell, + handleTopup, +} from "../../../src/modules/trading/handlers.js"; +import { savePortfolio } from "../../../src/modules/trading/portfolio.js"; +import { handleStats } from "../../../src/modules/trading/stats-handler.js"; +import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; + +/** Build a fake grammY context with .match, .from, and .reply spy */ +function makeCtx(match = "", userId = 42) { + const replies = []; + return { + match, + from: { id: userId }, + reply: vi.fn((text) => replies.push(text)), + replies, + }; +} + +/** Stub global.fetch to return canned price data */ +function stubFetch() { + global.fetch = vi.fn((url) => { + if (url.includes("coingecko")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + bitcoin: { vnd: 1500000000 }, + ethereum: { vnd: 50000000 }, + solana: { vnd: 3000000 }, + "pax-gold": { vnd: 72000000 }, + }), + }); + } + if (url.includes("tcbs")) { + const ticker = url.match(/ticker=(\w+)/)?.[1]; + const prices = { TCB: 25, VPB: 18, FPT: 120, VNM: 70, HPG: 28 }; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }), + }); + } + if (url.includes("er-api")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ rates: { VND: 25400 } }), + }); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); +} + +describe("trading/handlers", () => { + let db; + + beforeEach(() => { + db = createStore("trading", { KV: makeFakeKv() }); + stubFetch(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("handleTopup", () => { + it("tops up VND", async () => { + const ctx = makeCtx("5000000 VND"); + await handleTopup(ctx, db); + expect(ctx.reply).toHaveBeenCalledOnce(); + expect(ctx.replies[0]).toContain("5.000.000 VND"); + }); + + it("tops up USD and tracks VND equivalent in totalvnd", async () => { + const ctx = makeCtx("100 USD"); + await handleTopup(ctx, db); + expect(ctx.replies[0]).toContain("$100.00"); + }); + + it("defaults to VND when currency omitted", async () => { + const ctx = makeCtx("1000000"); + await handleTopup(ctx, db); + expect(ctx.replies[0]).toContain("VND"); + }); + + it("rejects missing args", async () => { + const ctx = makeCtx(""); + await handleTopup(ctx, db); + expect(ctx.replies[0]).toContain("Usage:"); + }); + + it("rejects negative amount", async () => { + const ctx = makeCtx("-100 VND"); + await handleTopup(ctx, db); + expect(ctx.replies[0]).toContain("positive"); + }); + + it("rejects unsupported currency", async () => { + const ctx = makeCtx("100 EUR"); + await handleTopup(ctx, db); + expect(ctx.replies[0]).toContain("Unsupported"); + }); + }); + + describe("handleBuy", () => { + it("buys crypto with sufficient VND", async () => { + // seed VND balance + const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js"); + const p = emptyPortfolio(); + p.currency.VND = 20000000000; + p.totalvnd = 20000000000; + await savePortfolio(db, 42, p); + + const ctx = makeCtx("0.01 BTC"); + await handleBuy(ctx, db); + expect(ctx.replies[0]).toContain("Bought"); + expect(ctx.replies[0]).toContain("BTC"); + }); + + it("rejects buy with insufficient VND", async () => { + const ctx = makeCtx("1 BTC"); + await handleBuy(ctx, db); + expect(ctx.replies[0]).toContain("Insufficient VND"); + }); + + it("rejects unknown symbol", async () => { + const ctx = makeCtx("1 NOPE"); + await handleBuy(ctx, db); + expect(ctx.replies[0]).toContain("Unknown symbol"); + }); + + it("rejects fractional stock quantity", async () => { + const ctx = makeCtx("1.5 TCB"); + await handleBuy(ctx, db); + expect(ctx.replies[0]).toContain("whole numbers"); + }); + + it("rejects missing args", async () => { + const ctx = makeCtx(""); + await handleBuy(ctx, db); + expect(ctx.replies[0]).toContain("Usage:"); + }); + }); + + describe("handleSell", () => { + it("sells crypto holding", async () => { + const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js"); + const p = emptyPortfolio(); + p.crypto.BTC = 0.5; + await savePortfolio(db, 42, p); + + const ctx = makeCtx("0.1 BTC"); + await handleSell(ctx, db); + expect(ctx.replies[0]).toContain("Sold"); + expect(ctx.replies[0]).toContain("BTC"); + }); + + it("rejects sell with insufficient holdings", async () => { + const ctx = makeCtx("1 BTC"); + await handleSell(ctx, db); + expect(ctx.replies[0]).toContain("Insufficient BTC"); + }); + }); + + describe("handleConvert", () => { + it("converts USD to VND", async () => { + const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js"); + const p = emptyPortfolio(); + p.currency.USD = 100; + await savePortfolio(db, 42, p); + + const ctx = makeCtx("50 USD VND"); + await handleConvert(ctx, db); + expect(ctx.replies[0]).toContain("Converted"); + expect(ctx.replies[0]).toContain("VND"); + }); + + it("rejects same currency conversion", async () => { + const ctx = makeCtx("100 VND VND"); + await handleConvert(ctx, db); + expect(ctx.replies[0]).toContain("same currency"); + }); + + it("rejects insufficient balance", async () => { + const ctx = makeCtx("100 USD VND"); + await handleConvert(ctx, db); + expect(ctx.replies[0]).toContain("Insufficient USD"); + }); + }); + + describe("handleStats", () => { + it("shows empty portfolio for new user", async () => { + const ctx = makeCtx(""); + await handleStats(ctx, db); + expect(ctx.replies[0]).toContain("Portfolio Summary"); + expect(ctx.replies[0]).toContain("Total value:"); + }); + + it("shows portfolio with assets", async () => { + const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js"); + const p = emptyPortfolio(); + p.currency.VND = 5000000; + p.crypto.BTC = 0.01; + p.totalvnd = 20000000; + await savePortfolio(db, 42, p); + + const ctx = makeCtx(""); + await handleStats(ctx, db); + expect(ctx.replies[0]).toContain("BTC"); + expect(ctx.replies[0]).toContain("P&L:"); + }); + }); +}); diff --git a/tests/modules/trading/portfolio.test.js b/tests/modules/trading/portfolio.test.js new file mode 100644 index 0000000..ee8f1ac --- /dev/null +++ b/tests/modules/trading/portfolio.test.js @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createStore } from "../../../src/db/create-store.js"; +import { + addAsset, + addCurrency, + deductAsset, + deductCurrency, + emptyPortfolio, + getPortfolio, + savePortfolio, +} from "../../../src/modules/trading/portfolio.js"; +import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; + +describe("trading/portfolio", () => { + /** @type {import("../../../src/db/kv-store-interface.js").KVStore} */ + let db; + + beforeEach(() => { + db = createStore("trading", { KV: makeFakeKv() }); + }); + + describe("emptyPortfolio", () => { + it("returns correct shape", () => { + const p = emptyPortfolio(); + expect(p.currency).toEqual({ VND: 0, USD: 0 }); + expect(p.stock).toEqual({}); + expect(p.crypto).toEqual({}); + expect(p.others).toEqual({}); + expect(p.totalvnd).toBe(0); + }); + }); + + describe("getPortfolio / savePortfolio", () => { + it("returns empty portfolio for new user", async () => { + const p = await getPortfolio(db, 123); + expect(p.currency.VND).toBe(0); + expect(p.totalvnd).toBe(0); + }); + + it("round-trips saved data", async () => { + const p = emptyPortfolio(); + p.currency.VND = 5000000; + p.totalvnd = 5000000; + await savePortfolio(db, 123, p); + const loaded = await getPortfolio(db, 123); + expect(loaded.currency.VND).toBe(5000000); + expect(loaded.totalvnd).toBe(5000000); + }); + + it("fills missing category keys on load (migration-safe)", async () => { + // simulate old data missing 'others' key + await db.putJSON("user:123", { currency: { VND: 100 }, stock: {}, crypto: {} }); + const p = await getPortfolio(db, 123); + expect(p.others).toEqual({}); + expect(p.totalvnd).toBe(0); + }); + }); + + describe("addCurrency / deductCurrency", () => { + it("adds currency", () => { + const p = emptyPortfolio(); + addCurrency(p, "VND", 1000000); + expect(p.currency.VND).toBe(1000000); + }); + + it("deducts currency when sufficient", () => { + const p = emptyPortfolio(); + p.currency.VND = 5000000; + const result = deductCurrency(p, "VND", 3000000); + expect(result.ok).toBe(true); + expect(p.currency.VND).toBe(2000000); + }); + + it("rejects deduction when insufficient", () => { + const p = emptyPortfolio(); + p.currency.VND = 1000; + const result = deductCurrency(p, "VND", 5000); + expect(result.ok).toBe(false); + expect(result.balance).toBe(1000); + expect(p.currency.VND).toBe(1000); // unchanged + }); + }); + + describe("addAsset / deductAsset", () => { + it("adds crypto asset", () => { + const p = emptyPortfolio(); + addAsset(p, "BTC", 0.5); + expect(p.crypto.BTC).toBe(0.5); + }); + + it("adds stock asset", () => { + const p = emptyPortfolio(); + addAsset(p, "TCB", 10); + expect(p.stock.TCB).toBe(10); + }); + + it("adds others asset", () => { + const p = emptyPortfolio(); + addAsset(p, "GOLD", 1); + expect(p.others.GOLD).toBe(1); + }); + + it("accumulates on repeated add", () => { + const p = emptyPortfolio(); + addAsset(p, "BTC", 0.1); + addAsset(p, "BTC", 0.2); + expect(p.crypto.BTC).toBeCloseTo(0.3); + }); + + it("deducts asset when sufficient", () => { + const p = emptyPortfolio(); + p.crypto.BTC = 1.0; + const result = deductAsset(p, "BTC", 0.3); + expect(result.ok).toBe(true); + expect(p.crypto.BTC).toBeCloseTo(0.7); + }); + + it("removes key when deducted to zero", () => { + const p = emptyPortfolio(); + p.crypto.BTC = 0.5; + deductAsset(p, "BTC", 0.5); + expect(p.crypto.BTC).toBeUndefined(); + }); + + it("rejects deduction when insufficient", () => { + const p = emptyPortfolio(); + p.crypto.BTC = 0.1; + const result = deductAsset(p, "BTC", 0.5); + expect(result.ok).toBe(false); + expect(result.held).toBe(0.1); + }); + + it("rejects deduction for unknown symbol", () => { + const p = emptyPortfolio(); + const result = deductAsset(p, "NOPE", 1); + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/tests/modules/trading/symbols.test.js b/tests/modules/trading/symbols.test.js new file mode 100644 index 0000000..ffa5955 --- /dev/null +++ b/tests/modules/trading/symbols.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + CURRENCIES, + SYMBOLS, + getSymbol, + listSymbols, +} from "../../../src/modules/trading/symbols.js"; + +describe("trading/symbols", () => { + it("SYMBOLS has 9 entries across 3 categories", () => { + expect(Object.keys(SYMBOLS)).toHaveLength(9); + const cats = new Set(Object.values(SYMBOLS).map((s) => s.category)); + expect(cats).toEqual(new Set(["crypto", "stock", "others"])); + }); + + it("getSymbol is case-insensitive", () => { + const btc = getSymbol("btc"); + expect(btc).toBeDefined(); + expect(btc.symbol).toBe("BTC"); + expect(btc.category).toBe("crypto"); + + expect(getSymbol("Tcb").symbol).toBe("TCB"); + }); + + it("getSymbol returns undefined for unknown symbols", () => { + expect(getSymbol("NOPE")).toBeUndefined(); + expect(getSymbol("")).toBeUndefined(); + expect(getSymbol(null)).toBeUndefined(); + }); + + it("CURRENCIES contains VND and USD", () => { + expect(CURRENCIES.has("VND")).toBe(true); + expect(CURRENCIES.has("USD")).toBe(true); + expect(CURRENCIES.has("EUR")).toBe(false); + }); + + it("listSymbols returns grouped output", () => { + const out = listSymbols(); + expect(out).toContain("Crypto:"); + expect(out).toContain("BTC — Bitcoin"); + expect(out).toContain("Stocks:"); + expect(out).toContain("TCB — Techcombank"); + expect(out).toContain("Others:"); + expect(out).toContain("GOLD — Gold"); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index a8ee6f4..9dad84d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,7 +5,7 @@ compatibility_date = "2025-10-01" # Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js. # Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list. [vars] -MODULES = "util,wordle,loldle,misc" +MODULES = "util,wordle,loldle,misc,trading" # KV namespace holding all module state. Each module auto-prefixes its keys via createStore(). # Create with: