mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +00:00
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:
102
plans/260414-1457-trading-module/phase-01-symbols-and-format.md
Normal file
102
plans/260414-1457-trading-module/phase-01-symbols-and-format.md
Normal 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
|
||||
120
plans/260414-1457-trading-module/phase-02-prices.md
Normal file
120
plans/260414-1457-trading-module/phase-02-prices.md
Normal 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
|
||||
90
plans/260414-1457-trading-module/phase-03-portfolio.md
Normal file
90
plans/260414-1457-trading-module/phase-03-portfolio.md
Normal 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
|
||||
134
plans/260414-1457-trading-module/phase-04-commands.md
Normal file
134
plans/260414-1457-trading-module/phase-04-commands.md
Normal 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)
|
||||
67
plans/260414-1457-trading-module/phase-05-wiring.md
Normal file
67
plans/260414-1457-trading-module/phase-05-wiring.md
Normal 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
|
||||
140
plans/260414-1457-trading-module/phase-06-tests.md
Normal file
140
plans/260414-1457-trading-module/phase-06-tests.md
Normal 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
|
||||
56
plans/260414-1457-trading-module/plan.md
Normal file
56
plans/260414-1457-trading-module/plan.md
Normal 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
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
80
src/modules/trading/format.js
Normal file
80
src/modules/trading/format.js
Normal file
@@ -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}%)`;
|
||||
}
|
||||
164
src/modules/trading/handlers.js
Normal file
164
src/modules/trading/handlers.js
Normal file
@@ -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 <amount> [currency=VND] */
|
||||
export async function handleTopup(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 1) return usageReply(ctx, "/trade_topup <amount> [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 <amount> <symbol> */
|
||||
export async function handleBuy(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_buy <amount> <SYMBOL>\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 <amount> <symbol> */
|
||||
export async function handleSell(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_sell <amount> <SYMBOL>\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 <amount> <from> <to> */
|
||||
export async function handleConvert(ctx, db) {
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 3)
|
||||
return usageReply(
|
||||
ctx,
|
||||
"/trade_convert <amount> <FROM> <TO>\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)}`);
|
||||
}
|
||||
52
src/modules/trading/index.js
Normal file
52
src/modules/trading/index.js
Normal file
@@ -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;
|
||||
107
src/modules/trading/portfolio.js
Normal file
107
src/modules/trading/portfolio.js
Normal file
@@ -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<Portfolio>}
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
131
src/modules/trading/prices.js
Normal file
131
src/modules/trading/prices.js
Normal file
@@ -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<number|null>}
|
||||
*/
|
||||
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<number>}
|
||||
*/
|
||||
export async function getForexRate(db, currency) {
|
||||
if (currency === "VND") return 1;
|
||||
const prices = await getPrices(db);
|
||||
return prices.forex?.[currency] ?? null;
|
||||
}
|
||||
63
src/modules/trading/stats-handler.js
Normal file
63
src/modules/trading/stats-handler.js
Normal file
@@ -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"));
|
||||
}
|
||||
53
src/modules/trading/symbols.js
Normal file
53
src/modules/trading/symbols.js
Normal file
@@ -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<Record<string, SymbolEntry>>} */
|
||||
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");
|
||||
}
|
||||
79
tests/modules/trading/format.test.js
Normal file
79
tests/modules/trading/format.test.js
Normal file
@@ -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%)");
|
||||
});
|
||||
});
|
||||
});
|
||||
216
tests/modules/trading/handlers.test.js
Normal file
216
tests/modules/trading/handlers.test.js
Normal file
@@ -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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
139
tests/modules/trading/portfolio.test.js
Normal file
139
tests/modules/trading/portfolio.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
tests/modules/trading/symbols.test.js
Normal file
46
tests/modules/trading/symbols.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user