Skip to content

fix(trader-stats): aggregate in the database instead of capping at 10k rows (GH#2510) - #2512

Merged
dcccrypto merged 2 commits into
playgroundfrom
fix/2510-trader-stats-db-aggregate
Aug 12, 2026
Merged

fix(trader-stats): aggregate in the database instead of capping at 10k rows (GH#2510)#2512
dcccrypto merged 2 commits into
playgroundfrom
fix/2510-trader-stats-db-aggregate

Conversation

@dcccrypto

@dcccrypto dcccrypto commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes #2510.

The bug

GET /api/trader/:wallet/stats returns fields named totalTrades, totalVolume, totalFees, uniqueMarkets, firstTradeAt, lastTradeAt. Both backends fetched at most 10 000 rows and reduced them in JavaScript, so any wallet past that cap had its partial history returned as exact totals — no flag, no cursor, no range.

One detail worth adding to the report: both queries order by created_at ASC, so the cap keeps the oldest 10 000 trades. lastTradeAt was therefore not an approximation of the most recent trade — it was the timestamp of the 10 000th oldest one, and it stops advancing entirely once a wallet crosses the cap. A trader's "last trade" would freeze at a date in their past and never move again.

Verified against playground@41bb1304: indexer-db.ts:466 (LIMIT 10000, ORDER BY created_at ASC) and the two .limit(10_000) calls at route.ts:140/148.

The fix

Primary path (local indexer) — exact. Aggregates in SQL over the full history via a new queryTraderStatsAggregate: no cap, and one row on the wire instead of up to 10 000.

The arithmetic mirrors the old JS reducer deliberately, so the two paths cannot drift:

volume += abs(trunc(size)) * round(price * 1e6) / 1e6
fees   += round(fee)

size is truncated at the decimal point to match the reducer's String(size).split(".")[0], and the sums stay in numeric, so they don't inherit the float rounding a Number() round-trip would introduce.

Supabase fallback — honest. Aggregating there needs a server-side function, i.e. a schema change. I've deliberately kept that out of scope, since a schema-deployment dependency is exactly what made #2504 unschedulable. What this path no longer does is present a partial sum as a total: when the row count reaches the cap it sets truncated: true.

The field is optional and additive (truncated?: boolean), so existing consumers are unaffected — absent means complete.

On scope

I could have made the fallback exact with a Postgres aggregate function in supabase/schema.sql. I didn't, because it would tie this to a deployment step and the primary path is the one actually serving these stats (hasIndexerDb() is preferred). Happy to follow up with the RPC if you'd rather have both paths exact — it's a small function, it just needs somewhere to land.

Tests

  • The flag on the fallback: set at the cap, absent below it, absent when empty.
  • The aggregate row mapping, including the zero-trade case — Postgres returns one row of NULLs for an aggregate over an empty set rather than no row, which is the case most likely to regress into NaN.

One test needed hardening, found by mutation rather than by reading. The "money stays a string" assertion originally used 987654321, which survives a Number() round-trip intact — so it passed even against an implementation that parsed the value as a float. It now uses a value above MAX_SAFE_INTEGER, and there's a comment recording why, so nobody "simplifies" it back.

Mutation-tested

round result
control 9 passed
drop the truncated flag 1 failed
aggregate returns first for lastTradeAt 1 failed
money mapped via String(Number(...)) 1 failed
control 9 passed

Verification

pnpm exec vitest run     303 files, 3056 passed, 17 skipped
pnpm exec tsc --noEmit   exit 0

The 22 pre-existing trader-stats tests (route, hook, rate-limit) all still pass unchanged.

Summary by CodeRabbit

  • New Features

    • Trader statistics are now calculated more efficiently while preserving existing totals, volumes, fees, markets, and trade dates.
    • Large monetary values remain accurate in trader statistics.
  • Bug Fixes

    • Results capped at 10,000 records are now clearly marked as partial.
    • Partial trade histories display an explanation and no longer show incomplete market counts.
  • Tests

    • Added coverage for aggregate calculations, rounding, empty results, large values, date handling, and capped responses.

…k rows (GH#2510)

`GET /api/trader/:wallet/stats` returns fields named `totalTrades`,
`totalVolume`, `totalFees`, `uniqueMarkets`, `firstTradeAt` and `lastTradeAt`.
Both backends fetched at most 10 000 rows and reduced them in JavaScript, so any
wallet past that cap got its PARTIAL history returned as exact totals, with no
flag, cursor or range to say so.

Worth being precise about how wrong it was: both queries order by
`created_at ASC`, so the cap kept the OLDEST 10 000 trades. `lastTradeAt` was
therefore not an approximation of the most recent trade -- it was the timestamp
of the 10 000th oldest one, and it would stop advancing entirely once a wallet
crossed the cap.

Primary path (local indexer) now aggregates in SQL over the full history via
queryTraderStatsAggregate: exact totals, no cap, and one row on the wire instead
of up to 10 000. The arithmetic mirrors the old JS reducer deliberately so the
two cannot drift:

    volume += abs(trunc(size)) * round(price * 1e6) / 1e6
    fees   += round(fee)

`size` is truncated at the decimal point, matching the reducer's
`String(size).split(".")[0]`, and the sums stay in `numeric` so they do not
inherit the float rounding a Number() round-trip would introduce.

Supabase fallback still reads rows, because aggregating there needs a
server-side function -- a schema change, deliberately out of scope here. What it
no longer does is present a partial sum as a total: when the row count reaches
the cap it sets `truncated: true` on the response. The field is optional and
additive, so existing consumers are unaffected; absent means complete.

Tests cover the flag on the fallback (set at the cap, absent below it, absent
when empty) and the mapping of the aggregate row, including the zero-trade case
-- Postgres returns one row of NULLs for an aggregate over an empty set rather
than no row, which is the case most likely to regress into NaN.

One test needed hardening after mutation testing, and says so in a comment: the
"money stays a string" assertion originally used 987654321, which survives a
Number() round-trip intact, so it passed even against an implementation that
parsed the value as a float. It now uses a value above MAX_SAFE_INTEGER, which
fails that mutation.

Mutation-tested, control either side:

  control                                   9 passed
  drop the `truncated` flag                 1 failed
  aggregate returns first for lastTradeAt   1 failed
  money mapped via String(Number(...))      1 failed
  control                                   9 passed

Verified: 303 files, 3056 passed, 17 skipped; tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
percolator-launch Ready Ready Preview Aug 12, 2026 2:25pm
percolator-mainnet Ready Ready Preview Aug 12, 2026 2:25pm
percolator-playground Ready Ready Preview Aug 12, 2026 2:25pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Trader statistics now use PostgreSQL aggregation for indexer data. Supabase fallback queries share a row cap and expose truncation metadata. The trade statistics panel labels capped histories as partial.

Changes

Trader statistics

Layer / File(s) Summary
Database aggregate statistics
app/lib/indexer-db.ts, app/__tests__/lib/trader-stats-aggregate.test.ts, app/__tests__/lib/trader-stats-sql-parity.test.ts
Added TraderStatsAggregate and queryTraderStatsAggregate. PostgreSQL computes counts, volumes, fees, markets, and timestamps. Tests cover empty results, numeric values, and SQL rounding parity.
Route aggregation and fallback metadata
app/app/api/trader/[wallet]/stats/route.ts, app/__tests__/api/trader-stats.test.ts
The route uses database aggregation for indexer data. Supabase fallback queries use SUPABASE_ROW_CAP and set truncated when the cap is reached.
Partial-history presentation
app/components/trade/TradeStatsPanel.tsx
Truncated responses display “Trades (partial)” and an earliest-trades explanation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TraderStatsPanel
  participant TraderStatsRoute
  participant queryTraderStatsAggregate
  participant PostgreSQL
  TraderStatsPanel->>TraderStatsRoute: request trader statistics
  TraderStatsRoute->>queryTraderStatsAggregate: request wallet aggregate
  queryTraderStatsAggregate->>PostgreSQL: execute aggregate query
  PostgreSQL-->>queryTraderStatsAggregate: return aggregate statistics
  queryTraderStatsAggregate-->>TraderStatsRoute: return full-history statistics
  TraderStatsRoute-->>TraderStatsPanel: return statistics and optional truncated flag
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states that trader statistics use database aggregation instead of a 10,000-row cap.
Linked Issues check ✅ Passed The changes address full-history aggregation, truncation metadata, partial-result labeling, precision, empty results, and regression coverage required by issue #2510.
Out of Scope Changes check ✅ Passed The changes remain within issue #2510 scope and support aggregation correctness, fallback behavior, UI labeling, and regression coverage.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/2510-trader-stats-db-aggregate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Bayyan16

Copy link
Copy Markdown
Contributor

Thanks for picking this up and for the detailed fix. I went through the patch again against the original #2510 report. Moving the primary indexer path to a full database aggregate addresses the main 10k truncation/root-cause cleanly, including the frozen lastTradeAt case.

I noticed two details that may be worth checking:

  • The original Trader stats silently truncate wallets above 10,000 trades and report partial history as totals #2510 invariant called for truncation to be explicit in both the API contract and UI when a bounded result is returned. The Supabase fallback now exposes truncated: true, but TradeStatsPanel does not appear to consume that field yet and still renders the values under the existing total labels. If keeping that UI handling outside this PR is intentional, a follow-up for the fallback path would cover that remaining part of the report.

  • Could we double-check the totalVolume equivalence for fractional values? The previous reducer performs integer division per trade:

totalVolume += (absSize * priceE6) / 1_000_000n

while the SQL currently sums the numeric per-row expressions and casts the final aggregate to bigint. For example, with size = 1 and price = 1.5, the JS path contributes 1, whereas the SQL expression produces 1.5 before the final cast. A small DB-level fractional fixture would help pin the intended semantics here.

Other than those two points, the primary-path change lines up well with the root cause reported in #2510. Thanks for addressing it.

Both points from @Bayyan16's review on #2512. The first is a real defect in what
I pushed, not a nit.

1. VOLUME ARITHMETIC. I claimed the SQL "mirrors the JS reducer exactly". It did
   not. The reducer accumulates with BigInt division:

       totalVolume += (absSize * priceE6) / 1_000_000n

   which TRUNCATES on every trade. The aggregate summed the fractional per-row
   values and let the final `::bigint` cast round once at the end. Those are
   different numbers, and the gap is not a rounding error -- it compounds with
   row count:

       size=1 price=1.5, x1     reducer 1    sum-then-round 2
       size=3 price=0.33        reducer 0    sum-then-round 1
       size=1 price=1.9, x10    reducer 10   sum-then-round 19

   The reviewer's 1.5 example is exact. Volume is now truncated per row before
   summing, so the sum is integral and the cast is exact.

   While fixing it, `round()` became `floor(x + 0.5)` for both price and fee:
   Postgres `round()` and JS `Math.round` disagree on negative halves
   (`Math.round(-1.5) === -1`, `round(-1.5) = -2`). The reducer used Math.round,
   so the SQL should too.

2. UI. #2510's invariant covers the contract AND the display. The API reporting
   `truncated` is not enough on its own, because the panel is where a reader
   forms the belief. TradeStatsPanel now renders "Trades (partial)" with
   "partial history — showing the earliest trades only" instead of "Total
   Trades" plus a market count when the flag is set.

Tests: fractional fixtures pinning per-row parity against the reducer, as the
review asked for, including the sub-unit case that truncates to zero and the
ten-row case that drifts to 19.

One thing worth flagging about those fixtures, since it would otherwise be
invisible: they model BOTH implementations in JavaScript, because CI has no
Postgres. That documents the contract but does not enforce it -- verified by
mutation, reverting the real SQL left all of them green. So the file also
asserts the per-row `trunc(` and the `floor(x + 0.5)` forms against the source
of `queryTraderStatsAggregate`. That pair binds: the fixtures say why, the
source assertions make a revert fail.

Mutation-tested, control either side:

  control                                 11 passed
  revert SQL to sum-then-round             1 failed
  swap floor(x+0.5) back to round()        1 failed
  control                                 11 passed

Verified: 304 files, 3067 passed, 17 skipped; tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dcccrypto

Copy link
Copy Markdown
Owner Author

Both points were right, and the first one was a real defect in what I pushed — thank you for catching it. Fixed in db42aab.

1. The volume arithmetic. My claim that the SQL "mirrors the JS reducer exactly" was wrong.

You identified it precisely. The reducer accumulates with BigInt division —

totalVolume += (absSize * priceE6) / 1_000_000n;

— which truncates on every trade. My aggregate summed the fractional per-row values and let the final ::bigint cast round once at the end. I ran both against fixtures rather than reasoning about it, and the gap is not a rounding error — it compounds with row count:

rows reducer my SQL as pushed
size=1, price=1.5 1 2
size=3, price=0.33 0 1
size=1, price=1.9 ×10 10 19

Your 1.5 example is exact. Volume is now truncated per row before summing, so the sum is integral and the cast is exact.

While in there I also changed round() to floor(x + 0.5) for both price and fee. Postgres round() and JS Math.round disagree on negative halves — Math.round(-1.5) === -1, round(-1.5) = -2 — and since the reducer used Math.round, the SQL should match it rather than be approximately right.

2. The UI. You're right that the invariant covers the display, not just the contract.

TradeStatsPanel now renders "Trades (partial)" with "partial history — showing the earliest trades only" when truncated is set, instead of "Total Trades" and a market count. The API being honest isn't sufficient on its own, because the panel is where a reader actually forms the belief.

On the fixtures you asked for — one caveat I want visible rather than buried.

I added the fractional fixtures, including the sub-unit case that truncates to zero and the ten-row case that drifts to 19. But they model both implementations in JavaScript, because CI has no Postgres — so they document the contract without enforcing it. I checked that rather than assuming: mutating the real SQL back to sum-then-round left every fixture green.

So the file also asserts the per-row trunc( and the floor(x + 0.5) forms against the source of queryTraderStatsAggregate. The pair is what binds — the fixtures say why the shape is required, the source assertions make a revert fail. Mutation-verified both ways:

round result
control 11 passed
revert SQL to sum-then-round 1 failed
swap floor(x+0.5) back to round() 1 failed
control 11 passed

A real DB-level fixture would be better than either, and if the indexer ever gets a test Postgres in CI that is where this belongs.

Full suite: 304 files, 3067 passed; tsc --noEmit exit 0.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/lib/indexer-db.ts`:
- Around line 519-520: Update the aggregate query against the trades table to
obtain the network via getServerNetwork(), pass it to the aggregate operation,
and add a network = ${network} predicate alongside the existing trader filter.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d1ad294-a894-43e4-9d72-e5833a7a3b3a

📥 Commits

Reviewing files that changed from the base of the PR and between 41bb130 and db42aab.

📒 Files selected for processing (6)
  • app/__tests__/api/trader-stats.test.ts
  • app/__tests__/lib/trader-stats-aggregate.test.ts
  • app/__tests__/lib/trader-stats-sql-parity.test.ts
  • app/app/api/trader/[wallet]/stats/route.ts
  • app/components/trade/TradeStatsPanel.tsx
  • app/lib/indexer-db.ts

Comment thread app/lib/indexer-db.ts
Comment on lines +519 to +520
FROM trades
WHERE trader = ${wallet}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline app/lib/indexer-db.ts --items all

rg -n -C 4 --glob '*.ts' --glob '*.tsx' \
  '\bqueryTraderStatsAggregate\s*\(|\bgetServerNetwork\s*\(|INDEXER_DATABASE_URL|\bnetwork\b' app

rg -n -i -C 3 --glob '*.sql' --glob '*.ts' --glob '*.tsx' \
  'create table.*trades|alter table.*trades|\bnetwork\b' .

Repository: dcccrypto/percolator-launch

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- indexer-db outline ---'
ast-grep outline app/lib/indexer-db.ts --items all | sed -n '1,220p'

printf '%s\n' '--- aggregate and nearby code ---'
rg -n -C 18 'queryTraderStatsAggregate|FROM trades|trader = \$\{wallet\}' app/lib/indexer-db.ts

printf '%s\n' '--- aggregate callers ---'
rg -n -C 12 '\bqueryTraderStatsAggregate\s*\(' app --glob '*.ts' --glob '*.tsx'

printf '%s\n' '--- indexer database configuration references ---'
rg -n -i -C 5 \
  'INDEXER_DATABASE_URL|indexer[-_ ]?(database|db)|DATABASE_URL|trades' \
  --glob '!**/node_modules/**' \
  --glob '!**/.next/**' \
  --glob '!**/dist/**' \
  --glob '!**/build/**' \
  .

printf '%s\n' '--- candidate schema/deployment files ---'
git ls-files | rg -i '(^|/)(schema|migration|migrations|docker-compose|compose|supabase|prisma|drizzle|indexer|database|db)([^/]*)($|/)|\.sql$' | sed -n '1,240p'

Repository: dcccrypto/percolator-launch

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- app/lib/indexer-db.ts: imports, config, and aggregate ---'
sed -n '1,180p' app/lib/indexer-db.ts
sed -n '430,560p' app/lib/indexer-db.ts

printf '%s\n' '--- exact aggregate references ---'
rg -n -C 10 \
  'queryTraderStatsAggregate|queryTraderStats|trader/\[wallet\]/stats|from ".*indexer-db"' \
  app/lib/indexer-db.ts \
  app/app/api \
  app/__tests__/api/trader-stats.test.ts

printf '%s\n' '--- exact indexer URL references ---'
rg -n -C 6 'INDEXER_DATABASE_URL' \
  --glob '!**/node_modules/**' \
  --glob '!**/.next/**' \
  --glob '!**/dist/**' \
  --glob '!**/build/**' \
  app .env* Dockerfile* docker-compose*.yml docker-compose*.yaml 2>/dev/null || true

printf '%s\n' '--- tracked SQL and indexer-related files ---'
git ls-files | rg -i '(^|/)([^/]*(schema|migration|migrations|indexer|database|db)[^/]*)$|\.sql$' | sed -n '1,300p'

Repository: dcccrypto/percolator-launch

Length of output: 50383


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- indexer-related tracked files ---'
git ls-files | rg -i 'indexer|migration|schema|postgres|database|supabase|docker|compose|railway|render|fly|heroku' | sed -n '1,400p'

printf '%s\n' '--- local indexer network references ---'
rg -n -C 5 \
  'network|mainnet|devnet|single[- ]network|multi[- ]network|shared' \
  app/lib/indexer-db.ts \
  app/app/api/trader/'[wallet]'/stats/route.ts \
  app/lib/supabase.ts \
  app/lib/config.ts \
  README.md PLAYGROUND.md 2>/dev/null || true

printf '%s\n' '--- trader stats fallback ---'
sed -n '114,220p' app/app/api/trader/'[wallet]'/stats/route.ts

printf '%s\n' '--- active server network implementation ---'
rg -n -C 12 \
  'export function getServerNetwork|function getServerNetwork|getServerNetwork\s*=' \
  app/lib app/app/api --glob '*.ts'

Repository: dcccrypto/percolator-launch

Length of output: 48013


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Dockerfile.indexer ---'
cat -n Dockerfile.indexer

printf '%s\n' '--- docker-compose indexer/database sections ---'
rg -n -C 12 \
  'indexer|postgres|DATABASE|NETWORK|mainnet|devnet|SUPABASE' \
  docker-compose.yml

printf '%s\n' '--- mainnet indexer environment example ---'
cat -n docs/env/indexer.env.mainnet.example

printf '%s\n' '--- architecture and deployment network/database references ---'
rg -n -C 8 \
  'indexer|database|Postgres|Supabase|network|mainnet|devnet|shared' \
  docs/ARCHITECTURE.md \
  docs/BACKEND-ARCHITECTURE.md \
  docs/MAINNET-READINESS.md \
  docs/MAINNET-ROADMAP.md \
  packages/indexer/README.md \
  scripts/deploy-mainnet-railway.sh 2>/dev/null || true

printf '%s\n' '--- network-column migrations ---'
sed -n '1,220p' supabase/migrations/20260329170000_add_network_column_PERC8192.sql
sed -n '1,220p' supabase/migrations/20260329180000_add_network_column.sql

Repository: dcccrypto/percolator-launch

Length of output: 50385


Scope the local aggregate by network.

The indexer trades table includes network, and INDEXER_DATABASE_URL does not guarantee a single-network database. Pass getServerNetwork() to the aggregate and add AND network = ${network}.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/indexer-db.ts` around lines 519 - 520, Update the aggregate query
against the trades table to obtain the network via getServerNetwork(), pass it
to the aggregate operation, and add a network = ${network} predicate alongside
the existing trader filter.

@dcccrypto

Copy link
Copy Markdown
Owner Author

On CodeRabbit's finding (missing network predicate on the aggregate) — it is a real issue, but it is pre-existing and repo-wide, not something this PR introduces, and I'd rather not fix it blind inside a PR scoped to #2510.

What I checked:

  • The query this replaces, queryTraderStatsRows, filtered on trader only — no network predicate. The aggregate preserves that behaviour exactly.
  • No query in indexer-db.ts filters by network. Not trades, not funding history, not candles. The column exists (it is in the module's own schema comment), it is simply never used on this read path.
  • The Supabase fallback in the same route does filter (.eq("network", getServerNetwork())), so the two backends already disagree about network isolation — again, before this PR.

So fixing it only in the aggregate would make trader stats network-isolated while candles, funding and trade history on the same database stay cross-network. That is a worse state than either consistent option, and it hides the real problem.

One thing that makes a blind fix riskier than it looks, and which I checked rather than assumed. The two sides derive the value from different environment variables:

side source values
indexer writes getNetwork() (@percolatorct/shared) → process.env.NETWORK "mainnet" | "devnet"
launch reads getServerNetwork()process.env.NEXT_PUBLIC_DEFAULT_NETWORK "mainnet" | "devnet"

The vocabularies match, which is the good news. But they are different variables, and both default to devnet when unset — so a deployment with NETWORK=mainnet on the indexer and NEXT_PUBLIC_DEFAULT_NETWORK unset on launch would start returning zero stats for every wallet rather than cross-network ones. Silently zeroing an endpoint is a worse failure than the one being fixed, and it is not something I can verify from here.

I have raised it as its own issue covering all of indexer-db.ts, with that env check as a prerequisite, so it does not get lost in a merged PR's comment thread.

Not making the change here. Happy to be overruled if you would rather take the narrow version now.

@Bayyan16

Copy link
Copy Markdown
Contributor

Agreed. From my side, I wouldn’t treat the network predicate as a blocker for #2512. Since the behavior is pre-existing across indexer-db.ts and the read/write network values come from separate environment variables, handling it consistently in #2513 after verifying the deployment configuration seems safer than changing only the aggregate here.

I’m fine treating the network-scoping item from #2510 as a follow-up rather than expanding this truncation fix. Thanks for checking the broader path before applying the narrow change.

@dcccrypto

Copy link
Copy Markdown
Owner Author

Review: looks good, merge

The bug is worse than "stats are approximate", and the PR gets that right.

The defect

The row cap orders by created_at ASC, so it kept the oldest 10 000 trades. lastTradeAt wasn't approximate — it was the timestamp of the 10 000th trade rather than the most recent one, presented as the wallet's last trade.

What I checked hardest: the SQL/JS arithmetic parity

This is where a "just move it into SQL" change goes wrong silently, and the PR documents that it did go wrong on the first attempt. The per-row trunc() is load-bearing: the JS reducer accumulates with BigInt division, truncating on every trade, so summing fractions and rounding once is a genuinely different number — the doc comment's example (ten trades of size 1 at price 1.9 giving 10 vs 19) is not a rounding error.

I mutation-tested the parity guard rather than trusting it:

mutation to the SQL result
drop the per-row trunc() caught
floor(x + 0.5)round() caught

Both are exactly the failure modes the comment warns about, and both fail the guard.

Scoping is honest

The indexer path aggregates over full history. The Supabase fallback can't without a server-side function, so it stays capped — but now sets truncated, and TradeStatsPanel relabels to "Trades (partial)" with "partial history — showing the earliest trades only". That satisfies the issue's invariant on both the API contract and the UI, rather than quietly fixing one backend and leaving the other lying.

Verified

  • Merges clean onto playground
  • tsc --noEmit: 0 errors
  • Suite: 3050 → 3067 passed, 0 failed (+17, no regressions)

One note, not blocking

Two assertions in trader-stats-sql-parity.test.ts are textual — regex over the indexer-db.ts source rather than over behaviour. They'd break on an equivalent reformat, and could in principle pass on SQL that matches the pattern but is wrong elsewhere. Given you can't stand up Postgres in a unit test, pairing a behavioural model of the formula with a textual pin that the SQL implements that formula is a reasonable trade — and as above, it demonstrably catches the two mutations that matter. Worth a comment noting the limitation if the SQL is ever reformatted.

@dcccrypto
dcccrypto merged commit 8d3e280 into playground Aug 12, 2026
15 checks passed
@dcccrypto
dcccrypto deleted the fix/2510-trader-stats-db-aggregate branch August 12, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants