> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sory.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# Playground

> Live venue books, merged and fee-adjusted, with the Python that produces them.

export const Playground = () => {
  const [op, setOp] = useState("book");
  const [symbol, setSymbol] = useState("BTC/USDT");
  const [venues, setVenues] = useState(DEFAULT_VENUES);
  const [fees, setFees] = useState(DEFAULT_FEES);
  const [showFees, setShowFees] = useState(false);
  const [side, setSide] = useState("buy");
  const [qty, setQty] = useState("5");
  const [orderType, setOrderType] = useState("market");
  const [price, setPrice] = useState("");
  const [slip, setSlip] = useState("15");
  const [allIn, setAllIn] = useState(true);
  const [view, setView] = useState("table");
  const [snapshots, setSnapshots] = useState(null);
  const [busy, setBusy] = useState(false);
  const [ranAt, setRanAt] = useState(null);
  const [showCode, setShowCode] = useState(true);
  const toggleVenue = v => {
    setVenues(cur => cur.includes(v) ? cur.filter(x => x !== v) : [...cur, v].sort());
  };
  const run = useCallback(async () => {
    if (!venues.length) return;
    setBusy(true);
    const results = await Promise.all(venues.map(v => fetchVenue(v, symbol)));
    setSnapshots(results);
    setRanAt(Date.now());
    setBusy(false);
  }, [venues, symbol]);
  useEffect(() => {
    run();
  }, [symbol]);
  const feeFrac = v => (fees[v] || 0) / 10000;
  const built = useMemo(() => {
    if (!snapshots) return null;
    const excluded = {};
    const included = [];
    const bidSides = [];
    const askSides = [];
    const rawBidSides = [];
    const rawAskSides = [];
    let venueTs = null;
    snapshots.forEach(s => {
      if (!s.ok) {
        excluded[s.venue] = s.reason;
        return;
      }
      included.push(s.venue);
      const fee = feeFrac(s.venue);
      bidSides.push(applyFees(s.bids, fee, true));
      askSides.push(applyFees(s.asks, fee, false));
      rawBidSides.push(s.bids);
      rawAskSides.push(s.asks);
      if (s.ts) venueTs = Math.max(venueTs || 0, s.ts);
    });
    if (!included.length) return {
      empty: true,
      excluded
    };
    const bids = mergeSide(bidSides, true);
    const asks = mergeSide(askSides, false);
    const res = resolveCrossed(bids, asks);
    return {
      symbol,
      venues: included.slice().sort(),
      excluded,
      allIn: true,
      crossed: res.crossed,
      bids: res.bids,
      asks: res.asks,
      rawBids: mergeSide(rawBidSides, true),
      rawAsks: mergeSide(rawAskSides, false),
      venueTs
    };
  }, [snapshots, fees]);
  const plan = useMemo(() => {
    if (!built || built.empty) return null;
    const isBuy = side === "buy";
    const levels = isBuy ? built.asks : built.bids;
    const q = Number(qty);
    if (!Number.isFinite(q) || q <= 0) return {
      error: "quantity must be a positive number"
    };
    const slipBps = orderType === "market" ? Number(slip) : null;
    const limitPrice = orderType === "limit" ? Number(price) : null;
    if (orderType === "market" && !Number.isFinite(slipBps)) {
      return {
        error: "a market order needs max_slippage_bps"
      };
    }
    if (orderType === "limit" && !Number.isFinite(limitPrice)) {
      return {
        error: "a limit order needs a price"
      };
    }
    const bound = boundFrom(levels, isBuy, slipBps);
    const res = walk(levels, q, isBuy, bound, limitPrice, feeFrac);
    const allocations = [];
    res.filled.forEach((v, venue) => {
      allocations.push({
        venue,
        amount: v.amount,
        expectedPrice: v.value / v.amount,
        levels: v.levels
      });
    });
    allocations.sort((a, b) => a.expectedPrice - b.expectedPrice || a.venue.localeCompare(b.venue));
    const allocated = allocations.reduce((t, a) => t + a.amount, 0);
    const unallocated = Math.max(0, q - allocated);
    const value = allocations.reduce((t, a) => t + a.amount * a.expectedPrice, 0);
    let reason = null;
    if (unallocated > 1e-12) {
      if (!allocations.length) {
        const why = Object.entries(built.excluded).map(([v, r]) => `${nameOf(v)} (${r})`).join(", ");
        reason = `no venue could take this order: ${why || "no venues"}`;
      } else {
        const at = res.stoppedBy === "limit" ? "limit price" : "price bound";
        reason = `only ${fmtAmt(allocated)} of ${fmtAmt(q)} available within the ${at}${bound !== null && res.stoppedBy !== "limit" ? ` (${fmtPrice(bound, true)})` : ""}`;
      }
    }
    return {
      symbol: built.symbol,
      side,
      requested: q,
      allocations,
      considered: [...built.venues, ...Object.keys(built.excluded)].sort(),
      excluded: built.excluded,
      unallocated,
      reason,
      bound,
      expectedPrice: allocated > 0 ? value / allocated : null,
      touch: levels.length ? levels[0].price : null
    };
  }, [built, side, qty, orderType, price, slip]);
  const code = buildCode(op, symbol, venues, fees, side, qty, orderType, price, slip);
  const jsonPayload = useMemo(() => {
    if (!built) return null;
    if (built.empty) return {
      book: null,
      excluded: built.excluded
    };
    if (op === "health") {
      const out = {};
      built.venues.forEach(v => {
        out[nameOf(v)] = "online";
      });
      Object.keys(built.excluded).forEach(v => {
        out[nameOf(v)] = "offline";
      });
      return out;
    }
    if (op === "book") {
      const side3 = ls => ls.slice(0, BOOK_ROWS).map(l => ({
        price: Number(fmtPrice(l.price, true)),
        amount: Number(fmtAmt(l.amount)),
        venue: nameOf(l.venue)
      }));
      return {
        symbol: built.symbol,
        all_in: true,
        venues: built.venues.map(nameOf),
        excluded: built.excluded,
        crossed: built.crossed,
        bids: side3(built.bids),
        asks: side3(built.asks)
      };
    }
    if (!plan || plan.error) return {
      error: plan ? plan.error : "no plan"
    };
    return {
      symbol: plan.symbol,
      side: plan.side,
      requested: Number(fmtAmt(plan.requested)),
      expected_price: plan.expectedPrice ? Number(fmtPrice(plan.expectedPrice, true)) : null,
      allocations: plan.allocations.map(a => ({
        venue: nameOf(a.venue),
        symbol: plan.symbol,
        amount: Number(fmtAmt(a.amount)),
        expected_price: Number(fmtPrice(a.expectedPrice, true))
      })),
      considered: plan.considered.map(nameOf),
      excluded: plan.excluded,
      unallocated: Number(fmtAmt(plan.unallocated)),
      unallocated_reason: plan.reason
    };
  }, [built, plan, op]);
  const bookRows = (levels, isBid) => {
    const list = levels.slice(0, BOOK_ROWS);
    const max = list.reduce((m, l) => Math.max(m, l.amount), 0) || 1;
    return list.map((l, i) => <tr key={`${l.venue}-${l.price}-${i}`} className={i === 0 ? "pg-touch" : ""}>
        <td className="pg-num">
          <span className="pg-bar" style={{
      width: `${l.amount / max * 100}%`
    }} />
          <span className={isBid ? "pg-bid" : "pg-ask"}>{fmtPrice(l.price, allIn)}</span>
        </td>
        <td className="pg-num pg-dim">{allIn ? fmtPrice(l.raw, false) : "—"}</td>
        <td className="pg-num">{fmtAmt(l.amount)}</td>
        <td>
          <VenueTag venue={l.venue} />
        </td>
      </tr>);
  };
  const displayed = isBid => {
    if (!built || built.empty) return [];
    if (allIn) return isBid ? built.bids : built.asks;
    return isBid ? built.rawBids : built.rawAsks;
  };
  const rawBestAsk = built && !built.empty && built.rawAsks.length ? built.rawAsks[0] : null;
  const allInBestAsk = built && !built.empty && built.asks.length ? built.asks[0] : null;
  const disagree = rawBestAsk && allInBestAsk && rawBestAsk.venue !== allInBestAsk.venue ? true : false;
  return <div className="sory-pg not-prose">
      <style dangerouslySetInnerHTML={{
    __html: CSS
  }} />

      {}
      <div className="pg-bar-top">
        <div className="pg-seg">
          {[["book", "Consolidated book"], ["route", "Routing plan"], ["health", "Venue health"]].map(([k, label]) => <button key={k} type="button" className={`pg-seg-btn ${op === k ? "pg-on" : ""}`} onClick={() => setOp(k)}>
              {label}
            </button>)}
        </div>

        <select className="pg-input" value={symbol} onChange={e => setSymbol(e.target.value)}>
          {SYMBOLS.map(s => <option key={s} value={s}>
              {s}
            </option>)}
        </select>

        <button type="button" className="pg-btn pg-btn-run" onClick={run} disabled={busy}>
          {busy ? "Fetching…" : "Run"}
        </button>

        {ranAt ? <span className="pg-stamp">
            {venues.length} venues · {new Date(ranAt).toLocaleTimeString()}
          </span> : null}
      </div>

      {}
      <div className="pg-venues">
        {VENUE_IDS.map(v => {
    const on = venues.includes(v);
    return <button key={v} type="button" className={`pg-chip ${on ? "pg-chip-on" : ""}`} style={{
      "--h": String(VENUES[v].hue)
    }} onClick={() => toggleVenue(v)}>
              {nameOf(v)}
              <span className="pg-chip-bps">{fees[v]}</span>
            </button>;
  })}
        <button type="button" className="pg-link" onClick={() => setShowFees(!showFees)}>
          {showFees ? "Hide fees" : "Edit fees"}
        </button>
      </div>

      {showFees ? <div className="pg-fees">
          <p className="pg-note">
            Taker fees in basis points. An example negotiated schedule — not any venue's published
            rates. SORY reads yours from <code>fees.toml</code> and treats a missing entry as a
            startup error, never a silent zero.
          </p>
          <div className="pg-fee-grid">
            {VENUE_IDS.map(v => <label key={v} className="pg-fee">
                <span>{nameOf(v)}</span>
                <input type="number" step="0.5" value={fees[v]} onChange={e => setFees({
    ...fees,
    [v]: Number(e.target.value)
  })} />
              </label>)}
          </div>
        </div> : null}

      {}
      {op === "route" ? <div className="pg-controls">
          <div className="pg-seg pg-seg-sm">
            {["buy", "sell"].map(s => <button key={s} type="button" className={`pg-seg-btn ${side === s ? "pg-on" : ""}`} onClick={() => setSide(s)}>
                {s}
              </button>)}
          </div>
          <label className="pg-field">
            <span>qty</span>
            <input className="pg-input" value={qty} onChange={e => setQty(e.target.value)} />
          </label>
          <div className="pg-seg pg-seg-sm">
            {["market", "limit"].map(t => <button key={t} type="button" className={`pg-seg-btn ${orderType === t ? "pg-on" : ""}`} onClick={() => setOrderType(t)}>
                {t}
              </button>)}
          </div>
          {orderType === "market" ? <label className="pg-field">
              <span>max_slippage_bps</span>
              <input className="pg-input" value={slip} onChange={e => setSlip(e.target.value)} />
            </label> : <label className="pg-field">
              <span>price</span>
              <input className="pg-input" placeholder={built && !built.empty && built.asks.length ? fmtPrice(built.asks[0].raw, false) : "raw price"} value={price} onChange={e => setPrice(e.target.value)} />
            </label>}
        </div> : null}

      {}
      <div className="pg-panel">
        <div className="pg-panel-head">
          <div className="pg-seg pg-seg-sm">
            {["table", "json"].map(v => <button key={v} type="button" className={`pg-seg-btn ${view === v ? "pg-on" : ""}`} onClick={() => setView(v)}>
                {v === "table" ? "Table" : "JSON"}
              </button>)}
          </div>
          {op === "book" ? <label className="pg-toggle">
              <input type="checkbox" checked={allIn} onChange={e => setAllIn(e.target.checked)} />
              <span>all-in prices</span>
            </label> : <span />}
          <CopyButton text={JSON.stringify(jsonPayload, null, 2)} label="Copy JSON" />
        </div>

        <div className="pg-out">
          {!built ? <p className="pg-empty">Press Run.</p> : built.empty ? <div>
              <p className="pg-empty">
                No venue returned a book for {symbol}. SORY would return <code>None</code> here
                rather than an empty book.
              </p>
              <ExcludedTable excluded={built.excluded} />
            </div> : view === "json" ? <HighlightedJson value={jsonPayload} /> : op === "health" ? <HealthTable built={built} /> : op === "book" ? <div>
              {disagree && allIn ? <div className="pg-insight">
                  Best raw ask is <b>{nameOf(rawBestAsk.venue)}</b> at{" "}
                  {fmtPrice(rawBestAsk.raw, false)}, but after fees the cheapest fill is{" "}
                  <b>{nameOf(allInBestAsk.venue)}</b> at {fmtPrice(allInBestAsk.price, true)}.
                  Routing on the quoted price would have picked the wrong venue.
                </div> : null}
              {built.crossed ? <div className="pg-warn">
                  The merged book crossed and was netted off. Aggregating across venues does that,
                  especially once fees move prices around — SORY resolves it and tells you.
                </div> : null}
              <div className="pg-books">
                {[["Asks", false], ["Bids", true]].map(([title, isBid]) => {
    const levels = displayed(isBid);
    return <div key={title}>
                      <h4 className="pg-h">
                        {title}
                        <span className="pg-h-sub">
                          top {Math.min(BOOK_ROWS, levels.length)} of {levels.length} merged
                        </span>
                      </h4>
                      <table className="pg-table">
                        <thead>
                          <tr>
                            <th>{allIn ? "all-in" : "price"}</th>
                            <th>raw</th>
                            <th>amount</th>
                            <th>venue</th>
                          </tr>
                        </thead>
                        <tbody>{bookRows(levels, isBid)}</tbody>
                      </table>
                    </div>;
  })}
              </div>
              <ExcludedTable excluded={built.excluded} />
            </div> : <PlanView plan={plan} />}
        </div>
      </div>

      {}
      <div className="pg-panel">
        <div className="pg-panel-head">
          <button type="button" className="pg-link" onClick={() => setShowCode(!showCode)}>
            {showCode ? "Hide the Python" : "Show the Python"}
          </button>
          <CopyButton text={code} label="Copy code" />
        </div>
        {showCode ? <pre className="pg-code">{code}</pre> : null}
      </div>
    </div>;
};

Pick venues, set your fees, and watch a consolidated book assemble across them — then route an
order against it and see where every unit goes.

<Playground />

## What you are looking at

<Note>
  This page calls each venue's **public REST order book directly from your browser** and applies
  SORY's merge, fee and allocation arithmetic — ported from `marketdata/aggregator.py` and
  `execution/router.py` — in JavaScript.

  It is not the Python package executing. SORY streams over WebSockets, needs a
  [licence key](/licensing), and reads *your* negotiated fees. So expect these differences:

  * **Depth and timing.** One REST snapshot per venue, on demand. SORY holds a live book per venue
    and re-emits every `emit_interval_ms`, with anything quiet marked `Stale` and dropped.
  * **Venue minimums.** The real router re-allocates around a venue that cannot take its slice, using
    instrument metadata from `load_markets`. That metadata is not available here, so slices are shown
    unrounded.
  * **Arithmetic.** SORY uses `Decimal` end to end; the browser has `float`.
  * **Fees.** The rates above are an example schedule, not any venue's published tier.
</Note>

Nine venues are reachable this way — `binance`, `okx`, `coinbaseexchange`, `kraken`, `bybit`,
`bitstamp`, `htx`, `bitget` and `cryptocom` — because they send permissive CORS headers on their
public book endpoint. The [other fifteen](/venues) work identically in the package; they just
cannot be called from a web page. If a venue shows up as unreachable, it is usually geo-blocking
your region, which is exactly what `excluded` reports in production too.

## Things worth trying

<AccordionGroup>
  <Accordion title="Turn all-in prices off, then on" icon="percent">
    Watch the row order change. With a wide enough fee spread, the venue at the top of the raw book
    is not the venue you should trade — that is the entire argument for SORY existing.
    See [Fees](/fees).
  </Accordion>

  <Accordion title="Raise one venue's fee to 60 bps" icon="sliders">
    It sinks down the all-in book and stops receiving allocations, without disappearing. Nothing is
    hidden; it is simply no longer the best price.
  </Accordion>

  <Accordion title="Ask for more than the book holds" icon="scale">
    Set qty to something large. The plan comes back partially allocated with
    `unallocated` and a reason — never a quiet short fill.
    `requested == allocated + unallocated`, always. See [Routing](/routing).
  </Accordion>

  <Accordion title="Drop max_slippage_bps to 1" icon="octagon-x">
    The price bound bites and allocation stops at the touch. SORY will not sweep to arbitrary
    depth, and it will not invent a bound for you.
  </Accordion>

  <Accordion title="Deselect every venue but one" icon="building-2">
    The consolidated book collapses to that venue's book, and the others appear under
    **Excluded** with a reason. Exclusion is never invisible.
  </Accordion>
</AccordionGroup>

## Run it for real

<Steps>
  <Step title="Install and set a key">
    ```bash theme={"dark"}
    pip install sory
    export SORY_LICENCE_KEY="..."
    ```

    [Request a key](mailto:manu.de.cara@gmail.com) if you do not have one.
  </Step>

  <Step title="Copy the code above">
    The **Copy code** button gives you a complete, runnable script with the venues, symbol and fees you
    selected already filled in.
  </Step>

  <Step title="Swap the inline fees for your own">
    The generated config inlines `fees_bps` so the snippet runs as-is. In production, point it at a
    `fees.toml` holding your negotiated rates:

    ```python theme={"dark"}
    config = {
        "market_data": {"exchanges": ["binance", "okx"], "symbols": ["BTC/USDT"]},
        "fees_bps": "fees.toml",
    }
    ```
  </Step>
</Steps>

<Columns cols={3}>
  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Install to routed order in five minutes.
  </Card>

  <Card title="Routing" icon="split" href="/routing">
    How allocation actually works.
  </Card>

  <Card title="Fees" icon="percent" href="/fees">
    Why all-in pricing is the only pricing.
  </Card>
</Columns>
