Cash Flow Detection in NAV-Only Data — How NakedPnL Handles Deposits and Withdrawals
Most exchange APIs report NAV but not cash flow history at usable granularity. This guide walks through how NakedPnL detects deposits and withdrawals and why the trade-offs matter.
- Time-weighted return depends on cleanly identifying every deposit and withdrawal so they can be excluded from the return calculation.
- Most exchange balance APIs report total NAV but do not surface deposit/withdrawal history at the granularity TWR requires.
- NakedPnL detects probable cash flows from large day-over-day NAV jumps, with venue-tuned thresholds.
- Detection is intentionally biased toward false positives over false negatives — over-flagging is correctable, under-flagging silently inflates returns.
- Confirmed cash flows are persisted in the append-only CashFlow table with provenance, source venue, and reviewer attribution.
Time-weighted return is only honest if every external cash flow into and out of the account is cleanly identified and excluded from the return calculation. A 50,000 USD deposit on a 100,000 USD account is not a 50% return — it is a 50% subscription. A 50,000 USD withdrawal on a 100,000 USD account is not a 50% loss. The TWR engine has to know about every flow to chain-link the sub-period returns correctly.
This would be straightforward if exchange APIs reliably reported deposits and withdrawals at usable granularity. They do not. The balance and equity endpoints are first-class; the cash-flow ledger is often paginated, rate-limited, locked behind separate scopes, or simply incomplete for older periods. NakedPnL has to detect probable cash flows from the NAV path itself and reconcile with whatever venue-side ledger data is available. This guide walks through how that detection works, what its failure modes look like, and how the CashFlow table records confirmed flows for verification.
Why NAV alone is not enough
Consider three days of NAV samples for a single account: 100,000 USD, 105,000 USD, 155,000 USD. The day 1 to day 2 change is +5%. The day 2 to day 3 change appears to be +47.6%. Did the trader make 47% in 24 hours, or did they deposit 50,000 USD overnight and earn nothing? With NAV alone the question cannot be answered.
If NakedPnL silently treated the day 3 NAV as pure return the published TWR would be wildly wrong, and the chain hash would be locked in around that wrong number. Conversely, if NakedPnL marked every large move as a deposit by default, traders who do produce occasional large gains (a single high-conviction trade, a payout from a prediction market resolving in their favor) would have their genuine returns hidden as cash flows.
What exchange APIs actually expose
| Venue | Endpoint | Granularity | Lookback | Quality |
|---|---|---|---|---|
| Binance | /sapi/v1/capital/deposit/hisrec, /withdraw/history | Per-transaction | 180 days default, 90 days reliable | Good for recent dates, gappy beyond 6 months |
| Bybit | /v5/asset/deposit-record, /withdraw-record | Per-transaction | 180 days | Good but rate-limited, paginated |
| OKX | /api/v5/asset/deposit-history, /withdraw-history | Per-transaction | 90 days | Acceptable, requires reading scope expansion |
| IBKR | Flex Web Service — DepositsWithdrawals statement | Per-event with timestamp | Up to inception | Excellent — IBKR's Flex queries are designed for audit |
| Polymarket | Subgraph + on-chain ERC20 transfers | Per-transfer at block time | Full history | Excellent — every flow is on Polygon and queryable |
| Kalshi | /v3/portfolio/positions does not include flows | None native | N/A | Detection-only; flows inferred from NAV jumps |
IBKR and Polymarket are the well-instrumented end of the spectrum. The Flex Web Service for IBKR returns a complete DepositsWithdrawals statement and the timestamps are second-precision. Polymarket every flow is an on-chain ERC20 transfer that can be reconstructed from a Polygon archive node or the official subgraph. For these venues NakedPnL imports the venue ledger directly and detection is reconciliation, not inference.
The crypto centralized-exchange APIs (Binance, Bybit, OKX) are the noisy end. Recent deposits and withdrawals are reliable; data older than three to six months becomes incomplete. For older periods NakedPnL falls back to detection-from-NAV-jumps and asks the trader to reconcile. Kalshi has no native cash-flow endpoint at all, so detection is the only mechanism.
The detection algorithm
The detector compares each daily NAV sample to its predecessor and flags any move that exceeds a venue-specific threshold expressed as a percent of NAV. The threshold is calibrated per venue based on the empirical distribution of daily returns observed across the active trader population. The thresholds in production are conservative — they are deliberately tighter than the 99.5th percentile of daily returns for the venue.
| Venue | Default threshold | 99.5th-percentile daily return |
|---|---|---|
| Binance (spot) | +/- 25% | approx +/- 12% |
| Binance (futures) | +/- 40% | approx +/- 22% |
| Bybit | +/- 35% | approx +/- 18% |
| OKX | +/- 35% | approx +/- 18% |
| IBKR | +/- 15% | approx +/- 5% |
| Polymarket | +/- 50% | approx +/- 35% |
| Kalshi | +/- 45% | approx +/- 25% |
When a sample crosses the threshold the detector raises a CandidateCashFlow row and freezes the TWR calculation for that trader at the previous day until the candidate is resolved. Resolution happens by automated reconciliation (matching against the venue's deposit/withdrawal endpoint when available) or by human review when the venue does not expose enough information for automated matching.
// lib/cash-flow/detector.ts (simplified)
const THRESHOLDS: Record<Venue, number> = {
BINANCE_SPOT: 0.25,
BINANCE_FUT: 0.40,
BYBIT: 0.35,
OKX: 0.35,
IBKR: 0.15,
POLYMARKET: 0.50,
KALSHI: 0.45,
};
export function detectCandidate(
prev: NavSnapshot,
curr: NavSnapshot,
venue: Venue,
): CandidateCashFlow | null {
if (prev.navUsd.isZero()) return null;
const change = curr.navUsd.minus(prev.navUsd).dividedBy(prev.navUsd);
if (change.abs().lessThan(THRESHOLDS[venue])) return null;
return {
traderId: curr.traderId,
detectedOn: curr.snapshotDate,
direction: change.isPositive() ? "INFLOW" : "OUTFLOW",
impliedAmountUsd: curr.navUsd.minus(prev.navUsd).abs(),
state: "PENDING_REVIEW",
};
}Reconciliation against the venue ledger
For venues that do expose cash-flow endpoints, the reconciliation worker fetches the deposit/withdrawal history for the candidate's date window (typically the day of the candidate plus or minus one day to absorb timezone fuzz). It tries to find a venue-side flow whose USD value is within 0.5% of the impliedAmountUsd. A match resolves the candidate to CONFIRMED with the venue's transaction id stored as provenance.
If no match is found, the candidate is escalated to the human-review queue. The reviewer (NakedPnL operations or, in the trader-self-resolution flow, the trader themselves with venue-side proof) classifies the candidate as either a real flow or a real return. A real flow is recorded in the CashFlow table; a real return is dismissed and the TWR calculation resumes including the day's NAV change as performance.
Worked example — a misclassified flow
Suppose a Binance trader with a 20,000 USD account hits a 100% return in a single day on a leveraged position. The new NAV is 40,000 USD. The detector sees a +100% day-over-day change, well above the +25% threshold, and raises a candidate. There is no matching deposit on Binance's deposit endpoint. The candidate is escalated to human review.
If the reviewer wrongly classifies it as a deposit the result is that a real return is hidden. The trader's published TWR for that day shows zero return, the NAV jump is offset by a synthetic 20,000 USD deposit in CashFlow, and the cumulative TWR is understated by approximately 100%. This is a serious error, but it is correctable: the trader sees the misclassification on their dashboard, supplies venue-side evidence (a screenshot of the trade history, an exported PnL report), and the reviewer reverses the call. A correction row is appended to the CashFlow table with reason=REVERSAL and the chain re-derives.
If the same flow is wrongly classified the other way — a real 20,000 USD deposit is treated as a 100% return — the failure is the opposite. The published TWR overstates by approximately 100% on that day and the cumulative track record is materially wrong. This is much harder to correct because the trader must also produce evidence, but a trader showing inflated returns has weaker incentive to correct the error than a trader showing deflated returns. This asymmetry of incentives is precisely why the thresholds are set tighter than the empirical distribution suggests.
| Outcome | Recorded as | Day TWR | Cumulative impact |
|---|---|---|---|
| Correct call (real return) | +100% return | +100% | Track record honest |
| Correct call (real deposit) | +0% return, +20k deposit | +0% | Track record honest |
| False positive (deposit, was return) | +0% return, +20k deposit | +0% (under) | Track record understated; trader will dispute |
| False negative (return, was deposit) | +100% return | +100% (over) | Track record overstated; trader unlikely to dispute |
The CashFlow table as append-only ledger
Confirmed cash flows are written to the CashFlow table. The table is append-only: existing rows are never updated or deleted. Corrections and reversals are themselves new rows that reference the original via correctsId. This is the same append-only discipline as the NavSnapshot chain, and it serves the same purpose: any verifier reading the table can see the full audit history of how a flow was classified and (if applicable) re-classified.
model CashFlow {
id String @id @default(cuid())
traderId String
flowDate DateTime // when the flow occurred at the venue
detectedAt DateTime // when the detector raised the candidate
resolvedAt DateTime
direction CashFlowDirection // INFLOW | OUTFLOW
amountUsd Decimal
source CashFlowSource // VENUE_LEDGER | TRADER_EVIDENCE | DETECTOR_INFERENCE
venueTxId String? // venue-side id when reconciled
reviewerId String? // operations user when human-reviewed
reason CashFlowReason // INITIAL | REVERSAL | ADJUSTMENT
correctsId String? // points at the row this row corrects
trader CryptoTrader @relation(fields: [traderId], references: [id])
corrects CashFlow? @relation("CashFlowCorrection",
fields: [correctsId], references: [id])
correctedBy CashFlow[] @relation("CashFlowCorrection")
@@index([traderId, flowDate])
}How TWR consumes confirmed flows
The TWR engine in lib/calculation/twr-engine.ts reads the CashFlow table at calculation time. For each trader, it loads all CONFIRMED flows ordered by flowDate. The day-by-day return calculation chain-links sub-period returns at the boundaries of each flow. A deposit that arrives mid-day is handled by the Modified Dietz day-weight approximation when the venue provides only a date, or by an exact intra-day weight when the venue provides a timestamp.
function subPeriodReturn(
navStart: Decimal,
navEnd: Decimal,
flow: CashFlow | null,
): Decimal {
if (!flow) return navEnd.dividedBy(navStart).minus(1);
// INFLOW between navStart and navEnd
if (flow.direction === "INFLOW") {
return navEnd.minus(flow.amountUsd).dividedBy(navStart).minus(1);
}
// OUTFLOW between navStart and navEnd
return navEnd.plus(flow.amountUsd).dividedBy(navStart).minus(1);
}What ends up on the verified track record
- The CashFlow rows are publicly visible (when the trader's user.isPublic flag is true) at /verify/chain/[handle].json under a flows array.
- Each row carries provenance: venue-side transaction id when available, reviewer id when human-confirmed, detector inference when neither is available.
- The TWR engine logs which CashFlow rows it consumed for each sub-period, so a verifier can re-derive the published TWR exactly.
- Reversals appear as separate rows pointing at the original via correctsId. The chain shows the full history.
- A trader cannot retroactively delete a wrongly-classified flow; they can only append a reversal that explains and corrects it.
Common edge cases
There are a handful of recurring edge cases worth flagging. The first is multiple flows in a single day. Most venues report each flow individually, but the day-over-day NAV jump nets them. The detector raises one candidate, reconciliation finds two ledger entries, and the CashFlow table records both with the same flowDate but different timestamps and venueTxIds.
The second is rebates and dust. Some venues credit small affiliate rebates or VIP rebates that look like flows but are properly returns. NakedPnL classifies these as returns by default with a 1 USD floor on flow detection — anything below that threshold is presumed return regardless of detector signal.
The third is venue token airdrops. When an exchange distributes a token to all users, the user's NAV jumps without a corresponding deposit. NakedPnL treats venue airdrops as flows because they are external to the trader's strategy. The provenance is DETECTOR_INFERENCE with a venueAirdrop=true flag for transparency.
Summary
- Time-weighted return requires clean cash-flow attribution. Most exchange APIs do not provide it in usable form.
- NakedPnL detects probable cash flows from large day-over-day NAV jumps with venue-tuned thresholds.
- Detection is biased toward false positives over false negatives because under-flagging silently inflates returns.
- Reconciliation against venue ledgers (where available) confirms or rejects each candidate. Otherwise human review with trader-supplied evidence resolves the case.
- The CashFlow table is append-only. Reversals are new rows that reference the original; existing rows are never edited.
- Verifiers can see every confirmed flow, its provenance, and any subsequent corrections directly from the public chain JSON.