feat(market): add sell orders view and default to 7-day window

- New "Sell orders" tab / sort=sellRecent: items with open asks, newest
  listing first, showing ask, sell qty, sell value and last listed time.
- MarketOverview and item quotes now include sellValue and lastSellAt.
- SDK MARKET_SORTS (and so the get_market MCP tool) accept sellRecent.
- Market list and API default to days=7 instead of 24h.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Code committed 2026-09-16 15:05:09 -04:00
1 parent ccf011d91c
commit 002303875c
8 files changed
+104 -33

No files matched your search

+6 -2
View File
@@ -1021,9 +1021,13 @@ interface MarketQuote {
/** Coins committed to unfilled bids, independent of the selected period. */
buyValue: number;
sellQuantity: number;
/** Completed item quantity in the selected period (24 hours by default). */
/** Coins asked for unfilled sell offers, independent of the selected period. */
sellValue: number;
/** Newest open sell offer creation time (Unix ms), or null without open asks. */
lastSellAt: number | null;
/** Completed item quantity in the selected period (7 days by default). */
volume: number;
/** Gross coins exchanged in the selected period (24 hours by default). */
/** Gross coins exchanged in the selected period (7 days by default). */
gross: number;
tax: number;
trades: number;
+7 -3
View File
@@ -153,14 +153,16 @@ No authentication is required. Responses may be cached for five seconds.
| `/api/market/items/{id}` | Canonical, unnoted tradeable item ID | `MarketQuote` |
| `/api/market/items/{id}/history` | `from`, `to`: Unix milliseconds; `interval`: 3600000 (hour) or 86400000 (day) | `{ item, from, to, interval, history }` |
The list and item endpoints also accept `days=1|7|30|90` (default `1`).
The list accepts `sort=relevance|name|name-desc|recent|rises|falls|marketCap|price|volume|value|buyValue`
The list and item endpoints also accept `days=1|7|30|90` (default `7`).
The list accepts `sort=relevance|name|name-desc|recent|rises|falls|marketCap|price|volume|value|buyValue|sellRecent`
(default `relevance` when searching, `name` otherwise). Rankings apply before pagination;
`rises`/`falls` include only positive/negative changes with trades in the period.
`price` ranks by last unit price, `volume` by units traded, and `value` by gross
trade value. `buyValue` ranks the current open buy book by the sum of remaining
quantity × bid price for each item, including items without completed trades.
This value is independent of `days` and excludes filled and cancelled quantities.
`sellRecent` lists items with open sell offers, newest listing first; like
`buyValue` it describes the current book and ignores `days`.
Search filters the chosen ranking. The response also includes
`days`, `sort`, `updatedAt`, a market-wide `summary` (`volume`, `gross`,
`activeItems`, `totalItems`), and the most-traded `featured` item, or `null`.
@@ -194,8 +196,10 @@ are omitted, never filled with invented prices or volume.
| `bid`, `ask` | Highest open buy limit / lowest open sell limit; `null` if absent |
| `buyQuantity`, `sellQuantity` | Remaining quantities in open offers |
| `buyValue` | Total gp committed to unfilled open buy offers, at each offer’s bid price |
| `sellValue` | Total gp asked for unfilled open sell offers, at each offer’s ask price |
| `lastSellAt` | Creation time of the newest open sell offer (Unix ms), or `null` |
| `lastPrice`, `lastTradeAt` | Most recent execution price and Unix milliseconds, or `null` |
| `volume` | Actual item units exchanged in the selected period (24 hours by default) |
| `volume` | Actual item units exchanged in the selected period (7 days by default) |
| `gross`, `tax`, `trades` | Selected-period gross coins, coins burned, and number of matched fills |
| `vwap` | Selected-period quantity-weighted average price; `null` without trades |
+7 -3
View File
@@ -1,5 +1,5 @@
/** Public, read-only Grand Exchange market data. Trading stays in the game. */
export const MARKET_SORTS = ['relevance', 'name', 'name-desc', 'recent', 'rises', 'falls', 'marketCap', 'price', 'volume', 'value', 'buyValue'] as const;
export const MARKET_SORTS = ['relevance', 'name', 'name-desc', 'recent', 'rises', 'falls', 'marketCap', 'price', 'volume', 'value', 'buyValue', 'sellRecent'] as const;
export type MarketSort = (typeof MARKET_SORTS)[number];
export type MarketDays = 1 | 7 | 30 | 90;
export interface MarketItemOptions {
@@ -26,9 +26,13 @@ export interface MarketQuote {
/** Coins committed to unfilled bids, independent of the selected period. */
buyValue: number;
sellQuantity: number;
/** Completed item quantity in the selected period (24 hours by default). */
/** Coins asked for unfilled sell offers, independent of the selected period. */
sellValue: number;
/** Newest open sell offer creation time (Unix ms), or null without open asks. */
lastSellAt: number | null;
/** Completed item quantity in the selected period (7 days by default). */
volume: number;
/** Gross coins exchanged in the selected period (24 hours by default). */
/** Gross coins exchanged in the selected period (7 days by default). */
gross: number;
tax: number;
trades: number;
+27 -19
View File
@@ -892,10 +892,11 @@
<div class="tabs" id="sort-tabs" role="group" aria-label="Market views">
<button data-sort="recent" class="active" aria-pressed="true">Recent trades</button>
<button data-sort="buyValue" aria-pressed="false">Buy orders</button>
<button data-sort="sellRecent" aria-pressed="false">Sell orders</button>
<button data-sort="value" class="metric-tab" aria-pressed="false">
<img src="/sprite/item/995-10000.png" alt="" />
<span
><span class="tab-label">Trade value · <span class="window-label">24h</span></span
><span class="tab-label">Trade value · <span class="window-label">7d</span></span
><strong id="total-gold">—</strong> <span class="tab-unit">gp</span></span
>
</button>
@@ -903,14 +904,14 @@
<button data-sort="volume" class="metric-tab" aria-pressed="false">
<img src="/sprite/item/1511.png" alt="" />
<span
><span class="tab-label">Most traded · <span class="window-label">24h</span></span
><span class="tab-label">Most traded · <span class="window-label">7d</span></span
><strong id="total-volume">—</strong></span
>
</button>
</div>
<div class="filters">
<div class="periods" id="list-periods" role="group" aria-label="Market time period">
<button data-days="1" class="active" aria-pressed="true">24H</button><button data-days="7" aria-pressed="false">7D</button><button data-days="30" aria-pressed="false">30D</button
<button data-days="1" aria-pressed="false">24H</button><button data-days="7" class="active" aria-pressed="true">7D</button><button data-days="30" aria-pressed="false">30D</button
><button data-days="90" aria-pressed="false">90D</button>
</div>
</div>
@@ -922,7 +923,7 @@
<th scope="col" id="price-column">Price · gp</th>
<th scope="col" id="change-column">Change</th>
<th scope="col" id="metric-column">Volume</th>
<th scope="col">Last traded</th>
<th scope="col" id="time-column">Last traded</th>
</tr>
</thead>
<tbody id="items">
@@ -1033,6 +1034,7 @@
const titles = {
recent: 'Recent trades',
buyValue: 'Buy orders',
sellRecent: 'Sell orders',
value: 'Trade value',
price: 'Biggest items',
volume: 'Most traded',
@@ -1041,7 +1043,7 @@
let offset = 0,
total = 0,
sort = 'recent',
days = 1,
days = 7,
detailDays = 30,
selected = null,
listSequence = 0,
@@ -1105,11 +1107,14 @@
$('list-title').textContent = titles[sort];
activate('sort-tabs', 'sort', sort);
activate('list-periods', 'days', days);
const buyOrders = sort === 'buyValue';
$('list-periods').parentElement.hidden = buyOrders;
$('price-column').textContent = buyOrders ? 'Bid · gp' : 'Price · gp';
$('change-column').textContent = buyOrders ? 'Buy qty' : 'Change';
$('metric-column').textContent = buyOrders ? 'Buy value · gp' : sort === 'value' ? 'Trade value · gp' : 'Volume';
const buyOrders = sort === 'buyValue',
sellOrders = sort === 'sellRecent',
orders = buyOrders || sellOrders;
$('list-periods').parentElement.hidden = orders;
$('price-column').textContent = buyOrders ? 'Bid · gp' : sellOrders ? 'Ask · gp' : 'Price · gp';
$('change-column').textContent = buyOrders ? 'Buy qty' : sellOrders ? 'Sell qty' : 'Change';
$('metric-column').textContent = buyOrders ? 'Buy value · gp' : sellOrders ? 'Sell value · gp' : sort === 'value' ? 'Trade value · gp' : 'Volume';
$('time-column').textContent = sellOrders ? 'Last listed' : 'Last traded';
try {
const d = await api('?' + new URLSearchParams({ q: $('search').value, limit, offset, sort, days }));
if (seq !== listSequence) return;
@@ -1121,14 +1126,17 @@
}
$('items').innerHTML = d.items.length
? d.items
.map(
item =>
`<tr data-item="${item.id}"><td><a class="itemlink" href="${escape(itemHash(item.id))}">${sprite(item.id)}<span>${escape(item.name)}</span></a></td>
<td title="${fmt(buyOrders ? item.bid : item.lastPrice)} gp">${short(buyOrders ? item.bid : item.lastPrice)}</td>
<td class="${buyOrders ? '' : 'change ' + tone(item.changePercent)}" title="${buyOrders ? fmt(item.buyQuantity) : ''}">${buyOrders ? short(item.buyQuantity) : changeText(item.changePercent)}</td>
<td title="${fmt(buyOrders ? item.buyValue : sort === 'value' ? item.gross : item.volume)}">${short(buyOrders ? item.buyValue : sort === 'value' ? item.gross : item.volume)}</td>
<td class="recent" title="${item.lastTradeAt ? new Date(item.lastTradeAt).toLocaleString() : 'No completed trades'}">${relative(item.lastTradeAt)}</td></tr>`
)
.map(item => {
const price = buyOrders ? item.bid : sellOrders ? item.ask : item.lastPrice,
quantity = buyOrders ? item.buyQuantity : item.sellQuantity,
metric = buyOrders ? item.buyValue : sellOrders ? item.sellValue : sort === 'value' ? item.gross : item.volume,
time = sellOrders ? item.lastSellAt : item.lastTradeAt;
return `<tr data-item="${item.id}"><td><a class="itemlink" href="${escape(itemHash(item.id))}">${sprite(item.id)}<span>${escape(item.name)}</span></a></td>
<td title="${fmt(price)} gp">${short(price)}</td>
<td class="${orders ? '' : 'change ' + tone(item.changePercent)}" title="${orders ? fmt(quantity) : ''}">${orders ? short(quantity) : changeText(item.changePercent)}</td>
<td title="${fmt(metric)}">${short(metric)}</td>
<td class="recent" title="${time != null ? new Date(time).toLocaleString() : 'No completed trades'}">${relative(time)}</td></tr>`;
})
.join('')
: '<tr><td colspan="5" class="empty">No results</td></tr>';
bindImages($('items'));
@@ -1347,7 +1355,7 @@
history.replaceState(null, '', '#' + params);
}
sort = location.hash === '#browse' ? 'name' : Object.hasOwn(titles, params.get('sort')) ? params.get('sort') : 'recent';
days = [1, 7, 30, 90].includes(Number(params.get('days'))) ? Number(params.get('days')) : 1;
days = [1, 7, 30, 90].includes(Number(params.get('days'))) ? Number(params.get('days')) : 7;
const requestedOffset = Number(params.get('offset'));
offset = Number.isFinite(requestedOffset) ? Math.floor(Math.min(100000, Math.max(0, requestedOffset)) / limit) * limit : 0;
$('search').value = (params.get('q') || '').slice(0, 80);
+10 -3
View File
@@ -13,6 +13,8 @@ export type MarketOverview = {
buyQuantity: number;
buyValue: number;
sellQuantity: number;
sellValue: number;
lastSellAt: number | null;
volume: number;
gross: number;
tax: number;
@@ -210,12 +212,14 @@ export class MarketStore {
}
quote(item: number, since = Date.now() - 86_400_000) {
const book = this.db
.query<{ bid: number | null; ask: number | null; buyQuantity: number; buyValue: number; sellQuantity: number }, [number]>(
.query<{ bid: number | null; ask: number | null; buyQuantity: number; buyValue: number; sellQuantity: number; sellValue: number; lastSellAt: number | null }, [number]>(
`SELECT
MAX(CASE WHEN side='buy' THEN price END) bid, MIN(CASE WHEN side='sell' THEN price END) ask,
COALESCE(SUM(CASE WHEN side='buy' THEN remaining ELSE 0 END),0) buyQuantity,
COALESCE(SUM(CASE WHEN side='buy' THEN remaining * price ELSE 0 END),0) buyValue,
COALESCE(SUM(CASE WHEN side='sell' THEN remaining ELSE 0 END),0) sellQuantity
COALESCE(SUM(CASE WHEN side='sell' THEN remaining ELSE 0 END),0) sellQuantity,
COALESCE(SUM(CASE WHEN side='sell' THEN remaining * price ELSE 0 END),0) sellValue,
MAX(CASE WHEN side='sell' AND remaining>0 THEN created END) lastSellAt
FROM offers WHERE item=? AND state='open'`
)
.get(item)!;
@@ -248,12 +252,15 @@ export class MarketStore {
MAX(CASE WHEN side='buy' THEN price END) bid, MIN(CASE WHEN side='sell' THEN price END) ask,
SUM(CASE WHEN side='buy' THEN remaining ELSE 0 END) buyQuantity,
SUM(CASE WHEN side='buy' THEN remaining * price ELSE 0 END) buyValue,
SUM(CASE WHEN side='sell' THEN remaining ELSE 0 END) sellQuantity
SUM(CASE WHEN side='sell' THEN remaining ELSE 0 END) sellQuantity,
SUM(CASE WHEN side='sell' THEN remaining * price ELSE 0 END) sellValue,
MAX(CASE WHEN side='sell' AND remaining>0 THEN created END) lastSellAt
FROM offers WHERE state='open' GROUP BY item),
period AS (SELECT item,SUM(quantity) volume,SUM(gross) gross,SUM(tax) tax,COUNT(*) trades
FROM trades WHERE time>=? AND time<? GROUP BY item)
SELECT a.item,b.bid,b.ask,COALESCE(b.buyQuantity,0) buyQuantity,COALESCE(b.sellQuantity,0) sellQuantity,
COALESCE(b.buyValue,0) buyValue,
COALESCE(b.sellValue,0) sellValue,b.lastSellAt,
COALESCE(p.volume,0) volume,COALESCE(p.gross,0) gross,COALESCE(p.tax,0) tax,COALESCE(p.trades,0) trades,
(SELECT price FROM trades WHERE item=a.item AND time<? ORDER BY time DESC,id DESC LIMIT 1) lastPrice,
(SELECT time FROM trades WHERE item=a.item AND time<? ORDER BY time DESC,id DESC LIMIT 1) lastTradeAt,
+6 -3
View File
@@ -13,7 +13,7 @@ function snapshot(days: number) {
return result;
}
function period(url: URL) {
const days = integer(url, 'days', 1, 1, 90);
const days = integer(url, 'days', 7, 1, 90);
if (![1, 7, 30, 90].includes(days)) throw new Error('Invalid days: use 1, 7, 30 or 90.');
return days;
}
@@ -46,7 +46,7 @@ export function handleMarket(req: Request, url: URL): Response | null {
offset = integer(url, 'offset', 0, 0, 100_000);
const days = period(url),
sort = url.searchParams.get('sort') ?? (q.trim() ? 'relevance' : 'name');
if (!['relevance', 'name', 'name-desc', 'recent', 'rises', 'falls', 'marketCap', 'price', 'volume', 'value', 'buyValue'].includes(sort)) throw new Error('Invalid sort.');
if (!['relevance', 'name', 'name-desc', 'recent', 'rises', 'falls', 'marketCap', 'price', 'volume', 'value', 'buyValue', 'sellRecent'].includes(sort)) throw new Error('Invalid sort.');
const data = snapshot(days),
byId = new Map(data.rows.map(row => [row.item, row]));
const empty = {
@@ -55,6 +55,8 @@ export function handleMarket(req: Request, url: URL): Response | null {
buyQuantity: 0,
buyValue: 0,
sellQuantity: 0,
sellValue: 0,
lastSellAt: null,
volume: 0,
gross: 0,
tax: 0,
@@ -72,10 +74,11 @@ export function handleMarket(req: Request, url: URL): Response | null {
if (sort === 'falls') matches = matches.filter(i => i.changePercent !== null && i.changePercent < 0 && i.volume > 0);
if (sort === 'marketCap') matches = matches.filter(i => i.marketCap !== null && i.marketCap > 0);
if (sort === 'buyValue') matches = matches.filter(i => i.buyValue > 0);
if (sort === 'sellRecent') matches = matches.filter(i => i.sellQuantity > 0 && i.lastSellAt !== null);
if (sort === 'price') matches = matches.filter(i => i.lastPrice !== null);
if (sort === 'recent') matches = matches.filter(i => i.lastTradeAt !== null);
if (sort === 'volume' || sort === 'value') matches = matches.filter(i => i.volume > 0);
const sortKeys = { recent: 'lastTradeAt', rises: 'changePercent', falls: 'changePercent', marketCap: 'marketCap', price: 'lastPrice', volume: 'volume', value: 'gross', buyValue: 'buyValue' } as const;
const sortKeys = { recent: 'lastTradeAt', rises: 'changePercent', falls: 'changePercent', marketCap: 'marketCap', price: 'lastPrice', volume: 'volume', value: 'gross', buyValue: 'buyValue', sellRecent: 'lastSellAt' } as const;
const key = sortKeys[sort as keyof typeof sortKeys];
if (key) matches.sort((a, b) => ((b[key] ?? -Infinity) - (a[key] ?? -Infinity)) * (sort === 'falls' ? -1 : 1) || a.name.localeCompare(b.name) || a.id - b.id);
const eligible = new Set(catalog().map(i => i.id)),
+20
View File
@@ -42,6 +42,13 @@ test('HTTP rankings, search, pagination and item details use the same period met
buyOffer.run(feather, 50, 100, 50, now);
buyOffer.run(logs, 10, 70, 10, now);
buyOffer.run(iron, 20, 5, 20, now);
const sellOffer = marketStore().db.query("INSERT INTO offers(owner,slot,item,side,quantity,price,remaining,created,state) VALUES('private-seller',-1,?,'sell',3,?,3,?,?)");
sellOffer.run(logs, 1000, now - 2000, 'open');
sellOffer.run(feather, 200, now - 1000, 'open');
sellOffer.run(logs, 1200, now - 5000, 'open');
sellOffer.run(iron, 100, now + 1000, 'cancelled');
sellOffer.run(logs, 900, now + 2000, 'cancelled');
sellOffer.run(logs, 800, now + 3000, 'completed');
const ranked = async (sort: string, extra = '') => await request('/api/market/items?days=7&sort=' + sort + extra).json();
expect((await ranked('recent')).items[0].id).toBe(iron);
const buys = await ranked('buyValue');
@@ -54,6 +61,19 @@ test('HTTP rankings, search, pagination and item details use the same period met
expect((await request('/api/market/items/' + feather + '?days=7').json()).buyValue).toBe(5000);
expect(JSON.stringify(buys)).not.toContain('private-buyer');
const sells = await ranked('sellRecent');
expect(sells.items.map((i: { id: number }) => i.id)).toEqual([coal, feather, logs]);
expect(sells.items.map((i: { lastSellAt: number }) => i.lastSellAt)).toEqual([now, now - 1000, now - 2000]);
expect(sells.items.map((i: { ask: number }) => i.ask)).toEqual([500, 200, 1000]);
expect(sells.items[1].lastTradeAt).toBeNull();
expect(sells.items[2].sellQuantity).toBe(6);
expect(sells.items[2].sellValue).toBe(6600);
expect((await ranked('sellRecent', '&limit=1&offset=1')).items[0].id).toBe(feather);
expect((await ranked('sellRecent', '&q=logs')).items.map((i: { id: number }) => i.id)).toEqual([logs]);
expect((await ranked('sellRecent', '&q=iron')).total).toBe(0);
expect((await request('/api/market/items?sort=sellRecent&days=1').json()).items.map((i: { id: number }) => i.id)).toEqual([coal, feather, logs]);
expect(JSON.stringify(sells)).not.toContain('private-seller');
expect((await ranked('rises')).items[0].id).toBe(coal);
const falls = await ranked('falls');
expect(falls.items.map((i: { id: number }) => i.id)).toEqual([iron]);
+21
View File
@@ -296,6 +296,27 @@ describe('Grand Exchange conservation and matching', () => {
});
describe('Market overview metrics', () => {
test('sell recency and value track remaining open offers through fills and cancellation', () => {
const s = store();
const first = s.place(new Wallet('older', 0, 5).account(), ITEM, 'sell', 5, 100, COINS);
const second = s.place(new Wallet('newer', 0, 3).account(), ITEM, 'sell', 3, 200, COINS);
s.db.query('UPDATE offers SET created=? WHERE id=?').run(1000, first.id);
s.db.query('UPDATE offers SET created=? WHERE id=?').run(2000, second.id);
const row = () => s.overview(0, Date.now() + 1)[0]!;
expect(row().lastSellAt).toBe(2000);
expect(row().sellValue).toBe(1100);
s.place(new Wallet('buyer', 200).account(), ITEM, 'buy', 2, 100, COINS);
expect(row().sellQuantity).toBe(6);
expect(row().sellValue).toBe(900);
expect(row().lastSellAt).toBe(2000);
s.cancel('newer', second.id);
expect(row().lastSellAt).toBe(1000);
expect(row().sellValue).toBe(300);
s.place(new Wallet('buyer2', 300).account(), ITEM, 'buy', 3, 100, COINS);
expect(row().sellQuantity).toBe(0);
expect(row().sellValue).toBe(0);
expect(row().lastSellAt).toBeNull();
});
test('buy value sums unfilled commitments at each bid price and drops after fills and cancellations', () => {
const s = store();
const a = new Wallet('a', 1000),