grand-exchange, bot-detection and drop-table-inference. All three produce unbounded or irreducible-error scores, unlike the first four, so all three normalise against a REFERENCE STRATEGY rather than an absolute -- which is what makes 1.000 reachable rather than aspirational. drop-table-inference is the clearest case. Scoring KL against the true drop table would put the ceiling out of reach, because sampling error is irreducible: ground truth scores 0.900 against the Bayesian posterior's 1.000, and that ordering is correct. The best estimate available from 1,200 kills is not the true table, and an environment that demands it is measuring luck. grand-exchange needed the market to carry structure a model can actually infer, or profit is noise and no oracle exists. Measured over 120 baskets: the reference earns 38,821 gp and is profitable in 120/120, random orders lose 561, and trading only the random-walk items -- which look like the widest-swinging lines on the board -- loses 16,860. bot-detection generates naive bots, cloaked bots that jitter on purpose, and efficient humans who look bot-like on every naive statistic. Timing features are drawn BEFORE the generator decides who is a bot, so every latency rule sits at chance. Reference discriminator F1 1.000, random 0.430. Each was built by one agent then attacked by two independent reviewers on exploitability and soundness. They earned their keep: drop-table-inference's first reward let a memorised constant score 0.901 without opening the kill log, and its reference was 19% worse than a flat number. probe.py now carries three named fences asserting those attacks stay dead. First evaluation also lands. Nemotron 3.5 Lightning, thinking off, 32 rollouts per environment: 0.570 down to 0.059, every gate at or near zero, nothing solved and nothing unsolvable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
12 KiB
Python
245 lines
12 KiB
Python
"""The market generator: price and volume streams with structure a reader can find.
|
|
|
|
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."""
|
|
|
|
# --- 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
|
|
|
|
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)
|
|
return Market(seed=seed, items=items, visible=visible, held_out=held_out,
|
|
capital=STARTING_CAPITAL)
|