Skip to content

Compute per-wallet trading PnL and a leaderboard from indexed events #155

Closed

Description

@Levi-Ojukwu

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: backfillEvents stores events in chain_events and calls applySideEffects only for newly stored ones. Trade updates currentYesProb, MarketResolved closes agent Positions, and Redeemed/MarketCreated are 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.
    • redeem pays settle_value(kind, yes, no) (void: (yes + no) / 2). Agent trades appear with trader = 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.ts getPosition(marketId, holder) returns on-chain Position { yes, no } for reconciliation. ChainEvent is indexed on (contractName, eventName), ledger and txHash.

Goals

  • New tables wallet_positions (per wallet × market × outcome) and wallet_stats_daily (per wallet × day × category), maintained incrementally from MarketCreated, Trade, Redeemed and MarketResolved events.
  • 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_events equals the incremental state.
  • GET /leaderboard?window=7d|30d|all&category=&metric=pnl|volume|roi&page&limit and GET /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

  1. 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_applied is the idempotency ledger (P2002 on insert → already applied).
  2. src/services/wallet-pnl.service.ts (new). Pure accounting first.
    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;
    Rules:
    • 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 is shares (1 unit per share) and the losing lot's is 0. For void, each share is worth ½, but void redemption pays (yes+no)/2 per position, so settle per wallet across both outcome rows together (integer division happens on the sum, matching settle_value). Realised PnL is value − costBasis, and lots become settled.
    • Creator seed shares: create_market gives the creator seed − reserve leftover shares with no Trade event (seed_reserves in math.rs). On MarketCreated, record them in a separate LP lot (outcome suffix :lp, cost basis = seed_amount) excluded from trading PnL, so a creator's later Redeemed reconciles.
    • Redeemed: payout should equal the settlement value of the holder's trading plus LP lots. Record it as redeemed and log a reconciliation warning when it differs.
    • Wallets that hold positions from before the indexer's first ledger (retention gap): mark incomplete = true on the row (add the column) and exclude them from rankings by default.
  3. Projection hook. In src/services/indexer.service.ts applySideEffects, route market-core MarketCreated, Trade, Redeemed and MarketResolved to applyWalletEvent(event). In one prisma.$transaction it inserts into wallet_events_applied, updates the affected WalletPosition rows under SELECT … FOR UPDATE, and upserts WalletStatsDaily keyed by ledgerClosedAt day and the market's category (from MarketCreated when the backend does not know the market). Because side effects are never retried today, also add rebuildWalletPnl({ fromScratch: true }), which truncates the three tables and replays every relevant chain_events row in (ledger, id) order through the same function. Expose it as npm run pnl:rebuild (scripts/rebuild-wallet-pnl.ts, new).
  4. 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 lastEventId arrives, record the market in wallet_pnl_dirty_markets and rebuild its rows from chain_events in a background pass.
  5. Read models.
    • GET /leaderboard (src/routes/leaderboard.routes.ts, src/controllers/leaderboard.controller.ts, both new): zod-validated query. For window=all, pnl = realized + unrealized, marked with the latest reserves (stored as raw reserve columns by the indexer's existing Trade handler). For 7d/30d, use wallet_stats_daily realised 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 (validated G…/C…): totals, per-category breakdown, open positions with marks, and the last 50 trades from chain_events.
    • Label CONTRACTS.treasury as "OracleDesk agent (treasury)". Mount both routers in src/routes/index.ts and cache responses for 30 s in Redis (getRedis()).
  6. Docs. Add a "Leaderboard and wallet stats" section to docs/api.md with the exact accounting rules (average cost, fee treatment, void settlement, windowed PnL definition, incomplete flag), 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 >= 0 and shares >= 0 after every step.
  • Selling more shares than the ledger shows (incomplete history) must clamp, mark incomplete and log, never go negative.
  • Void settlement per row would round twice; settle both outcome lots together.
  • claim_pool_remainder emits no Redeemed, so LP remainder never appears as trading PnL.
  • Prisma Decimal ↔ BigInt conversion: never go through Number. Add helpers toBig(d: Prisma.Decimal) and toDec(b: bigint).
  • The wallet_events_applied insert 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), applyRedeem and markToMarket against hand-computed examples using calcBuy/calcSell from src/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 rebuild and applying 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.json plus synthetic Redeemed events.
  • 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_daily and wallet_events_applied exist, 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:rebuild rebuilds everything.
  • GET /leaderboard supports windows, category filter, metrics and pagination, and labels the treasury as the agent.
  • GET /wallets/:address/stats returns totals, breakdowns, open positions and recent trades.
  • Wallets with history before the indexer's first ledger are flagged incomplete and excluded from rankings by default.
  • docs/api.md documents the endpoints and accounting rules.
  • npm run build, npm test and npm run check:contracts pass.

Resources


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

  1. lekan305 commented on Oct 17, 2026

    @lekan305

    @lekan305 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    Hi maintainer, I'd very much appreciate the opportunity to work on this issue.
    Having reviewed the acceptance criteria, I'm confident that I'd be able to have a PR up shortly after being assigned.
    Kindly assign this issue to me.
    Thank you!

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @lekan305 to this issue.

  2. bade2brazy commented on Oct 17, 2026

    @bade2brazy
    Contributor

    @bade2brazy has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    assign this issue to me

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @bade2brazy to this issue.

  3. Nuruddeen61 commented on Oct 17, 2026

    @Nuruddeen61

    @Nuruddeen61 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    I love to work on this issue please assign me 🙏🙏🙏

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @Nuruddeen61 to this issue.

  4. bless22611 commented on Oct 17, 2026

    @bless22611

    @bless22611 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    Hi, I’d love to take on this issue! With my expertise in Rust, Stellar, and smart contract development, I can implement a clean and efficient solution.

    🚀 Why Me?
    Proven Blockchain Experience – Hands-on work with Stellar, Solidity, and Rust, building secure and optimized smart contracts.
    Open Source Contributor – Actively contributed to 50+ open-source projects, showcasing adaptability and collaboration.
    Efficient & Reliable – I deliver high-quality, production-ready solutions quickly, ensuring security and scalability.
    ⏱ Timeline
    I estimate completing this within 24 hours. Let me know if I can proceed!

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @bless22611 to this issue.

  5. koladearthur commented on Oct 17, 2026

    @koladearthur

    @koladearthur has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    Hi Maintainer, I’m interested in taking on this issue. I’ve read through the requirements and I’m ready to work on a solution that meets the project’s needs.

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @koladearthur to this issue.

  6. drips-wave commented on Oct 17, 2026

    @drips-wave

    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 🌊

  7. bade2brazy commented on Oct 17, 2026

    @bade2brazy
    Contributor

    Resolved in PR #161

  8. drips-wave commented on Oct 25, 2026

    @drips-wave

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions