Predictu
Trading Engine

Resolution & Settlement

When a prediction market reaches its end date or an outcome becomes known, the market enters the resolution phase. Predictu supports two resolution paths: resolveMarket (normal outcome determination with payouts) and voidMarket (cancellation with full refunds). Both are initiated by a God Mode admin or automatically via an oracle trigger.

Atomic guarantees: All settlement operations run inside a database transaction. Either every position is settled and every payout is credited, or the entire operation rolls back. There are no partial settlements.

Resolve Market Flow

The resolveMarket function is the primary settlement path. It determines winners and losers, calculates payouts, credits winners, notifies losers, and records the complete settlement.

Step-by-Step Flow

1
Load market - Fetch the market record and validate it is inopen or closed status. Markets already resolved or voided are rejected.
2
Set winning outcome - The admin selects which outcome won (e.g., “Yes” or “No”). This is stored on the market record asresolved_outcome.
3
Fetch all open positions - Load every position wheremarket_id matches and status = 'open'. This includes positions from all users across all operators.
4
Classify positions - Each position is classified as a winner or loser based on whether its outcome matches the resolved_outcome.
5
Calculate payouts - Winners receive $1.00 per share. Losers receive $0.00. The payout per winner is simply theirquantity (number of shares held).
6
Credit winners via wallet adapter - For each winning position, call walletAdapter.creditForWin(userId, payout, positionId). The wallet adapter sends a BET_WIN S2S callback to the operator’s server.
7
Notify losers via wallet adapter - For each losing position, call walletAdapter.notifyLoss(userId, costBasis, positionId). This sends a BET_LOSE notification to the operator’s server.
8
Create settlement record - A settlement row is inserted with aggregate statistics about the resolution.
9
Mark positions resolved - All positions for this market are updated to status = 'resolved' with the payout amount recorded.
10
Update market status - The market record is set tostatus = 'resolved' with the resolution timestamp.

Implementation

async function resolveMarket(
  marketId: string,
  winningOutcome: string,
  resolvedBy: string
): Promise<SettlementRecord> {
  return await db.transaction(async (tx) => {
    // Step 1: Load and validate market
    const market = await tx.getMarket(marketId);
    if (market.status === "resolved" || market.status === "voided") {
      throw new Error("Market already settled");
    }

    // Step 2: Set winning outcome
    await tx.updateMarket(marketId, {
      resolved_outcome: winningOutcome,
      resolved_by: resolvedBy,
      resolved_at: new Date().toISOString(),
    });

    // Step 3: Fetch all open positions
    const positions = await tx.getPositions({
      market_id: marketId,
      status: "open",
    });

    // Step 4 & 5: Classify and calculate
    const winners = positions.filter(
      (p) => p.outcome === winningOutcome
    );
    const losers = positions.filter(
      (p) => p.outcome !== winningOutcome
    );

    let totalPayout = 0;

    // Step 6: Credit winners ($1/share)
    for (const position of winners) {
      const payout = position.quantity; // $1 per share
      const adapter = getWalletAdapter(position.operator_id);
      await adapter.creditForWin(
        position.user_id,
        payout,
        position.id
      );
      totalPayout += payout;
    }

    // Step 7: Notify losers ($0 payout)
    for (const position of losers) {
      const adapter = getWalletAdapter(position.operator_id);
      await adapter.notifyLoss(
        position.user_id,
        position.cost_basis,
        position.id
      );
    }

    // Step 8: Create settlement record
    const totalCostBasis = positions.reduce(
      (sum, p) => sum + p.cost_basis, 0
    );
    const settlement = await tx.createSettlement({
      market_id: marketId,
      resolved_outcome: winningOutcome,
      total_positions: positions.length,
      winners_count: winners.length,
      losers_count: losers.length,
      total_payout: totalPayout,
      total_cost_basis: totalCostBasis,
      casino_profit: totalCostBasis - totalPayout,
      resolved_by: resolvedBy,
    });

    // Step 9: Mark positions resolved
    for (const position of positions) {
      const payout = position.outcome === winningOutcome
        ? position.quantity
        : 0;
      await tx.updatePosition(position.id, {
        status: "resolved",
        payout_amount: payout,
        resolved_at: new Date().toISOString(),
      });
    }

    // Step 10: Update market status
    await tx.updateMarket(marketId, { status: "resolved" });

    return settlement;
  });
}

Payout Calculation

Predictu uses a binary outcome model. Shares are priced between $0 and $1 based on implied probability. At resolution, the math is simple:

ScenarioPayout Per ShareExample
Winner (outcome matches)$1.00Bought 50 shares at $0.60 each ($30 cost). Receives $50. Profit: $20.
Loser (outcome does not match)$0.00Bought 50 shares at $0.40 each ($20 cost). Receives $0. Loss: $20.

Casino Profit Calculation

// Casino profit = total money collected - total money paid out
// total_cost_basis = sum of all position cost bases (money collected)
// total_payout = sum of all winner payouts (money paid out)

casino_profit = total_cost_basis - total_payout

// Example market:
// 100 "Yes" shares sold at avg $0.65 = $65 collected
// 80 "No" shares sold at avg $0.35 = $28 collected
// Total cost basis = $93
//
// If "Yes" wins: pay 100 × $1 = $100. Profit = $93 - $100 = -$7 (loss)
// If "No" wins: pay 80 × $1 = $80. Profit = $93 - $80 = $13 (profit)
Casino profit can be negative. When the winning side had more volume or lower average prices, the platform pays out more than it collected. The risk engine’s 5-wall defense system is designed to keep these losses bounded.

Void Market Flow

The voidMarket function cancels a market and refunds every user their original cost basis. This is used when a market is created in error, an event is cancelled, or the outcome becomes unknowable.

Step-by-Step Flow

1
Load and validate market - Same validation as resolve. Market must not already be settled.
2
Fetch all open positions - Load every open position for the market.
3
Refund all users - For each position, callwalletAdapter.refund(userId, costBasis, positionId). Every user gets back exactly what they paid, regardless of which outcome they bet on.
4
Create settlement record - Record the void withcasino_profit = 0 and total_payout = total_cost_basis.
5
Mark positions voided - All positions are updated tostatus = 'voided' with payout_amount = cost_basis.
6
Update market status - Market is set tostatus = 'voided'.

Implementation

async function voidMarket(
  marketId: string,
  reason: string,
  voidedBy: string
): Promise<SettlementRecord> {
  return await db.transaction(async (tx) => {
    const market = await tx.getMarket(marketId);
    if (market.status === "resolved" || market.status === "voided") {
      throw new Error("Market already settled");
    }

    const positions = await tx.getPositions({
      market_id: marketId,
      status: "open",
    });

    let totalRefunded = 0;

    // Refund every position at cost basis
    for (const position of positions) {
      const adapter = getWalletAdapter(position.operator_id);
      await adapter.refund(
        position.user_id,
        position.cost_basis,
        position.id
      );
      totalRefunded += position.cost_basis;
    }

    const settlement = await tx.createSettlement({
      market_id: marketId,
      resolved_outcome: null,
      void_reason: reason,
      total_positions: positions.length,
      winners_count: 0,
      losers_count: 0,
      total_payout: totalRefunded,
      total_cost_basis: totalRefunded,
      casino_profit: 0,
      resolved_by: voidedBy,
    });

    for (const position of positions) {
      await tx.updatePosition(position.id, {
        status: "voided",
        payout_amount: position.cost_basis,
        resolved_at: new Date().toISOString(),
      });
    }

    await tx.updateMarket(marketId, {
      status: "voided",
      void_reason: reason,
    });

    return settlement;
  });
}

Settlement Record Schema

Every resolution or void creates a settlement record that captures the complete financial summary. These records are used for operator invoicing, revenue reporting, and audit trails.

interface SettlementRecord {
  id: string;                    // UUID
  market_id: string;             // Market that was settled
  resolved_outcome: string | null; // Winning outcome (null for voids)
  void_reason: string | null;    // Reason for voiding (null for resolves)
  total_positions: number;       // Total positions settled
  winners_count: number;         // Positions that won (0 for voids)
  losers_count: number;          // Positions that lost (0 for voids)
  total_payout: number;          // Total amount paid out
  total_cost_basis: number;      // Total amount originally collected
  casino_profit: number;         // cost_basis - payout
  resolved_by: string;           // Admin user ID who triggered settlement
  created_at: string;            // ISO 8601 timestamp
}

Example Settlement Records

FieldResolve ExampleVoid Example
resolved_outcome"Yes"null
void_reasonnull"Event cancelled"
total_positions180180
winners_count1000
losers_count800
total_payout$100.00$93.00
total_cost_basis$93.00$93.00
casino_profit-$7.00$0.00

Wallet Adapter & Settlement

The wallet adapter pattern abstracts settlement payouts so the resolution engine does not need to know the details of the operator’s wallet implementation.

Adapter Methods Used During Settlement

MethodCalled WhenS2S Callback
creditForWinPosition wonSends BET_WIN callback to operator
notifyLossPosition lostSends BET_LOSE callback to operator
refundMarket voidedSends BET_REFUND callback to operator
S2S failure handling: If an S2S callback fails during settlement, the resolution engine retries with exponential backoff (up to 5 attempts). If all retries fail, the position is marked as settlement_pending and flagged for manual review in the God Mode dashboard. The overall transaction is not rolled back to avoid blocking settlement for other users.

Post-Settlement

After settlement completes, several downstream processes are triggered:

  • User scoring update - The scoring engine recalculates sharpness scores based on the settlement results. Users who consistently pick winners may see their score increase.
  • Operator revenue calculation - The settlement’scasino_profit is attributed to the appropriate operator(s) for invoicing.
  • Dashboard refresh - Both God Mode and Operator dashboards update their revenue metrics in near real-time.
  • Risk exposure update - The resolved market’s exposure is released from all risk walls, freeing capacity for new trades.

Settlement is a one-way operation. Once a market is resolved or voided, it cannot be un-resolved. If a resolution was made in error, the only recourse is to create manual adjustments via the God Mode dashboard.