Skill: brs999/kalshi-paper-trading (version 1.0.0)

--- name: kalshi-paper-trading description: Kalshi-native paper trading ledger and CLI for binary prediction contracts. Use for paper opens, marks, reconciliation, valuation, and review without relying on the generic spot-style paper trader. homepage: https://docs.kalshi.com user-invocable: true disable-model-invocation: false metadata: openclaw: emoji: "📈" requires: bins: [node] --- # Kalshi Paper Trading Use this skill for Kalshi paper execution and ledger management. Kalshi-native paper trading should use a dedicated ledger and CLI rather than the generic `paper-trading` skill. ## When to Use Use this skill when the user wants to: - design or build a Kalshi-specific paper trader - inspect or reconcile Kalshi paper positions - store Kalshi prices correctly in cents without mixing units - compute Kalshi realized and unrealized PnL - add Kalshi-native exposure or risk rules ## Key Rule Do not route Kalshi paper executions through the generic `skills/paper-trading` ledger unless the user explicitly asks to keep that compatibility. Default assumptions for this skill: - Kalshi execution prices are stored as integer cents in `[0, 100]` - settlement is `100` or `0` cents - positions are keyed by `market_ticker + contract_side` - risk controls are based on premium and event exposure, not stop-distance percent risk - live market sync supports both legacy cent fields and modern Kalshi `*_dollars` quote fields ## Primary Commands Initialize the paper account: ```bash node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts init --account kalshi --starting-balance-usd 1000 ``` Check status: ```bash node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts status --account kalshi --format json --pretty ``` Sync a live market quote into the ledger: ```bash node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts sync-market --market <TICKER> --format json --pretty ``` Open a paper trade from the live Kalshi ask: ```bash node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts buy-from-market --account kalshi --market <TICKER> --side YES --contracts 1 --format json --pretty ``` Reconcile a finalized market: ```bash node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts reconcile --account kalshi --market <TICKER> --winning-side YES ``` Review account performance: ```bash node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts review --account kalshi --format json --pretty ``` ## Integration Pair this skill with a separate Kalshi API read skill for: - market discovery - liquidity validation - trades and orderbook checks - pre-trade candidate ranking This skill owns the paper ledger and execution side only. ## Design Reference Read the proposal before making structural changes: - [references/kalshi-paper-ledger.md](references/kalshi-paper-ledger.md) Use that document for: - schema design - command surface - valuation rules - settlement logic - migration plan away from the generic paper trader ## Tests Run the Kalshi paper-trader test file: ```bash node --test {baseDir}/tests/kalshi_paper.test.mjs ```

path: scripts/kalshi_paper.ts
content: #!/usr/bin/env node

import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";

type ContractSide = "YES" | "NO";
type ExecutionAction = "BUY" | "SELL";
type MarkMethod = "mid" | "bid" | "last_trade" | "settlement" | "unknown";
type MarketStatus = "open" | "closed" | "finalized";
type KalshiApiMarketStatus = "active" | "closed" | "finalized" | "settled" | string;
type KalshiMarketPayload = {
  ticker?: string;
  event_ticker?: string;
  status?: KalshiApiMarketStatus;
  close_time?: string | null;
  yes_bid?: number | null;
  yes_ask?: number | null;
  no_bid?: number | null;
  no_ask?: number | null;
  last_price?: number | null;
  yes_bid_dollars?: string | number | null;
  yes_ask_dollars?: string | number | null;
  no_bid_dollars?: string | number | null;
  no_ask_dollars?: string | number | null;
  last_price_dollars?: string | number | null;
  result?: string | null;
};

type CliMap = {
  _: string[];
  [key: string]: string | boolean | string[];
};

type AccountRow = {
  id: string;
  name: string;
  base_currency: string;
  starting_balance_usd: number;
  created_at: string;
};

type ExecutionRow = {
  market_ticker: string;
  event_ticker: string | null;
  series_ticker: string | null;
  contract_side: ContractSide;
  action: ExecutionAction;
  contracts: number;
  price_cents: number;
  fee_usd: number;
  created_at: string;
};

type OpenPosition = {
  marketTicker: string;
  eventTicker: string | null;
  seriesTicker: string | null;
  contractSide: ContractSide;
  contracts: number;
  avgEntryCents: number;
  openFeesRemainingUsd: number;
};

type PositionMetrics = {
  realizedPnlUsd: number;
  closedCount: number;
  wins: number;
  losses: number;
};

type MarketMark = {
  marketTicker: string;
  status: MarketStatus;
  closeTimeUtc: string | null;
  settlementSide: ContractSide | null;
  yesBidCents: number | null;
  yesAskCents: number | null;
  lastYesTradeCents: number | null;
  markMethod: MarkMethod;
  markCents: number | null;
  updatedAt: string;
};

const DEFAULT_DB = `${homedir()}/.openclaw/kalshi-paper.db`;

function nowIso(): string {
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}

function parseArgs(argv: string[]): CliMap {
  const out: CliMap = { _: [] };
  for (let i = 0; i < argv.length; i += 1) {
    const [REDACTED_SECRET];
    if (!token.startsWith("--")) {
      out._.push(token);
      continue;
    }

    const key = token.slice(2);
    const next = argv[i + 1];
    if (!next || next.startsWith("--")) {
      out[key] = true;
      continue;
    }

    out[key] = next;
    i += 1;
  }
  return out;
}

function asString(value: unknown, field: string): string {
  if (typeof value !== "string" || !value.trim()) {
    throw new Error(`${field} is required`);
  }
  return value.trim();
}

function asNumber(value: unknown, field: string): number {
  const num = Number(value);
  if (!Number.isFinite(num)) {
    throw new Error(`${field} must be a number`);
  }
  return num;
}

function asOptionalString(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function parseContractSide(value: unknown): ContractSide {
  const side = asString(value, "side").toUpperCase();
  if (side !== "YES" && side !== "NO") {
    throw new Error("side must be YES or NO");
  }
  return side;
}

function parseMarketStatus(value: unknown, field = "status"): MarketStatus {
  const status = asString(value, field).toLowerCase();
  if (status !== "open" && status !== "closed" && status !== "finalized") {
    throw new Error(`${field} must be open, closed, or finalized`);
  }
  return status;
}

function parseMarkMethod(value: unknown): MarkMethod {
  const method = asString(value, "mark-method").toLowerCase();
  if (
    method !== "mid" &&
    method !== "bid" &&
    method !== "last_trade" &&
    method !== "settlement" &&
    method !== "unknown"
  ) {
    throw new Error("mark-method must be mid, bid, last_trade, settlement, or unknown");
  }
  return method;
}

function printJson(value: unknown, pretty = false): void {
  console.log(JSON.stringify(value, null, pretty ? 2 : undefined));
}

function asInteger(value: unknown, field: string): number {
  const num = asNumber(value, field);
  if (!Number.isInteger(num)) {
    throw new Error(`${field} must be an integer`);
  }
  return num;
}

function validatePriceCents(priceCents: number, field = "price-cents"): void {
  if (!Number.isInteger(priceCents)) {
    throw new Error(`${field} must be an integer`);
  }
  if (priceCents < 0 || priceCents > 100) {
    throw new Error(`${field} must be between 0 and 100`);
  }
}

function parsePriceFieldToCents(
  rawValue: string | number | null | undefined,
  fallbackValue: string | number | null | undefined,
): number | null {
  if (rawValue != null && rawValue !== "") {
    const parsed = Number(rawValue);
    if (!Number.isFinite(parsed)) {
      return null;
    }
    return parsed;
  }

  if (fallbackValue != null && fallbackValue !== "") {
    const parsed = Number(fallbackValue);
    if (!Number.isFinite(parsed)) {
      return null;
    }
    return Math.round(parsed * 100);
  }

  return null;
}

function positionKey(marketTicker: string, contractSide: ContractSide): string {
  return `${marketTicker.toUpperCase()}:${contractSide}`;
}

function getDbPath(args: CliMap): string {
  const raw = (args.db as string | undefined) ?? DEFAULT_DB;
  return resolve(raw.replace(/^~\//, `${homedir()}/`));
}

function getKalshiBaseUrl(args: CliMap): string {
  const raw =
    (args["kalshi-base-url"] as string | undefined) ??
    process.env.KALSHI_BASE_URL ??
    "https://api.elections.kalshi.com/trade-api/v2";
  return raw.replace(/\/+$/, "");
}

function inferSeriesTicker(marketTicker: string, eventTicker: string | null): string | null {
  const source = eventTicker || marketTicker;
  const idx = source.indexOf("-");
  if (idx <= 0) return null;
  return source.slice(0, idx).toUpperCase();
}

async function fetchJson(url: string): Promise<unknown> {
  const res = await fetch(url, {
    method: "GET",
    headers: {
      accept: "application/json",
      "user-agent": "openclaw-skills-kalshi-paper-trading/1.0",
    },
  });
  const text = await res.text();
  let json: unknown;
  try {
    json = JSON.parse(text);
  } catch {
    json = { raw: text };
  }

  if (!res.ok) {
    throw new Error(`HTTP ${res.status} for ${url}: ${typeof json === "object" ? JSON.stringify(json) : String(json)}`);
  }
  return json;
}

async function fetchKalshiMarket(
  marketTicker: string,
  args: CliMap,
): Promise<{ market: KalshiMarketPayload; sourceUrl: string; rawResponse: unknown }> {
  const baseUrl = getKalshiBaseUrl(args);
  const url = `${baseUrl}/markets/${encodeURIComponent(marketTicker)}`;
  const response = await fetchJson(url) as { market?: KalshiMarketPayload };
  if (!response.market) {
    throw new Error(`market payload missing for ${marketTicker}`);
  }
  return {
    market: response.market,
    sourceUrl: url,
    rawResponse: response,
  };
}

function normalizeKalshiStatus(status: KalshiApiMarketStatus): MarketStatus {
  const value = String(status || "").toLowerCase();
  if (value === "finalized" || value === "settled") {
    return "finalized";
  }
  if (value === "closed") {
    return "closed";
  }
  return "open";
}

function inferSettlementSide(market: {
  status?: KalshiApiMarketStatus;
  result?: string;
  yes_bid?: number;
  yes_ask?: number;
  last_price?: number;
  yes_bid_dollars?: string | number | null;
  yes_ask_dollars?: string | number | null;
  last_price_dollars?: string | number | null;
}): ContractSide | null {
  const result = String(market.result || "").trim().toLowerCase();
  if (result === "yes") return "YES";
  if (result === "no") return "NO";

  if (normalizeKalshiStatus(market.status ?? "") !== "finalized") {
    return null;
  }

  const yesBidCents = parsePriceFieldToCents(market.yes_bid, market.yes_bid_dollars);
  const yesAskCents = parsePriceFieldToCents(market.yes_ask, market.yes_ask_dollars);
  const lastPriceCents = parsePriceFieldToCents(market.last_price, market.last_price_dollars);

  if (yesBidCents === 100 || yesAskCents === 100 || lastPriceCents === 100) {
    return "YES";
  }
  if (yesBidCents === 0 || yesAskCents === 0 || lastPriceCents === 0) {
    return "NO";
  }
  return null;
}

function computeMarkFromQuote(params: {
  status: MarketStatus;
  settlementSide: ContractSide | null;
  yesBidCents: number | null;
  yesAskCents: number | null;
  lastYesTradeCents: number | null;
}): { markMethod: MarkMethod; markCents: number | null } {
  if (params.status === "finalized") {
    if (!params.settlementSide) {
      throw new Error("finalized market is missing settlement side");
    }
    return {
      markMethod: "settlement",
      markCents: params.settlementSide === "YES" ? 100 : 0,
    };
  }

  if (params.yesBidCents != null && params.yesAskCents != null) {
    return {
      markMethod: "mid",
      markCents: Math.round((params.yesBidCents + params.yesAskCents) / 2),
    };
  }
  if (params.yesBidCents != null) {
    return {
      markMethod: "bid",
      markCents: params.yesBidCents,
    };
  }
  if (params.lastYesTradeCents != null) {
    return {
      markMethod: "last_trade",
      markCents: params.lastYesTradeCents,
    };
  }
  return {
    markMethod: "unknown",
    markCents: null,
  };
}

function openDb(path: string): DatabaseSync {
  mkdirSync(dirname(path), { recursive: true });
  const db = new DatabaseSync(path);
  db.exec("PRAGMA foreign_keys = ON;");
  db.exec(`
    CREATE TABLE IF NOT EXISTS accounts (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      base_currency TEXT NOT NULL DEFAULT 'USD',
      starting_balance_usd REAL NOT NULL,
      created_at TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS executions (
      id TEXT PRIMARY KEY,
      account_id TEXT NOT NULL,
      market_ticker TEXT NOT NULL,
      event_ticker TEXT,
      series_ticker TEXT,
      contract_side TEXT NOT NULL,
      action TEXT NOT NULL,
      contracts REAL NOT NULL,
      price_cents INTEGER NOT NULL,
      fee_usd REAL NOT NULL DEFAULT 0,
      source TEXT NOT NULL,
      note TEXT,
      meta_json TEXT,
      created_at TEXT NOT NULL,
      FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_executions_account_time
      ON executions(account_id, created_at);

    CREATE INDEX IF NOT EXISTS idx_executions_account_market_side
      ON executions(account_id, market_ticker, contract_side, created_at);

    CREATE TABLE IF NOT EXISTS market_marks (
      market_ticker TEXT PRIMARY KEY,
      status TEXT NOT NULL,
      close_time_utc TEXT,
      settlement_side TEXT,
      yes_bid_cents INTEGER,
      yes_ask_cents INTEGER,
      last_yes_trade_cents INTEGER,
      mark_method TEXT NOT NULL,
      mark_cents INTEGER,
      raw_json TEXT,
      updated_at TEXT NOT NULL
    );
  `);
  return db;
}

function requireAccount(db: DatabaseSync, accountId: string): AccountRow {
  const row = db
    .prepare(
      "SELECT id, name, base_currency, starting_balance_usd, created_at FROM accounts WHERE id = ?",
    )
    .get(accountId) as AccountRow | undefined;
  if (!row) {
    throw new Error(`account '${accountId}' not found; run init first`);
  }
  return row;
}

function addExecution(
  db: DatabaseSync,
  params: {
    accountId: string;
    marketTicker: string;
    eventTicker?: string;
    seriesTicker?: string;
    contractSide: ContractSide;
    action: ExecutionAction;
    contracts: number;
    priceCents: number;
    feeUsd?: number;
    source: string;
    note?: string;
    meta?: Record<string, unknown>;
  },
): string {
  const id = randomUUID();
  db.prepare(
    `INSERT INTO executions (
      id, account_id, market_ticker, event_ticker, series_ticker, contract_side,
      action, contracts, price_cents, fee_usd, source, note, meta_json, created_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  ).run(
    id,
    params.accountId,
    params.marketTicker.toUpperCase(),
    params.eventTicker ?? null,
    params.seriesTicker ?? null,
    params.contractSide,
    params.action,
    params.contracts,
    params.priceCents,
    params.feeUsd ?? 0,
    params.source,
    params.note ?? null,
    params.meta ? JSON.stringify(params.meta) : null,
    nowIso(),
  );
  return id;
}

function upsertMarketMark(
  db: DatabaseSync,
  params: {
    marketTicker: string;
    status: MarketStatus;
    closeTimeUtc?: string | null;
    settlementSide?: ContractSide | null;
    yesBidCents?: number | null;
    yesAskCents?: number | null;
    lastYesTradeCents?: number | null;
    markMethod: MarkMethod;
    markCents?: number | null;
    rawJson?: string | null;
  },
): void {
  db.prepare(
    `INSERT INTO market_marks (
      market_ticker, status, close_time_utc, settlement_side, yes_bid_cents,
      yes_ask_cents, last_yes_trade_cents, mark_method, mark_cents, raw_json, updated_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(market_ticker) DO UPDATE SET
      status = excluded.status,
      close_time_utc = excluded.close_time_utc,
      settlement_side = excluded.settlement_side,
      yes_bid_cents = excluded.yes_bid_cents,
      yes_ask_cents = excluded.yes_ask_cents,
      last_yes_trade_cents = excluded.last_yes_trade_cents,
      mark_method = excluded.mark_method,
      mark_cents = excluded.mark_cents,
      raw_json = excluded.raw_json,
      updated_at = excluded.updated_at`,
  ).run(
    params.marketTicker.toUpperCase(),
    params.status,
    params.closeTimeUtc ?? null,
    params.settlementSide ?? null,
    params.yesBidCents ?? null,
    params.yesAskCents ?? null,
    params.lastYesTradeCents ?? null,
    params.markMethod,
    params.markCents ?? null,
    params.rawJson ?? null,
    nowIso(),
  );
}

function replay(
  db: DatabaseSync,
  accountId: string,
): { positions: Map<string, OpenPosition>; metrics: PositionMetrics } {
  const rows = db
    .prepare(
      `SELECT market_ticker, event_ticker, series_ticker, contract_side, action, contracts, price_cents, fee_usd, created_at
       FROM executions
       WHERE account_id = ?
       ORDER BY created_at ASC, rowid ASC`,
    )
    .all(accountId) as ExecutionRow[];

  const positions = new Map<string, OpenPosition>();
  const metrics: PositionMetrics = { realizedPnlUsd: 0, closedCount: 0, wins: 0, losses: 0 };

  for (const row of rows) {
    const marketTicker = row.market_ticker.toUpperCase();
    const side = row.contract_side;
    const key = positionKey(marketTicker, side);
    const contracts = Number(row.contracts ?? 0);
    const priceCents = Number(row.price_cents ?? 0);
    const feeUsd = Number(row.fee_usd ?? 0);
    if (contracts <= 0) continue;

    if (row.action === "BUY") {
      const existing = positions.get(key);
      if (!existing) {
        positions.set(key, {
          marketTicker,
          eventTicker: row.event_ticker ?? null,
          seriesTicker: row.series_ticker ?? null,
          contractSide: side,
          contracts,
          avgEntryCents: priceCents,
          openFeesRemainingUsd: feeUsd,
        });
        continue;
      }

      const nextContracts = existing.contracts + contracts;
      existing.avgEntryCents =
        (existing.avgEntryCents * existing.contracts + priceCents * contracts) /
        nextContracts;
      existing.contracts = nextContracts;
      existing.openFeesRemainingUsd += feeUsd;
      existing.eventTicker = existing.eventTicker ?? row.event_ticker ?? null;
      existing.seriesTicker = existing.seriesTicker ?? row.series_ticker ?? null;
      continue;
    }

    if (row.action === "SELL") {
      const existing = positions.get(key);
      if (!existing || existing.contracts <= 0) {
        continue;
      }
      const closeContracts = Math.min(contracts, existing.contracts);
      if (closeContracts <= 0) continue;

      let feeAllocUsd = 0;
      if (existing.openFeesRemainingUsd > 0 && existing.contracts > 0) {
        feeAllocUsd = existing.openFeesRemainingUsd * (closeContracts / existing.contracts);
        existing.openFeesRemainingUsd -= feeAllocUsd;
      }

      const realized =
        (closeContracts * (priceCents - existing.avgEntryCents)) / 100 -
        feeAllocUsd -
        feeUsd;

      metrics.realizedPnlUsd += realized;
      metrics.closedCount += 1;
      if (realized > 0) metrics.wins += 1;
      else if (realized < 0) metrics.losses += 1;

      existing.contracts -= closeContracts;
      if (existing.contracts <= 1e-12) {
        positions.delete(key);
      }
    }
  }

  return { positions, metrics };
}

function getLatestMarks(db: DatabaseSync): Map<string, MarketMark> {
  const rows = db
    .prepare(
      `SELECT market_ticker, status, close_time_utc, settlement_side, yes_bid_cents, yes_ask_cents,
              last_yes_trade_cents, mark_method, mark_cents, updated_at
       FROM market_marks`,
    )
    .all() as Array<{
    market_ticker: string;
    status: MarketStatus;
    close_time_utc: string | null;
    settlement_side: ContractSide | null;
    yes_bid_cents: number | null;
    yes_ask_cents: number | null;
    last_yes_trade_cents: number | null;
    mark_method: MarkMethod;
    mark_cents: number | null;
    updated_at: string;
  }>;

  const marks = new Map<string, MarketMark>();
  for (const row of rows) {
    marks.set(row.market_ticker.toUpperCase(), {
      marketTicker: row.market_ticker.toUpperCase(),
      status: row.status,
      closeTimeUtc: row.close_time_utc,
      settlementSide: row.settlement_side,
      yesBidCents: row.yes_bid_cents,
      yesAskCents: row.yes_ask_cents,
      lastYesTradeCents: row.last_yes_trade_cents,
      markMethod: row.mark_method,
      markCents: row.mark_cents,
      updatedAt: row.updated_at,
    });
  }
  return marks;
}

function derivePositionMarkCents(position: OpenPosition, mark: MarketMark | undefined): number | null {
  if (!mark || mark.markCents == null) {
    return null;
  }
  if (position.contractSide === "YES") {
    return mark.markCents;
  }
  return 100 - mark.markCents;
}

function sumOpenCostBasisUsd(positions: Map<string, OpenPosition>): number {
  let total = 0;
  for (const position of positions.values()) {
    total += (position.contracts * position.avgEntryCents) / 100;
  }
  return total;
}

function computeCashUsd(
  account: AccountRow,
  db: DatabaseSync,
  accountId: string,
): number {
  const rows = db
    .prepare(
      "SELECT action, contracts, price_cents, fee_usd FROM executions WHERE account_id = ? ORDER BY created_at ASC, rowid ASC",
    )
    .all(accountId) as Array<{
    action: ExecutionAction;
    contracts: number;
    price_cents: number;
    fee_usd: number;
  }>;

  let cash = Number(account.starting_balance_usd);
  for (const row of rows) {
    const contracts = Number(row.contracts ?? 0);
    const priceUsd = Number(row.price_cents ?? 0) / 100;
    const feeUsd = Number(row.fee_usd ?? 0);
    if (row.action === "BUY") {
      cash -= contracts * priceUsd + feeUsd;
    } else {
      cash += contracts * priceUsd - feeUsd;
    }
  }
  return cash;
}

function buildStatus(db: DatabaseSync, accountId: string) {
  const account = requireAccount(db, accountId);
  const { positions, metrics } = replay(db, accountId);
  const marks = getLatestMarks(db);
  const cashUsd = computeCashUsd(account, db, accountId);
  const openCostBasisUsd = sumOpenCostBasisUsd(positions);

  let openMarkValueUsd = 0;
  let unrealizedPnlUsd = 0;
  let maxLossRemainingUsd = 0;

  const rows = [...positions.values()]
    .sort((a, b) =>
      `${a.marketTicker}:${a.contractSide}`.localeCompare(`${b.marketTicker}:${b.contractSide}`),
    )
    .map((position) => {
      const mark = marks.get(position.marketTicker);
      const markCents = derivePositionMarkCents(position, mark);
      const markValueUsd = markCents == null ? null : (position.contracts * markCents) / 100;
      const costBasisUsd = (position.contracts * position.avgEntryCents) / 100;
      const unrealizedPnl =
        markValueUsd == null ? null : markValueUsd - costBasisUsd - position.openFeesRemainingUsd;
      const maxLossRemaining = position.contractSide === "YES"
        ? (position.contracts * (100 - position.avgEntryCents)) / 100
        : (position.contracts * position.avgEntryCents) / 100;

      if (markValueUsd != null) openMarkValueUsd += markValueUsd;
      if (unrealizedPnl != null) unrealizedPnlUsd += unrealizedPnl;
      maxLossRemainingUsd += maxLossRemaining;

      return {
        marketTicker: position.marketTicker,
        eventTicker: position.eventTicker,
        seriesTicker: position.seriesTicker,
        contractSide: position.contractSide,
        contracts: position.contracts,
        avgEntryCents: position.avgEntryCents,
        avgEntryDollars: position.avgEntryCents / 100,
        costBasisUsd,
        markCents,
        markDollars: markCents == null ? null : markCents / 100,
        markValueUsd,
        unrealizedPnlUsd: unrealizedPnl,
        openFeesRemainingUsd: position.openFeesRemainingUsd,
        markStatus: mark?.status ?? null,
        markMethod: mark?.markMethod ?? null,
        markUpdatedAt: mark?.updatedAt ?? null,
        staleMark: markCents == null,
      };
    });

  const equityUsd = cashUsd + openMarkValueUsd;

  return {
    account: {
      id: account.id,
      name: account.name,
      baseCurrency: account.base_currency,
      startingBalanceUsd: Number(account.starting_balance_usd),
      createdAt: account.created_at,
    },
    summary: {
      cashUsd,
      realizedPnlUsd: metrics.realizedPnlUsd,
      unrealizedPnlUsd,
      openCostBasisUsd,
      openMarkValueUsd,
      maxLossRemainingUsd,
      equityUsd,
      openPositions: rows.length,
      closedTrades: metrics.closedCount,
      wins: metrics.wins,
      losses: metrics.losses,
      winRate: metrics.closedCount > 0 ? (metrics.wins / metrics.closedCount) * 100 : null,
    },
    positions: rows,
  };
}

function cmdInit(db: DatabaseSync, args: CliMap): void {
  const accountId = (args.account as string) ?? "kalshi";
  const name = (args.name as string) ?? "Kalshi Paper";
  const baseCurrency = ((args["base-currency"] as string) ?? "USD").toUpperCase();
  const startingBalanceUsd = asNumber(
    args["starting-balance-usd"] ?? args["starting-balance"],
    "starting-balance-usd",
  );

  const exists = db.prepare("SELECT 1 AS ok FROM accounts WHERE id = ?").get(accountId) as
    | { ok: 1 }
    | undefined;
  if (exists) {
    throw new Error(`account '${accountId}' already exists`);
  }

  db.prepare(
    "INSERT INTO accounts (id, name, base_currency, starting_balance_usd, created_at) VALUES (?, ?, ?, ?, ?)",
  ).run(accountId, name, baseCurrency, startingBalanceUsd, nowIso());

  console.log(`initialized account=${accountId}`);
}

function validateTradeInput(
  marketTicker: string,
  contracts: number,
  priceCents: number,
  feeUsd: number,
): void {
  if (!marketTicker.trim()) {
    throw new Error("market is required");
  }
  if (!Number.isFinite(contracts) || contracts <= 0) {
    throw new Error("contracts must be > 0");
  }
  validatePriceCents(priceCents);
  if (!Number.isFinite(feeUsd) || feeUsd < 0) {
    throw new Error("fee-usd must be >= 0");
  }
}

function cmdBuy(db: DatabaseSync, args: CliMap): void {
  const accountId = (args.account as string) ?? "kalshi";
  const marketTicker = asString(args.market, "market").toUpperCase();
  const eventTicker = asOptionalString(args.event)?.toUpperCase();
  const seriesTicker = asOptionalString(args.series)?.toUpperCase();
  const contractSide = parseContractSide(args.side);
  const contracts = asNumber(args.contracts, "contracts");
  const priceCents = asInteger(args["price-cents"], "price-cents");
  const feeUsd = args["fee-usd"] == null ? 0 : asNumber(args["fee-usd"], "fee-usd");
  const note = asOptionalString(args.note);

  requireAccount(db, accountId);
  validateTradeInput(marketTicker, contracts, priceCents, feeUsd);

  const id = addExecution(db, {
    accountId,
    marketTicker,
    eventTicker,
    seriesTicker,
    contractSide,
    action: "BUY",
    contracts,
    priceCents,
    feeUsd,
    source: "manual",
    note,
    meta: { command: "buy" },
  });

  console.log(`bought execution=${id} ${contractSide} ${contracts} ${marketTicker} @ ${priceCents}c`);
}

function cmdSell(db: DatabaseSync, args: CliMap): void {
  const accountId = (args.account as string) ?? "kalshi";
  const marketTicker = asString(args.market, "market").toUpperCase();
  const contractSide = parseContractSide(args.side);
  const contracts = asNumber(args.contracts, "contracts");
  const priceCents = asInteger(args["price-cents"], "price-cents");
  const feeUsd = args["fee-usd"] == null ? 0 : asNumber(args["fee-usd"], "fee-usd");
  const note = asOptionalString(args.note);

  requireAccount(db, accountId);
  validateTradeInput(marketTicker, contracts, priceCents, feeUsd);
  const { positions } = replay(db, accountId);
  const existing = positions.get(positionKey(marketTicker, contractSide));
  if (!existing || existing.contracts <= 0) {
    throw new Error(`no open ${contractSide} position for ${marketTicker}`);
  }
  if (contracts > existing.contracts + 1e-12) {
    throw new Error(`cannot sell ${contracts} contracts; only ${existing.contracts} open`);
  }

  const id = addExecution(db, {
    accountId,
    marketTicker,
    eventTicker: existing.eventTicker ?? undefined,
    seriesTicker: existing.seriesTicker ?? undefined,
    contractSide,
    action: "SELL",
    contracts,
    priceCents,
    feeUsd,
    source: "manual",
    note,
    meta: { command: "sell" },
  });

  console.log(`sold execution=${id} ${contractSide} ${contracts} ${marketTicker} @ ${priceCents}c`);
}

function cmdMark(db: DatabaseSync, args: CliMap): void {
  const marketTicker = asString(args.market, "market").toUpperCase();
  const status = parseMarketStatus(args.status ?? "open");
  const yesBidCents =
    args["yes-bid-cents"] == null ? null : asInteger(args["yes-bid-cents"], "yes-bid-cents");
  const yesAskCents =
    args["yes-ask-cents"] == null ? null : asInteger(args["yes-ask-cents"], "yes-ask-cents");
  const lastYesTradeCents =
    args["last-yes-trade-cents"] == null
      ? null
      : asInteger(args["last-yes-trade-cents"], "last-yes-trade-cents");
  const closeTimeUtc = asOptionalString(args["close-time-utc"]) ?? null;
  const settlementSide =
    args["settlement-side"] == null ? null : parseContractSide(args["settlement-side"]);

  if (yesBidCents != null) validatePriceCents(yesBidCents, "yes-bid-cents");
  if (yesAskCents != null) validatePriceCents(yesAskCents, "yes-ask-cents");
  if (lastYesTradeCents != null) validatePriceCents(lastYesTradeCents, "last-yes-trade-cents");

  const { markMethod, markCents } = computeMarkFromQuote({
    status,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
  });

  upsertMarketMark(db, {
    marketTicker,
    status,
    closeTimeUtc,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
    markMethod,
    markCents,
    rawJson: asOptionalString(args["raw-json"]) ?? null,
  });

  console.log(
    `marked ${marketTicker} status=${status} mark=${markCents == null ? "n/a" : `${markCents}c`} method=${markMethod}`,
  );
}

async function cmdSyncMarket(db: DatabaseSync, args: CliMap): Promise<void> {
  const marketTicker = asString(args.market, "market").toUpperCase();
  const pretty = Boolean(args.pretty);
  const format = (args.format as string) ?? "text";
  const { market, sourceUrl, rawResponse } = await fetchKalshiMarket(marketTicker, args);

  const status = normalizeKalshiStatus(market.status ?? "active");
  const yesBidCents = parsePriceFieldToCents(market.yes_bid, market.yes_bid_dollars);
  const yesAskCents = parsePriceFieldToCents(market.yes_ask, market.yes_ask_dollars);
  const lastYesTradeCents = parsePriceFieldToCents(market.last_price, market.last_price_dollars);
  const settlementSide = inferSettlementSide(market);

  if (yesBidCents != null) validatePriceCents(yesBidCents, "yes_bid");
  if (yesAskCents != null) validatePriceCents(yesAskCents, "yes_ask");
  if (lastYesTradeCents != null) validatePriceCents(lastYesTradeCents, "last_price");

  const { markMethod, markCents } = computeMarkFromQuote({
    status,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
  });

  upsertMarketMark(db, {
    marketTicker,
    status,
    closeTimeUtc: market.close_time ?? null,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
    markMethod,
    markCents,
    rawJson: JSON.stringify(rawResponse),
  });

  const payload = {
    marketTicker,
    eventTicker: market.event_ticker ?? null,
    status,
    closeTimeUtc: market.close_time ?? null,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
    markMethod,
    markCents,
    sourceUrl,
  };

  if (format === "json") {
    printJson(payload, pretty);
    return;
  }

  console.log(
    `synced ${marketTicker} status=${status} yes_bid=${yesBidCents ?? "n/a"} yes_ask=${yesAskCents ?? "n/a"} last=${lastYesTradeCents ?? "n/a"} mark=${markCents == null ? "n/a" : `${markCents}c`} method=${markMethod}`,
  );
}

async function cmdBuyFromMarket(db: DatabaseSync, args: CliMap): Promise<void> {
  const accountId = (args.account as string) ?? "kalshi";
  const marketTicker = asString(args.market, "market").toUpperCase();
  const contractSide = parseContractSide(args.side);
  const contracts = asNumber(args.contracts, "contracts");
  const feeUsd = args["fee-usd"] == null ? 0 : asNumber(args["fee-usd"], "fee-usd");
  const note = asOptionalString(args.note);
  const pretty = Boolean(args.pretty);
  const format = (args.format as string) ?? "json";

  requireAccount(db, accountId);

  const { market, sourceUrl, rawResponse } = await fetchKalshiMarket(marketTicker, args);

  const status = normalizeKalshiStatus(market.status ?? "active");
  const yesBidCents = parsePriceFieldToCents(market.yes_bid, market.yes_bid_dollars);
  const yesAskCents = parsePriceFieldToCents(market.yes_ask, market.yes_ask_dollars);
  const noAskCents = parsePriceFieldToCents(market.no_ask, market.no_ask_dollars);
  const lastYesTradeCents = parsePriceFieldToCents(market.last_price, market.last_price_dollars);
  const settlementSide = inferSettlementSide(market);

  if (yesBidCents != null) validatePriceCents(yesBidCents, "yes_bid");
  if (yesAskCents != null) validatePriceCents(yesAskCents, "yes_ask");
  if (noAskCents != null) validatePriceCents(noAskCents, "no_ask");
  if (lastYesTradeCents != null) validatePriceCents(lastYesTradeCents, "last_price");

  const { markMethod, markCents } = computeMarkFromQuote({
    status,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
  });

  upsertMarketMark(db, {
    marketTicker,
    status,
    closeTimeUtc: market.close_time ?? null,
    settlementSide,
    yesBidCents,
    yesAskCents,
    lastYesTradeCents,
    markMethod,
    markCents,
    rawJson: JSON.stringify(rawResponse),
  });

  const priceCents = contractSide === "YES" ? yesAskCents : noAskCents;
  if (priceCents == null) {
    throw new Error(`missing ${contractSide.toLowerCase()} ask for ${marketTicker}`);
  }

  validateTradeInput(marketTicker, contracts, priceCents, feeUsd);

  const eventTicker = market.event_ticker?.toUpperCase() ?? undefined;
  const seriesTicker = inferSeriesTicker(marketTicker, market.event_ticker ?? null) ?? undefined;
  const executionId = addExecution(db, {
    accountId,
    marketTicker,
    eventTicker,
    seriesTicker,
    contractSide,
    action: "BUY",
    contracts,
    priceCents,
    feeUsd,
    source: "sync-market",
    note,
    meta: {
      command: "buy-from-market",
      sourceUrl,
      status,
      yesBidCents,
      yesAskCents,
      noAskCents,
      lastYesTradeCents,
      markCents,
      markMethod,
    },
  });

  const statusPayload = buildStatus(db, accountId);
  const out = {
    trade: {
      executionId,
      accountId,
      marketTicker,
      eventTicker: eventTicker ?? null,
      seriesTicker: seriesTicker ?? null,
      contractSide,
      contracts,
      priceCents,
      priceDollars: priceCents / 100,
      feeUsd,
      sourceUrl,
    },
    mark: {
      status,
      yesBidCents,
      yesAskCents,
      noAskCents,
      lastYesTradeCents,
      markMethod,
      markCents,
      settlementSide,
      closeTimeUtc: market.close_time ?? null,
    },
    status: statusPayload,
  };

  if (format === "json") {
    printJson(out, pretty);
    return;
  }

  console.log(
    `bought-from-market execution=${executionId} ${contractSide} ${contracts} ${marketTicker} @ ${priceCents}c mark=${markCents == null ? "n/a" : `${markCents}c`} method=${markMethod}`,
  );
  cmdStatus(db, { _: ["status"], account: accountId, format, pretty });
}

function cmdReconcile(db: DatabaseSync, args: CliMap): void {
  const accountId = (args.account as string) ?? "kalshi";
  const marketTicker = asString(args.market, "market").toUpperCase();
  const status = parseMarketStatus(args.status ?? "finalized");
  const winningSide = parseContractSide(args["winning-side"]);

  requireAccount(db, accountId);
  upsertMarketMark(db, {
    marketTicker,
    status,
    settlementSide: winningSide,
    markMethod: "settlement",
    markCents: winningSide === "YES" ? 100 : 0,
    closeTimeUtc: asOptionalString(args["close-time-utc"]) ?? null,
    rawJson: asOptionalString(args["raw-json"]) ?? null,
  });

  const { positions } = replay(db, accountId);
  const closed: string[] = [];

  for (const side of ["YES", "NO"] as const) {
    const existing = positions.get(positionKey(marketTicker, side));
    if (!existing || existing.contracts <= 0) continue;
    const settlementCents = side === winningSide ? 100 : 0;
    addExecution(db, {
      accountId,
      marketTicker,
      eventTicker: existing.eventTicker ?? undefined,
      seriesTicker: existing.seriesTicker ?? undefined,
      contractSide: side,
      action: "SELL",
      contracts: existing.contracts,
      priceCents: settlementCents,
      feeUsd: 0,
      source: "reconcile",
      note: `settlement ${winningSide}`,
      meta: { command: "reconcile", winningSide },
    });
    closed.push(`${side}:${existing.contracts}@${settlementCents}c`);
  }

  if (closed.length === 0) {
    console.log(`reconciled ${marketTicker}; no open positions to settle`);
    return;
  }

  console.log(`reconciled ${marketTicker}; settled ${closed.join(", ")}`);
}

function cmdStatus(db: DatabaseSync, args: CliMap): void {
  const accountId = (args.account as string) ?? "kalshi";
  const format = (args.format as string) ?? "text";
  const pretty = Boolean(args.pretty);
  const payload = buildStatus(db, accountId);

  if (format === "json") {
    console.log(JSON.stringify(payload, null, pretty ? 2 : undefined));
    return;
  }

  const s = payload.summary;
  console.log(`account=${payload.account.id} equity_usd=${s.equityUsd.toFixed(2)} cash_usd=${s.cashUsd.toFixed(2)}`);
  console.log(
    `realized_pnl_usd=${s.realizedPnlUsd.toFixed(2)} unrealized_pnl_usd=${s.unrealizedPnlUsd.toFixed(
      2,
    )} open_positions=${s.openPositions} closed_trades=${s.closedTrades}`,
  );
  if (s.winRate != null) {
    console.log(`win_rate=${s.winRate.toFixed(2)}% (${s.wins}/${s.closedTrades})`);
  }
  if (payload.positions.length > 0) {
    console.log("positions:");
    for (const p of payload.positions) {
      const mark = p.markCents == null ? "n/a" : `${p.markCents}c`;
      const unrealized =
        p.unrealizedPnlUsd == null ? "n/a" : p.unrealizedPnlUsd.toFixed(2);
      console.log(
        `- ${p.marketTicker} ${p.contractSide} contracts=${p.contracts.toFixed(2)} avg=${p.avgEntryCents.toFixed(
          2,
        )}c mark=${mark} unrealized_usd=${unrealized}${p.staleMark ? " stale_mark=true" : ""}`,
      );
    }
  }
}

function cmdReview(db: DatabaseSync, args: CliMap): void {
  const accountId = (args.account as string) ?? "kalshi";
  const format = (args.format as string) ?? "text";
  const pretty = Boolean(args.pretty);
  const payload = buildStatus(db, accountId);
  const expectancy =
    payload.summary.closedTrades > 0
      ? payload.summary.realizedPnlUsd / payload.summary.closedTrades
      : 0;

  const out = {
    summary: payload.summary,
    expectancyPerClosedTradeUsd: expectancy,
  };

  if (format === "json") {
    console.log(JSON.stringify(out, null, pretty ? 2 : undefined));
    return;
  }

  console.log(`closed_trades=${payload.summary.closedTrades} realized_usd=${payload.summary.realizedPnlUsd.toFixed(2)}`);
  console.log(`expectancy_per_closed_trade_usd=${expectancy.toFixed(2)}`);
}

function printHelp(): void {
  console.log(`kalshi_paper.ts - Kalshi-native SQLite paper trading CLI

Usage:
  node --experimental-strip-types kalshi_paper.ts [--db <path>] <command> [options]

Commands:
  init --starting-balance-usd <n> [--account kalshi] [--name <name>] [--base-currency USD]
  buy --account kalshi --market <ticker> [--event <ticker>] [--series <ticker>] --side YES|NO --contracts <n> --price-cents <0-100> [--fee-usd 0] [--note <text>]
  buy-from-market --account kalshi --market <ticker> --side YES|NO --contracts <n> [--fee-usd 0] [--note <text>] [--kalshi-base-url <url>] [--format text|json] [--pretty]
  sell --account kalshi --market <ticker> --side YES|NO --contracts <n> --price-cents <0-100> [--fee-usd 0] [--note <text>]
  mark --market <ticker> [--status open|closed|finalized] [--yes-bid-cents <n>] [--yes-ask-cents <n>] [--last-yes-trade-cents <n>] [--settlement-side YES|NO] [--close-time-utc <iso>]
  sync-market --market <ticker> [--kalshi-base-url <url>] [--format text|json] [--pretty]
  reconcile --account kalshi --market <ticker> --winning-side YES|NO [--status finalized] [--close-time-utc <iso>]
  status [--account kalshi] [--format text|json] [--pretty]
  review [--account kalshi] [--format text|json] [--pretty]
`);
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));
  const command = args._[0];

  if (!command || command === "help" || command === "--help") {
    printHelp();
    return;
  }

  const db = openDb(getDbPath(args));
  try {
    switch (command) {
      case "init":
        cmdInit(db, args);
        break;
      case "buy":
        cmdBuy(db, args);
        break;
      case "buy-from-market":
        await cmdBuyFromMarket(db, args);
        break;
      case "sell":
        cmdSell(db, args);
        break;
      case "mark":
        cmdMark(db, args);
        break;
      case "sync-market":
        await cmdSyncMarket(db, args);
        break;
      case "reconcile":
        cmdReconcile(db, args);
        break;
      case "status":
        cmdStatus(db, args);
        break;
      case "review":
        cmdReview(db, args);
        break;
      default:
        throw new Error(`unknown command '${command}'`);
    }
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 1;
  } finally {
    db.close();
  }
}

void main();


path: tests/kalshi_paper.test.mjs
content: import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, spawnSync } from "node:child_process";
import http from "node:http";
import { once } from "node:events";
import { DatabaseSync } from "node:sqlite";

const __dirname = dirname(fileURLToPath(import.meta.url));
const scriptPath = resolve(__dirname, "../scripts/kalshi_paper.ts");

function mkTmpDbPath(prefix = "kalshi-paper-test-") {
  const dir = mkdtempSync(join(tmpdir(), prefix));
  return { dir, db: join(dir, "kalshi-paper.db") };
}

function runCli(args, dbPath, extraEnv = {}) {
  const res = spawnSync(
    process.execPath,
    ["--experimental-strip-types", scriptPath, "--db", dbPath, ...args],
    { encoding: "utf8", env: { ...process.env, ...extraEnv } },
  );
  return {
    code: res.status ?? 1,
    stdout: res.stdout ?? "",
    stderr: res.stderr ?? "",
  };
}

async function runCliAsync(args, dbPath, extraEnv = {}) {
  const child = spawn(
    process.execPath,
    ["--experimental-strip-types", scriptPath, "--db", dbPath, ...args],
    { env: { ...process.env, ...extraEnv } },
  );

  let stdout = "";
  let stderr = "";
  child.stdout.on("data", (chunk) => {
    stdout += String(chunk);
  });
  child.stderr.on("data", (chunk) => {
    stderr += String(chunk);
  });

  const [code] = await once(child, "close");
  return {
    code: code ?? 1,
    stdout,
    stderr,
  };
}

function runOk(args, dbPath, extraEnv = {}) {
  const out = runCli(args, dbPath, extraEnv);
  assert.equal(out.code, 0, `Expected success. stderr:\n${out.stderr}\nstdout:\n${out.stdout}`);
  return out;
}

function runFail(args, dbPath, extraEnv = {}) {
  const out = runCli(args, dbPath, extraEnv);
  assert.notEqual(out.code, 0, `Expected failure. stdout:\n${out.stdout}`);
  return out;
}

function parseJsonStdout(stdout) {
  return JSON.parse(stdout.trim());
}

function assertAlmostEqual(actual, expected, epsilon = 1e-9) {
  assert.ok(
    Math.abs(actual - expected) <= epsilon,
    `Expected ${actual} to be within ${epsilon} of ${expected}`,
  );
}

test("init creates account and duplicate init fails", () => {
  const { dir, db } = mkTmpDbPath();
  try {
    runOk(["init", "--account", "kalshi", "--starting-balance-usd", "1000"], db);
    const dup = runFail(["init", "--account", "kalshi", "--starting-balance-usd", "1000"], db);
    assert.match(dup.stderr, /already exists/);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});

test("status values YES positions from cent-based marks", () => {
  const { dir, db } = mkTmpDbPath();
  try {
    runOk(["init", "--starting-balance-usd", "1000"], db);
    runOk(
      [
        "buy",
        "--market",
        "KXBTC-TEST-1",
        "--event",
        "KXBTC-TEST",
        "--series",
        "KXBTC",
        "--side",
        "YES",
        "--contracts",
        "10",
        "--price-cents",
        "84",
      ],
      db,
    );
    runOk(
      [
        "mark",
        "--market",
        "KXBTC-TEST-1",
        "--yes-bid-cents",
        "86",
        "--yes-ask-cents",
        "88",
      ],
      db,
    );

    const status = runOk(["status", "--format", "json"], db);
    const payload = parseJsonStdout(status.stdout);

    assertAlmostEqual(payload.summary.cashUsd, 991.6);
    assertAlmostEqual(payload.summary.openMarkValueUsd, 8.7);
    assertAlmostEqual(payload.summary.unrealizedPnlUsd, 0.3);
    assertAlmostEqual(payload.summary.equityUsd, 1000.3);
    assert.equal(payload.positions[0].avgEntryCents, 84);
    assert.equal(payload.positions[0].markCents, 87);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});

test("sell realizes pnl and reduces open contracts", () => {
  const { dir, db } = mkTmpDbPath();
  try {
    runOk(["init", "--starting-balance-usd", "1000"], db);
    runOk(
      ["buy", "--market", "KXETH-TEST-1", "--side", "YES", "--contracts", "10", "--price-cents", "40"],
      db,
    );
    runOk(
      ["sell", "--market", "KXETH-TEST-1", "--side", "YES", "--contracts", "4", "--price-cents", "65"],
      db,
    );
    runOk(
      ["mark", "--market", "KXETH-TEST-1", "--yes-bid-cents", "50", "--yes-ask-cents", "54"],
      db,
    );

    const status = runOk(["status", "--format", "json"], db);
    const payload = parseJsonStdout(status.stdout);

    assertAlmostEqual(payload.summary.realizedPnlUsd, 1);
    assert.equal(payload.summary.openPositions, 1);
    assert.equal(payload.positions[0].contracts, 6);
    assert.equal(payload.positions[0].avgEntryCents, 40);
    assert.equal(payload.positions[0].markCents, 52);
    assertAlmostEqual(payload.summary.unrealizedPnlUsd, 0.72);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});

test("reconcile settles both YES winner and NO loser positions", () => {
  const { dir, db } = mkTmpDbPath();
  try {
    runOk(["init", "--starting-balance-usd", "1000"], db);
    runOk(
      ["buy", "--market", "KXINX-TEST-1", "--side", "YES", "--contracts", "5", "--price-cents", "35"],
      db,
    );
    runOk(
      ["buy", "--market", "KXINX-TEST-1", "--side", "NO", "--contracts", "2", "--price-cents", "55"],
      db,
    );
    runOk(
      ["reconcile", "--market", "KXINX-TEST-1", "--winning-side", "YES"],
      db,
    );

    const status = runOk(["status", "--format", "json"], db);
    const payload = parseJsonStdout(status.stdout);

    assert.equal(payload.summary.openPositions, 0);
    assert.equal(payload.summary.closedTrades, 2);
    assertAlmostEqual(payload.summary.realizedPnlUsd, 2.15);
    assertAlmostEqual(payload.summary.cashUsd, 1002.15);
    assertAlmostEqual(payload.summary.equityUsd, 1002.15);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});

test("buy rejects out-of-range cent prices", () => {
  const { dir, db } = mkTmpDbPath();
  try {
    runOk(["init", "--starting-balance-usd", "1000"], db);
    const out = runFail(
      ["buy", "--market", "KXBTC-TEST-2", "--side", "YES", "--contracts", "1", "--price-cents", "101"],
      db,
    );
    assert.match(out.stderr, /price-cents must be between 0 and 100/);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});

test("sync-market fetches a live-style Kalshi quote and stores a midpoint mark", async () => {
  const { dir, db } = mkTmpDbPath();
  const server = http.createServer((req, res) => {
    if (req.url !== [REDACTED_SECRET]) {
      res.writeHead(404, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "not found" }));
      return;
    }

    res.writeHead(200, { "content-type": "application/json" });
    res.end(
      JSON.stringify({
        market: {
          ticker: "KXBTC-TEST-3",
          event_ticker: "KXBTC-TEST",
          status: "active",
          close_time: "2026-03-13T21:00:00Z",
          yes_bid: 7,
          yes_ask: 12,
          last_price: 10,
        },
      }),
    );
  });

  try {
    server.listen(0, "127.0.0.1");
    await once(server, "listening");
    const address = server.address();
    assert.ok(address && typeof address === "object");
    const baseUrl = `http://127.0.0.1:${address.port}/trade-api/v2`;

    const out = await runCliAsync(["sync-market", "--market", "KXBTC-TEST-3", "--format", "json"], db, {
      KALSHI_BASE_URL: baseUrl,
    });
    assert.equal(out.code, 0, `Expected success. stderr:\n${out.stderr}\nstdout:\n${out.stdout}`);
    const payload = parseJsonStdout(out.stdout);

    assert.equal(payload.marketTicker, "KXBTC-TEST-3");
    assert.equal(payload.status, "open");
    assert.equal(payload.yesBidCents, 7);
    assert.equal(payload.yesAskCents, 12);
    assert.equal(payload.markCents, 10);
    assert.equal(payload.markMethod, "mid");

    const verify = new DatabaseSync(db);
    const row = verify
      .prepare(
        "SELECT market_ticker, status, yes_bid_cents, yes_ask_cents, last_yes_trade_cents, mark_method, mark_cents, raw_json FROM market_marks WHERE market_ticker = ?",
      )
      .get("KXBTC-TEST-3");
    verify.close();

    assert.equal(row.market_ticker, "KXBTC-TEST-3");
    assert.equal(row.status, "open");
    assert.equal(row.yes_bid_cents, 7);
    assert.equal(row.yes_ask_cents, 12);
    assert.equal(row.last_yes_trade_cents, 10);
    assert.equal(row.mark_method, "mid");
    assert.equal(row.mark_cents, 10);
    assert.match(row.raw_json, /KXBTC-TEST-3/);
  } finally {
    await new Promise((resolve, reject) => {
      server.close((error) => {
        if (error) reject(error);
        else resolve();
      });
    });
    rmSync(dir, { recursive: true, force: true });
  }
});

test("buy-from-market syncs quote, buys at ask, and returns updated status", async () => {
  const { dir, db } = mkTmpDbPath();
  const server = http.createServer((req, res) => {
    if (req.url !== [REDACTED_SECRET]) {
      res.writeHead(404, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "not found" }));
      return;
    }

    res.writeHead(200, { "content-type": "application/json" });
    res.end(
      JSON.stringify({
        market: {
          ticker: "KXETH-TEST-4",
          event_ticker: "KXETH-TEST",
          status: "active",
          close_time: "2026-03-14T21:00:00Z",
          yes_bid: 21,
          yes_ask: 24,
          no_bid: 76,
          no_ask: 79,
          last_price: 22,
        },
      }),
    );
  });

  try {
    runOk(["init", "--account", "demo", "--starting-balance-usd", "1000"], db);

    server.listen(0, "127.0.0.1");
    await once(server, "listening");
    const address = server.address();
    assert.ok(address && typeof address === "object");
    const baseUrl = `http://127.0.0.1:${address.port}/trade-api/v2`;

    const out = await runCliAsync(
      [
        "buy-from-market",
        "--account",
        "demo",
        "--market",
        "KXETH-TEST-4",
        "--side",
        "YES",
        "--contracts",
        "5",
        "--format",
        "json",
      ],
      db,
      { KALSHI_BASE_URL: baseUrl },
    );
    assert.equal(out.code, 0, `Expected success. stderr:\n${out.stderr}\nstdout:\n${out.stdout}`);
    const payload = parseJsonStdout(out.stdout);

    assert.equal(payload.trade.marketTicker, "KXETH-TEST-4");
    assert.equal(payload.trade.contractSide, "YES");
    assert.equal(payload.trade.contracts, 5);
    assert.equal(payload.trade.priceCents, 24);
    assert.equal(payload.mark.markCents, 23);
    assert.equal(payload.mark.markMethod, "mid");
    assert.equal(payload.status.summary.openPositions, 1);
    assertAlmostEqual(payload.status.summary.cashUsd, 998.8);
    assertAlmostEqual(payload.status.summary.openMarkValueUsd, 1.15);
    assertAlmostEqual(payload.status.summary.unrealizedPnlUsd, -0.05);
    assert.equal(payload.status.positions[0].avgEntryCents, 24);
    assert.equal(payload.status.positions[0].markCents, 23);

    const verify = new DatabaseSync(db);
    const execution = verify
      .prepare(
        "SELECT market_ticker, event_ticker, series_ticker, contract_side, action, contracts, price_cents, source FROM executions WHERE market_ticker = ?",
      )
      .get("KXETH-TEST-4");
    const mark = verify
      .prepare(
        "SELECT market_ticker, yes_bid_cents, yes_ask_cents, mark_method, mark_cents FROM market_marks WHERE market_ticker = ?",
      )
      .get("KXETH-TEST-4");
    verify.close();

    assert.equal(execution.market_ticker, "KXETH-TEST-4");
    assert.equal(execution.event_ticker, "KXETH-TEST");
    assert.equal(execution.series_ticker, "KXETH");
    assert.equal(execution.contract_side, "YES");
    assert.equal(execution.action, "BUY");
    assert.equal(execution.contracts, 5);
    assert.equal(execution.price_cents, 24);
    assert.equal(execution.source, "sync-market");
    assert.equal(mark.market_ticker, "KXETH-TEST-4");
    assert.equal(mark.yes_bid_cents, 21);
    assert.equal(mark.yes_ask_cents, 24);
    assert.equal(mark.mark_method, "mid");
    assert.equal(mark.mark_cents, 23);
  } finally {
    await new Promise((resolve, reject) => {
      server.close((error) => {
        if (error) reject(error);
        else resolve();
      });
    });
    rmSync(dir, { recursive: true, force: true });
  }
});


path: references/kalshi-paper-ledger.md
content: # Kalshi Paper Ledger Proposal

## Why

The generic paper trader is a poor fit for Kalshi contracts.

Main mismatches:

- Kalshi quotes are naturally cent-denominated, while the generic ledger assumes dollar prices.
- Kalshi positions are binary contracts with bounded settlement values of `0` or `100` cents.
- Open risk is contract premium and event exposure, not token-style stop-distance risk.
- Missing live marks should not silently collapse open Kalshi positions to zero contribution.

This proposal defines a small Kalshi-native paper ledger and CLI.

## Goals

- Store Kalshi executions in native units without ambiguity.
- Keep realized and unrealized PnL mathematically correct.
- Support reconciliation against finalized markets.
- Support paper-only scouting and execution workflows used by Kalshi-focused skills.
- Make it hard to mix cents and dollars incorrectly.

## Non-Goals

- Live Kalshi order placement.
- General multi-venue abstraction.
- Token-style stop and TP automation in the first version.

## Core Decision

Store Kalshi execution prices as integer cents in the ledger.

Rules:

- `price_cents` is always an integer in `[0, 100]`
- reporting may derive `price_dollars = price_cents / 100`
- settlement is stored as:
  - `100` cents for the winning side
  - `0` cents for the losing side

This keeps storage aligned with Kalshi-native semantics and avoids the bug class where raw cents are mixed with dollar settlement values.

## Data Model

### Accounts

One row per paper account.

Suggested fields:

- `id`
- `name`
- `base_currency`
- `starting_balance_usd`
- `created_at`

### Executions

Append-only execution log.

Suggested fields:

- `id`
- `account_id`
- `market_ticker`
- `event_ticker`
- `series_ticker`
- `contract_side`
  - `YES`
  - `NO`
- `action`
  - `BUY`
  - `SELL`
- `contracts`
- `price_cents`
- `fee_usd`
- `source`
  - `manual`
  - `kalshi-openapi`
  - `reconcile`
- `note`
- `meta_json`
- `created_at`

### Market Marks

Latest known market state for valuation and reconciliation.

Suggested fields:

- `market_ticker`
- `status`
  - `open`
  - `closed`
  - `finalized`
- `close_time_utc`
- `settlement_side`
  - `YES`
  - `NO`
  - `NULL` when not finalized
- `yes_bid_cents`
- `yes_ask_cents`
- `last_yes_trade_cents`
- `mark_method`
  - `mid`
  - `bid`
  - `last_trade`
  - `settlement`
  - `unknown`
- `mark_cents`
- `raw_json`
- `updated_at`

## Position Model

An open position is grouped by:

- `account_id`
- `market_ticker`
- `contract_side`

Do not reuse `LONG` and `SHORT` plus `mint`.
For Kalshi, the natural exposure is:

- buy YES contracts
- buy NO contracts

That keeps settlement and valuation simple.

## Accounting Rules

### Cost Basis

For a `BUY`:

- cash decreases by `contracts * price_cents / 100 + fee_usd`

For a `SELL`:

- cash increases by `contracts * price_cents / 100 - fee_usd`

### Realized PnL

Use average-cost matching in v1.

For a closed portion:

- `realized_pnl_usd = contracts_closed * (exit_cents - avg_entry_cents) / 100 - allocated_open_fees - close_fee`

This formula is the same for YES and NO positions when the position is keyed by `contract_side`.

### Settlement

If market finalizes:

- winning `contract_side` settles at `100`
- losing `contract_side` settles at `0`

Settlement can be recorded as a synthetic `SELL` execution with source `reconcile`, or as a separate settlement event. For MVP, a synthetic sell is simpler.

### Mark-to-Market

Preferred valuation order:

1. midpoint of `yes_bid_cents` and `yes_ask_cents`
2. conservative bid-side exit value
3. last trade
4. cost basis with stale warning

For YES positions:

- mark from YES-side market fields

For NO positions:

- derive NO mark as `100 - yes_mark_cents` when direct NO mark is unavailable

### Equity

Expose all of these separately:

- `cash_usd`
- `realized_pnl_usd`
- `unrealized_pnl_usd`
- `open_cost_basis_usd`
- `open_mark_value_usd`
- `max_loss_remaining_usd`
- `equity_usd`

`equity_usd` should be:

- `cash_usd + open_mark_value_usd`

This is clearer than treating missing marks as zero without explanation.

## Risk Model

Remove token-style `--max-risk-pct` from Kalshi paper flows.

Use Kalshi-native controls instead:

- `max_cost_usd_per_trade`
- `max_contracts_per_trade`
- `max_open_premium_usd`
- `max_market_exposure_usd`
- `max_event_exposure_usd`
- `max_series_exposure_usd`
- `min_cash_reserve_usd`

These are easier to reason about than stop-based risk for binary contracts.

## CLI Surface

Suggested MVP commands:

- `init`
- `status`
- `buy`
- `sell`
- `mark`
- `reconcile`
- `review`

### `init`

Example:

```bash
node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts init \
  --account kalshi \
  --starting-balance-usd 1000
```

### `buy`

Example:

```bash
node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts buy \
  --account kalshi \
  --market KXBTC-26MAR1317-B69750 \
  --event KXBTC-26MAR1317 \
  --series KXBTC \
  --side YES \
  --contracts 15 \
  --price-cents 84
```

### `sell`

Example:

```bash
node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts sell \
  --account kalshi \
  --market KXBTC-26MAR1317-B69750 \
  --side YES \
  --contracts 15 \
  --price-cents 91
```

### `mark`

Writes current market state and derived mark:

```bash
node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts mark \
  --market KXBTC-26MAR1317-B69750 \
  --yes-bid-cents 84 \
  --yes-ask-cents 86 \
  --status open
```

### `reconcile`

Reads market status from Kalshi OpenAPI and settles finalized positions.

Example:

```bash
node --experimental-strip-types {baseDir}/scripts/kalshi_paper.ts reconcile \
  --account kalshi \
  --market KXBTC-26MAR1317-B69750 \
  --status finalized \
  --winning-side YES
```

### `status`

Should show:

- account summary
- cash
- realized and unrealized PnL
- open positions with cost basis, mark, stale-mark flag, and expiry
- per-event exposure rollup

## Integration

Kalshi-focused scouting skills should stop calling the generic paper trader for Kalshi.

Instead:

- discovery still uses Kalshi OpenAPI read scripts
- paper execution calls the Kalshi-specific paper CLI
- all price values passed to the new CLI should be raw Kalshi cents or `*_dollars` normalized once at the adapter boundary

Preferred adapter rule:

- if the API response exposes `*_dollars`, convert to integer cents once before writing to the ledger
- otherwise use raw Kalshi cent fields directly

The new ledger should reject:

- negative `price_cents`
- `price_cents > 100`
- non-integer cent values

## Migration Plan

### Phase 1

- Add the new Kalshi-specific ledger and CLI.
- Leave generic paper trading unchanged for token strategies.
- Update Kalshi workflow instructions to use the new CLI for paper operations.

### Phase 2

- Add mark refresh and reconciliation helpers around Kalshi OpenAPI.
- Add tests covering settlement, stale marks, and YES and NO valuation.

### Phase 3

- Migrate or archive old Kalshi rows from the generic paper ledger.
- Add a one-time validation script to detect impossible price values like `84` dollars for binary contracts.

## Validation Rules

Hard validation in the new CLI:

- `contracts > 0`
- `price_cents` integer in `[0, 100]`
- settlement side required for finalized markets
- cannot sell more contracts than currently open for the same `market_ticker + contract_side`

Soft warnings:

- missing live mark
- stale market mark
- mark outside bid and ask
- open exposure concentrated in one event or series

## Open Questions

- Whether v1 should support both average-cost and FIFO accounting
- Whether to persist raw OpenAPI snapshots in the ledger or only in external reports
- Whether `SELL` should be allowed before expiry for partial profit-taking in MVP, or only settlement plus full close

## Recommendation

Build a dedicated Kalshi paper ledger rather than extending the generic paper trader again.

That gives us:

- correct units
- correct settlement math
- clearer exposure controls
- less cross-venue complexity
- better failure modes for missing Kalshi marks


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)