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.
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.
checkMarketTradeable(sb, marketId, category, liquidity, midPrice)assessTrade(sb, userId, marketId, "buy", amount, category)For buys, the execution price is the ask price (mid + half spread + skew).
shares = amount / (executionPrice / 100)BET_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.casino_trades with all execution details: prices, shares, cost, balance snapshot, S2S transaction ID.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.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.
casino_positions for this market and outcome. If no position exists or insufficient shares are held, reject with a descriptive error.proceeds = sharesToSell * (bidPrice / 100)
costBasis = sharesToSell * (avgBuyPrice / 100)
realizedPnl = proceeds - costBasisBET_SELL callback with sale context including realized P&L.shares column.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.
/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
| Input | Value |
|---|---|
| Side | buy |
| Mid price | 50c |
| Amount (USD) | $100.00 |
| Spread | 4% |
| Output | Value |
|---|---|
| Execution price (ask) | 52c |
| Shares received | 192.31 |
| Cost | $100.00 |
| Potential payout | $192.31 (if outcome is correct) |
| Effective spread | 4.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
| Scenario | Outcome | Player Impact |
|---|---|---|
| Debit fails (insufficient funds) | Trade rejected | No balance change |
| Debit succeeds, trade recording fails | Rollback issued | Balance restored via BET_ROLLBACK |
| Debit succeeds, position upsert fails | Rollback issued | Balance restored via BET_ROLLBACK |
| All steps succeed | Trade complete | Balance debited, position updated |
| Debit succeeds, rollback fails | CRITICAL alert | Balance incorrect - manual intervention required |
// 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
| Attempt | Action |
|---|---|
| 1st attempt | Normal 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 failure | The 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.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key (auto-generated). |
user_id | uuid | FK to users table. |
market_id | text | Polymarket condition ID or internal market ID. |
market_title | text | Human-readable market question snapshot. |
side | text | "buy" or "sell". |
outcome | text | "yes" or "no". |
shares | numeric | Number of shares traded. |
price_mid | numeric | Market mid price at time of execution (cents). |
price_executed | numeric | Actual execution price after spread (cents). |
spread_applied | numeric | Spread in cents: |price_executed - price_mid|. |
cost | numeric | USD amount: cost for buys, proceeds for sells. |
balance_before | numeric | Player's balance before this trade. |
balance_after | numeric | Player's balance after this trade. |
operator_id | uuid | FK to operators table (multi-tenancy). |
s2s_transaction_id | uuid | FK to s2s_transactions if this was an S2S trade. |
created_at | timestamptz | When 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
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key. |
user_id | uuid | FK to users. |
market_id | text | Market identifier. |
market_title | text | Market question snapshot. |
outcome | text | "yes" or "no". |
shares | numeric | Current share count. |
avg_price | numeric | Weighted average buy price in cents. |
realized_pnl | numeric | Accumulated realized P&L from partial sells. |
status | text | "open", "closed", or "settled". |
operator_id | uuid | FK to operators (multi-tenancy). |
created_at | timestamptz | When the position was first opened. |
updated_at | timestamptz | Last 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| Column | Type | Description |
|---|---|---|
market_id | text PK | One row per market. |
yes_shares | numeric | Total YES shares outstanding. |
yes_cost | numeric | Total USD collected from YES buyers. |
no_shares | numeric | Total NO shares outstanding. |
no_cost | numeric | Total USD collected from NO buyers. |
net_exposure | numeric | Signed imbalance between YES and NO exposure. |
max_loss | numeric | Maximum 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
/api/casino/tradeExecutes 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
/api/casino/quoteReturns a non-executing price preview. No balance check, no risk check - just the spread calculation.
Get Positions
/api/casino/positionsReturns the authenticated user's open positions with current market prices for unrealized P&L calculation.
Get Trade History
/api/casino/tradesReturns the authenticated user's trade history, newest first.
