Predictu
Trading Engine

Trade Execution

The Trade Executor is the central orchestrator for all buy and sell operations in the Predictu Casino Integration. It coordinates between the spread engine, risk engine, wallet adapter, position manager, and exposure tracker to execute trades atomically.

Source of truth: The trade executor lives at src/lib/engines/trade-executor.ts. It uses a compensating transaction pattern to ensure consistency across multiple database tables and the external S2S callback system.

Buy Flow

Buying shares of a prediction market outcome follows an 8-step pipeline. Each step can fail, and the executor handles rollback to maintain consistency.

1
Validate inputs - Check that the amount is positive. Basic sanity check before any database calls.
2
Check market tradeability - The Market Selection Engine validates that the market is: (a) active and not paused, (b) not disabled by the operator, (c) not past its expiry, (d) within the price bounds for trading. Markets near 0c or 100c may be restricted.

checkMarketTradeable(sb, marketId, category, liquidity, midPrice)
3
Assess risk (5 walls of defence) - The Risk Engine checks: per-trade limits (Wall 1), per-market exposure cap (Wall 2), per-category exposure cap (Wall 3), global exposure cap (Wall 4), and circuit breakers (Wall 5). If any wall blocks, the trade is rejected with a specific reason and wall number.

assessTrade(sb, userId, marketId, "buy", amount, category)
4
Calculate quote - The Spread Engine computes the execution price. It fetches any market-specific spread overrides, applies per-user sharp adjustments (wider spread for high-scoring or restricted users), and factors in extreme price widening and liquidity adjustments.

For buys, the execution price is the ask price (mid + half spread + skew).

shares = amount / (executionPrice / 100)
5
Debit balance via Wallet Adapter - The wallet adapter sends aBET_MAKE S2S callback to the operator’s server with the full bet context.

If the debit fails (INSUFFICIENT_FUNDS, PLAYER_NOT_FOUND), the trade is rejected immediately.
6
Record trade - Insert a row into casino_trades with all execution details: prices, shares, cost, balance snapshot, S2S transaction ID.
7
Update position - Upsert the player's position in casino_positions. If the player already has an open position in this market and outcome, average the new shares into the existing position (weighted average price). If no position exists, create a new one. Uses optimistic concurrency control.
8
Update exposure - Update the casino_exposure table with the new aggregate shares and cost for this market, and recalculate the net exposure and max loss figures used by the risk engine.
// Buy execution pseudocode
async function executeBuy(sb, req, config) {
  // Step 1: Validate
  if (amount <= 0) return error("Amount must be positive");

  // Step 2: Market tradeability
  const tradeable = await checkMarketTradeable(sb, marketId, ...);
  if (!tradeable.tradeable) return error(tradeable.reason);

  // Step 3: Risk assessment (5 walls)
  const risk = await assessTrade(sb, userId, marketId, "buy", amount, category);
  if (!risk.allowed) return error(risk.reason);

  // Step 4: Calculate spread-adjusted price
  const spread = calculateSpread(midPrice, config, { sharpAdjustment });
  const executionPrice = spread.askPrice;
  const shares = amount / (executionPrice / 100);

  // Step 5: Debit via wallet adapter
  const adapter = await getWalletAdapter(sb, userId);
  const debit = await adapter.debitForBet(cost, { marketId, outcome, shares, ... });
  if (!debit.success) return error(debit.error);

  try {
    // Step 6: Record trade
    const trade = await sb.from("casino_trades").insert({ ... });

    // Step 7: Upsert position (with optimistic concurrency)
    const positionId = await upsertBuyPosition(sb, userId, marketId, ...);

    // Step 8: Update exposure
    await updateExposure(sb, marketId, outcome, "buy", shares, cost);

    return { success: true, tradeId, positionId, executionPrice, shares, cost };
  } catch (err) {
    // COMPENSATING TRANSACTION: reverse the debit
    await adapter.rollback(cost, { marketId, reason: err.message });
    return error("Trade failed and balance was restored");
  }
}

Sell Flow

Selling shares follows a 7-step pipeline. The key differences from buying are: position lookup, proceeds calculation (not cost), realized P&L tracking, and position closure logic.

1
Validate inputs - Check that shares to sell is positive.
2
Check market tradeability (relaxed) - Sells are allowed on disabled or paused markets so that players can always exit positions. Only exposure caps that apply to the casino (not the player) can block sells, and even those are relaxed since sellingreduces exposure.
3
Find existing position - Look up the player's open position incasino_positions for this market and outcome. If no position exists or insufficient shares are held, reject with a descriptive error.
4
Calculate quote and P&L - The execution price is the bid price(mid - half spread - skew). Proceeds = shares × (bidPrice / 100). Realized P&L = proceeds - cost basis (based on the position's average buy price).
proceeds    = sharesToSell * (bidPrice / 100)
costBasis   = sharesToSell * (avgBuyPrice / 100)
realizedPnl = proceeds - costBasis
5
Credit balance via Wallet Adapter - For S2S operators, sends aBET_SELL callback with sale context including realized P&L.
6
Record trade and update position - Insert the sell trade, then either reduce the position shares (partial sell) or close the position entirely (full sell). Uses optimistic concurrency control on the shares column.
7
Update exposure - Reduce the casino's exposure for this market and outcome.

Quote Endpoint (Non-Executing)

The quote function returns a price preview without executing the trade. This is used by the UI to show the player what they would get before they confirm.

POST/api/casino/quote
// getQuote(side, outcome, amount, midPrice, config)
function getQuote(side, outcome, amount, midPrice, config) {
  const spread = calculateSpread(midPrice, config);
  const executionPrice = side === "buy" ? spread.askPrice : spread.bidPrice;

  let shares, cost;
  if (side === "buy") {
    cost = amount;                           // amount is USD to spend
    shares = cost / (executionPrice / 100);  // calculate shares received
  } else {
    shares = amount;                         // amount is shares to sell
    cost = shares * (executionPrice / 100);  // calculate USD received
  }

  return {
    executionPrice,               // cents (0-100)
    shares,                       // number of shares
    cost,                         // USD
    spreadPct: spread.spreadPct,  // effective spread %
    payout: shares,               // $1 per share if correct (same as shares in USD)
    midPrice,                     // original mid price
  };
}

Quote Example

InputValue
Sidebuy
Mid price50c
Amount (USD)$100.00
Spread4%
OutputValue
Execution price (ask)52c
Shares received192.31
Cost$100.00
Potential payout$192.31 (if outcome is correct)
Effective spread4.00%

Atomic Execution & Compensating Transactions

Since Supabase's JS client does not support multi-table database transactions, the trade executor uses a compensating transaction pattern to maintain consistency.

The Pattern

// 1. Perform the critical, externally-visible operation FIRST
const debit = await adapter.debitForBet(cost, context);
if (!debit.success) return error;

try {
  // 2. Perform internal operations (these can fail)
  await recordTrade();
  await upsertPosition();
  await updateExposure();

  return success;
} catch (err) {
  // 3. COMPENSATE: reverse the external operation
  //    This sends a BET_ROLLBACK callback to the operator
  await adapter.rollback(cost, { reason: err.message });

  return error("Trade failed and balance was restored");
}

Guarantees

ScenarioOutcomePlayer Impact
Debit fails (insufficient funds)Trade rejectedNo balance change
Debit succeeds, trade recording failsRollback issuedBalance restored via BET_ROLLBACK
Debit succeeds, position upsert failsRollback issuedBalance restored via BET_ROLLBACK
All steps succeedTrade completeBalance debited, position updated
Debit succeeds, rollback failsCRITICAL alertBalance incorrect - manual intervention required
Rollback failure is a critical event. If the compensating transaction also fails (e.g., the S2S callback for BET_ROLLBACK times out), the system logs a CRITICAL error with the user ID and amount. This requires manual reconciliation by an operator admin.
// Critical rollback failure handling
const reversal = await adapter.rollback(cost, { reason });
if (!reversal.success) {
  console.error(
    `[trade-executor] CRITICAL: Failed to reverse debit for user ${userId},` +
    ` amount $${cost}. Manual intervention required.`
  );
}

return {
  success: false,
  error: `Trade failed and balance was ${
    reversal.success ? "restored" : "NOT restored (contact support)"
  }.`,
};

Concurrent Position Modification

Multiple trades for the same player and market can execute concurrently (e.g., two browser tabs, or a trade and a resolution happening simultaneously). The executor uses optimistic concurrency control to detect and handle conflicts.

Optimistic Locking

When updating a position, the executor includes the current shares value in the WHERE clause. If another transaction modified the position between the read and the write, the UPDATE affects zero rows, signaling a conflict.

// Optimistic concurrency check
const { data: updateResult } = await sb
  .from("casino_positions")
  .update({
    shares: totalShares,
    avg_price: newAvgPrice,
    updated_at: new Date().toISOString(),
  })
  .eq("id", existingPos.id)
  .eq("shares", existingPos.shares)  // <-- optimistic lock
  .select("id");

// If zero rows affected, the position changed concurrently
if (!updateResult || updateResult.length === 0) {
  if (retryCount < 1) {
    console.warn("[trade-executor] Position changed concurrently, retrying once");
    return upsertBuyPosition(..., retryCount + 1);
  }
  throw new Error("Position was modified concurrently - please retry the trade");
}

Retry Strategy

AttemptAction
1st attemptNormal execution. If optimistic lock fails, retry once.
2nd attempt (retry)Re-read the position with fresh data and re-calculate the weighted average. If this also fails, throw an error which triggers the compensating transaction.
After retry failureThe balance debit is rolled back. The player sees "Position was modified concurrently - please retry the trade."

Trade Record Schema

Every executed trade is stored in the casino_trades table with full execution context.

ColumnTypeDescription
iduuidPrimary key (auto-generated).
user_iduuidFK to users table.
market_idtextPolymarket condition ID or internal market ID.
market_titletextHuman-readable market question snapshot.
sidetext"buy" or "sell".
outcometext"yes" or "no".
sharesnumericNumber of shares traded.
price_midnumericMarket mid price at time of execution (cents).
price_executednumericActual execution price after spread (cents).
spread_appliednumericSpread in cents: |price_executed - price_mid|.
costnumericUSD amount: cost for buys, proceeds for sells.
balance_beforenumericPlayer's balance before this trade.
balance_afternumericPlayer's balance after this trade.
operator_iduuidFK to operators table (multi-tenancy).
s2s_transaction_iduuidFK to s2s_transactions if this was an S2S trade.
created_attimestamptzWhen the trade was executed.

Position Management

The casino_positions table tracks each player's aggregate holding per market and outcome. Positions are upserted on buys and updated on sells.

Buy Upsert Logic

When a player buys shares, the executor either creates a new position or averages into an existing one using a weighted average price.

// New position
if (!existingPos) {
  await sb.from("casino_positions").insert({
    user_id: userId,
    market_id: marketId,
    market_title: marketTitle,
    outcome: "yes",
    shares: 100,
    avg_price: 52,         // execution price in cents
    status: "open",
    operator_id: operatorId,
  });
}

// Existing position: weighted average
if (existingPos) {
  const totalShares = existingShares + newShares;
  const newAvg = (existingAvg * existingShares + executionPrice * newShares)
                 / totalShares;

  await sb.from("casino_positions").update({
    shares: totalShares,
    avg_price: Math.round(newAvg * 10000) / 10000,
  }).eq("id", existingPos.id);
}

Sell Update Logic

Sells either reduce the position (partial sell) or close it entirely (full sell).

const remaining = existingShares - sharesToSell;

if (remaining < 0.01) {
  // Full sell - close position
  await sb.from("casino_positions").update({
    shares: 0,
    realized_pnl: existingPnl + realizedPnl,
    status: "closed",
  }).eq("id", position.id);
} else {
  // Partial sell - reduce shares
  await sb.from("casino_positions").update({
    shares: remaining,
    realized_pnl: existingPnl + realizedPnl,
  }).eq("id", position.id);
}

Position Schema

ColumnTypeDescription
iduuidPrimary key.
user_iduuidFK to users.
market_idtextMarket identifier.
market_titletextMarket question snapshot.
outcometext"yes" or "no".
sharesnumericCurrent share count.
avg_pricenumericWeighted average buy price in cents.
realized_pnlnumericAccumulated realized P&L from partial sells.
statustext"open", "closed", or "settled".
operator_iduuidFK to operators (multi-tenancy).
created_attimestamptzWhen the position was first opened.
updated_attimestamptzLast modification timestamp.

Exposure Tracking

The casino_exposure table tracks aggregate exposure per market. This data feeds into the Risk Engine's Walls 2, 3, and 4 (per-market, per-category, and global caps).

Exposure Calculation

// Net exposure = what the casino loses in the worst case
// If YES wins: casino pays yesShares * $1 - yesCost (they collected yesCost from buyers)
// If NO wins:  casino pays noShares * $1 - noCost

const yesLoss = yesShares - yesCost;  // casino loss if YES wins
const noLoss = noShares - noCost;     // casino loss if NO wins

net_exposure = yesLoss - noLoss;      // signed imbalance
max_loss = Math.max(yesLoss, noLoss); // worst case for the casino
ColumnTypeDescription
market_idtext PKOne row per market.
yes_sharesnumericTotal YES shares outstanding.
yes_costnumericTotal USD collected from YES buyers.
no_sharesnumericTotal NO shares outstanding.
no_costnumericTotal USD collected from NO buyers.
net_exposurenumericSigned imbalance between YES and NO exposure.
max_lossnumericMaximum casino loss regardless of outcome.

Wallet Adapter Pattern

The trade executor never calls the S2S dispatcher directly. Instead, it uses the Wallet Adapter interface, which routes all balance operations through S2S callbacks to the operator’s server.

// Factory resolves the adapter for each user
const adapter = await getWalletAdapter(sb, userId);

// The trade executor calls adapter methods:
adapter.getBalance()                    // → { balance: number }
adapter.debitForBet(cost, context)      // → WalletResult (BET_MAKE)
adapter.creditForWin(amount, context)   // → WalletResult (BET_WIN)
adapter.notifyLoss(context)             // → WalletResult (BET_LOST)
adapter.creditForSell(proceeds, context)// → WalletResult (BET_SELL)
adapter.refund(amount, context)         // → WalletResult (BET_REFUND)
adapter.rollback(amount, context)       // → WalletResult (BET_ROLLBACK)

How It Works

The adapter resolves the operator’s S2S configuration from the user’soperator_id, then sends JWT-signed callbacks to the operator’s registered callback URL for every balance operation. The operator’s server processes the request and returns the updated balance.

Trade API Endpoints

Execute Trade

POST/api/casino/trade

Executes a buy or sell trade. Requires authentication. The endpoint resolves the user, fetches the market mid price, and delegates to executeBuy or executeSell.

Get Quote

POST/api/casino/quote

Returns a non-executing price preview. No balance check, no risk check - just the spread calculation.

Get Positions

GET/api/casino/positions

Returns the authenticated user's open positions with current market prices for unrealized P&L calculation.

Get Trade History

GET/api/casino/trades

Returns the authenticated user's trade history, newest first.