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.
This commit is contained in:
2026-04-14 15:16:53 +07:00
parent e752548733
commit c9270764f2
20 changed files with 1841 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 <amount> [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 <amount> <symbol>`
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 <amount> <symbol>`
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 <amount> <from> <to>`
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 <amount> <symbol>` |
| `/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)

View File

@@ -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

View File

@@ -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

View File

@@ -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