Appearance
FeeManager
Handles fee tier lookup and 14-day trailing volume tracking for trade fee calculations.
Purpose
FeeManager determines the fee rate for each trade based on a 7-tier volume-based fee structure. It tracks daily trading volume in a daily_volume SQLite table and uses the trailing 14-day volume (excluding the current day) to determine the user's fee tier.
The manager supports both taker fees (market orders, close orders) and maker fees (limit orders). All monetary calculations use BigNumber for exact decimal precision, since SQLite's REAL type lacks the precision needed for crypto trading.
High-Level Design
7-Tier Fee Structure
Tiers are determined by the 14-day trailing volume (excluding the current day):
| Tier | Volume Threshold | Taker Fee | Maker Fee |
|---|---|---|---|
| 0 | $0 | 0.045% | 0.015% |
| 1 | $5M | 0.040% | 0.012% |
| 2 | $25M | 0.035% | 0.008% |
| 3 | $100M | 0.030% | 0.004% |
| 4 | $500M | 0.028% | 0.000% |
| 5 | $2B | 0.026% | 0.000% |
| 6 | $7B | 0.024% | 0.000% |
Tiers are sorted descending by threshold; the first tier where volume >= threshold wins.
Fee Calculation Flow
Trade submitted (market order / limit order execution / close)
↓
getCurrentFeeTier()
→ get14DayVolume() queries daily_volume for [today-14d, today)
→ Match against FEE_TIERS_BY_VOLUME_DESCENDING
↓
calculateFee(sizeUsd, feeType)
→ fee = sizeUsd × feeRate
↓
Fee adjustment (in OrderExecutionManager)
→ actualFee = min(calculatedFee, max(balance, 0))
→ Orders always execute; fee reduced if balance insufficient
↓
Phase 2 commit (atomic transaction)
→ balanceManager.applyPnL(realizedPnL, actualFee)
→ feeManager.updateVolume(sizeUsd, actualFee, feeType)Taker vs Maker Classification
- Taker: Market orders and close orders — immediate execution, takes liquidity
- Maker: Limit orders — passive, waits for price to cross threshold before executing
The fee type is determined by the caller (OrderExecutionManager passes FeeType.TAKER for market/close, PriceAlertManager passes FeeType.MAKER for limit order execution).
14-Day Rolling Volume Window
Today: 2025-02-01
Window: [2025-01-18 00:00 UTC, 2025-02-01 00:00 UTC)
2025-01-17: Excluded (outside window)
2025-01-18: Included (start of window)
2025-01-31: Included (last day in window)
2025-02-01: Excluded (current day)Current day exclusion: The current day's trades are excluded from the volume calculation. This prevents the fee tier from changing mid-day as the user trades. The tier is effectively locked at UTC midnight.
No tier caching: The tier is recalculated fresh on every order by querying the database. This guarantees correctness without stale cache concerns.
Daily Volume Tracking
updateVolume() maintains a per-day, per-account record with taker/maker breakdown:
- If record exists for today: Reads current values, adds new volume/fee via BigNumber arithmetic, updates in place
- If no record: Inserts a new row with the trade's volume and fee
Each record tracks volume_usd (total), taker_volume_usd, maker_volume_usd, and total_fee_usd — providing a per-day breakdown for analytics and audit.
The volume_usd column is TEXT (not REAL) because SQLite REAL has ~15 significant digits — insufficient for high-volume accounts. All arithmetic is done in JavaScript with BigNumber, then serialized back to TEXT.
Account Scoping
All volume queries are scoped to account_id, not user_id. Each challenge attempt or account reset creates a new account_id, so:
- Volume doesn't carry across resets
- Each new attempt starts at Tier 0
- Historical volume data is retained (immutable) but isolated by account
Fee Affordability Adjustment
Orders always execute regardless of balance. If the user's balance can't cover the full fee, the fee is reduced to whatever the balance can afford (floored at zero). Only the actualFee (the capped amount) is recorded in the trades table. This "eventually correct" approach keeps trading functional while MLL breach detection handles account failure separately.
Volume Accounting Rules
- Only executed trades update volume (pending orders and cancellations do not)
- Volume updates happen inside the Phase 2 atomic transaction alongside balance and position changes
- Idempotency keys at the RPC layer prevent duplicate volume entries from retried requests
Edge Cases & Error Handling
cleanupOldVolume()is a no-op: historical volume data is kept for audit trail since queries filter byaccount_id- Fee tiers are recalculated per-order (no caching) to avoid stale tier after UTC midnight rollover
See Also
- OrderExecutionManager - Calls fee calculation during trade execution
- AnalyticsManager - Reads volume history
- UserPaperTradePortfolio - Parent DO