grand-exchange-live: the stepped market engine, and the turn budget binds
Arena's first interactive environment, as its own wheel beside grand-exchange so the one-shot scores stay comparable rather than conflated. One basket of orders becomes a loop: quote, see fills and the next tick, re-quote, with inventory, open orders and cash carrying between turns. The engine and its measurement only. No taskset, no config, no probe row — the spec's constants were derived from a throwaway engine and did not reconcile (impatient computed to 0.612 against a reported 0.623), so every constant here is chosen from a ladder re-measured in the code that ships. House rule 4 asks whether the turn budget binds. It does: exhaustive 0.631 vs oracle 1.000, gap 0.369, stdev 0.054 over five blocks, worst block 0.316, and 0.354-0.405 at four further seed bases. That is 18x the proposed margin and it clears the 0.25 quantum of the binary gate outright, so the comparison is not a cliff. The saturation worry does not fire: per-basket clipping is not clipping the mean, so exhaustive averages 0.849 on profit_ratio rather than 1.000. The first draft's gap was FAKE — a clean-looking 0.152 produced by an under-tuned denominator whose every-look sibling earned 22% more gp while scoring below it. It was caught only by sweeping the reference's own family. Nothing that scores below the oracle may earn more than it; that check belongs in every interactive environment that follows this one. freeze is derived from market.seed inside build_market so viability and scoring see one value, roc is on peak rather than average capital employed, and the reference is cached per seed so scoring never re-simulates the played episode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""grand-exchange-live — the stepped form of `grand-exchange`.
|
||||
|
||||
⚠️ ENGINE ONLY at this commit. `taskset.py`, the protocol layer and the config are
|
||||
deliberately not here yet: the spec's constants (`horizon`, `FREEZE_RANGE`, `TARGET_SHARE`,
|
||||
`TURNS`) were locked against arithmetic from an engine that no longer exists, and the build
|
||||
order is engine → measurement → constants → taskset. `measure_ladder.py` is the
|
||||
measurement. Nothing imports `verifiers` yet, so the package is importable — and gradeable
|
||||
— with nothing installed, exactly as `probe.py` needs it to be.
|
||||
"""
|
||||
|
||||
from grand_exchange_live.live import HORIZON, TURNS, Leg, Reference, Result, play
|
||||
|
||||
__all__ = ["HORIZON", "TURNS", "Leg", "Reference", "Result", "play"]
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Executing the agent's orders against the held-out window, and scoring the result.
|
||||
|
||||
⚠️ A COPY of `grand_exchange/book.py`, held byte-identical in behaviour by
|
||||
`tests/test_market_copy.py` — see the note at the top of `market.py` for why it is a copy
|
||||
and not an import. Nothing in this file steps: it is the one-shot engine, kept here because
|
||||
the live reference bootstraps its first quote out of `reference_orders`, `crossings` and
|
||||
`MIN_CROSSINGS`, and because `execute` is the fixed point the stepped engine is checked
|
||||
against (`live.py`'s `test_single_look_matches_one_shot`). The stepped engine is `live.py`.
|
||||
|
||||
Realised profit is unbounded above and has no theoretical optimum the data supports, so
|
||||
scoring it against one would put the ceiling out of reach — which house rule 3 forbids.
|
||||
Every ratio here is therefore taken against a REFERENCE STRATEGY (`reference_orders`)
|
||||
that is computed from the visible half and nothing else, and executed through the same
|
||||
engine as the agent's orders. That is what makes 1.000 reachable by construction, and it
|
||||
is honest: the model is asked to match a strategy available to anything that can read the
|
||||
prompt, not to beat hindsight it was never shown.
|
||||
|
||||
A reference in the denominator has its own failure mode, and the first cut of this file had
|
||||
it. If the ceiling is the reference EXACTLY, then above the clip excess profit is worth
|
||||
nothing while every deviation still costs, so the reward stops being a profit metric and
|
||||
becomes an imitation score for a strategy whose constants are nowhere in the prompt. It was
|
||||
measurable: a wider sell band earned sixteen percent more gp and scored 0.812, and one
|
||||
wasted unit of capital with identical realised profit cost 0.146. Two changes answer it.
|
||||
The reference's own constants are now the profit-maximising point of its family, swept on
|
||||
seeds no task is built from. And the ceiling is a BAND — TARGET_SHARE of the reference —
|
||||
so the whole plateau around it, and everything above it, scores 1.000.
|
||||
|
||||
Three numbers come out of a run:
|
||||
|
||||
profit_ratio clip(realised / (TARGET_SHARE x reference realised), 0, 1). No orders is
|
||||
zero, and a round trip that does not clear the tax is negative and also
|
||||
zero.
|
||||
efficiency realised profit per coin LOCKED behind an offer, against the reference's
|
||||
own. This is where the volume limit bites: a fat margin on an item
|
||||
carrying three thousand gp a tick is an offer that sits there, and a
|
||||
sitting offer is capital a better item did not get. It also catches the
|
||||
plan that fills perfectly at prices that were never worth reaching, which
|
||||
a plain fill rate scores as discipline.
|
||||
clean cleared the bar on both. Binary, because a trading run either was worth
|
||||
doing or was not.
|
||||
|
||||
`efficiency` is never a reward on its own — one tiny order that traded perfectly would earn
|
||||
it in full for near-inaction, which is the free-points defect schema-migration shipped with.
|
||||
It multiplies into profit instead, so it can only ever qualify profit that exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
|
||||
from grand_exchange_live.market import (
|
||||
DUMP_BASE,
|
||||
DUMP_CAP,
|
||||
DUMP_IMPACT,
|
||||
FILL_SHARE,
|
||||
TAX,
|
||||
Item,
|
||||
Market,
|
||||
build_market,
|
||||
)
|
||||
|
||||
MAX_ORDERS = 12
|
||||
BUY_BAND = 0.05
|
||||
SELL_BAND = 0.05
|
||||
"""The band the reference buys under its anchor and sells over it.
|
||||
|
||||
These two numbers are the reward's denominator, so where they sit is not a taste question.
|
||||
They are the profit-maximising point of the reference's own family, found by sweeping
|
||||
(buy band, sell band, crossings threshold, purse cap, size multiplier) over 160 baskets
|
||||
drawn from SEED 400,000 ONWARD — a range no task is ever built from — and they are within
|
||||
three percent of the sweep's best cell over the whole plateau.
|
||||
|
||||
The first cut used a sell band of 0.01, and that was the defect a reviewer found: a strategy
|
||||
with a wider sell band earned sixteen percent more gp than the reference and scored 0.812,
|
||||
because every ratio here divides by the reference and clips at one. When the denominator is
|
||||
a strategy that leaves money on the table, the reward's argmax is the denominator's
|
||||
hyperparameters rather than the profit. Tuning the reference to its own family's optimum is
|
||||
half the fix; TARGET_SHARE below is the other half."""
|
||||
|
||||
TARGET_SHARE = 0.90
|
||||
"""What counts as a full score, as a share of the reference's realised profit.
|
||||
|
||||
A ratio that divides by the reference EXACTLY makes the ceiling a single point, and a point
|
||||
ceiling turns the reward into an imitation score: excess profit is worth nothing above the
|
||||
clip while any deviation is punished, so the gradient near the top points at replicating a
|
||||
strategy the prompt does not contain rather than at making money. Measured on the first cut,
|
||||
one wasted unit of capital with byte-identical realised profit cost 0.146 of total reward,
|
||||
and a one-gp change to every buy limit that EARNED 463 gp a basket more cost 0.195.
|
||||
|
||||
Normalising against nine tenths of the reference makes the ceiling a BAND. Everything from
|
||||
"ten percent short of the reference" upward scores 1.000, so the whole plateau around the
|
||||
reference — and everything above it — is the argmax, and a rounding difference costs
|
||||
nothing. The reference still scores exactly 1.000, so house rule 3 is unchanged."""
|
||||
|
||||
MIN_CROSSINGS = 0.25
|
||||
"""Below this share of ticks crossing the mean, the reference will not trade the item at
|
||||
all. Over 4,000 baskets a reverting series recrosses its own mean 0.362 of the time and a
|
||||
random walk 0.113, and the threshold sits in the gap: it lets 4.2% of walks through and
|
||||
turns away 4.1% of reverters. This one line is the difference between a strategy and a
|
||||
superstition — without it the reference buys the widest-swinging line in the basket, which
|
||||
is exactly the line that has no anchor to revert to."""
|
||||
|
||||
MAX_ITEM_SHARE = 0.40
|
||||
"""No more than this share of the purse behind one name. The anchor is an ESTIMATE, and one
|
||||
bad estimate carrying the whole purse is the only way this strategy loses money over a
|
||||
window. Spread over the 3.4 names it trades on average it profits in 3,976 of 4,000
|
||||
baskets, and `viable_market` refuses the other twenty-four."""
|
||||
|
||||
VIABILITY_TRIES = 64
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Order:
|
||||
item: str
|
||||
quantity: int
|
||||
buy: int
|
||||
sell: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fills:
|
||||
"""What one plan actually did in the window."""
|
||||
|
||||
realised: float = 0.0
|
||||
"""Closing capital minus starting capital: the only number that pays."""
|
||||
paper: float = 0.0
|
||||
"""What the orders CLAIM, filled in full at the limit prices. Not part of any reward —
|
||||
it exists so `arith_ok` can ask whether the agent's stated expected profit matches the
|
||||
sum its own orders imply."""
|
||||
bought: int = 0
|
||||
sold: int = 0
|
||||
planned: int = 0
|
||||
spent: float = 0.0
|
||||
dumped: int = 0
|
||||
orders: int = 0
|
||||
committed: float = 0.0
|
||||
"""Coins actually locked behind offers. Capital left idle earns nothing, which is the
|
||||
only reason allocation is a decision."""
|
||||
offered: int = 0
|
||||
"""Units the purse could fund, against `planned` units asked for."""
|
||||
|
||||
@property
|
||||
def roc(self) -> float:
|
||||
"""Realised profit per coin locked behind an offer.
|
||||
|
||||
This is the counterweight the profit term is qualified by, and it replaced a plain
|
||||
fill rate. A fill rate only asks whether an offer was reachable; it says nothing
|
||||
about whether reaching it was worth doing, so a plan that bought badly at prices it
|
||||
was always going to reach scored as disciplined as one that bought well. Return on
|
||||
the coins actually committed asks both questions at once, and it cannot be won by
|
||||
committing nothing: it multiplies into profit, which needs the coins.
|
||||
"""
|
||||
if self.committed <= 0.0:
|
||||
return 0.0
|
||||
return self.realised / self.committed
|
||||
|
||||
@property
|
||||
def conversion(self) -> float:
|
||||
"""Of the coins locked behind offers, the share that actually bought stock.
|
||||
|
||||
The first version of this was realised profit over CLAIMED profit, and it was
|
||||
gameable in one line: set the sell limit a hair over the buy limit and the claim
|
||||
goes to nearly nothing, so the ratio goes to nearly anything. Coins converted
|
||||
cannot be talked down — the only way to raise it is to offer a quantity the volume
|
||||
supports at a price the market reaches, which is the lesson the number is for.
|
||||
"""
|
||||
if self.committed <= 0.0:
|
||||
return 0.0
|
||||
return min(1.0, self.spent / self.committed)
|
||||
|
||||
|
||||
def paper_profit(orders: list[Order]) -> float:
|
||||
"""The profit the orders assert, at their own limit prices, net of tax on the sale.
|
||||
Deliberately the naive multiplication — it is the sum the agent is claiming, and
|
||||
`arith_ok` checks whether the agent can do it."""
|
||||
return sum(o.quantity * (o.sell * (1.0 - TAX) - o.buy) for o in orders)
|
||||
|
||||
|
||||
def execute(market: Market, orders: list[Order]) -> Fills:
|
||||
"""Walk the held-out ticks once, filling what the purse and the book support.
|
||||
|
||||
Rules, all quoted to the agent:
|
||||
- COINS ARE LOCKED WHEN THE OFFER IS PLACED, quantity times buy price, in the order
|
||||
the orders were listed. This is the exchange's own behaviour and it is what makes
|
||||
the task an allocation: an offer that never fills has still spent the capital a
|
||||
better offer needed, so ordering the whole basket at its buy limit is not free.
|
||||
It is also why a buy limit of a billion buys nothing — nobody can fund that offer.
|
||||
- a buy fills at the tick's price when it is at or under the limit; a sell fills at
|
||||
the tick's price when it is at or over. You are never filled at your own limit
|
||||
when the market is better than it, because that is not how a limit works.
|
||||
- at most FILL_SHARE of a tick's volume per side, per item, and only on the ticks the
|
||||
offer was eligible on — flow you were not in the market for is not yours to bank.
|
||||
- stock bought this tick cannot be sold this tick. Without that, a buy limit above a
|
||||
sell limit is a free round trip on a single price.
|
||||
- sale proceeds land in the purse but fund nothing: every offer was placed up front.
|
||||
- stock still held at the close is forced out at DUMP_BASE under the last price plus
|
||||
DUMP_IMPACT for every tick's worth of the item's median volume being pushed through.
|
||||
Depth is taken from the window the stock is actually being sold into, which is the
|
||||
window the agent estimated from the visible median.
|
||||
"""
|
||||
fills = Fills(orders=len(orders), planned=sum(o.quantity for o in orders))
|
||||
fills.paper = paper_profit(orders)
|
||||
|
||||
# Placement: fund each offer in submission order out of one purse.
|
||||
purse = float(market.capital)
|
||||
live: list[tuple[Order, Item, int]] = []
|
||||
for order in orders:
|
||||
item = market.item(order.item)
|
||||
if item is None or order.buy <= 0:
|
||||
continue
|
||||
qty = min(order.quantity, item.buy_limit, int(purse // order.buy))
|
||||
if qty <= 0:
|
||||
continue
|
||||
purse -= qty * order.buy
|
||||
live.append((order, item, qty))
|
||||
fills.committed = market.capital - purse
|
||||
fills.offered = sum(q for _, _, q in live)
|
||||
|
||||
remaining = [q for _, _, q in live]
|
||||
available = [0] * len(live)
|
||||
pending = [0] * len(live)
|
||||
# Capacity carries between eligible ticks instead of being truncated at each one. An
|
||||
# item that trades two units a tick would otherwise be untradeable rather than thin,
|
||||
# because a quarter of two is zero every time — and thin is what this is about.
|
||||
buy_room = [0.0] * len(live)
|
||||
sell_room = [0.0] * len(live)
|
||||
proceeds = 0.0
|
||||
|
||||
for t in range(market.visible, market.visible + market.held_out):
|
||||
for i in range(len(live)):
|
||||
available[i] += pending[i]
|
||||
pending[i] = 0
|
||||
for i, (order, item, _) in enumerate(live):
|
||||
price = item.prices[t]
|
||||
flow = FILL_SHARE * item.volumes[t]
|
||||
# Room accrues only on the ticks the order was actually eligible on. Accruing it
|
||||
# every tick banks the flow of ticks the price never reached, which would let an
|
||||
# offer fill far past the volume that was ever available to it — and the volume
|
||||
# limit is the whole reason allocation is a decision here.
|
||||
if price <= order.buy:
|
||||
buy_room[i] += flow
|
||||
if price >= order.sell:
|
||||
sell_room[i] += flow
|
||||
if price <= order.buy and remaining[i] > 0:
|
||||
qty = min(remaining[i], int(buy_room[i]))
|
||||
if qty > 0:
|
||||
buy_room[i] -= qty
|
||||
remaining[i] -= qty
|
||||
pending[i] += qty
|
||||
fills.spent += qty * price
|
||||
fills.bought += qty
|
||||
if price >= order.sell and available[i] > 0:
|
||||
qty = min(available[i], int(sell_room[i]))
|
||||
if qty > 0:
|
||||
sell_room[i] -= qty
|
||||
proceeds += qty * price * (1.0 - TAX)
|
||||
available[i] -= qty
|
||||
fills.sold += qty
|
||||
|
||||
for i, (_, item, _) in enumerate(live):
|
||||
left = available[i] + pending[i]
|
||||
if left:
|
||||
depth = statistics.median(item.volumes[market.visible:])
|
||||
haircut = min(DUMP_CAP, DUMP_BASE + DUMP_IMPACT * (left / max(1.0, depth)))
|
||||
fills.dumped += left
|
||||
proceeds += left * item.prices[-1] * (1.0 - haircut) * (1.0 - TAX)
|
||||
# Coins locked behind an offer that never filled come back when the window closes, so
|
||||
# the only thing a wasted offer costs is the profit the capital did not make. That is
|
||||
# the right price for it: an opportunity cost, not a fine.
|
||||
fills.realised = proceeds - fills.spent
|
||||
return fills
|
||||
|
||||
|
||||
def crossings(prices: list[int]) -> float:
|
||||
"""Share of consecutive ticks that straddle the series' own mean.
|
||||
|
||||
The whole discrimination, in one countable number, so it is available to anything that
|
||||
can read the prompt — no variance ratio, no regression, just how often the line cuts
|
||||
its own average.
|
||||
"""
|
||||
if len(prices) < 2:
|
||||
return 0.0
|
||||
anchor = statistics.fmean(prices)
|
||||
above = [p > anchor for p in prices]
|
||||
return sum(1 for i in range(1, len(above)) if above[i] != above[i - 1]) / (len(above) - 1)
|
||||
|
||||
|
||||
def reference_orders(market: Market) -> list[Order]:
|
||||
"""The strategy the reward is normalised against, computed from the visible half only.
|
||||
|
||||
Per item: anchor on the mean of the visible prices, buy a band under it, sell a band
|
||||
over it. Then three judgements, and each of them is a way the reward discriminates:
|
||||
|
||||
does it revert `crossings` over MIN_CROSSINGS, or the item is skipped. The decoy
|
||||
swings widest and is worth nothing.
|
||||
what it pays the average visible price BELOW the buy limit against the average
|
||||
ABOVE the sell limit — because a limit fills at the market, not at
|
||||
the limit, so a wide reverting item pays far more than its band.
|
||||
Ranking by the band alone ranks every item identically.
|
||||
what it can hold a quarter of a typical tick's volume, over the ticks that touched
|
||||
the buy limit. Capital committed beyond that is capital locked
|
||||
behind an offer that will not fill.
|
||||
|
||||
Best return on capital first, until the purse is gone. Nothing here reads a held-out
|
||||
tick; every input is a column the agent was shown.
|
||||
"""
|
||||
plans = []
|
||||
for item in market.items:
|
||||
prices = item.visible_prices(market.visible)
|
||||
volumes = item.visible_volumes(market.visible)
|
||||
if crossings(prices) < MIN_CROSSINGS:
|
||||
continue
|
||||
anchor = statistics.fmean(prices)
|
||||
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
|
||||
sell = max(buy + 1, round(anchor * (1.0 + SELL_BAND)))
|
||||
lows = [p for p in prices if p <= buy]
|
||||
highs = [p for p in prices if p >= sell]
|
||||
if not lows or not highs:
|
||||
continue
|
||||
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
|
||||
expected = (exit_ * (1.0 - TAX) - entry) / entry
|
||||
if expected <= 0:
|
||||
continue
|
||||
reachable = int(
|
||||
FILL_SHARE * statistics.median(volumes) * (len(lows) / len(prices)) * market.held_out
|
||||
)
|
||||
qty = min(item.buy_limit, reachable, int(MAX_ITEM_SHARE * market.capital // buy))
|
||||
if qty <= 0:
|
||||
continue
|
||||
plans.append((expected, item.name, qty, buy, sell))
|
||||
|
||||
plans.sort(key=lambda p: -p[0])
|
||||
orders, purse = [], float(market.capital)
|
||||
for _, name, qty, buy, sell in plans:
|
||||
qty = min(qty, int(purse // buy))
|
||||
if qty <= 0:
|
||||
continue
|
||||
purse -= qty * buy
|
||||
orders.append(Order(item=name, quantity=qty, buy=buy, sell=sell))
|
||||
return orders
|
||||
|
||||
|
||||
def viable_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
|
||||
"""The next basket from `seed` onward in which the reference strategy makes money.
|
||||
|
||||
Every ratio in the reward divides by the reference's realised profit, so a basket where
|
||||
the reference loses has no reachable ceiling and would quietly break house rule 3 for
|
||||
that task — the oracle would score below 1.000 and nothing would say why. About one
|
||||
basket in two thousand is like that, from an anchor the visible half happened to
|
||||
mis-estimate. Skipping it is a guard, not a crutch, and the seed that was used travels
|
||||
on the Market so scoring rebuilds exactly the basket that was shown.
|
||||
"""
|
||||
for offset in range(VIABILITY_TRIES):
|
||||
market = build_market(seed + offset, num_items, visible, held_out)
|
||||
if execute(market, reference_orders(market)).realised > 0:
|
||||
return market
|
||||
return build_market(seed, num_items, visible, held_out)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Outcome:
|
||||
"""One run, beside the reference run it is normalised against."""
|
||||
|
||||
fills: Fills
|
||||
reference: Fills
|
||||
stated: float | None
|
||||
dropped: int = 0
|
||||
"""Orders that named nothing on the board, or asked for a quantity or price of zero."""
|
||||
|
||||
@property
|
||||
def target(self) -> float:
|
||||
"""The gp a full score is worth in this basket. See TARGET_SHARE."""
|
||||
return TARGET_SHARE * self.reference.realised
|
||||
|
||||
@property
|
||||
def profit_ratio(self) -> float:
|
||||
if self.target <= 0.0:
|
||||
return 0.0
|
||||
return min(1.0, max(0.0, self.fills.realised / self.target))
|
||||
|
||||
@property
|
||||
def efficiency(self) -> float:
|
||||
"""Return on committed capital, against the reference's return on its own.
|
||||
|
||||
Not discounted by TARGET_SHARE: it is already a rate rather than a total, so a
|
||||
strategy that trades well on a small book is not penalised for being small, and
|
||||
giving it a band as well would hand out the term for free."""
|
||||
if self.reference.roc <= 0.0:
|
||||
return 0.0
|
||||
return min(1.0, max(0.0, self.fills.roc / self.reference.roc))
|
||||
|
||||
@property
|
||||
def conversion_ratio(self) -> float:
|
||||
"""Of the coins locked, the share that bought stock, against the reference's share.
|
||||
Recorded, never rewarded — `efficiency` is what qualifies profit now. It stays in
|
||||
the trace because it separates the two ways `efficiency` falls: offers that never
|
||||
filled, and offers that filled at prices not worth reaching."""
|
||||
if self.reference.conversion <= 0.0:
|
||||
return 0.0
|
||||
return min(1.0, self.fills.conversion / self.reference.conversion)
|
||||
|
||||
@property
|
||||
def discipline(self) -> float:
|
||||
"""Profit, qualified by what each committed coin earned. A product, not a term
|
||||
beside it: a term would pay in full for one tiny order that traded perfectly, which
|
||||
is inaction with a receipt — the free-points defect schema-migration shipped with."""
|
||||
return self.profit_ratio * self.efficiency
|
||||
|
||||
@property
|
||||
def clean(self) -> bool:
|
||||
"""A bar, not a knife-edge.
|
||||
|
||||
It used to require matching the reference on both ratios to within 1e-9, which is a
|
||||
demand for replication rather than for a good run: a reference plan with one extra
|
||||
one-unit order that never fills has identical realised profit and lost 0.146 of
|
||||
total reward. The bar is now TARGET_SHARE of the reference on the money AND on the
|
||||
return that money made, which is a run that was worth doing however it got there.
|
||||
"""
|
||||
return (
|
||||
self.fills.realised >= TARGET_SHARE * self.reference.realised
|
||||
and self.fills.roc >= TARGET_SHARE * self.reference.roc
|
||||
)
|
||||
|
||||
@property
|
||||
def arith_ok(self) -> bool:
|
||||
"""Did the agent's stated expected profit match what its own ACCEPTED orders imply.
|
||||
|
||||
Not a reward — a probe into the trace. The target model gets simple arithmetic wrong
|
||||
with thinking off, and without this line a bad multiplication and a bad strategy are
|
||||
the same low number and nothing in the trace tells them apart. Measured against the
|
||||
orders that survived parsing, so claiming profit from an order for an item that is
|
||||
not on the board reads as the arithmetic error it is.
|
||||
"""
|
||||
if self.stated is None:
|
||||
return False
|
||||
return abs(self.stated - self.fills.paper) <= max(50.0, 0.02 * abs(self.fills.paper))
|
||||
|
||||
@property
|
||||
def arith_error(self) -> float:
|
||||
if self.stated is None or self.fills.paper == 0.0:
|
||||
return 0.0
|
||||
return abs(self.stated - self.fills.paper) / abs(self.fills.paper)
|
||||
|
||||
|
||||
def measure(market: Market, orders: list[Order], stated: float | None) -> Outcome:
|
||||
"""Run the agent's plan and the reference plan through the same engine."""
|
||||
clean, seen, dropped = [], set(), 0
|
||||
for order in orders[:MAX_ORDERS]:
|
||||
item = market.item(order.item)
|
||||
# One order per item: holdings pool per item, so two orders on one name would make
|
||||
# "which sell limit does this unit belong to" a question the engine has to invent an
|
||||
# answer to. The first one submitted is the one that counts.
|
||||
if item is None or order.quantity <= 0 or order.buy <= 0 or order.sell <= 0:
|
||||
dropped += 1
|
||||
continue
|
||||
if item.name in seen:
|
||||
dropped += 1
|
||||
continue
|
||||
seen.add(item.name)
|
||||
clean.append(Order(item.name, order.quantity, order.buy, order.sell))
|
||||
return Outcome(
|
||||
fills=execute(market, clean),
|
||||
reference=execute(market, reference_orders(market)),
|
||||
stated=stated,
|
||||
dropped=dropped,
|
||||
)
|
||||
|
||||
|
||||
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
def parse_orders(reply: str) -> tuple[list[Order], float | None]:
|
||||
"""The last JSON block in the reply: an object with "orders" and, optionally,
|
||||
"expected_profit". A bare array of orders is accepted too — a model that answers the
|
||||
question and skips the arithmetic has traded, and should be graded on the trade.
|
||||
|
||||
A reply that parses to nothing is an empty plan, not an error: it scores what doing
|
||||
nothing scores. Raising here would turn a formatting slip into a crashed rollout — and
|
||||
the reward runs inside the metric, so a raise takes the whole rollout with it rather
|
||||
than scoring zero.
|
||||
|
||||
Which is why the except clauses below are wider than they look like they need to be.
|
||||
Python's `json.loads` is not strict JSON: it accepts the bare literals Infinity,
|
||||
-Infinity and NaN, and it overflows 1e309 to inf rather than refusing it. RecursionError
|
||||
is not a ValueError, so twenty thousand nested arrays crashed the decoder; OverflowError
|
||||
is not a ValueError either, so `int(float("inf"))` crashed the row loop. Both were live
|
||||
on the first cut and both were reachable from a reply a model can actually emit.
|
||||
"""
|
||||
blocks = _BLOCK.findall(reply or "")
|
||||
raw = blocks[-1] if blocks else (reply or "")
|
||||
try:
|
||||
parsed = json.loads(raw.strip())
|
||||
except (ValueError, RecursionError):
|
||||
return [], None
|
||||
|
||||
stated: float | None = None
|
||||
if isinstance(parsed, dict):
|
||||
rows = parsed.get("orders")
|
||||
value = parsed.get("expected_profit")
|
||||
# Finite only. `Infinity` and `NaN` parse, and either one propagates through
|
||||
# `arith_error` into the trace as a non-finite metric, which is a corrupted training
|
||||
# signal rather than a bad answer. An unusable claim is no claim.
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
try:
|
||||
stated = float(value) if math.isfinite(value) else None
|
||||
except OverflowError:
|
||||
stated = None
|
||||
else:
|
||||
rows = parsed
|
||||
if not isinstance(rows, list):
|
||||
return [], stated
|
||||
|
||||
orders = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not isinstance(row.get("item"), str):
|
||||
continue
|
||||
try:
|
||||
orders.append(
|
||||
Order(
|
||||
item=row["item"],
|
||||
quantity=int(row.get("quantity", 0)),
|
||||
buy=int(row.get("buy", 0)),
|
||||
sell=int(row.get("sell", 0)),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
return orders, stated
|
||||
@@ -0,0 +1,667 @@
|
||||
"""The stepped exchange: quote, watch the tape, re-quote, over a turn budget.
|
||||
|
||||
The one-shot environment asks for one basket of limit orders and runs it against a window
|
||||
the agent never sees. This one hands the agent the SAME market and lets it act inside the
|
||||
window: eight looks spread over sixty ticks, with inventory, open offers, cumulative buy
|
||||
limits and one purse carrying between them. What is held out is no longer the whole window
|
||||
— it is the tail after the last look, plus every tick the agent chose not to spend a look
|
||||
on. That is a weaker held-out claim than the one-shot form's and it is stated as one: the
|
||||
agent sees 49 of the 60 graded ticks if it takes every look.
|
||||
|
||||
Three mechanics carry the whole design, and each of them exists to price a turn.
|
||||
|
||||
the freeze Any look that touches a book which is already live freezes the WHOLE book
|
||||
for `market.freeze` ticks — one to four, drawn per basket. Nothing fills
|
||||
while frozen. This is the counterweight in the turn dimension that house
|
||||
rule 2 demands under multi-turn: without it, re-quoting on every look is
|
||||
free information and the optimal policy is "use every turn", which is
|
||||
rule 4's definition of measuring nothing. The FIRST placement is free —
|
||||
there is nothing to re-quote when the book is empty, and charging for it
|
||||
would only tax entering the market at all.
|
||||
|
||||
the queue An amended offer goes to the back of the queue: its accrued fill room is
|
||||
reset to zero. In the one-shot engine, room accrues on the ticks an offer
|
||||
was eligible on and carries between them, which is what makes a thin item
|
||||
tradeable at all. Re-quoting throws that accrual away, so moving a limit
|
||||
by one gp is not free even when the freeze has expired.
|
||||
|
||||
the cumulative
|
||||
buy limit The GE's per-item limit is over the WHOLE window, not per offer. Cancel a
|
||||
half-filled offer and re-place it and the units already bought still
|
||||
count. Without this, re-quoting resets the limit and the limit stops being
|
||||
a limit.
|
||||
|
||||
`roc` is over PEAK capital employed, never average. Average-employed is a measured
|
||||
free-points bug in this shape: a policy that commits the purse for two ticks, sells, and
|
||||
sits in cash for fifty-eight has a tiny average and its realised profit divided by that
|
||||
average is enormous, so the counterweight pays MORE for doing less. The peak asks the
|
||||
question the counterweight is for — how much of the purse did this plan need at its
|
||||
fattest — and it cannot be talked down by exiting early.
|
||||
|
||||
Everything is scored against a REFERENCE POLICY (`Reference`) that plays the same stepped
|
||||
engine through the same interface the agent does, so 1.000 is reachable by construction and
|
||||
by something the prompt describes rather than by hindsight. The reference re-quotes when
|
||||
the anchor it can compute from the ticks it has seen has moved materially, and not
|
||||
otherwise — which is the judgement the freeze exists to make expensive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from grand_exchange_live.book import (
|
||||
BUY_BAND,
|
||||
MAX_ITEM_SHARE,
|
||||
MIN_CROSSINGS,
|
||||
SELL_BAND,
|
||||
Order,
|
||||
crossings,
|
||||
reference_orders,
|
||||
)
|
||||
from grand_exchange_live.market import (
|
||||
DUMP_BASE,
|
||||
DUMP_CAP,
|
||||
DUMP_IMPACT,
|
||||
FILL_SHARE,
|
||||
TAX,
|
||||
Item,
|
||||
Market,
|
||||
build_market,
|
||||
)
|
||||
|
||||
TURNS = 8
|
||||
"""Looks the agent gets. The last one is still worth taking — `TAIL_TICKS` run after it —
|
||||
but it is worth taking only if there is something to change."""
|
||||
|
||||
TICKS_PER_TURN = 7
|
||||
TAIL_TICKS = 4
|
||||
HORIZON = TURNS * TICKS_PER_TURN + TAIL_TICKS
|
||||
"""Graded ticks: 8 x 7 + 4 = 60. The tail is what makes the last look a decision rather
|
||||
than a formality, and it is the floor on the held-out slice — an agent that takes every
|
||||
look still has TICKS_PER_TURN + TAIL_TICKS = 11 ticks executed after its final observation.
|
||||
"""
|
||||
|
||||
REQUOTE_BAND = 0.025
|
||||
IDLE_SHARE = 0.50
|
||||
"""The two triggers that make the reference re-quote.
|
||||
|
||||
They are the reward's denominator, so where they sit is not a taste question: they are the
|
||||
profit-maximising point of the reference's own family, swept over 120 baskets by
|
||||
`measure_ladder.py --sweep-reference`. REQUOTE_BAND is how far the rolling anchor has to
|
||||
have moved before chasing it is worth a freeze; IDLE_SHARE is how much of the purse has to
|
||||
be sitting in cash before putting it back to work is.
|
||||
|
||||
The second trigger was missing from the first cut and its absence was measurable in exactly
|
||||
the way `book.py` warns about. A reference that never redeployed sale proceeds was beaten by
|
||||
22% on gp and 12% on return-on-capital by a policy that simply re-quoted on every look, so
|
||||
the reward was an imitation score for an under-tuned strategy rather than a profit metric,
|
||||
and the rule-4 gap it reported (0.152) was an artifact of the denominator rather than a
|
||||
measurement of anything. Redeploying is the capability the stepped form EXISTS to reward —
|
||||
a one-shot plan cannot spend what its own sales earned — and leaving it out meant the
|
||||
environment measured the freeze instead of the trading. With it, the reference earns 76,744
|
||||
gp against the every-look policy's 63,802 and dominates it on return as well, and the gap
|
||||
becomes a real one.
|
||||
|
||||
Two measured facts kept here rather than in a commit message:
|
||||
|
||||
the anchor trigger is worth about nothing on its own. The sweep's argmax turns it off
|
||||
entirely (band 1.0, idle 0.50: 77,599 gp) and 0.025 costs 1.1% of gp against it. It is
|
||||
kept at the top of its own plateau because a reference whose only trigger is a cash
|
||||
threshold cannot express "the anchor moved" at all, and 1.1% is inside the three-percent
|
||||
tolerance `probe.py` already holds the one-shot bands to. The cell that earns more scores
|
||||
0.976, well above the 0.90 that assertion demands.
|
||||
|
||||
the idle trigger is flat between 0.0 and 0.5 and falls off a cliff above it — 63,472 gp
|
||||
at 1.01, where it effectively never fires. There is no knife-edge here to tune."""
|
||||
|
||||
|
||||
# --- what the agent submits ------------------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class Leg:
|
||||
"""One line of a delta sheet: the offer this item should carry from now on.
|
||||
|
||||
A leg REPLACES whatever is live on that item — there is one offer per name, exactly as
|
||||
in the one-shot form, so "which sell limit does this unit belong to" is never a question
|
||||
the engine has to invent an answer to. `quantity <= 0` cancels.
|
||||
"""
|
||||
|
||||
item: str
|
||||
quantity: int
|
||||
buy: int = 0
|
||||
sell: int = 0
|
||||
|
||||
|
||||
# --- what the agent sees ---------------------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class OpenOrder:
|
||||
item: str
|
||||
quantity: int
|
||||
"""Units still sought on the buy side of this offer."""
|
||||
buy: int
|
||||
sell: int
|
||||
locked: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnView:
|
||||
"""Everything true at the moment of a look. Plain types only — this is what a protocol
|
||||
layer renders and what `trace.info` would carry, and a dataclass or a numpy scalar in
|
||||
there fails the trace write for the whole rollout."""
|
||||
|
||||
turn: int
|
||||
turns_left: int
|
||||
ticks_left: int
|
||||
freeze: int
|
||||
frozen_for: int
|
||||
cash: float
|
||||
market: Market
|
||||
new_prices: dict[str, list[int]]
|
||||
"""Ticks that elapsed since the previous look, per item. Empty on turn 1."""
|
||||
new_volumes: dict[str, list[int]]
|
||||
seen: int
|
||||
"""How many ticks of each stream have been observed, warmup included."""
|
||||
held: dict[str, int]
|
||||
bought: dict[str, int]
|
||||
"""Cumulative units bought per item, against the item's window limit."""
|
||||
open_orders: dict[str, OpenOrder]
|
||||
realised_so_far: float
|
||||
|
||||
|
||||
# --- the engine's own state ------------------------------------------------------------
|
||||
@dataclass
|
||||
class _Live:
|
||||
"""An offer standing in the book. Not visible to the agent in this shape — `OpenOrder`
|
||||
is."""
|
||||
|
||||
item: Item
|
||||
buy: int
|
||||
sell: int
|
||||
remaining: int
|
||||
locked: float
|
||||
buy_room: float = 0.0
|
||||
sell_room: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ledger:
|
||||
"""The purse, the stock and the cost basis, carried across every turn.
|
||||
|
||||
One invariant holds this together and the tests assert it after every tick: cash, plus
|
||||
coins locked behind offers, plus the cost basis of stock held, plus realised profit,
|
||||
equals the starting capital. Every mechanic here moves coins between those buckets and
|
||||
creates none, so a bug that mints money shows up as a broken identity rather than as a
|
||||
good score.
|
||||
"""
|
||||
|
||||
capital: float
|
||||
cash: float
|
||||
locked: float = 0.0
|
||||
held: dict[str, int] = field(default_factory=dict)
|
||||
basis: dict[str, float] = field(default_factory=dict)
|
||||
bought: dict[str, int] = field(default_factory=dict)
|
||||
incoming: dict[str, int] = field(default_factory=dict)
|
||||
"""Units bought on the current tick, not yet sellable. Settled at the top of the next
|
||||
tick — stock bought on a tick cannot be sold on that tick, or a buy limit above a sell
|
||||
limit is a free round trip on one price."""
|
||||
incoming_basis: dict[str, float] = field(default_factory=dict)
|
||||
spent: float = 0.0
|
||||
proceeds: float = 0.0
|
||||
sold: int = 0
|
||||
dumped: int = 0
|
||||
peak_employed: float = 0.0
|
||||
|
||||
@property
|
||||
def employed(self) -> float:
|
||||
"""Coins not sitting idle: locked behind an offer, or sunk into stock at what it
|
||||
cost. The PEAK of this is the denominator of `roc`. Average-employed is a measured
|
||||
free-points bug — see the module docstring."""
|
||||
return self.locked + sum(self.basis.values()) + sum(self.incoming_basis.values())
|
||||
|
||||
@property
|
||||
def realised(self) -> float:
|
||||
return self.cash + self.employed - self.capital
|
||||
|
||||
def mark(self) -> None:
|
||||
self.peak_employed = max(self.peak_employed, self.employed)
|
||||
|
||||
def settle(self) -> None:
|
||||
for name, qty in self.incoming.items():
|
||||
self.held[name] = self.held.get(name, 0) + qty
|
||||
self.basis[name] = self.basis.get(name, 0.0) + self.incoming_basis.get(name, 0.0)
|
||||
self.incoming.clear()
|
||||
self.incoming_basis.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""One episode, in the shape every reward reads."""
|
||||
|
||||
realised: float
|
||||
peak_employed: float
|
||||
looks: int
|
||||
"""Looks that submitted a non-empty sheet. Recorded, never rewarded — a turn-count
|
||||
reward is a second single-sided term and the freeze already prices the turn."""
|
||||
amendments: int
|
||||
"""Looks that touched a live book, i.e. the ones that actually paid a freeze."""
|
||||
frozen_ticks: int
|
||||
bought: int
|
||||
bought_by_item: dict[str, int]
|
||||
"""Cumulative units per name, for the cumulative-buy-limit invariant and for the trace.
|
||||
A plain dict of plain ints — `trace.info` fails the whole rollout on anything else."""
|
||||
sold: int
|
||||
dumped: int
|
||||
spent: float
|
||||
dropped: int
|
||||
"""Legs that named nothing on the board or asked for a price of zero."""
|
||||
unfunded: int
|
||||
"""Legs the purse could not fund at all when they were submitted."""
|
||||
seen_ticks: int
|
||||
"""Graded ticks the agent observed. HORIZON minus this is the held-out slice."""
|
||||
|
||||
@property
|
||||
def roc(self) -> float:
|
||||
if self.peak_employed <= 0.0:
|
||||
return 0.0
|
||||
return self.realised / self.peak_employed
|
||||
|
||||
|
||||
Policy = Callable[[TurnView], list[Leg]]
|
||||
|
||||
|
||||
def play(market: Market, policy: Policy, *, turns: int = TURNS,
|
||||
step: int = TICKS_PER_TURN, tail: int = TAIL_TICKS) -> Result:
|
||||
"""Run one policy through one basket, look by look.
|
||||
|
||||
The tick loop is the one-shot engine's, moved inside a turn loop and given three things
|
||||
it did not have: a freeze that suppresses fills, a queue reset on amendment, and a purse
|
||||
that sale proceeds actually return to. With a single look on turn 1 and nothing after
|
||||
it, the two engines produce byte-identical realised profit and identical committed
|
||||
capital — `tests/test_live.py::test_single_look_matches_one_shot` is that assertion, and
|
||||
it is what says the stepped form is the same market rather than a different one.
|
||||
"""
|
||||
ledger = Ledger(capital=float(market.capital), cash=float(market.capital))
|
||||
live: dict[str, _Live] = {}
|
||||
dropped = unfunded = looks = amendments = frozen_ticks = 0
|
||||
frozen_until = 0
|
||||
t = market.visible
|
||||
end = market.visible + market.held_out
|
||||
last_seen = market.visible
|
||||
ledger.mark()
|
||||
|
||||
for turn in range(1, turns + 1):
|
||||
view = TurnView(
|
||||
turn=turn,
|
||||
turns_left=turns - turn,
|
||||
ticks_left=end - t,
|
||||
freeze=market.freeze,
|
||||
frozen_for=max(0, frozen_until - t),
|
||||
cash=ledger.cash,
|
||||
market=market,
|
||||
new_prices={i.name: i.prices[last_seen:t] for i in market.items},
|
||||
new_volumes={i.name: i.volumes[last_seen:t] for i in market.items},
|
||||
seen=t,
|
||||
held=dict(ledger.held),
|
||||
bought=dict(ledger.bought),
|
||||
open_orders={
|
||||
name: OpenOrder(name, o.remaining, o.buy, o.sell, o.locked)
|
||||
for name, o in live.items()
|
||||
},
|
||||
realised_so_far=ledger.cash + ledger.employed - ledger.capital,
|
||||
)
|
||||
last_seen = t
|
||||
sheet = policy(view) or []
|
||||
touched, drop, unfund = _apply(market, ledger, live, sheet)
|
||||
dropped += drop
|
||||
unfunded += unfund
|
||||
if touched:
|
||||
looks += 1
|
||||
# The first placement is free: there is nothing to re-quote when the book is
|
||||
# empty, and charging for entering the market taxes acting at all rather than
|
||||
# taxing acting AGAIN, which is the thing a turn budget is supposed to price.
|
||||
if view.open_orders:
|
||||
amendments += 1
|
||||
frozen_until = max(frozen_until, t + market.freeze)
|
||||
ledger.mark()
|
||||
|
||||
run = step if turn < turns else step + tail
|
||||
for _ in range(min(run, end - t)):
|
||||
frozen_ticks += _tick(market, ledger, live, t, frozen=t < frozen_until)
|
||||
t += 1
|
||||
|
||||
while t < end:
|
||||
frozen_ticks += _tick(market, ledger, live, t, frozen=t < frozen_until)
|
||||
t += 1
|
||||
|
||||
_close(market, ledger, live)
|
||||
return Result(
|
||||
realised=ledger.cash - ledger.capital,
|
||||
peak_employed=ledger.peak_employed,
|
||||
looks=looks,
|
||||
amendments=amendments,
|
||||
frozen_ticks=frozen_ticks,
|
||||
bought=sum(ledger.bought.values()),
|
||||
bought_by_item=dict(ledger.bought),
|
||||
sold=ledger.sold,
|
||||
dumped=ledger.dumped,
|
||||
spent=ledger.spent,
|
||||
dropped=dropped,
|
||||
unfunded=unfunded,
|
||||
seen_ticks=last_seen - market.visible,
|
||||
)
|
||||
|
||||
|
||||
def _apply(market: Market, ledger: Ledger, live: dict[str, _Live],
|
||||
sheet: list[Leg]) -> tuple[bool, int, int]:
|
||||
"""Put a delta sheet into the book. Returns (touched anything, dropped, unfunded)."""
|
||||
touched = dropped = unfunded = 0
|
||||
seen: set[str] = set()
|
||||
for leg in sheet:
|
||||
item = market.item(leg.item) if isinstance(leg.item, str) else None
|
||||
if item is None or item.name in seen:
|
||||
dropped += 1
|
||||
continue
|
||||
seen.add(item.name)
|
||||
existing = live.get(item.name)
|
||||
|
||||
if leg.quantity <= 0:
|
||||
# A cancel. The coins behind the unfilled remainder come back; the stock does
|
||||
# not, and with no offer on the name it can now only leave through the close-out
|
||||
# haircut. Cancelling is not free — it is just not charged as a fine.
|
||||
if existing is None:
|
||||
dropped += 1
|
||||
continue
|
||||
ledger.locked -= existing.locked
|
||||
ledger.cash += existing.locked
|
||||
del live[item.name]
|
||||
touched += 1
|
||||
continue
|
||||
|
||||
if leg.buy <= 0 or leg.sell <= 0:
|
||||
dropped += 1
|
||||
continue
|
||||
|
||||
# Release the old offer first, so its coins are available to fund the new one. This
|
||||
# is what makes an amendment a re-quote rather than a second offer.
|
||||
if existing is not None:
|
||||
ledger.locked -= existing.locked
|
||||
ledger.cash += existing.locked
|
||||
del live[item.name]
|
||||
|
||||
room = item.buy_limit - ledger.bought.get(item.name, 0)
|
||||
qty = max(0, min(leg.quantity, room, int(ledger.cash // leg.buy)))
|
||||
if qty <= 0 and not ledger.held.get(item.name, 0):
|
||||
# The purse cannot fund it, or the window's buy limit is already spent, and
|
||||
# there is no stock for the sell side to work on either. Recorded rather than
|
||||
# silently ignored: a plan whose offers do not fit is a plan that allocated
|
||||
# badly, and the trace should say which.
|
||||
unfunded += 1
|
||||
if existing is not None:
|
||||
touched += 1
|
||||
continue
|
||||
# qty may be zero while stock is held — that is a SELL-ONLY offer, and it has to be
|
||||
# expressible. An agent that has bought its whole window limit still needs a way to
|
||||
# move the price it is asking, and "cancel" is not that way: cancelling strands the
|
||||
# stock in the close-out haircut.
|
||||
ledger.cash -= qty * leg.buy
|
||||
ledger.locked += qty * leg.buy
|
||||
# Queue room starts at zero. An amended offer is a new offer to the book.
|
||||
live[item.name] = _Live(item=item, buy=leg.buy, sell=max(leg.buy + 1, leg.sell),
|
||||
remaining=qty, locked=float(qty * leg.buy))
|
||||
touched += 1
|
||||
return bool(touched), dropped, unfunded
|
||||
|
||||
|
||||
def _tick(market: Market, ledger: Ledger, live: dict[str, _Live], t: int,
|
||||
*, frozen: bool) -> int:
|
||||
"""One tick of the exchange. Returns 1 if the tick was frozen out.
|
||||
|
||||
Identical to `book.execute`'s inner loop, with two additions: a frozen tick fills
|
||||
nothing at all, and sale proceeds land in the purse where the next look can spend them.
|
||||
Settlement is NOT frozen — the freeze is on the book, not on stock already paid for, and
|
||||
stalling settlement too would be a second, unstated penalty on the same action.
|
||||
"""
|
||||
ledger.settle()
|
||||
if frozen:
|
||||
ledger.mark()
|
||||
return 1
|
||||
|
||||
for name, order in live.items():
|
||||
item = order.item
|
||||
price = item.prices[t]
|
||||
flow = FILL_SHARE * item.volumes[t]
|
||||
# Room accrues only on the ticks the offer was actually eligible on — banking the
|
||||
# flow of ticks the price never reached would let an offer fill far past the volume
|
||||
# that was ever available to it, and the volume limit is the whole reason allocation
|
||||
# is a decision here.
|
||||
if price <= order.buy:
|
||||
order.buy_room += flow
|
||||
if price >= order.sell:
|
||||
order.sell_room += flow
|
||||
|
||||
if price <= order.buy and order.remaining > 0:
|
||||
room = item.buy_limit - ledger.bought.get(name, 0)
|
||||
qty = min(order.remaining, int(order.buy_room), room)
|
||||
if qty > 0:
|
||||
order.buy_room -= qty
|
||||
order.remaining -= qty
|
||||
order.locked -= qty * order.buy
|
||||
ledger.locked -= qty * order.buy
|
||||
# Reserved at the limit, paid at the market: the difference returns to the
|
||||
# purse, exactly as the exchange does it.
|
||||
ledger.cash += qty * (order.buy - price)
|
||||
ledger.spent += qty * price
|
||||
ledger.bought[name] = ledger.bought.get(name, 0) + qty
|
||||
ledger.incoming[name] = ledger.incoming.get(name, 0) + qty
|
||||
ledger.incoming_basis[name] = (
|
||||
ledger.incoming_basis.get(name, 0.0) + qty * price
|
||||
)
|
||||
|
||||
avail = ledger.held.get(name, 0)
|
||||
if price >= order.sell and avail > 0:
|
||||
qty = min(avail, int(order.sell_room))
|
||||
if qty > 0:
|
||||
order.sell_room -= qty
|
||||
ledger.basis[name] -= ledger.basis[name] * (qty / avail)
|
||||
ledger.held[name] = avail - qty
|
||||
ledger.cash += qty * price * (1.0 - TAX)
|
||||
ledger.proceeds += qty * price * (1.0 - TAX)
|
||||
ledger.sold += qty
|
||||
ledger.mark()
|
||||
return 0
|
||||
|
||||
|
||||
def _close(market: Market, ledger: Ledger, live: dict[str, _Live]) -> None:
|
||||
"""The window shuts. Offers are cancelled, stock is forced out."""
|
||||
ledger.settle()
|
||||
for order in live.values():
|
||||
ledger.locked -= order.locked
|
||||
ledger.cash += order.locked
|
||||
live.clear()
|
||||
for item in market.items:
|
||||
left = ledger.held.get(item.name, 0)
|
||||
if not left:
|
||||
continue
|
||||
depth = statistics.median(item.volumes[market.visible:])
|
||||
haircut = min(DUMP_CAP, DUMP_BASE + DUMP_IMPACT * (left / max(1.0, depth)))
|
||||
ledger.cash += left * item.prices[-1] * (1.0 - haircut) * (1.0 - TAX)
|
||||
ledger.dumped += left
|
||||
ledger.held[item.name] = 0
|
||||
ledger.basis[item.name] = 0.0
|
||||
|
||||
|
||||
# --- the strategy the reward is normalised against --------------------------------------
|
||||
def desired_orders(items: list[Item], prices: dict[str, list[int]],
|
||||
volumes: dict[str, list[int]], purse: float, ticks_left: int,
|
||||
limits: dict[str, int]) -> list[Order]:
|
||||
"""`book.reference_orders`, generalised to a moment inside the window.
|
||||
|
||||
Same three judgements — does it revert, what does it pay, what can it hold — over
|
||||
whatever ticks have been observed by now, against whatever purse and whatever remaining
|
||||
per-item window limits are left. On turn 1, with the whole warmup observed, the whole
|
||||
purse free and no units bought, this returns exactly what `reference_orders` returns:
|
||||
`tests/test_live.py::test_turn_one_matches_reference_orders` asserts that, and it is
|
||||
what keeps the live reference recognisably the same strategy as the one-shot one rather
|
||||
than a second, unswept family of constants.
|
||||
"""
|
||||
plans = []
|
||||
for item in items:
|
||||
window, flow = prices[item.name], volumes[item.name]
|
||||
room = limits.get(item.name, item.buy_limit)
|
||||
if room <= 0 or crossings(window) < MIN_CROSSINGS:
|
||||
continue
|
||||
anchor = statistics.fmean(window)
|
||||
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
|
||||
sell = max(buy + 1, round(anchor * (1.0 + SELL_BAND)))
|
||||
lows = [p for p in window if p <= buy]
|
||||
highs = [p for p in window if p >= sell]
|
||||
if not lows or not highs:
|
||||
continue
|
||||
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
|
||||
expected = (exit_ * (1.0 - TAX) - entry) / entry
|
||||
if expected <= 0:
|
||||
continue
|
||||
reachable = int(
|
||||
FILL_SHARE * statistics.median(flow) * (len(lows) / len(window)) * ticks_left
|
||||
)
|
||||
qty = min(room, reachable, int(MAX_ITEM_SHARE * purse // buy))
|
||||
if qty <= 0:
|
||||
continue
|
||||
plans.append((expected, item.name, qty, buy, sell))
|
||||
|
||||
plans.sort(key=lambda p: -p[0])
|
||||
orders, left = [], float(purse)
|
||||
for _, name, qty, buy, sell in plans:
|
||||
qty = min(qty, int(left // buy))
|
||||
if qty <= 0:
|
||||
continue
|
||||
left -= qty * buy
|
||||
orders.append(Order(item=name, quantity=qty, buy=buy, sell=sell))
|
||||
return orders
|
||||
|
||||
|
||||
class Reference:
|
||||
"""The reference POLICY: quote from the warmup, then chase the anchor when it moves.
|
||||
|
||||
It is a policy and not a plan because the reward has to be reachable by playing the
|
||||
game rather than by pre-computing it, and because a denominator that never re-quotes
|
||||
would make the whole stepped form decorative — every ratio would be maximised by the
|
||||
one-shot answer and the turn budget would be measuring nothing.
|
||||
|
||||
What it does on a look:
|
||||
1. Recompute each live item's anchor over every tick observed so far.
|
||||
2. Mark an item for re-quote only if that anchor has moved more than REQUOTE_BAND.
|
||||
This is the judgement the freeze prices: chasing a two-gp drift costs `freeze`
|
||||
ticks of fills across the WHOLE book and throws away the offer's queue position,
|
||||
and it is worth doing only when the old limits are on the wrong side of where the
|
||||
price now lives.
|
||||
3. Re-run the same three judgements over the marked items and the items carrying no
|
||||
offer at all, against the cash that would be free once the marked offers are
|
||||
released and the buy-limit room actually left.
|
||||
|
||||
Stateful, so one instance plays one episode. `play(market, Reference())`.
|
||||
"""
|
||||
|
||||
def __init__(self, requote_band: float = REQUOTE_BAND,
|
||||
idle_share: float = IDLE_SHARE) -> None:
|
||||
self.requote_band = requote_band
|
||||
self.idle_share = idle_share
|
||||
self.prices: dict[str, list[int]] = {}
|
||||
self.volumes: dict[str, list[int]] = {}
|
||||
|
||||
def _observe(self, view: TurnView) -> None:
|
||||
if not self.prices:
|
||||
for item in view.market.items:
|
||||
self.prices[item.name] = list(item.visible_prices(view.market.visible))
|
||||
self.volumes[item.name] = list(item.visible_volumes(view.market.visible))
|
||||
for name, ticks in view.new_prices.items():
|
||||
self.prices[name].extend(ticks)
|
||||
self.volumes[name].extend(view.new_volumes[name])
|
||||
|
||||
def __call__(self, view: TurnView) -> list[Leg]:
|
||||
self._observe(view)
|
||||
items = view.market.items
|
||||
limits = {i.name: i.buy_limit - view.bought.get(i.name, 0) for i in items}
|
||||
|
||||
idle = view.cash >= self.idle_share * view.market.capital
|
||||
free = [i for i in items if i.name not in view.open_orders]
|
||||
|
||||
if not view.open_orders:
|
||||
purse = view.cash
|
||||
candidates = items
|
||||
else:
|
||||
marked = set()
|
||||
for name, open_order in view.open_orders.items():
|
||||
anchor = statistics.fmean(self.prices[name])
|
||||
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
|
||||
if abs(buy - open_order.buy) / max(1, open_order.buy) > self.requote_band:
|
||||
marked.add(name)
|
||||
elif idle and open_order.quantity == 0 and limits[name] > 0:
|
||||
# The buy side of this offer is done and the coins it earned are sitting
|
||||
# in cash. Re-quoting it is not chasing a drift, it is putting the purse
|
||||
# back to work, and the window limit says there is room to do so.
|
||||
marked.add(name)
|
||||
if not marked and not (idle and free):
|
||||
# Nothing has moved far enough to be worth a freeze and there is nothing
|
||||
# idle to deploy. Taking the look and doing nothing is the correct play, and
|
||||
# it is the play the exhaustive policy in the ladder refuses to make.
|
||||
return []
|
||||
purse = view.cash + sum(
|
||||
o.locked for n, o in view.open_orders.items() if n in marked
|
||||
)
|
||||
candidates = [i for i in items if i.name in marked or i.name not in view.open_orders]
|
||||
|
||||
orders = desired_orders(
|
||||
candidates,
|
||||
{i.name: self.prices[i.name] for i in candidates},
|
||||
{i.name: self.volumes[i.name] for i in candidates},
|
||||
purse,
|
||||
max(1, view.ticks_left),
|
||||
limits,
|
||||
)
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
|
||||
|
||||
|
||||
def live_reference(market: Market) -> Result:
|
||||
"""The denominator, cached per basket.
|
||||
|
||||
`viable_live_market` calls this once per candidate seed and `scan()` calls it again for
|
||||
every episode on the seed that was chosen, so an uncached reference is `VIABILITY_TRIES`
|
||||
stepped simulations per task before the first token is emitted — 3,072 of them for a
|
||||
default 48-task load, on every eval AND every `--dry-run`. The cache is keyed on the
|
||||
seed and the window shape, which is everything the result depends on.
|
||||
"""
|
||||
key = (market.seed, market.visible, market.held_out, market.freeze)
|
||||
hit = _REFERENCE_CACHE.get(key)
|
||||
if hit is None:
|
||||
hit = _REFERENCE_CACHE[key] = play(market, Reference())
|
||||
return hit
|
||||
|
||||
|
||||
_REFERENCE_CACHE: dict[tuple[int, int, int, int], Result] = {}
|
||||
|
||||
LIVE_VIABILITY_TRIES = 16
|
||||
"""Candidate seeds `viable_live_market` will try before giving up, against the one-shot
|
||||
form's 64. A stepped reference is eight turns and sixty ticks rather than one pass, so the
|
||||
worst case is the load cost, not the failure rate: at 48 tasks a 64-try cap is 3,072
|
||||
simulations of a market nobody has looked at yet. Measured skip rate is low enough that 16
|
||||
never binds in practice — `measure_ladder.py` reports how often it does."""
|
||||
|
||||
|
||||
def viable_live_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
|
||||
"""The next basket from `seed` onward in which the LIVE reference makes money.
|
||||
|
||||
Retargeted from `book.viable_market`, which asks whether the ONE-SHOT reference profits.
|
||||
That is the wrong question here: every ratio divides by the live reference, so a basket
|
||||
where the live reference loses has no reachable ceiling and quietly breaks house rule 3
|
||||
for that task. The freeze is drawn inside `build_market` off its own RNG precisely so
|
||||
this function can see it — it used to be drawn after viability had already answered,
|
||||
which meant viability accepted a basket under one cost of re-quoting and the run played
|
||||
it under another.
|
||||
"""
|
||||
for offset in range(LIVE_VIABILITY_TRIES):
|
||||
market = build_market(seed + offset, num_items, visible, held_out)
|
||||
if live_reference(market).realised > 0:
|
||||
return market
|
||||
return build_market(seed, num_items, visible, held_out)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""The market generator: price and volume streams with structure a reader can find.
|
||||
|
||||
⚠️ This file is a COPY of `grand_exchange/market.py`, not an import of it. The one-shot
|
||||
environment's scores have to stay comparable across time, and an import would mean a change
|
||||
made for the live form silently moved every historical `grand-exchange` number. The copy is
|
||||
held to the original byte-for-byte by `tests/test_market_copy.py`, which builds the same
|
||||
seeds through both packages and compares every price and volume. The ONLY intentional
|
||||
divergence is the freeze draw below, and it is drawn off a separate RNG so it cannot
|
||||
perturb a single tick of either stream.
|
||||
|
||||
If prices were a random walk this environment would be worthless and would still look
|
||||
fine — expected profit is zero for every strategy, so the reward is noise, no oracle
|
||||
exists, and the numbers coming out of it would be plausible and meaningless. So the
|
||||
structure is put in deliberately and is the whole design:
|
||||
|
||||
price_t = fundamental_t * (1 + x_t)
|
||||
|
||||
`fundamental_t` drifts by FUND_DRIFT a tick, worth about one percent over a whole stream.
|
||||
`x_t` is an AR(1) around zero with PHI decay and a stationary spread of NOISE_SD — nine
|
||||
percent on the deep items, fifteen or sixteen on the thin ones. Noise dominates drift by an
|
||||
order of magnitude, which is what makes the mean of the visible prices a usable estimate of
|
||||
the fundamental, and "buy under the estimate, sell over it" a real strategy rather than a
|
||||
superstition. `book.reference_orders` is that strategy; `probe.py` measures how far it beats
|
||||
trading at random, and refuses to pass if the margin is thin.
|
||||
|
||||
The visible and held-out windows are two halves of ONE stream from ONE seed: the graded
|
||||
ticks are the next ticks the generator would have produced, not a differently-seeded
|
||||
population that could have moved somewhere the visible half gave no warning of.
|
||||
|
||||
Two things are here to punish reading the price column alone. One or two items per basket do
|
||||
not revert at all (see DRIFT_STEP and WALKS) and swing widest of everything on screen. And
|
||||
liquidity — price times volume, gp a tick — is uncorrelated with price, so the fattest
|
||||
visible margins sit on the items that can absorb the least of the purse.
|
||||
|
||||
Item names are invented. Formulas and market mechanics are facts about a kind of game;
|
||||
item tables are somebody's copyrighted content, and none of it is here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
# --- the rules of the exchange, quoted to the agent verbatim in the prompt --------------
|
||||
TAX = 0.01
|
||||
"""Charged on every sale, the GE's own. It is the counterweight that kills thin flips:
|
||||
a round trip has to clear it twice over before it is worth doing, so "trade everything"
|
||||
is a losing strategy rather than a neutral one."""
|
||||
|
||||
FILL_SHARE = 0.25
|
||||
"""Share of a tick's volume one participant can take, and it accrues only on the ticks an
|
||||
offer was actually eligible on. Without it a fat margin on an item carrying three thousand
|
||||
gp a tick is worth as much as one carrying a hundred thousand, and the environment stops
|
||||
measuring allocation."""
|
||||
|
||||
DUMP_BASE = 0.03
|
||||
DUMP_IMPACT = 0.05
|
||||
DUMP_CAP = 0.40
|
||||
"""Stock still held when the window closes is forced out at the last price, minus a haircut
|
||||
of DUMP_BASE plus DUMP_IMPACT for every full tick's worth of the item's median volume that
|
||||
has to be pushed through, capped at DUMP_CAP.
|
||||
|
||||
A FLAT haircut was the first cut of this and it was the wrong shape. Under a flat five
|
||||
percent, the cost of holding stock at the close was the same whether the leftovers were
|
||||
twenty units of an item that trades thirty a tick or four thousand units of one that trades
|
||||
six hundred — so oversizing was only ever punished through the purse, and the purse punishes
|
||||
it as a cliff: the first over-large offer eats the whole budget and everything after it is
|
||||
never placed. A cliff is not a gradient. Scaling the haircut by position-over-depth prices
|
||||
the thing that is actually true — forcing size out costs you in proportion to how much of
|
||||
the book you are pushing through — and it makes reading the volume column pay smoothly,
|
||||
which is what the column is here to teach.
|
||||
|
||||
It also prices a bad anchor. A buy limit set too high fills fast and leaves the sell limit
|
||||
out of reach, so the position that a sloppy anchor builds is exactly the position that has
|
||||
to be dumped, and now it is dumped at a price that scales with its size."""
|
||||
|
||||
STARTING_CAPITAL = 250_000
|
||||
|
||||
WALKS = (1, 2)
|
||||
"""How many of the basket's items do not mean-revert, drawn uniformly from this range.
|
||||
|
||||
It used to be exactly one, and a fixed count is a free prior: "drop the single widest line
|
||||
on the board" scores what computing `book.crossings` scores, without computing anything.
|
||||
It is the same defect `bot_detection` ships an assertion against — a class balance the model
|
||||
can count on is a class balance it will use instead of the discriminator. With the count
|
||||
unknown the shape statistic is the only thing that answers the question, and a basket can
|
||||
punish both over- and under-rejection."""
|
||||
|
||||
SEED_BASE = 60_000
|
||||
"""Where task seeds start. Lives here rather than on the taskset so `probe.py`, which
|
||||
cannot import the taskset without `verifiers`, grades the baskets a run would actually
|
||||
serve rather than a different set that happens to share a generator."""
|
||||
|
||||
FREEZE_RANGE = (1, 4)
|
||||
FREEZE_SALT = 0x5F3E_11CE
|
||||
"""How long the book is frozen after a re-quote, in ticks, drawn per basket.
|
||||
|
||||
Drawn HERE, off `random.Random(seed ^ FREEZE_SALT)`, and carried on the Market. It used to
|
||||
be drawn in the taskset after the basket was chosen, which is circular: `viable_market`
|
||||
decides whether the reference makes money in a basket, the reference's profit depends on
|
||||
what re-quoting costs, and the cost was not drawn yet. Viability would then have accepted a
|
||||
basket under one freeze and the run would have played it under another, so the denominator
|
||||
of every reward would be a strategy the model never played against. A separate RNG keyed off
|
||||
the same seed makes the draw deterministic, available before the first simulation, and
|
||||
provably free of any effect on the price and volume streams — which is what lets the copy
|
||||
assertion in `tests/` be an equality rather than an approximation.
|
||||
|
||||
The value is a per-look cost paid by the WHOLE book: see `live.freeze`. Flat, not
|
||||
proportional to the number of items re-quoted — the settled decision. Per-item is the
|
||||
better mechanism and is the identified strengthening, not a thing to build unmeasured on
|
||||
the critical path."""
|
||||
|
||||
# --- stream parameters -----------------------------------------------------------------
|
||||
PHI = 0.45
|
||||
"""AR(1) decay of the mispricing. Half-life under a tick, so a visible window holds many
|
||||
independent draws around the fundamental — which is what makes the mean of it an estimate
|
||||
rather than a guess — and a held-out window holds many excursions, so the reward is not one
|
||||
lucky draw."""
|
||||
FUND_DRIFT = 0.0008
|
||||
"""Per-tick drift of the fundamental. Small on purpose: the fundamental has to be
|
||||
ESTIMABLE from the visible half or there is nothing to learn."""
|
||||
VOLUME_SD = 0.35
|
||||
|
||||
DRIFT_STEP = 0.055
|
||||
"""Per-tick step of the items that do not mean-revert at all — their price is a pure random
|
||||
walk, so the fundamental IS wherever it last was.
|
||||
|
||||
This is the trap the whole environment is built around, put inside the task instead of
|
||||
left as a hazard the designer has to avoid. A random walk has no anchor, so buying under
|
||||
its moving average is not a discount, it is a coin flip that pays the tax and the dump
|
||||
slippage every time. On screen it is the widest-swinging line in the basket and therefore
|
||||
the most attractive one, because amplitude is what a careless reader ranks by. The two can
|
||||
only be told apart by SHAPE: a reverting series crosses its own mean constantly, a walk
|
||||
wanders on one side of it for a dozen ticks at a time. `book.crossings` is that statistic
|
||||
and the reference strategy will not trade an item that fails it."""
|
||||
|
||||
# (names, base price range, base volume range, buy limit, mispricing spread)
|
||||
#
|
||||
# What separates these is LIQUIDITY IN GP PER TICK — base price times base volume — and it
|
||||
# is deliberately uncorrelated with the price. That is the allocation problem: the purse is
|
||||
# 250,000 gp and a quarter of the flow over thirty ticks is what any one offer can absorb,
|
||||
# so a deep tier can take a third of the purse and a thin one can take a twentieth of it no
|
||||
# matter how good the margin looks. An earlier cut of this file made the expensive items the
|
||||
# thin ones, which sounds right and is not: eight units a tick of a 46,000 gp item is 368,000
|
||||
# gp of flow, the deepest thing on the board. Thin means small in coins, not small in units.
|
||||
#
|
||||
# The two thin tiers also carry the widest mispricing spread, so they show the fattest margin
|
||||
# and can absorb the least. That is the trap, and it is the same trap either way an agent
|
||||
# falls into it: ignore the volume column and either the offers sit unfilled or the purse
|
||||
# sits idle.
|
||||
TIERS = [
|
||||
# deep, ~40k-130k gp a tick: this is where the purse actually goes
|
||||
(["Thornroot poultice", "Chipped bone charm", "Bogwater draught", "Coarse fletching feather"],
|
||||
(90, 170), (400, 800), 5000, 0.09),
|
||||
(["Emberglass shard", "Stormrune tablet", "Marrowsteel nail", "Pale grimoire page"],
|
||||
(900, 1700), (40, 90), 400, 0.09),
|
||||
# thin, ~3k-15k gp a tick, and the widest swings on the board
|
||||
(["Gilded harpoon head", "Cinderweave cloak", "Wyrmbone talisman", "Frostbitten ledger"],
|
||||
(200, 420), (12, 30), 800, 0.15),
|
||||
(["Duskforged sigil", "Heart of the sunken cairn", "Voidglass lens", "Tideworn crown"],
|
||||
(6000, 14000), (1.2, 3.0), 40, 0.16),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
name: str
|
||||
reverting: bool
|
||||
"""Whether this item has an anchor at all. Never shown to the agent — it is here so the
|
||||
probe can assert that the trap is a trap, and that the reference avoids it for a reason
|
||||
rather than by luck."""
|
||||
buy_limit: int
|
||||
"""Units per item per window, the GE's own limit. With finite capital it is what turns
|
||||
the task into an allocation problem instead of a single pick."""
|
||||
prices: list[int]
|
||||
volumes: list[int]
|
||||
|
||||
def visible_prices(self, visible: int) -> list[int]:
|
||||
return self.prices[:visible]
|
||||
|
||||
def visible_volumes(self, visible: int) -> list[int]:
|
||||
return self.volumes[:visible]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Market:
|
||||
seed: int
|
||||
"""Carried on the basket so the taskset can store the seed it actually used. Baskets are
|
||||
skipped when the reference strategy is not profitable in them (see `book.viable_market`),
|
||||
so the seed a task was built from is not always the one it was asked for."""
|
||||
items: list[Item]
|
||||
visible: int
|
||||
held_out: int
|
||||
capital: int
|
||||
freeze: int = 0
|
||||
"""Ticks the book is frozen for after any re-quote. Zero on a market built by the
|
||||
one-shot generator; drawn from FREEZE_RANGE here. See FREEZE_SALT."""
|
||||
|
||||
def item(self, name: str) -> Item | None:
|
||||
wanted = name.strip().casefold()
|
||||
for item in self.items:
|
||||
if item.name.casefold() == wanted:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _volumes(rng: random.Random, volume: float, ticks: int) -> list[int]:
|
||||
return [max(1, round(volume * math.exp(rng.gauss(0.0, VOLUME_SD)))) for _ in range(ticks)]
|
||||
|
||||
|
||||
def _walk(rng: random.Random, base: float, ticks: int) -> list[int]:
|
||||
"""The decoy: no anchor, no reversion, just a wide random walk. Whatever a moving
|
||||
average says about where this price belongs is a statement about the past only."""
|
||||
price = base
|
||||
out = []
|
||||
for _ in range(ticks):
|
||||
price *= 1.0 + rng.gauss(0.0, DRIFT_STEP)
|
||||
out.append(max(1, round(price)))
|
||||
return out
|
||||
|
||||
|
||||
def _stream(rng: random.Random, base: float, volume: float, noise_sd: float, ticks: int
|
||||
) -> tuple[list[int], list[int]]:
|
||||
"""One item's price and volume history, visible and held-out ticks together."""
|
||||
# eps is scaled so the AR(1) settles at exactly noise_sd rather than drifting toward it
|
||||
# over the first few ticks; the visible half would otherwise be quieter than the graded
|
||||
# half and every anchor estimated from it would be too tight.
|
||||
eps_sd = noise_sd * math.sqrt(1.0 - PHI * PHI)
|
||||
x = rng.gauss(0.0, noise_sd)
|
||||
fundamental = base
|
||||
prices = []
|
||||
for _ in range(ticks):
|
||||
fundamental *= 1.0 + rng.gauss(0.0, FUND_DRIFT)
|
||||
x = PHI * x + rng.gauss(0.0, eps_sd)
|
||||
prices.append(max(1, round(fundamental * (1.0 + x))))
|
||||
return prices, _volumes(rng, volume, ticks)
|
||||
|
||||
|
||||
def build_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
|
||||
"""One basket, from one seed.
|
||||
|
||||
Every tier is represented before any tier repeats, so a thin item and a deep one are
|
||||
always both on the table: the allocation choice is the task, and a basket that happened
|
||||
to be all-deep or all-thin would not pose it.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
ticks = visible + held_out
|
||||
order = list(range(len(TIERS)))
|
||||
rng.shuffle(order)
|
||||
picks = [order[i % len(order)] for i in range(num_items)]
|
||||
|
||||
# Which slots are walks, and how many, are both drawn here. The tier is drawn
|
||||
# independently of the walk flag, so a basket where the walk was always the cheap item —
|
||||
# solvable by reading the price column and never the shape — cannot arise.
|
||||
decoys = set(rng.sample(range(num_items), rng.randint(*WALKS)))
|
||||
|
||||
used: set[str] = set()
|
||||
items = []
|
||||
for slot, tier_idx in enumerate(picks):
|
||||
names, (lo, hi), (vlo, vhi), limit, noise_sd = TIERS[tier_idx]
|
||||
choices = [n for n in names if n not in used] or names
|
||||
name = rng.choice(choices)
|
||||
used.add(name)
|
||||
base, volume = rng.uniform(lo, hi), rng.uniform(vlo, vhi)
|
||||
if slot in decoys:
|
||||
prices, volumes = _walk(rng, base, ticks), _volumes(rng, volume, ticks)
|
||||
else:
|
||||
prices, volumes = _stream(rng, base, volume, noise_sd, ticks)
|
||||
items.append(Item(name=name, reverting=slot not in decoys, buy_limit=limit,
|
||||
prices=prices, volumes=volumes))
|
||||
items.sort(key=lambda i: i.name)
|
||||
# Off its own RNG, so `rng` above is left exactly where the one-shot generator leaves
|
||||
# it and every stream is byte-identical to the original's.
|
||||
freeze = random.Random(seed ^ FREEZE_SALT).randint(*FREEZE_RANGE)
|
||||
return Market(seed=seed, items=items, visible=visible, held_out=held_out,
|
||||
capital=STARTING_CAPITAL, freeze=freeze)
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Measure the policy ladder in the SHIPPED engine, before any constant is locked.
|
||||
|
||||
The spec this environment came from locked `horizon`, `FREEZE_RANGE`, `TARGET_SHARE` and
|
||||
`TURNS` against arithmetic from a throwaway engine that no longer exists, and its ladder did
|
||||
not reconcile with its own reward — impatient computes to 0.612 where 0.623 was reported.
|
||||
Nothing here is taken from that document. Every row below is produced by `live.play`, the
|
||||
engine the taskset will actually run, over the baskets a run will actually serve.
|
||||
|
||||
Two questions this file exists to answer, in this order:
|
||||
|
||||
1. What does each policy score? Nine of them, from doing nothing to the reference itself,
|
||||
each differing from its neighbour by one judgement.
|
||||
2. **Does the turn budget bind?** Every reward in Arena saturates — `profit_ratio` and
|
||||
`efficiency` clip at 1.0 and `clean` is a boolean band — so an exhaustive policy that
|
||||
clears the band scores 1.000 and house rule 4 is structurally unreachable. That is
|
||||
risk #2 in the programme's register, it is answered as a NUMBER, and if the number is
|
||||
zero that is a finding about the reward shape rather than a failure of this lane.
|
||||
|
||||
Read block-wise, never as one mean: realised P&L over sixty ticks is the noisiest thing in
|
||||
the repository, and `probe.py` already documents this generator swinging 0.48 against 0.18
|
||||
between blocks of twenty-four. A ladder read off one block is a ladder read off the seed.
|
||||
|
||||
uv run --no-project python measure_ladder.py
|
||||
uv run --no-project python measure_ladder.py --sweep-target
|
||||
uv run --no-project python measure_ladder.py --sweep-requote
|
||||
uv run --no-project python measure_ladder.py --sweep-reference
|
||||
uv run --no-project python measure_ladder.py --sweep-freeze
|
||||
uv run --no-project python measure_ladder.py --sweep-shape
|
||||
|
||||
--- what it measured, 2026-08-21, amd-server, 120 baskets from SEED_BASE ------------------
|
||||
|
||||
TURNS=8, TICKS_PER_TURN=7, TAIL_TICKS=4, HORIZON=60, FREEZE_RANGE=(1,4), TARGET_SHARE=0.90.
|
||||
|
||||
policy total profit effcy discip clean gp looks
|
||||
inaction 0.000 0.000 0.000 0.000 0.000 0 0.00
|
||||
staller 0.000 0.000 0.000 0.000 0.000 0 0.00
|
||||
crude (market orders, one look) 0.126 0.207 0.186 0.095 0.017 -2,684 1.00
|
||||
spammer 0.158 0.262 0.219 0.114 0.025 13,938 7.89
|
||||
restate (re-quote the same book) 0.219 0.375 0.362 0.168 0.000 25,244 7.90
|
||||
plausible (mean anchor, no filter) 0.406 0.563 0.524 0.391 0.142 37,084 1.00
|
||||
churn (re-place at full size) 0.447 0.684 0.550 0.430 0.042 49,926 8.00
|
||||
impatient (one-shot ref, one look) 0.538 0.739 0.722 0.560 0.150 50,172 1.00
|
||||
hindsight (whole-window anchor) 0.598 0.792 0.763 0.630 0.208 54,599 1.00
|
||||
exhaustive (re-quote every look) 0.631 0.849 0.749 0.669 0.192 63,802 7.97
|
||||
oracle (the live reference) 1.000 1.000 1.000 1.000 1.000 76,744 3.99
|
||||
|
||||
Every row reconciles with 0.45*profit + 0.30*discipline + 0.25*clean to three decimals,
|
||||
which the spec's ladder did not.
|
||||
|
||||
**The budget binds. Oracle - exhaustive = 0.369, stdev 0.054 over five blocks of 24, worst
|
||||
block 0.316.** That is fifteen times the 0.02 margin B2 proposed and it clears the 0.25
|
||||
quantum of the binary `gate` term outright, so the comparison is not a cliff. It is also
|
||||
not an artifact of the denominator: the reference earns 76,744 gp against exhaustive's
|
||||
63,802 and out-returns it on peak capital, 0.302 against 0.219.
|
||||
|
||||
`hindsight` is the row that says the stepped form measures something the one-shot form
|
||||
cannot. It quotes off the mean of the WHOLE window, held-out ticks included, and still
|
||||
scores 0.598 — perfect price foresight in a single plan is worth less than playing the
|
||||
window with none.
|
||||
|
||||
Three things this measurement overturned:
|
||||
|
||||
the first engine's rule-4 gap was fake. With a reference that never redeployed sale
|
||||
proceeds, exhaustive earned 22% MORE gp than the reference and scored 0.848: the gap was
|
||||
the denominator being under-tuned, exactly the imitation-score defect `book.py` documents
|
||||
paying for once already. Fixed by giving the reference the idle-cash trigger.
|
||||
|
||||
the freeze alone is not what binds. At freeze 0 the gap is still 0.180 — the queue reset
|
||||
and the capital churn carry it. The freeze sets the slope: 0.238 at 1 tick, 0.505 at 4,
|
||||
0.902 at 8. FREEZE_RANGE=(1,4) is kept because 8 turns a look into a catastrophe rather
|
||||
than a cost, and because flat-per-look is the settled decision.
|
||||
|
||||
more turns bind harder, monotonically: 4x14+4 gives 0.155, 8x7+4 gives 0.369, 12x4+12
|
||||
gives 0.517. Eight is chosen for the token cost of a rollout, not because the gap needs
|
||||
it. If the gap ever needs widening, this is the lever, and it is measured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import statistics
|
||||
from dataclasses import replace
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_shim = types.ModuleType("grand_exchange_live")
|
||||
_shim.__path__ = [str(HERE / "grand_exchange_live")]
|
||||
sys.modules["grand_exchange_live"] = _shim
|
||||
|
||||
from grand_exchange_live.book import ( # noqa: E402
|
||||
BUY_BAND,
|
||||
FILL_SHARE,
|
||||
MAX_ITEM_SHARE,
|
||||
MIN_CROSSINGS,
|
||||
SELL_BAND,
|
||||
TAX,
|
||||
Order,
|
||||
crossings,
|
||||
reference_orders,
|
||||
)
|
||||
from grand_exchange_live.live import ( # noqa: E402
|
||||
HORIZON,
|
||||
IDLE_SHARE,
|
||||
LIVE_VIABILITY_TRIES,
|
||||
REQUOTE_BAND,
|
||||
TAIL_TICKS,
|
||||
TICKS_PER_TURN,
|
||||
TURNS,
|
||||
Leg,
|
||||
Reference,
|
||||
desired_orders,
|
||||
live_reference,
|
||||
play,
|
||||
viable_live_market,
|
||||
)
|
||||
from grand_exchange_live.market import SEED_BASE, build_market # noqa: E402
|
||||
|
||||
HEADER = "requote / idle"
|
||||
BLOCK = 24
|
||||
BLOCKS = 5
|
||||
TARGET_SHARE = 0.90
|
||||
"""What counts as a full score, as a share of the LIVE reference's realised profit and of
|
||||
its return on peak capital. Chosen by `--sweep-target`, not carried over from the spec,
|
||||
which proposed 0.85 against an engine that no longer exists.
|
||||
|
||||
The two things it trades off are both measured over 120 baskets. Raising it widens the
|
||||
rule-4 gap (exhaustive scores 0.723 at 0.80 and 0.574 at 1.00) and narrows the ceiling
|
||||
plateau — and the plateau is what stops the reward being an imitation score for the
|
||||
reference's own constants. At 0.90 the best-earning member of the reference's family that
|
||||
is NOT the reference earns 1.1% more gp and still scores 0.976, far above the 0.90 bar
|
||||
`probe.py` holds the one-shot form to, and the gap to exhaustive is 0.372. It is also the
|
||||
one-shot environment's value, which is one fewer constant that differs between two forms of
|
||||
the same market for no measured reason."""
|
||||
|
||||
|
||||
# --- the baskets a run actually serves --------------------------------------------------
|
||||
def baskets(count: int, base: int = SEED_BASE):
|
||||
"""Skip-aware, exactly as `Taskset.load` will be: `viable_live_market` may pass over a
|
||||
seed the LIVE reference loses money in, and the next task starts after the seed it
|
||||
landed on. Grading a contiguous `range` instead would grade a different set of baskets
|
||||
from the one a model is served."""
|
||||
out, seed = [], base
|
||||
for _ in range(count):
|
||||
market = viable_live_market(seed, 5, 56, HORIZON)
|
||||
seed = market.seed + 1
|
||||
out.append(market)
|
||||
return out
|
||||
|
||||
|
||||
# --- scoring ----------------------------------------------------------------------------
|
||||
def score(result, reference, target_share: float = TARGET_SHARE) -> dict[str, float]:
|
||||
"""The reward, in one place, so no two rows can disagree about the same episode."""
|
||||
target = target_share * reference.realised
|
||||
profit = 0.0 if target <= 0 else min(1.0, max(0.0, result.realised / target))
|
||||
efficiency = 0.0 if reference.roc <= 0 else min(1.0, max(0.0, result.roc / reference.roc))
|
||||
discipline = profit * efficiency
|
||||
clean = float(result.realised >= target_share * reference.realised
|
||||
and result.roc >= target_share * reference.roc)
|
||||
return {
|
||||
"total": 0.45 * profit + 0.30 * discipline + 0.25 * clean,
|
||||
"profit_ratio": profit,
|
||||
"efficiency": efficiency,
|
||||
"discipline": discipline,
|
||||
"clean": clean,
|
||||
}
|
||||
|
||||
|
||||
# --- the ladder, as policies rather than as adjectives ----------------------------------
|
||||
def inaction(view):
|
||||
"""Never submits a sheet. House rule 3's floor."""
|
||||
return []
|
||||
|
||||
|
||||
def staller(view):
|
||||
"""Takes every look and does nothing with any of them. Scores what inaction scores —
|
||||
the point of the row is that BURNING the budget is not itself worth anything, which is
|
||||
the degenerate strategy a time-averaged reward would pay for."""
|
||||
return []
|
||||
|
||||
|
||||
def spammer(seed: int):
|
||||
"""Random legs on every look. Acts constantly, reads nothing."""
|
||||
rng = random.Random(seed)
|
||||
|
||||
def policy(view):
|
||||
items = view.market.items
|
||||
return [
|
||||
Leg(i.name, rng.randint(1, i.buy_limit),
|
||||
round(i.prices[view.seen - 1] * rng.uniform(0.85, 1.15)),
|
||||
round(i.prices[view.seen - 1] * rng.uniform(0.85, 1.15)))
|
||||
for i in items if rng.random() < 0.6
|
||||
]
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def one_look(plan_of):
|
||||
"""Submit a plan on turn 1, never look again. The one-shot form, played live."""
|
||||
def make(market):
|
||||
state = {"done": False}
|
||||
orders = plan_of(market)
|
||||
|
||||
def policy(view):
|
||||
if state["done"]:
|
||||
return []
|
||||
state["done"] = True
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
|
||||
|
||||
return policy
|
||||
|
||||
return make
|
||||
|
||||
|
||||
def band(market, items, anchor, sizer, filtered=False, ranked=False):
|
||||
"""`probe.py`'s strategy family, unchanged, so the live ladder's crude and plausible
|
||||
rungs are the same strategies the one-shot ladder grades."""
|
||||
plans, out, purse = [], [], float(market.capital)
|
||||
for item in items:
|
||||
prices = item.visible_prices(market.visible)
|
||||
volumes = item.visible_volumes(market.visible)
|
||||
if filtered and crossings(prices) < MIN_CROSSINGS:
|
||||
continue
|
||||
a = anchor(prices)
|
||||
buy = max(1, round(a * (1.0 - BUY_BAND)))
|
||||
sell = max(buy + 1, round(a * (1.0 + SELL_BAND)))
|
||||
lows = [p for p in prices if p <= buy]
|
||||
highs = [p for p in prices if p >= sell]
|
||||
qty = sizer(item, market, buy, prices, volumes)
|
||||
if qty <= 0:
|
||||
continue
|
||||
s = 0.0
|
||||
if ranked:
|
||||
if not lows or not highs:
|
||||
continue
|
||||
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
|
||||
s = (exit_ * (1.0 - TAX) - entry) / entry
|
||||
if s <= 0:
|
||||
continue
|
||||
plans.append((s, item.name, qty, buy, sell))
|
||||
plans.sort(key=lambda p: -p[0])
|
||||
for _, name, qty, buy, sell in plans:
|
||||
qty = min(qty, int(purse // buy))
|
||||
if qty <= 0:
|
||||
continue
|
||||
purse -= qty * buy
|
||||
out.append(Order(name, qty, buy, sell))
|
||||
return out
|
||||
|
||||
|
||||
def by_volume(item, market, buy, prices, volumes):
|
||||
lows = [p for p in prices if p <= buy]
|
||||
reach = int(FILL_SHARE * statistics.median(volumes) * (len(lows) / len(prices))
|
||||
* market.held_out)
|
||||
return min(item.buy_limit, reach, int(MAX_ITEM_SHARE * market.capital // buy))
|
||||
|
||||
|
||||
def market_orders(market):
|
||||
"""Buy ten percent over the last price and sell ten under: every offer fills and every
|
||||
round trip pays the tax. The crude maximiser."""
|
||||
return [
|
||||
Order(i.name, i.buy_limit, round(i.prices[market.visible - 1] * 1.10),
|
||||
round(i.prices[market.visible - 1] * 0.90))
|
||||
for i in market.items
|
||||
]
|
||||
|
||||
|
||||
def churn(market):
|
||||
"""Re-place the ORIGINAL plan at full size on every look.
|
||||
|
||||
Not the same policy as re-stating the book: the units already bought release their
|
||||
reservation and sale proceeds have returned to the purse, so asking for the full size
|
||||
again spends them. It earns roughly what a single look earns off materially more peak
|
||||
capital, which is precisely the shape `efficiency` exists to catch.
|
||||
"""
|
||||
orders = reference_orders(market)
|
||||
return lambda view: [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
|
||||
|
||||
|
||||
def restate(market):
|
||||
"""Place the one-shot reference on turn 1, then re-submit the book exactly as it stands
|
||||
on every look after it.
|
||||
|
||||
The pure cost of looking, isolated: same names, same prices, same units still sought,
|
||||
nothing learned, one freeze and one queue reset per turn. Its gap below `impatient` — an
|
||||
identical plan that simply stops looking — is the price of a look with no information in
|
||||
it, in reward units.
|
||||
"""
|
||||
orders = reference_orders(market)
|
||||
|
||||
def policy(view):
|
||||
if view.turn == 1:
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in view.open_orders.values()]
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def exhaustive(market):
|
||||
"""Spend the entire budget: re-quote on EVERY look, off everything observed so far.
|
||||
|
||||
This is the rule-4 policy. It makes every judgement the reference makes and one more
|
||||
look than the reference thinks is worth paying for, so if it scores at the oracle the
|
||||
budget is free and the environment measures nothing the one-shot form did not.
|
||||
"""
|
||||
inner = Reference(requote_band=0.0)
|
||||
return inner
|
||||
|
||||
|
||||
def perfect_hindsight(market):
|
||||
"""Not shipped, not a rung: a sanity ceiling. Quotes off the mean of the WHOLE window,
|
||||
held-out ticks included. If the oracle were close to this, the visible half would not be
|
||||
the thing being read."""
|
||||
prices = {i.name: list(i.prices) for i in market.items}
|
||||
volumes = {i.name: list(i.volumes) for i in market.items}
|
||||
orders = desired_orders(market.items, prices, volumes, float(market.capital),
|
||||
market.held_out, {i.name: i.buy_limit for i in market.items})
|
||||
return one_look(lambda m: orders)(market)
|
||||
|
||||
|
||||
LADDER = [
|
||||
("inaction", lambda m: inaction),
|
||||
("staller", lambda m: staller),
|
||||
("spammer", lambda m: spammer(m.seed)),
|
||||
("crude (market orders, one look)", one_look(market_orders)),
|
||||
("plausible (mean anchor, no filter)",
|
||||
one_look(lambda m: band(m, m.items, statistics.fmean, by_volume))),
|
||||
("impatient (one-shot reference, one look)", one_look(reference_orders)),
|
||||
("restate (re-quote the same book every look)", restate),
|
||||
("churn (re-place at full size every look)", churn),
|
||||
("exhaustive (re-quote every look)", exhaustive),
|
||||
("oracle (the live reference)", lambda m: Reference()),
|
||||
("hindsight (not a rung: whole-window anchor)", perfect_hindsight),
|
||||
]
|
||||
|
||||
|
||||
def ladder(markets, target_share: float = TARGET_SHARE) -> dict[str, dict[str, float]]:
|
||||
rows: dict[str, dict[str, float]] = {}
|
||||
for name, make in LADDER:
|
||||
totals: dict[str, float] = {}
|
||||
gp = looks = frozen = 0.0
|
||||
peak = 0.0
|
||||
for market in markets:
|
||||
result = play(market, make(market))
|
||||
for key, value in score(result, live_reference(market), target_share).items():
|
||||
totals[key] = totals.get(key, 0.0) + value
|
||||
gp += result.realised
|
||||
looks += result.looks
|
||||
frozen += result.frozen_ticks
|
||||
peak += result.peak_employed
|
||||
n = len(markets)
|
||||
rows[name] = {k: v / n for k, v in totals.items()}
|
||||
rows[name].update(gp=gp / n, looks=looks / n, frozen=frozen / n, peak=peak / n)
|
||||
return rows
|
||||
|
||||
|
||||
def table(markets, target_share: float = TARGET_SHARE) -> None:
|
||||
rows = ladder(markets, target_share)
|
||||
print(f"{'policy':<46}{'total':>8}{'profit':>8}{'effcy':>8}{'discip':>8}{'clean':>7}"
|
||||
f"{'gp':>12}{'peak gp':>10}{'looks':>7}{'frozen':>7}")
|
||||
for name, row in rows.items():
|
||||
print(f"{name:<46}{row['total']:>8.3f}{row['profit_ratio']:>8.3f}"
|
||||
f"{row['efficiency']:>8.3f}{row['discipline']:>8.3f}{row['clean']:>7.3f}"
|
||||
f"{row['gp']:>12,.0f}{row['peak']:>10,.0f}{row['looks']:>7.2f}"
|
||||
f"{row['frozen']:>7.2f}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
flags = set(sys.argv[1:])
|
||||
started = time.monotonic()
|
||||
seeds = baskets(BLOCK * BLOCKS)
|
||||
build = time.monotonic() - started
|
||||
blocks = [seeds[i:i + BLOCK] for i in range(0, len(seeds), BLOCK)]
|
||||
skipped = sum(1 for i, m in enumerate(seeds)
|
||||
if m.seed != (seeds[i - 1].seed + 1 if i else SEED_BASE))
|
||||
|
||||
print(f"engine TURNS={TURNS} TICKS_PER_TURN={TICKS_PER_TURN} TAIL={TAIL_TICKS} "
|
||||
f"HORIZON={HORIZON} REQUOTE_BAND={REQUOTE_BAND:.4f} TARGET_SHARE={TARGET_SHARE}")
|
||||
print(f"baskets {len(seeds)} over {BLOCKS} blocks of {BLOCK}, "
|
||||
f"{skipped} seeds skipped by viability (cap {LIVE_VIABILITY_TRIES}), "
|
||||
f"built in {build:.2f}s")
|
||||
print(f"freeze {statistics.fmean([m.freeze for m in seeds]):.2f} ticks mean, "
|
||||
f"distribution {sorted({f: [m.freeze for m in seeds].count(f) for f in {m.freeze for m in seeds}}.items())}")
|
||||
print()
|
||||
|
||||
print("=== the ladder, all 120 baskets ===")
|
||||
table(seeds)
|
||||
print()
|
||||
|
||||
print("=== does the budget bind? exhaustive against oracle, block by block ===")
|
||||
print(f"{'block':<22}{'exhaustive':>12}{'oracle':>10}{'gap':>10}"
|
||||
f"{'exh gp':>12}{'ora gp':>12}{'exh looks':>11}{'ora looks':>11}")
|
||||
gaps = []
|
||||
for i, block in enumerate(blocks):
|
||||
rows = ladder(block)
|
||||
e, o = rows["exhaustive (re-quote every look)"], rows["oracle (the live reference)"]
|
||||
gaps.append(o["total"] - e["total"])
|
||||
print(f"{f'{block[0].seed}-{block[-1].seed}':<22}{e['total']:>12.3f}{o['total']:>10.3f}"
|
||||
f"{o['total'] - e['total']:>10.3f}{e['gp']:>12,.0f}{o['gp']:>12,.0f}"
|
||||
f"{e['looks']:>11.2f}{o['looks']:>11.2f}")
|
||||
print(f"{'mean':<22}{'':>12}{'':>10}{statistics.fmean(gaps):>10.3f}")
|
||||
print(f"{'stdev':<22}{'':>12}{'':>10}{statistics.stdev(gaps):>10.3f}")
|
||||
print(f"{'worst block':<22}{'':>12}{'':>10}{min(gaps):>10.3f}")
|
||||
print()
|
||||
|
||||
print("=== every rung, block by block (total only) ===")
|
||||
per_block = [ladder(b) for b in blocks]
|
||||
print(f"{'policy':<46}" + "".join(f"{f'b{i}':>9}" for i in range(len(blocks)))
|
||||
+ f"{'spread':>9}")
|
||||
for name, _ in LADDER:
|
||||
values = [rows[name]["total"] for rows in per_block]
|
||||
print(f"{name:<46}" + "".join(f"{v:>9.3f}" for v in values)
|
||||
+ f"{max(values) - min(values):>9.3f}")
|
||||
print()
|
||||
|
||||
if "--sweep-target" in flags:
|
||||
print("=== TARGET_SHARE sweep (the ceiling band) ===")
|
||||
names = [n for n, _ in LADDER]
|
||||
print(f"{'share':<8}" + "".join(f"{n.split(' ')[0]:>12}" for n in names))
|
||||
for share in (0.75, 0.80, 0.85, 0.90, 0.95, 1.00):
|
||||
rows = ladder(seeds, share)
|
||||
print(f"{share:<8.2f}" + "".join(f"{rows[n]['total']:>12.3f}" for n in names))
|
||||
print()
|
||||
|
||||
if "--sweep-requote" in flags:
|
||||
print("=== REQUOTE_BAND sweep (what the reference should pay a freeze for) ===")
|
||||
print(f"{'band':<8}{'gp':>12}{'roc':>10}{'looks':>8}{'frozen':>8}")
|
||||
for band_value in (0.0, 0.005, 0.01, 0.015, 0.025, 0.04, 0.06, 0.10, 1.0):
|
||||
gp = roc = looks = frozen = 0.0
|
||||
for market in seeds:
|
||||
result = play(market, Reference(requote_band=band_value))
|
||||
gp += result.realised
|
||||
roc += result.roc
|
||||
looks += result.looks
|
||||
frozen += result.frozen_ticks
|
||||
n = len(seeds)
|
||||
print(f"{band_value:<8.3f}{gp / n:>12,.0f}{roc / n:>10.4f}"
|
||||
f"{looks / n:>8.2f}{frozen / n:>8.2f}")
|
||||
print()
|
||||
|
||||
if "--sweep-reference" in flags:
|
||||
# The denominator has to be the profit-maximising point of its OWN family, or some
|
||||
# other member earns more gp and scores less, and the reward's argmax is a pair of
|
||||
# constants rather than a strategy. This is the same sweep `book.py` documents for
|
||||
# the one-shot bands, over the two triggers that make the live reference act.
|
||||
print("=== reference family sweep: gp per basket ===")
|
||||
idles = (0.0, 0.05, 0.10, 0.15, 0.20, 0.30, 0.50, 1.01)
|
||||
print(f"{HEADER:<16}" + "".join(f"{i:>10.2f}" for i in idles))
|
||||
best = (0.0, None)
|
||||
for band_value in (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0):
|
||||
row = []
|
||||
for idle in idles:
|
||||
gp = sum(play(m, Reference(band_value, idle)).realised for m in seeds)
|
||||
row.append(gp / len(seeds))
|
||||
if row[-1] > best[0]:
|
||||
best = (row[-1], (band_value, idle))
|
||||
print(f"{band_value:<16.3f}" + "".join(f"{v:>10,.0f}" for v in row))
|
||||
print(f"argmax {best[1]} at {best[0]:,.0f} gp per basket; shipped "
|
||||
f"({REQUOTE_BAND}, {IDLE_SHARE}) is "
|
||||
f"{sum(play(m, Reference()).realised for m in seeds) / len(seeds):,.0f}")
|
||||
print()
|
||||
|
||||
print("=== the same family, in return on peak capital ===")
|
||||
print(f"{HEADER:<16}" + "".join(f"{i:>10.2f}" for i in idles))
|
||||
for band_value in (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0):
|
||||
row = []
|
||||
for idle in idles:
|
||||
roc = sum(play(m, Reference(band_value, idle)).roc for m in seeds)
|
||||
row.append(roc / len(seeds))
|
||||
print(f"{band_value:<16.3f}" + "".join(f"{v:>10.4f}" for v in row))
|
||||
print()
|
||||
|
||||
print("=== and what each family member SCORES against the shipped reference ===")
|
||||
print(f"{HEADER:<16}" + "".join(f"{i:>10.2f}" for i in idles))
|
||||
for band_value in (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0):
|
||||
row = []
|
||||
for idle in idles:
|
||||
total = sum(score(play(m, Reference(band_value, idle)), live_reference(m))["total"]
|
||||
for m in seeds)
|
||||
row.append(total / len(seeds))
|
||||
print(f"{band_value:<16.3f}" + "".join(f"{v:>10.3f}" for v in row))
|
||||
print()
|
||||
|
||||
if "--sweep-freeze" in flags:
|
||||
# If no freeze makes looking expensive enough for the optimum to be interior, the
|
||||
# freeze is decoration and rule 4 is being carried by the denominator alone.
|
||||
print("=== freeze sweep: what a look has to cost before it is worth declining ===")
|
||||
print(f"{'freeze':<10}{'exhaust gp':>12}{'oracle gp':>12}{'exh score':>11}"
|
||||
f"{'gap':>8}{'ora looks':>11}{'ora frozen':>12}")
|
||||
for forced in (0, 1, 2, 3, 4, 6, 8, 12):
|
||||
shaped = [replace(m, freeze=forced) for m in seeds]
|
||||
e = g = 0.0
|
||||
egp = ogp = looks = frozen = 0.0
|
||||
for m in shaped:
|
||||
ref = play(m, Reference())
|
||||
exh = play(m, Reference(requote_band=0.0, idle_share=0.0))
|
||||
if ref.realised <= 0:
|
||||
continue
|
||||
e += score(exh, ref)["total"]
|
||||
g += 1.0 - score(exh, ref)["total"]
|
||||
egp += exh.realised
|
||||
ogp += ref.realised
|
||||
looks += ref.looks
|
||||
frozen += ref.frozen_ticks
|
||||
n = len(shaped)
|
||||
print(f"{forced:<10}{egp / n:>12,.0f}{ogp / n:>12,.0f}{e / n:>11.3f}"
|
||||
f"{g / n:>8.3f}{looks / n:>11.2f}{frozen / n:>12.2f}")
|
||||
print()
|
||||
|
||||
if "--sweep-shape" in flags:
|
||||
print("=== window shape sweep: does a different budget bind harder? ===")
|
||||
print(f"{'turns x step + tail':<22}{'horizon':>9}{'exhaust':>9}{'oracle':>9}"
|
||||
f"{'gap':>9}{'ora looks':>11}{'held out':>10}")
|
||||
for turns, step, tail in ((4, 14, 4), (6, 9, 6), (8, 7, 4), (8, 5, 20),
|
||||
(10, 5, 10), (12, 4, 12)):
|
||||
horizon = turns * step + tail
|
||||
shaped = [build_market(m.seed, 5, 56, horizon) for m in seeds]
|
||||
e = o = 0.0
|
||||
looks = 0.0
|
||||
for market in shaped:
|
||||
ref = play(market, Reference(), turns=turns, step=step, tail=tail)
|
||||
exh = play(market, Reference(requote_band=0.0), turns=turns, step=step,
|
||||
tail=tail)
|
||||
if ref.realised <= 0:
|
||||
continue
|
||||
e += score(exh, ref)["total"]
|
||||
o += score(ref, ref)["total"]
|
||||
looks += ref.looks
|
||||
n = len(shaped)
|
||||
print(f"{f'{turns} x {step} + {tail}':<22}{horizon:>9}{e / n:>9.3f}{o / n:>9.3f}"
|
||||
f"{(o - e) / n:>9.3f}{looks / n:>11.2f}{step + tail:>10}")
|
||||
print()
|
||||
|
||||
print(f"total wall clock {time.monotonic() - started:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "grand-exchange-live"
|
||||
version = "0.1.0"
|
||||
description = "grand-exchange-live — quote, watch the tape, re-quote, over a turn budget."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["verifiers"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["grand_exchange_live"]
|
||||
@@ -0,0 +1,498 @@
|
||||
"""The stepped engine's invariants, as tests rather than as docstrings.
|
||||
|
||||
Five claims are load-bearing and every one of them is a way the environment could be quietly
|
||||
wrong rather than loudly broken:
|
||||
|
||||
the fixed point one look on turn 1 and nothing after it must reproduce the one-shot
|
||||
engine EXACTLY — same realised profit, same capital committed. If it
|
||||
does not, the live form is a different market wearing the same
|
||||
generator, and no number measured here is comparable to a `grand-exchange`
|
||||
number.
|
||||
the ledger cash, coins locked, cost basis and realised profit must sum to the
|
||||
starting capital after every single tick. A bug that mints money reads
|
||||
as a good score, not as a crash.
|
||||
the buy limit cumulative across the whole window, so cancel-and-re-place cannot buy
|
||||
the same item twice over. Without it, re-quoting resets the limit and
|
||||
the limit stops being a limit.
|
||||
the freeze a look that touches a live book stops the WHOLE book for `market.freeze`
|
||||
ticks, and the first placement is free.
|
||||
the queue an amended offer starts again at zero accrued fill room, so moving a
|
||||
limit costs even after the freeze has expired.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parents[1]
|
||||
ONE_SHOT = HERE.parent / "grand_exchange"
|
||||
|
||||
for _root in (HERE, ONE_SHOT):
|
||||
_package = _root / _root.name
|
||||
_shim = types.ModuleType(_package.name)
|
||||
_shim.__path__ = [str(_package)]
|
||||
sys.modules[_package.name] = _shim
|
||||
|
||||
from grand_exchange_live.book import Order, execute, reference_orders # noqa: E402
|
||||
from grand_exchange_live.live import ( # noqa: E402
|
||||
HORIZON,
|
||||
TAIL_TICKS,
|
||||
TICKS_PER_TURN,
|
||||
TURNS,
|
||||
Leg,
|
||||
Ledger,
|
||||
Reference,
|
||||
desired_orders,
|
||||
play,
|
||||
)
|
||||
from grand_exchange_live.market import build_market # noqa: E402
|
||||
|
||||
SEEDS = range(60_000, 60_024)
|
||||
|
||||
|
||||
def market(seed: int, held_out: int = HORIZON):
|
||||
return build_market(seed, 5, 56, held_out)
|
||||
|
||||
|
||||
def once(orders):
|
||||
"""Submit a plan on turn 1 and never look again — the one-shot strategy, played live."""
|
||||
state = {"done": False}
|
||||
|
||||
def policy(view):
|
||||
if state["done"]:
|
||||
return []
|
||||
state["done"] = True
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def restate(view):
|
||||
"""Re-submit the book exactly as it already stands, on every look.
|
||||
|
||||
The true no-op re-quote, and the only honest way to price the freeze and the queue
|
||||
reset on their own: same names, same prices, same units still sought. Nothing about the
|
||||
plan changes, so any difference in what it buys or earns IS the cost of looking.
|
||||
|
||||
Note that re-submitting the ORIGINAL quantities instead is a different policy and a
|
||||
bigger one — the units already bought free up their reservation, sale proceeds return
|
||||
to the purse, and asking for the full size again spends them. That policy is `churn` in
|
||||
the ladder; it earns about the same gp off a third more peak capital, which is what
|
||||
`efficiency` is for.
|
||||
"""
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in view.open_orders.values()]
|
||||
|
||||
|
||||
class FixedPointTests(unittest.TestCase):
|
||||
def test_single_look_matches_one_shot(self) -> None:
|
||||
"""The stepped engine, used exactly the way the one-shot engine is used, IS the
|
||||
one-shot engine. This is the assertion that makes the two forms comparable."""
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
plan = reference_orders(m)
|
||||
stepped = play(m, once(plan))
|
||||
flat = execute(m, plan)
|
||||
self.assertAlmostEqual(stepped.realised, flat.realised, places=6, msg=f"seed {seed}")
|
||||
self.assertAlmostEqual(stepped.peak_employed, flat.committed, places=6,
|
||||
msg=f"seed {seed}")
|
||||
self.assertEqual(stepped.bought, flat.bought, f"seed {seed}")
|
||||
self.assertEqual(stepped.sold, flat.sold, f"seed {seed}")
|
||||
self.assertEqual(stepped.dumped, flat.dumped, f"seed {seed}")
|
||||
self.assertEqual(stepped.frozen_ticks, 0, "the first placement is not free")
|
||||
|
||||
def test_turn_one_matches_reference_orders(self) -> None:
|
||||
"""`desired_orders` is `book.reference_orders` generalised to a moment in the
|
||||
window, and on turn 1 the moment is the one the one-shot form scores."""
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
wanted = desired_orders(
|
||||
m.items,
|
||||
{i.name: list(i.visible_prices(m.visible)) for i in m.items},
|
||||
{i.name: list(i.visible_volumes(m.visible)) for i in m.items},
|
||||
float(m.capital),
|
||||
m.held_out,
|
||||
{i.name: i.buy_limit for i in m.items},
|
||||
)
|
||||
self.assertEqual(
|
||||
[(o.item, o.quantity, o.buy, o.sell) for o in wanted],
|
||||
[(o.item, o.quantity, o.buy, o.sell) for o in reference_orders(m)],
|
||||
f"seed {seed}",
|
||||
)
|
||||
|
||||
|
||||
class LedgerTests(unittest.TestCase):
|
||||
def test_nothing_mints_coins(self) -> None:
|
||||
"""Cash + locked + cost basis + realised == capital, checked after every tick of
|
||||
every turn by instrumenting `Ledger.mark`, which the engine calls on each one."""
|
||||
broken: list[str] = []
|
||||
original = Ledger.mark
|
||||
|
||||
def checked(self) -> None:
|
||||
total = self.cash + self.locked + sum(self.basis.values()) \
|
||||
+ sum(self.incoming_basis.values())
|
||||
if abs((total - self.realised) - self.capital) > 1e-6:
|
||||
broken.append(f"identity: {total:.6f} against {self.capital:.6f}")
|
||||
# The purse is the binding constraint on everything: an offer is funded out of
|
||||
# it or it is not placed. A negative purse is the engine having spent coins that
|
||||
# were not there, which reads as a good score rather than as a crash.
|
||||
if self.cash < -1e-9:
|
||||
broken.append(f"purse: {self.cash:.6f}")
|
||||
original(self)
|
||||
|
||||
Ledger.mark = checked
|
||||
try:
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
for policy in (Reference(), once(reference_orders(m)), restate):
|
||||
play(m, policy)
|
||||
finally:
|
||||
Ledger.mark = original
|
||||
self.assertEqual(broken, [], "the ledger identity broke")
|
||||
|
||||
def test_inaction_is_exactly_zero(self) -> None:
|
||||
for seed in SEEDS:
|
||||
result = play(market(seed), lambda view: [])
|
||||
self.assertEqual(result.realised, 0.0)
|
||||
self.assertEqual(result.peak_employed, 0.0)
|
||||
self.assertEqual(result.roc, 0.0)
|
||||
self.assertEqual(result.looks, 0)
|
||||
|
||||
def test_capital_employed_can_exceed_the_starting_purse(self) -> None:
|
||||
"""A policy that recycles sale proceeds can employ more than the starting capital,
|
||||
and that is the whole point of a purse the proceeds return to — a one-shot plan
|
||||
cannot spend what its own sales earned. What it can never do is employ coins that
|
||||
do not exist, and that is `test_nothing_mints_coins`'s negative-purse check, not a
|
||||
bound on the final realised profit: gains made mid-window and given back later are
|
||||
real capital while they are employed."""
|
||||
peaks = [play(market(s), Reference()).peak_employed for s in SEEDS]
|
||||
self.assertTrue(all(p > 0.0 for p in peaks))
|
||||
self.assertTrue(any(p > 250_000 for p in peaks),
|
||||
"no basket ever employs more than the starting purse — sale "
|
||||
"proceeds are not being redeployed and the live form is a "
|
||||
"one-shot form with extra steps")
|
||||
|
||||
|
||||
class BuyLimitTests(unittest.TestCase):
|
||||
def test_the_window_limit_is_cumulative_across_requotes(self) -> None:
|
||||
"""Ask for the whole limit on every one of the eight looks. The window limit is the
|
||||
window's, not the offer's, so the total bought can never exceed it."""
|
||||
greedy = lambda view: [
|
||||
Leg(i.name, i.buy_limit, round(i.prices[view.seen - 1] * 1.20),
|
||||
round(i.prices[view.seen - 1] * 0.80))
|
||||
for i in view.market.items
|
||||
]
|
||||
seen_a_cap = False
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
result = play(m, greedy)
|
||||
for item in m.items:
|
||||
got = result.bought_by_item.get(item.name, 0)
|
||||
self.assertLessEqual(got, item.buy_limit,
|
||||
f"seed {seed}: bought {got} of {item.name} against a "
|
||||
f"window limit of {item.buy_limit}")
|
||||
seen_a_cap = seen_a_cap or got == item.buy_limit
|
||||
self.assertTrue(seen_a_cap, "no basket ever reached a buy limit — the invariant is "
|
||||
"untested by this policy rather than upheld by the engine")
|
||||
|
||||
|
||||
class FreezeTests(unittest.TestCase):
|
||||
def test_the_first_placement_is_free_and_the_second_is_not(self) -> None:
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
plan = reference_orders(m)
|
||||
if not plan:
|
||||
continue
|
||||
self.assertEqual(play(m, once(plan)).frozen_ticks, 0)
|
||||
|
||||
state = {"n": 0}
|
||||
|
||||
def twice(view, plan=plan, state=state):
|
||||
state["n"] += 1
|
||||
if state["n"] <= 2:
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in plan]
|
||||
return []
|
||||
|
||||
result = play(m, twice)
|
||||
self.assertEqual(result.frozen_ticks, m.freeze,
|
||||
f"seed {seed}: one re-quote should freeze {m.freeze} ticks")
|
||||
self.assertEqual(result.amendments, 1)
|
||||
self.assertEqual(result.looks, 2)
|
||||
|
||||
def test_a_frozen_tick_fills_nothing(self) -> None:
|
||||
"""The freeze has to bite on the tape, not only on a counter. Re-quoting the
|
||||
identical plan changes nothing about what is wanted, so any difference in units
|
||||
bought is the freeze and the queue reset — and it must never be an increase."""
|
||||
churned = fresh = 0
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
plan = reference_orders(m)
|
||||
fresh += play(m, once(plan)).bought
|
||||
churned += play(m, restate).bought
|
||||
self.assertLess(churned, fresh,
|
||||
f"churning the identical plan bought {churned} units against "
|
||||
f"{fresh} — re-quoting costs nothing")
|
||||
|
||||
def test_churn_earns_less_than_standing_still(self) -> None:
|
||||
"""The same claim in gp, which is what the reward actually reads."""
|
||||
churned = fresh = 0.0
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
plan = reference_orders(m)
|
||||
fresh += play(m, once(plan)).realised
|
||||
churned += play(m, restate).realised
|
||||
self.assertLess(churned, fresh, f"churn earned {churned:,.0f} gp against {fresh:,.0f}")
|
||||
|
||||
|
||||
class QueueTests(unittest.TestCase):
|
||||
def test_an_amendment_resets_accrued_fill_room(self) -> None:
|
||||
"""A thin item accrues a quarter of a tick's volume per eligible tick and carries it
|
||||
between ticks — that carry is the only reason an item trading two units a tick is
|
||||
tradeable rather than untradeable. Throwing it away on every look must show up as
|
||||
fewer units bought even when the freeze is one tick, so the two mechanics are
|
||||
separable rather than one mechanic counted twice."""
|
||||
thin = [s for s in SEEDS if build_market(s, 5, 56, HORIZON).freeze == 1]
|
||||
self.assertTrue(thin, "no one-tick-freeze basket in the block — test proves nothing")
|
||||
worse = 0
|
||||
for seed in thin:
|
||||
m = market(seed)
|
||||
plan = reference_orders(m)
|
||||
if play(m, restate).bought < play(m, once(plan)).bought:
|
||||
worse += 1
|
||||
self.assertGreater(worse, 0, "with a one-tick freeze, re-quoting cost nothing at all")
|
||||
|
||||
|
||||
class HeldOutTests(unittest.TestCase):
|
||||
def test_the_tail_after_the_last_look_is_never_observed(self) -> None:
|
||||
"""House rule 1, in its WEAKER live form, asserted on the size of the slice rather
|
||||
than on the frontier. An agent that takes every look still has TICKS_PER_TURN +
|
||||
TAIL_TICKS ticks executed after its final observation."""
|
||||
seen: list[int] = []
|
||||
|
||||
def watcher(view):
|
||||
seen.append(view.seen - view.market.visible)
|
||||
return []
|
||||
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
seen.clear()
|
||||
play(m, watcher)
|
||||
self.assertEqual(len(seen), TURNS)
|
||||
self.assertEqual(seen[0], 0, "the first look sees a graded tick")
|
||||
self.assertEqual(seen[-1], (TURNS - 1) * TICKS_PER_TURN)
|
||||
self.assertEqual(HORIZON - seen[-1], TICKS_PER_TURN + TAIL_TICKS)
|
||||
self.assertGreaterEqual(HORIZON - seen[-1], 4)
|
||||
|
||||
def test_a_view_never_carries_a_graded_tick_early(self) -> None:
|
||||
"""The observation handed over on a look must contain only ticks that have already
|
||||
been executed against — a one-tick lookahead here would hand the agent the answer."""
|
||||
for seed in (60_000, 60_007, 60_013):
|
||||
m = market(seed)
|
||||
|
||||
def strict(view, m=m):
|
||||
for item in m.items:
|
||||
got = view.new_prices[item.name]
|
||||
start = view.seen - len(got)
|
||||
self.assertEqual(got, item.prices[start:view.seen])
|
||||
self.assertLessEqual(view.seen, m.visible + m.held_out)
|
||||
return []
|
||||
|
||||
play(m, strict)
|
||||
|
||||
|
||||
class SheetTests(unittest.TestCase):
|
||||
def test_a_cancel_returns_the_coins_and_stops_the_buying(self) -> None:
|
||||
m = market(60_000)
|
||||
plan = reference_orders(m)
|
||||
name = plan[0].item
|
||||
|
||||
def cancel_after_one(view, plan=plan, name=name):
|
||||
if view.turn == 1:
|
||||
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in plan]
|
||||
if view.turn == 2:
|
||||
return [Leg(name, 0)]
|
||||
return []
|
||||
|
||||
held = play(m, once(plan)).bought_by_item.get(name, 0)
|
||||
cut = play(m, cancel_after_one).bought_by_item.get(name, 0)
|
||||
self.assertLess(cut, held, "cancelling an offer did not stop it buying")
|
||||
|
||||
def test_a_leg_naming_nothing_on_the_board_is_dropped_not_fatal(self) -> None:
|
||||
"""A malformed sheet scores what doing nothing scores. It must never raise: the
|
||||
reward runs inside the metric, so a raise takes the whole rollout with it rather
|
||||
than scoring zero."""
|
||||
junk = lambda view: [
|
||||
Leg("Nonexistent bauble", 10, 5, 6),
|
||||
Leg("", 1, 1, 2),
|
||||
Leg(view.market.items[0].name, 5, 0, 0),
|
||||
Leg(view.market.items[0].name, -3),
|
||||
]
|
||||
result = play(market(60_000), junk)
|
||||
self.assertEqual(result.realised, 0.0)
|
||||
self.assertGreaterEqual(result.dropped, 4)
|
||||
|
||||
def test_two_legs_on_one_name_keep_the_first(self) -> None:
|
||||
m = market(60_000)
|
||||
name = m.items[0].name
|
||||
|
||||
def duplicated(view, name=name):
|
||||
if view.turn > 1:
|
||||
return []
|
||||
price = view.market.item(name).prices[view.seen - 1]
|
||||
return [Leg(name, 10, round(price * 0.95), round(price * 1.05)),
|
||||
Leg(name, 10_000, round(price * 1.50), round(price * 1.60))]
|
||||
|
||||
result = play(m, duplicated)
|
||||
self.assertEqual(result.dropped, 1)
|
||||
|
||||
|
||||
class ReferencePolicyTests(unittest.TestCase):
|
||||
def test_the_reference_does_not_requote_every_turn(self) -> None:
|
||||
"""If it did, the freeze would be a constant cost and the environment would be
|
||||
measuring the one-shot answer with extra steps."""
|
||||
amendments = [play(market(s), Reference()).amendments for s in SEEDS]
|
||||
# Per-basket, the reference will sometimes want every look it is given — a basket
|
||||
# where the purse keeps freeing up is a basket where redeploying it keeps paying.
|
||||
# The claim that matters is the average, and it is that a look is declined often
|
||||
# enough for declining to be a judgement rather than an accident.
|
||||
self.assertLess(statistics.fmean(amendments), TURNS - 2,
|
||||
"the reference re-quotes on nearly every look — the budget is a "
|
||||
"formality")
|
||||
self.assertGreater(sum(amendments), 0,
|
||||
"the reference never re-quotes — the stepped form is decorative")
|
||||
|
||||
def test_the_reference_makes_money_in_most_baskets(self) -> None:
|
||||
earned = [play(market(s), Reference()).realised for s in SEEDS]
|
||||
won = sum(1 for e in earned if e > 0)
|
||||
self.assertGreaterEqual(won, len(earned) - 2,
|
||||
f"the reference profits in only {won}/{len(earned)} baskets "
|
||||
"— the reward's denominator is not reliable")
|
||||
|
||||
def test_it_is_deterministic(self) -> None:
|
||||
for seed in (60_000, 60_011):
|
||||
m = market(seed)
|
||||
a, b = play(m, Reference()), play(m, Reference())
|
||||
self.assertEqual(a, b)
|
||||
|
||||
|
||||
class RandomSheetTests(unittest.TestCase):
|
||||
def test_random_legs_never_break_the_engine(self) -> None:
|
||||
"""The hostile battery, cheaply: random garbage on every look, replayed per turn,
|
||||
must never raise and must never mint a coin."""
|
||||
rng = random.Random(7)
|
||||
for seed in SEEDS:
|
||||
m = market(seed)
|
||||
|
||||
def noise(view, rng=rng):
|
||||
return [
|
||||
Leg(rng.choice([i.name for i in view.market.items] + ["", "nope"]),
|
||||
rng.choice([-5, 0, 1, 10 ** 9]),
|
||||
rng.choice([0, -1, 1, 10 ** 9]),
|
||||
rng.choice([0, 1, 10 ** 9]))
|
||||
for _ in range(rng.randint(0, 8))
|
||||
]
|
||||
|
||||
result = play(m, noise)
|
||||
self.assertLessEqual(result.peak_employed, m.capital + 1e-6)
|
||||
self.assertGreaterEqual(result.realised, -float(m.capital))
|
||||
|
||||
|
||||
class LadderGateTests(unittest.TestCase):
|
||||
"""House rules 3 and 4, executable, in this package rather than in `probe.py`.
|
||||
|
||||
`probe.py` is B1's file and five workstreams are queued behind it; the gate this
|
||||
environment lives or dies on should not wait on a merge, and it should fail here first
|
||||
if the engine drifts. When A's probe row lands it asserts the same three things over the
|
||||
same helper.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
sys.path.insert(0, str(HERE))
|
||||
import measure_ladder
|
||||
|
||||
self.ladder = measure_ladder
|
||||
self.markets = measure_ladder.baskets(measure_ladder.BLOCK * measure_ladder.BLOCKS)
|
||||
self.blocks = [self.markets[i:i + measure_ladder.BLOCK]
|
||||
for i in range(0, len(self.markets), measure_ladder.BLOCK)]
|
||||
|
||||
def test_the_floor_is_exactly_zero_and_the_ceiling_is_reachable(self) -> None:
|
||||
rows = self.ladder.ladder(self.markets)
|
||||
self.assertEqual(rows["inaction"]["total"], 0.0)
|
||||
self.assertEqual(rows["staller"]["total"], 0.0)
|
||||
self.assertEqual(rows["oracle (the live reference)"]["total"], 1.0)
|
||||
|
||||
def test_the_budget_binds_in_every_block(self) -> None:
|
||||
"""Rule 4. Not on the 120-basket mean — `probe.py` documents this generator swinging
|
||||
0.48 against 0.18 between blocks of twenty-four, and a mean that holds while one
|
||||
block of the size a run actually grades goes the other way is a mean measured off
|
||||
the seed."""
|
||||
gaps = []
|
||||
for block in self.blocks:
|
||||
rows = self.ladder.ladder(block)
|
||||
gap = (rows["oracle (the live reference)"]["total"]
|
||||
- rows["exhaustive (re-quote every look)"]["total"])
|
||||
gaps.append(gap)
|
||||
self.assertGreater(gap, 0.10,
|
||||
f"baskets {block[0].seed}-{block[-1].seed}: spending every "
|
||||
f"look scores {gap:.3f} below the reference — the turn "
|
||||
"budget is a formality")
|
||||
self.assertGreater(statistics.fmean(gaps), 0.25)
|
||||
|
||||
def test_the_reference_is_not_merely_the_denominator(self) -> None:
|
||||
"""The gap has to be a fact about money, not about normalisation. If the exhaustive
|
||||
policy EARNED more and scored less, the reward would be an imitation score for the
|
||||
reference's constants — which is precisely what the first cut of this engine did,
|
||||
at 63,974 gp against the reference's 51,520."""
|
||||
rows = self.ladder.ladder(self.markets)
|
||||
oracle = rows["oracle (the live reference)"]
|
||||
for name in ("exhaustive (re-quote every look)", "churn (re-place at full size every look)",
|
||||
"impatient (one-shot reference, one look)",
|
||||
"restate (re-quote the same book every look)"):
|
||||
self.assertLess(rows[name]["gp"], oracle["gp"],
|
||||
f"{name} earns more gp than the reference it is scored against")
|
||||
|
||||
def test_looking_costs_and_a_wasted_look_costs_more(self) -> None:
|
||||
rows = self.ladder.ladder(self.markets)
|
||||
self.assertLess(rows["restate (re-quote the same book every look)"]["total"],
|
||||
rows["impatient (one-shot reference, one look)"]["total"],
|
||||
"re-quoting the identical book every turn is free")
|
||||
self.assertLess(rows["crude (market orders, one look)"]["total"],
|
||||
rows["plausible (mean anchor, no filter)"]["total"])
|
||||
self.assertLess(rows["plausible (mean anchor, no filter)"]["total"],
|
||||
rows["impatient (one-shot reference, one look)"]["total"])
|
||||
|
||||
|
||||
class LoadCostTests(unittest.TestCase):
|
||||
def test_a_default_taskset_load_is_not_three_thousand_simulations(self) -> None:
|
||||
"""`num_tasks` defaults to 48 and viability retries; a live `viable_market` running
|
||||
a full stepped reference per candidate is thousands of simulations before the first
|
||||
token, on every eval AND every `--dry-run`. The reference is cached per seed and the
|
||||
try cap is 16 rather than 64, and this is the assertion that says so."""
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(HERE))
|
||||
import measure_ladder
|
||||
from grand_exchange_live import live
|
||||
|
||||
live._REFERENCE_CACHE.clear()
|
||||
started = time.monotonic()
|
||||
markets = measure_ladder.baskets(48)
|
||||
cold = time.monotonic() - started
|
||||
self.assertEqual(len(markets), 48)
|
||||
self.assertLess(cold, 2.0, f"a 48-task load took {cold:.2f}s")
|
||||
|
||||
hits = len(live._REFERENCE_CACHE)
|
||||
started = time.monotonic()
|
||||
for market in markets:
|
||||
live.live_reference(market)
|
||||
self.assertEqual(len(live._REFERENCE_CACHE), hits, "scoring re-simulated the "
|
||||
"reference the model played against")
|
||||
self.assertLess(time.monotonic() - started, 0.05)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""The vendored generator is a COPY, and this is the assertion that keeps it one.
|
||||
|
||||
`grand_exchange_live/market.py` and `book.py` are copies of the one-shot environment's, not
|
||||
imports of it. The reason is that the one-shot scores have to stay comparable across time:
|
||||
an import would mean any change made for the live form silently moved every historical
|
||||
`grand-exchange` number, and nothing would say so. The cost of a copy is drift, and drift is
|
||||
what this file refuses to allow — every price and every volume of every item, over a block
|
||||
of seeds, through both packages, compared exactly.
|
||||
|
||||
The freeze draw is the one intentional divergence. It is drawn off `random.Random(seed ^
|
||||
FREEZE_SALT)` rather than off the generator's own `rng`, so it cannot consume a draw and
|
||||
cannot perturb a single tick. That claim is not an argument here, it is the test below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parents[1]
|
||||
ONE_SHOT = HERE.parent / "grand_exchange"
|
||||
|
||||
# `probe.py`'s shim, for `probe.py`'s reason: each package's `__init__` imports its taskset,
|
||||
# which imports verifiers, and a self-check that only runs once the training stack is
|
||||
# installed is a self-check nobody runs. Registering the package name as a namespace
|
||||
# pointing at the source directory keeps intra-package imports resolving while `__init__`
|
||||
# never executes.
|
||||
for _root in (HERE, ONE_SHOT):
|
||||
_package = _root / _root.name
|
||||
_shim = types.ModuleType(_package.name)
|
||||
_shim.__path__ = [str(_package)]
|
||||
sys.modules[_package.name] = _shim
|
||||
|
||||
import grand_exchange.book as one_shot_book # noqa: E402
|
||||
import grand_exchange.market as one_shot # noqa: E402
|
||||
import grand_exchange_live.book as live_book # noqa: E402
|
||||
import grand_exchange_live.market as copy # noqa: E402
|
||||
|
||||
SEEDS = range(60_000, 60_048)
|
||||
SHAPES = ((5, 56, 30), (5, 56, 60), (3, 24, 12))
|
||||
|
||||
|
||||
class GeneratorCopyTests(unittest.TestCase):
|
||||
def test_every_stream_is_byte_identical(self) -> None:
|
||||
for num_items, visible, held_out in SHAPES:
|
||||
for seed in SEEDS:
|
||||
a = one_shot.build_market(seed, num_items, visible, held_out)
|
||||
b = copy.build_market(seed, num_items, visible, held_out)
|
||||
self.assertEqual(a.seed, b.seed)
|
||||
self.assertEqual(a.capital, b.capital)
|
||||
self.assertEqual(a.visible, b.visible)
|
||||
self.assertEqual(a.held_out, b.held_out)
|
||||
self.assertEqual([i.name for i in a.items], [i.name for i in b.items])
|
||||
for x, y in zip(a.items, b.items):
|
||||
self.assertEqual(x.reverting, y.reverting)
|
||||
self.assertEqual(x.buy_limit, y.buy_limit)
|
||||
self.assertEqual(x.prices, y.prices, f"prices diverge on {seed}/{x.name}")
|
||||
self.assertEqual(x.volumes, y.volumes, f"volumes diverge on {seed}/{x.name}")
|
||||
|
||||
def test_the_constants_did_not_drift(self) -> None:
|
||||
"""A copy that produces the same streams from a different tax rate is still a
|
||||
broken copy — the streams would match and every execution would not."""
|
||||
for name in ("TAX", "FILL_SHARE", "DUMP_BASE", "DUMP_IMPACT", "DUMP_CAP",
|
||||
"STARTING_CAPITAL", "WALKS", "SEED_BASE", "PHI", "FUND_DRIFT",
|
||||
"VOLUME_SD", "DRIFT_STEP", "TIERS"):
|
||||
self.assertEqual(getattr(one_shot, name), getattr(copy, name), name)
|
||||
for name in ("MAX_ORDERS", "BUY_BAND", "SELL_BAND", "TARGET_SHARE", "MIN_CROSSINGS",
|
||||
"MAX_ITEM_SHARE", "VIABILITY_TRIES"):
|
||||
self.assertEqual(getattr(one_shot_book, name), getattr(live_book, name), name)
|
||||
|
||||
def test_the_execution_engine_agrees_order_for_order(self) -> None:
|
||||
"""The one-shot engine is copied too, and `live.play` is checked against it. If the
|
||||
copy of `execute` drifted, that check would be comparing the stepped engine to the
|
||||
wrong fixed point and would still pass."""
|
||||
for seed in SEEDS:
|
||||
a = one_shot.build_market(seed, 5, 56, 60)
|
||||
b = copy.build_market(seed, 5, 56, 60)
|
||||
plan_a = one_shot_book.reference_orders(a)
|
||||
plan_b = live_book.reference_orders(b)
|
||||
self.assertEqual(
|
||||
[(o.item, o.quantity, o.buy, o.sell) for o in plan_a],
|
||||
[(o.item, o.quantity, o.buy, o.sell) for o in plan_b],
|
||||
)
|
||||
fa = one_shot_book.execute(a, plan_a)
|
||||
fb = live_book.execute(b, plan_b)
|
||||
self.assertEqual(fa.realised, fb.realised)
|
||||
self.assertEqual(fa.committed, fb.committed)
|
||||
self.assertEqual(fa.bought, fb.bought)
|
||||
self.assertEqual(fa.sold, fb.sold)
|
||||
self.assertEqual(fa.dumped, fb.dumped)
|
||||
|
||||
def test_the_freeze_is_drawn_without_touching_the_streams(self) -> None:
|
||||
"""The divergence is bounded to one integer, and that integer moves."""
|
||||
freezes = {copy.build_market(s, 5, 56, 60).freeze for s in SEEDS}
|
||||
self.assertEqual(freezes - set(range(copy.FREEZE_RANGE[0], copy.FREEZE_RANGE[1] + 1)),
|
||||
set())
|
||||
self.assertGreater(len(freezes), 1, "every basket freezes for the same number of "
|
||||
"ticks — the cost of a look is a free prior")
|
||||
# Deterministic in the seed alone: viability and scoring must see the same value,
|
||||
# which is the whole reason it is not drawn in `Taskset.load()`.
|
||||
self.assertEqual(copy.build_market(60_000, 5, 56, 60).freeze,
|
||||
copy.build_market(60_000, 5, 56, 60).freeze)
|
||||
self.assertEqual(one_shot.build_market(60_000, 5, 56, 60).items[0].prices,
|
||||
copy.build_market(60_000, 5, 56, 60).items[0].prices)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user