Skip to main content
Position management handles the lifecycle of open positions, from creation through exit plan updates to automated closures via stop-loss and take-profit triggers.

Orders Table: Single Source of Truth

All position and trade data is stored in the Orders table with a status-based state model:

OPEN Status

Active positions shown in the Positions tab. Unrealized P&L calculated live from current market prices.

CLOSED Status

Completed trades shown in the Trades tab. Realized P&L and exit price recorded at close time.
export const orders = pgTable(
  "Orders",
  {
    id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
    modelId: text("modelId").notNull().references(() => models.id),

    // Position details
    symbol: text("symbol").notNull(),
    side: orderSideEnum("side").notNull(), // LONG | SHORT
    quantity: numeric("quantity", { precision: 18, scale: 8 }).notNull(),
    leverage: numeric("leverage", { precision: 10, scale: 2 }),
    entryPrice: numeric("entryPrice", { precision: 18, scale: 8 }).notNull(),

    // Exit plan (stop-loss, take-profit, invalidation)
    exitPlan: jsonb("exitPlan").$type<{
      stop: number | null;              // Stop-loss price
      target: number | null;            // Take-profit price
      invalidation: string | null;      // Text description of invalidation condition
      invalidationPrice: number | null; // Numeric invalidation price
      confidence: number | null;        // AI confidence in exit plan (0-100)
      timeExit: string | null;          // Time-based exit condition
      cooldownUntil: string | null;     // Prevent re-entry until this timestamp
    }>(),

    // Status: OPEN = active position, CLOSED = completed trade
    status: orderStatusEnum("status").notNull().default("OPEN"),

    // Exit details (populated when closed)
    exitPrice: numeric("exitPrice", { precision: 18, scale: 8 }),
    realizedPnl: numeric("realizedPnl", { precision: 18, scale: 2 }),
    closeTrigger: text("closeTrigger"), // null (manual) | "STOP" | "TARGET"

    // Lighter exchange order indices for real SL/TP orders
    slOrderIndex: text("slOrderIndex"),
    tpOrderIndex: text("tpOrderIndex"),
    slTriggerPrice: numeric("slTriggerPrice", { precision: 18, scale: 8 }),
    tpTriggerPrice: numeric("tpTriggerPrice", { precision: 18, scale: 8 }),

    // Timestamps
    openedAt: timestamp("openedAt").defaultNow().notNull(),
    closedAt: timestamp("closedAt"),
    updatedAt: timestamp("updatedAt").defaultNow().notNull(),
  },
);
Location: src/db/schema.ts:125

Exit Plans

Exit plans define the conditions under which a position should be closed. They are stored in the exitPlan JSONB column and used for both manual reference and automated execution.

Exit Plan Structure

interface ExitPlan {
  stop: number | null;              // Stop-loss price (close if price hits this level)
  target: number | null;            // Take-profit price (close if price reaches this level)
  invalidation: string | null;      // Text description of invalidation condition
  invalidationPrice: number | null; // Numeric price level for invalidation
  confidence: number | null;        // AI confidence in this plan (0-100)
  timeExit: string | null;          // ISO timestamp for time-based exit
  cooldownUntil: string | null;     // Prevent re-entry until this timestamp
}
Confidence represents the AI’s confidence in the exit plan, not the trade itself. A high confidence exit plan means the AI is certain about where to place stops and targets.

Creating Exit Plans

Exit plans are specified when creating positions and can be updated later via the updateExitPlanTool.
const positions: PositionRequest[] = [
  {
    symbol: "BTC",
    side: "LONG",
    quantity: 0.5,
    leverage: 3,
    profitTarget: 52000,    // Take profit at $52k
    stopLoss: 48000,        // Stop loss at $48k
    invalidationCondition: "Break below 47k invalidates bullish structure",
    confidence: 85,         // 85% confidence in this exit plan
  },
];

const results = await createPosition(account, positions);

Updating Exit Plans

AI agents can update exit plans for open positions using the updateExitPlanTool.
export const updateExitPlanTool = {
  name: "update_exit_plan",
  description: `Update the exit plan (stop-loss, take-profit, invalidation) for an open position.
    Use this when market conditions change or when you want to adjust risk management.`,
  parameters: z.object({
    symbol: z.string(),
    stopLoss: z.number().nullable().optional(),
    profitTarget: z.number().nullable().optional(),
    invalidationCondition: z.string().nullable().optional(),
    confidence: z.number().min(0).max(100).nullable().optional(),
  }),
  handler: async (args, ctx: ToolContext) => {
    const { symbol, stopLoss, profitTarget, invalidationCondition, confidence } = args;
    const dbOrder = await getOpenOrderBySymbol(ctx.account.id, symbol);

    if (!dbOrder) {
      return { success: false, error: `No open position found for ${symbol}` };
    }

    // Merge new values with existing exit plan
    const updatedExitPlan = {
      stop: stopLoss ?? dbOrder.exitPlan?.stop ?? null,
      target: profitTarget ?? dbOrder.exitPlan?.target ?? null,
      invalidation: invalidationCondition ?? dbOrder.exitPlan?.invalidation ?? null,
      invalidationPrice: dbOrder.exitPlan?.invalidationPrice ?? null,
      confidence: confidence ?? dbOrder.exitPlan?.confidence ?? null,
      timeExit: dbOrder.exitPlan?.timeExit ?? null,
      cooldownUntil: dbOrder.exitPlan?.cooldownUntil ?? null,
    };

    await updateExitPlan({
      orderId: dbOrder.id,
      exitPlan: updatedExitPlan,
    });

    // Update SL/TP orders on exchange
    if (!IS_SIMULATION_ENABLED) {
      const client = await SignerClientFactory.create({ ... });
      await updateSlTpOrdersOnExchange(
        client,
        symbol,
        dbOrder.side,
        parseFloat(dbOrder.quantity),
        dbOrder.id,
        dbOrder.slOrderIndex,
        dbOrder.tpOrderIndex,
        stopLoss ?? null,
        profitTarget ?? null,
      );
    }

    return { success: true, updatedExitPlan };
  },
};
Location: src/server/features/trading/agent/tools/updateExitPlanTool.ts
Updating the exit plan in live mode cancels existing SL/TP orders and places new ones with the updated prices.

Stop-Loss and Take-Profit Automation

Live Trading: Exchange Orders

In live trading mode, SL/TP are real orders placed on the Lighter exchange that execute automatically when price triggers are hit.
export async function placeSlTpOrders(
  client: SignerClientInstance,
  params: SlTpOrderParams,
): Promise<SlTpOrderResult> {
  const market = MARKETS[params.symbol as keyof typeof MARKETS];
  if (!market) {
    return { success: false, error: `Market ${params.symbol} not found` };
  }

  const isLong = params.side === "LONG";
  // For closing positions: LONG -> sell (isAsk=true), SHORT -> buy (isAsk=false)
  const closeIsAsk = isLong;
  const baseAmount = Math.round(Math.abs(params.quantity) * market.qtyDecimals);

  let slOrderIndex: string | undefined;
  let tpOrderIndex: string | undefined;

  try {
    // Place stop-loss order if provided
    if (params.stopLoss != null && params.stopLoss > 0) {
      const slPrice = Math.round(params.stopLoss * market.priceDecimals);
      const slTriggerPrice = Math.round(params.stopLoss * market.priceDecimals);

      console.log(`[SlTpManager] Placing SL order for ${params.symbol}: trigger=${params.stopLoss}, isAsk=${closeIsAsk}`);

      await client.createOrder({
        marketIndex: market.marketId,
        clientOrderIndex: market.slClientOrderIndex,
        baseAmount,
        price: slPrice,
        isAsk: closeIsAsk,
        orderType: SignerClient.ORDER_TYPE_STOP_LOSS,
        timeInForce: SignerClient.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME,
        reduceOnly: 1, // SL/TP should always be reduce-only
        triggerPrice: slTriggerPrice,
        orderExpiry: SignerClient.DEFAULT_28_DAY_ORDER_EXPIRY,
      });

      slOrderIndex = market.slClientOrderIndex.toString();
      console.log(`[SlTpManager] SL order placed: clientOrderIndex=${slOrderIndex}`);
    }

    // Place take-profit order if provided
    if (params.takeProfit != null && params.takeProfit > 0) {
      const tpPrice = Math.round(params.takeProfit * market.priceDecimals);
      const tpTriggerPrice = Math.round(params.takeProfit * market.priceDecimals);

      console.log(`[SlTpManager] Placing TP order for ${params.symbol}: trigger=${params.takeProfit}, isAsk=${closeIsAsk}`);

      await client.createOrder({
        marketIndex: market.marketId,
        clientOrderIndex: market.tpClientOrderIndex,
        baseAmount,
        price: tpPrice,
        isAsk: closeIsAsk,
        orderType: SignerClient.ORDER_TYPE_TAKE_PROFIT,
        timeInForce: SignerClient.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME,
        reduceOnly: 1,
        triggerPrice: tpTriggerPrice,
        orderExpiry: SignerClient.DEFAULT_28_DAY_ORDER_EXPIRY,
      });

      tpOrderIndex = market.tpClientOrderIndex.toString();
      console.log(`[SlTpManager] TP order placed: clientOrderIndex=${tpOrderIndex}`);
    }

    // Update database with order indices
    if (slOrderIndex || tpOrderIndex) {
      await updateSlTpOrders({
        orderId: params.orderId,
        slOrderIndex: slOrderIndex ?? null,
        tpOrderIndex: tpOrderIndex ?? null,
        slTriggerPrice: params.stopLoss?.toString() ?? null,
        tpTriggerPrice: params.takeProfit?.toString() ?? null,
      });
    }

    return {
      success: true,
      slOrderIndex,
      tpOrderIndex,
    };
  } catch (error) {
    const errorMsg = error instanceof Error ? error.message : String(error);
    console.error(`[SlTpManager] Failed to place SL/TP orders:`, error);
    return {
      success: false,
      slOrderIndex,
      tpOrderIndex,
      error: errorMsg,
    };
  }
}
Location: src/server/features/trading/slTpOrderManager.ts:35 Order Types:
  • STOP_LOSS: Triggers when price moves against position (Mark price ≤ trigger for LONG)
  • TAKE_PROFIT: Triggers when price moves in favor (Mark price ≥ trigger for LONG)
  • Both use reduceOnly: 1 to ensure they only close positions, never open new ones
  • Expiry set to 28 days (longest allowed by Lighter exchange)
Database Tracking: The slOrderIndex and tpOrderIndex are stored in the Orders table to enable cancellation when:
  • Position is manually closed
  • Exit plan is updated
  • Position is scaled into (new quantity requires new orders)

Simulation: In-Memory Triggers

In simulation mode, SL/TP are checked in-memory during the market refresh polling cycle.
private async refreshAll() {
  // Update mark prices for all markets
  for (const [symbol, market] of this.markets) {
    try {
      const snapshot = await market.refresh();
      for (const account of this.accounts.values()) {
        account.updateMarkPrice(symbol, snapshot.midPrice);
      }
    } catch (error) {
      console.warn(`[Simulator] Market refresh failed for ${symbol}`);
    }
  }

  // Collect exit plan triggers
  const autoCloseQueue: Array<{
    accountId: string;
    symbol: string;
    trigger: "STOP" | "TARGET";
  }> = [];

  for (const [accountId, account] of this.accounts.entries()) {
    const triggers = account.collectExitPlanTriggers();
    for (const trigger of triggers) {
      const key = `${accountId}:${trigger.symbol}`;
      if (this.pendingAutoCloses.has(key)) {
        continue; // Already processing this close
      }
      this.pendingAutoCloses.add(key);
      autoCloseQueue.push({
        accountId,
        symbol: trigger.symbol,
        trigger: trigger.trigger,
      });
    }
  }

  // Execute auto-closes
  for (const request of autoCloseQueue) {
    try {
      const outcomes = await this.closePositions(
        [request.symbol],
        request.accountId,
      );
      const outcome = outcomes[request.symbol] ?? outcomes[normalizeSymbol(request.symbol)];

      if (outcome && positionBefore) {
        // Calculate P&L
        const isLong = positionBefore.side === "LONG";
        const pnl = isLong
          ? (outcome.averagePrice - positionBefore.avgEntryPrice) * positionBefore.quantity
          : (positionBefore.avgEntryPrice - outcome.averagePrice) * positionBefore.quantity;

        // Update Orders table
        const dbOrder = await getOpenOrderBySymbol(request.accountId, normalizeSymbol(request.symbol));
        if (dbOrder) {
          await closeOrder({
            orderId: dbOrder.id,
            exitPrice: outcome.averagePrice.toString(),
            realizedPnl: pnl.toString(),
            closeTrigger: request.trigger, // "STOP" or "TARGET"
          });
        }

        // Record in ToolCalls table for audit trail
        const invocation = await createInvocationMutation(request.accountId);
        await createToolCallMutation({
          invocationId: invocation.id,
          type: ToolCallType.CLOSE_POSITION,
          metadata: JSON.stringify({
            symbols: [request.symbol],
            closedPositions: [closedPosition],
            autoTrigger: request.trigger,
          }),
        });

        await emitAllDataChanged(request.accountId);
      }
    } finally {
      this.pendingAutoCloses.delete(`${request.accountId}:${request.symbol}`);
    }
  }
}
Location: src/server/features/simulator/exchangeSimulator.ts:261 Trigger Detection (in AccountState):
collectExitPlanTriggers(): Array<{ symbol: string; trigger: "STOP" | "TARGET" }> {
  const triggers: Array<{ symbol: string; trigger: "STOP" | "TARGET" }> = [];

  for (const position of this.positions.values()) {
    if (!position.exitPlan) continue;

    const markPrice = position.markPrice;
    const isLong = position.side === "LONG";

    // Check stop-loss trigger
    if (position.exitPlan.stop != null) {
      const stopHit = isLong
        ? markPrice <= position.exitPlan.stop
        : markPrice >= position.exitPlan.stop;

      if (stopHit) {
        triggers.push({ symbol: position.symbol, trigger: "STOP" });
        continue;
      }
    }

    // Check take-profit trigger
    if (position.exitPlan.target != null) {
      const targetHit = isLong
        ? markPrice >= position.exitPlan.target
        : markPrice <= position.exitPlan.target;

      if (targetHit) {
        triggers.push({ symbol: position.symbol, trigger: "TARGET" });
      }
    }
  }

  return triggers;
}
Auto-Close Flow:
  1. Every 5 seconds: Refresh market prices for all symbols
  2. Update mark prices: Apply new prices to all open positions
  3. Collect triggers: Check if any position’s SL or TP has been hit
  4. Queue auto-closes: Add triggered positions to close queue
  5. Execute closes: Place reverse orders to close positions
  6. Update database: Set status=CLOSED, exitPrice, realizedPnl, closeTrigger
  7. Emit events: Notify UI of position closures
The simulator restores OPEN positions from the database on startup to ensure auto-close triggers survive server restarts. See exchangeSimulator.ts:203 for the restoration logic.

P&L Calculation

Unrealized P&L (OPEN Positions)

Calculated live from current mark prices, not stored in the database.
function calculateUnrealizedPnl(
  side: "LONG" | "SHORT",
  quantity: number,
  entryPrice: number,
  markPrice: number,
): number {
  const isLong = side === "LONG";
  return isLong
    ? (markPrice - entryPrice) * quantity
    : (entryPrice - markPrice) * quantity;
}
Example (LONG):
  • Entry: 1.0 BTC @ $50,000
  • Mark: $51,000
  • Unrealized P&L: (51,00051,000 - 50,000) × 1.0 = $1,000
Example (SHORT):
  • Entry: 1.0 BTC @ $50,000
  • Mark: $49,000
  • Unrealized P&L: (50,00050,000 - 49,000) × 1.0 = $1,000

Realized P&L (CLOSED Positions)

Calculated once at close and stored in realizedPnl field.
function calculateRealizedPnl(
  side: "LONG" | "SHORT",
  quantity: number,
  entryPrice: number,
  exitPrice: number,
): number {
  const isLong = side === "LONG";
  return isLong
    ? (exitPrice - entryPrice) * quantity
    : (entryPrice - exitPrice) * quantity;
}
Example (LONG):
  • Entry: 1.0 BTC @ $50,000
  • Exit: 1.0 BTC @ $52,000
  • Realized P&L: (52,00052,000 - 50,000) × 1.0 = $2,000

Notional Values

Entry Notional: entryPrice × quantity Exit Notional: exitPrice × quantity These are derived values, not stored in the database. Calculated on-the-fly when needed.
Notional values are useful for calculating return percentages: Return % = (exitNotional - entryNotional) / entryNotional × 100

Position Lifecycle

1

Position Created

  • Order placed and filled on exchange/simulator
  • Orders table record created with status=OPEN
  • SL/TP orders placed (live mode) or exit plan stored (simulation)
  • openedAt timestamp recorded
2

Position Active

  • Unrealized P&L calculated live from mark prices
  • Exit plan can be updated via updateExitPlanTool
  • Additional quantity can be added via position scaling
  • SL/TP orders updated when exit plan changes
3

Position Closed

  • Reverse order placed to close position
  • Exit price and realized P&L calculated
  • Orders table updated: status=CLOSED, exitPrice, realizedPnl, closedAt
  • closeTrigger set: null (manual), "STOP", or "TARGET"
  • SL/TP orders cancelled (live mode)

Best Practices

Always Set SL/TP

Every position should have a stop-loss to limit downside risk. Take-profit targets help lock in gains.

Update Exit Plans Dynamically

Use updateExitPlanTool to adjust stops and targets as market conditions evolve.

Store Confidence

Record AI confidence in exit plans to help analyze which strategies work best.

Use Descriptive Invalidations

Provide clear text descriptions of invalidation conditions to help with post-trade analysis.

Next Steps

Risk Controls

Learn about position sizing and risk management

Order Execution

Understand how orders are placed and filled

Build docs developers (and LLMs) love