Repository navigation
Compute per-wallet trading PnL and a leaderboard from indexed events #155
Description
Overview
The only performance numbers the API returns are the agent's own database floats: getPortfolioSummary (src/services/portfolio.service.ts) sums Position.pnl/size against a hard-coded bankroll = 10_000. They are not derived from the chain, ignore fees and redemptions, and cannot be compared with human or copy traders, who are not tracked at all.
Market-core emits everything needed to compute exact, verifiable trading results for every wallet: Trade { market_id, trader, outcome, is_buy, collateral, shares, fee, reserve_yes, reserve_no }, Redeemed { market_id, holder, payout } and MarketResolved { market_id, outcome: Option<Outcome> }. The indexer already stores these in chain_events. From them we can build a per-wallet, per-market, per-outcome ledger with average-cost basis, realised and unrealised PnL, volume, fees and win rate, in which the agent (the treasury contract) is just another trader.
This issue builds that ledger as an incrementally maintained projection that is correct under replays, and adds GET /leaderboard and GET /wallets/:address/stats.
Background & current behavior
src/services/indexer.service.ts:backfillEventsstores events inchain_eventsand callsapplySideEffectsonly for newly stored ones.TradeupdatescurrentYesProb,MarketResolvedcloses agentPositions, andRedeemed/MarketCreatedare stored but not projected.- Contract semantics (
contracts/market-core/src/lib.rs):buy→collateral = collateral_in(gross, including the fee),shares = shares_out.sell→collateral = collateral_out(net received),shares = shares_in.redeempayssettle_value(kind, yes, no)(void:(yes + no) / 2). Agent trades appear withtrader= the treasury contract.
src/generated/fpmm.ts:priceYesBps(reserveYes, reserveNo),calcSell,collateralOutForShares(the exact proceeds from selling N shares, useful for a liquidation-value mark),settleValue.chain.service.tsgetPosition(marketId, holder)returns on-chainPosition { yes, no }for reconciliation.ChainEventis indexed on(contractName, eventName),ledgerandtxHash.
Goals
- New tables
wallet_positions(per wallet × market × outcome) andwallet_stats_daily(per wallet × day × category), maintained incrementally fromMarketCreated,Trade,RedeemedandMarketResolvedevents. - Exact integer accounting in 7-decimal base units (BigInt/NUMERIC), with average-cost basis and realised PnL on partial sells.
- Unrealised PnL at the FPMM price, and settlement value once final (including void).
- Idempotent under re-delivery, and a full rebuild from
chain_eventsequals the incremental state. GET /leaderboard?window=7d|30d|all&category=&metric=pnl|volume|roi&page&limitandGET /wallets/:address/stats.- The treasury address is labelled as the OracleDesk agent.
Non-goals
- Full LP PnL for market creators (seed liquidity,
claim_pool_remainder). - Fees collected by the protocol (
FeesSwept). - A generic replay engine (separate issue); only a rebuild for these tables.
Proposed implementation
- Schema (new migration).
model WalletPosition { wallet String onChainMarketId BigInt outcome String // 'Yes' | 'No' shares Decimal @db.Decimal(39,0) // current shares held costBasis Decimal @db.Decimal(39,0) // total cost of current shares (avg-cost) realizedPnl Decimal @db.Decimal(39,0) boughtCollateral Decimal @db.Decimal(39,0) soldCollateral Decimal @db.Decimal(39,0) feesPaid Decimal @db.Decimal(39,0) redeemed Decimal @db.Decimal(39,0) tradeCount Int lastEventId String // highest applied chain_events.id for this row settled Boolean @default(false) updatedAt DateTime @updatedAt @@id([wallet, onChainMarketId, outcome]) @@index([onChainMarketId]) @@map("wallet_positions") } model WalletStatsDaily { wallet String; day DateTime @db.Date; category String; volume Decimal @db.Decimal(39,0); realizedPnl Decimal @db.Decimal(39,0); fees Decimal @db.Decimal(39,0); trades Int; wins Int; losses Int @@id([wallet, day, category]) @@index([day]) @@map("wallet_stats_daily") } model WalletEventApplied { eventId String @id; appliedAt DateTime @default(now()) @@map("wallet_events_applied") }
wallet_events_appliedis the idempotency ledger (P2002 on insert → already applied). src/services/wallet-pnl.service.ts(new). Pure accounting first.Rules:export interface Lot { shares: bigint; cost: bigint; realized: bigint; fees: bigint; bought: bigint; sold: bigint; redeemed: bigint; trades: number } export function applyBuy(l: Lot, collateralIn: bigint, sharesOut: bigint, fee: bigint): Lot; // cost += collateralIn; shares += sharesOut export function applySell(l: Lot, collateralOut: bigint, sharesIn: bigint, fee: bigint): { lot: Lot; realized: bigint }; // avgCost = cost * sharesIn / shares (floor, remainder stays in basis); realized = collateralOut - removedCost export function applySettlement(yes: Lot, no: Lot, kind: 'yes'|'no'|'void'): { yes: Lot; no: Lot; realized: bigint }; export function applyRedeem(l: Lot, payout: bigint): Lot; // reconciles against settlement export function markToMarket(l: Lot, outcome: 'Yes'|'No', reserveYes: bigint, reserveNo: bigint, mode: 'mid'|'liquidation', feeBps: number): bigint;
- Fees: buy collateral includes the fee (so it sits in cost basis, also tracked in
fees); sell collateral is net of the fee. - Settlement: on
MarketResolved(Some(o)), the winning lot's value isshares(1 unit per share) and the losing lot's is 0. For void, each share is worth ½, but void redemption pays(yes+no)/2per position, so settle per wallet across both outcome rows together (integer division happens on the sum, matchingsettle_value). Realised PnL isvalue − costBasis, and lots becomesettled. - Creator seed shares:
create_marketgives the creatorseed − reserveleftover shares with noTradeevent (seed_reservesinmath.rs). OnMarketCreated, record them in a separate LP lot (outcomesuffix:lp, cost basis =seed_amount) excluded from trading PnL, so a creator's laterRedeemedreconciles. - Redeemed:
payoutshould equal the settlement value of the holder's trading plus LP lots. Record it asredeemedand log a reconciliation warning when it differs. - Wallets that hold positions from before the indexer's first ledger (retention gap): mark
incomplete = trueon the row (add the column) and exclude them from rankings by default.
- Fees: buy collateral includes the fee (so it sits in cost basis, also tracked in
- Projection hook. In
src/services/indexer.service.tsapplySideEffects, route market-coreMarketCreated,Trade,RedeemedandMarketResolvedtoapplyWalletEvent(event). In oneprisma.$transactionit inserts intowallet_events_applied, updates the affectedWalletPositionrows underSELECT … FOR UPDATE, and upsertsWalletStatsDailykeyed byledgerClosedAtday and the market's category (fromMarketCreatedwhen the backend does not know the market). Because side effects are never retried today, also addrebuildWalletPnl({ fromScratch: true }), which truncates the three tables and replays every relevantchain_eventsrow in(ledger, id)order through the same function. Expose it asnpm run pnl:rebuild(scripts/rebuild-wallet-pnl.ts, new). - Ordering. Events must be applied in chain order per market. The indexer processes pages in order, but a retried or failed side effect could be applied late. When an event older than a row's
lastEventIdarrives, record the market inwallet_pnl_dirty_marketsand rebuild its rows fromchain_eventsin a background pass. - Read models.
GET /leaderboard(src/routes/leaderboard.routes.ts,src/controllers/leaderboard.controller.ts, both new): zod-validated query. Forwindow=all,pnl = realized + unrealized, marked with the latest reserves (stored as raw reserve columns by the indexer's existingTradehandler). For 7d/30d, usewallet_stats_dailyrealised PnL plus the mark change.roi = pnl / max(bought, 1). Return{ rank, wallet, label, pnlRaw, volumeRaw, roiBps, trades, winRate, incomplete }, with amounts as decimal strings.GET /wallets/:address/stats(validatedG…/C…): totals, per-category breakdown, open positions with marks, and the last 50 trades fromchain_events.- Label
CONTRACTS.treasuryas"OracleDesk agent (treasury)". Mount both routers insrc/routes/index.tsand cache responses for 30 s in Redis (getRedis()).
- Docs. Add a "Leaderboard and wallet stats" section to
docs/api.mdwith the exact accounting rules (average cost, fee treatment, void settlement, windowed PnL definition,incompleteflag), so third parties can reproduce the numbers from chain data.
Edge cases & pitfalls
- Average-cost removal must conserve value (remainder stays in the basis); assert
costBasis >= 0andshares >= 0after every step. - Selling more shares than the ledger shows (incomplete history) must clamp, mark
incompleteand log, never go negative. - Void settlement per row would round twice; settle both outcome lots together.
claim_pool_remainderemits noRedeemed, so LP remainder never appears as trading PnL.- Prisma
Decimal↔ BigInt conversion: never go throughNumber. Add helperstoBig(d: Prisma.Decimal)andtoDec(b: bigint). - The
wallet_events_appliedinsert and position updates must share one transaction.
Testing plan
tests/wallet-pnl.test.ts(new):- unit tests for
applyBuy,applySell(partial and full),applySettlement(yes, no, void),applyRedeemandmarkToMarketagainst hand-computed examples usingcalcBuy/calcSellfromsrc/generated/fpmm.ts; void settlement matches settle_value for combined yes+no position;creator seed shares are tracked as an LP lot and reconcile with Redeemed;- property test, with a seeded PRNG generating random sequences of buys, sells, resolve and redeem across 3 wallets and 2 markets:
incremental application equals full rebuildandapplying any event twice is a no-op; out-of-order event marks market dirty and rebuild restores exact state;sell beyond known shares marks position incomplete instead of going negative;- indexer integration with
tests/fixtures/events.jsonplus syntheticRedeemedevents.
- unit tests for
tests/http.test.ts:GET /leaderboard validates window and metric,GET /wallets/:address/stats rejects malformed addresses.- Commands:
npx prisma migrate dev --name wallet_pnl,npm run build,npm test,npm run check:contracts.
Acceptance criteria
-
wallet_positions,wallet_stats_dailyandwallet_events_appliedexist, with money in integer base units (no floats). - Indexed market-core events update the wallet ledger idempotently, one transaction per event.
- Average-cost accounting, fee handling and yes/no/void settlement match the documented rules and contract math, verified by unit tests.
- A property test proves that incremental results equal a full rebuild from
chain_events, and that duplicate events are no-ops. - Out-of-order events trigger a per-market rebuild instead of corrupting state, and
npm run pnl:rebuildrebuilds everything. -
GET /leaderboardsupports windows, category filter, metrics and pagination, and labels the treasury as the agent. -
GET /wallets/:address/statsreturns totals, breakdowns, open positions and recent trades. - Wallets with history before the indexer's first ledger are flagged
incompleteand excluded from rankings by default. -
docs/api.mddocuments the endpoints and accounting rules. -
npm run build,npm testandnpm run check:contractspass.
Resources
src/services/indexer.service.ts,src/services/portfolio.service.ts,src/services/chain.service.ts,src/generated/fpmm.tssrc/routes/index.ts,src/utils/response.util.ts,src/lib/redis.ts,src/config/contracts.tsprisma/schema.prisma,docs/api.md,tests/indexer.test.ts,tests/fixtures/events.json- Contracts repo:
contracts/market-core/src/lib.rs(buy,sell,redeem, events),contracts/market-core/src/math.rs(settle_value) - Stellar RPC
getEvents: https://developers.stellar.org/docs/data/apis/rpc/api-reference/methods/getEvents - Contracts repo
model/fpmm_model.py(reference FPMM model) - Prisma Decimal: https://www.prisma.io/docs/orm/reference/prisma-schema-reference#decimal
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
lekan305 commentedon Oct 17, 2026
@lekan305 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @lekan305 to this issue.
bade2brazy commentedon Oct 17, 2026
@bade2brazy has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @bade2brazy to this issue.
Nuruddeen61 commentedon Oct 17, 2026
@Nuruddeen61 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Nuruddeen61 to this issue.
bless22611 commentedon Oct 17, 2026
@bless22611 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @bless22611 to this issue.
koladearthur commentedon Oct 17, 2026
@koladearthur has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @koladearthur to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @bade2brazy! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @bade2brazy: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
bade2brazy commentedon Oct 17, 2026
Resolved in PR #161
drips-wave commentedon Oct 25, 2026
This issue has been completed by @bade2brazy as part of the Stellar Wave Program's 10th Wave 🥳
😎 @bade2brazy: You earned 200 Points for completing this issue! Once the Wave's rewards are finalized, you'll be eligible for a percentage of the Wave's reward pool based on the percentage of total points you've earned. Learn more here. You can also Leave a review to share your experience working on this issue.
🧑💻 Repo maintainers: How'd the contributor do? Leave a review to share your experience working with them.