1"""Classical (non-LLM) trading policies. Each is a small, documented heuristic with explicit2parameters; none sees the latent fundamental, only the Observation.34Price arithmetic is in ticks; quantities in lots. Every policy returns at most a handful of intents.5"""6from __future__ import annotations78from dataclasses import dataclass910import numpy as np1112from ..exchange.orders import Action, CancelIntent, OrderIntent, OrderType, Side13from .base import Observation, clip_qty141516def _limit_or_market(rng: np.random.Generator, p_market: float) -> OrderType:17 return OrderType.MARKET if rng.random() < p_market else OrderType.LIMIT181920def _cancel_all(obs: Observation, asset: int | None = None) -> list[CancelIntent]:21 return [CancelIntent(o.order_id) for o in obs.open_orders if asset is None or o.asset == asset]222324def _affordable(obs: Observation, price_ticks: float, tick_cents: int) -> int:25 return int(max(0, obs.free_cash // max(1, int(price_ticks * tick_cents * 1.001))))262728@dataclass29class Fundamentalist:30 """Value trader. Posterior value = public PV adjusted by the private signal (if informed):31 V_hat = public_value + w * (signal - x_hat_public) / (1+r-rho) with w = prec/(1+prec).32 Trades toward the value when the relative mispricing exceeds `threshold`; order size scales with33 the mispricing (capped) and with available cash/shares. The signal adjustment is relative to the34 public posterior mean x_hat (shown in the Observation), scaled by 1/(1+r-rho)."""35 name: str = "fundamentalist"36 threshold: float = 0.00537 aggressiveness: float = 20.0 # lots per 1% mispricing38 p_market: float = 0.339 max_qty: int = 5040 improve_ticks: int = 1 # limit orders are placed inside the spread by this many ticks41 cross_mult: float = 2.0 # beyond cross_mult*threshold the limit order is marketable (at the far quote)4243 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:44 act = Action()45 for a, book in obs.books.items():46 if book.halted:47 continue48 tc = obs.tick_cents[a]49 v_pub = obs.public_value[a] / tc # ticks50 v_hat = v_pub51 if a in obs.private_signals:52 prec = obs.signal_precision.get(a, 0.0)53 w = prec / (1.0 + prec)54 rho = 0.9855 # posterior mean of x_n combines the public prior (in v_pub) with the private signal, weight w56 v_hat = v_pub + w * (obs.private_signals[a] - obs.x_hat_public.get(a, 0.0)) / (1.0 + obs.r_period - rho) / tc57 mid = book.mid58 if mid <= 0:59 continue60 m = (v_hat - mid) / mid61 if abs(m) < self.threshold:62 continue63 q = clip_qty(abs(m) * 100.0 * self.aggressiveness, min(self.max_qty, obs.max_order_qty))64 if q == 0:65 continue66 act.cancels += _cancel_all(obs, a)67 otype = _limit_or_market(rng, self.p_market)68 if m > 0:69 q = min(q, _affordable(obs, book.best_ask or mid, tc))70 if q <= 0:71 continue72 if otype is OrderType.MARKET:73 price = None74 elif abs(m) > self.cross_mult * self.threshold and book.best_ask is not None:75 price = int(min(v_hat, book.best_ask))76 else:77 price = int(min(v_hat, (book.best_bid or mid) + self.improve_ticks))78 act.orders.append(OrderIntent(a, Side.BUY, otype, q, price, tag=self.name))79 else:80 held = obs.positions.get(a, 0)81 q = min(q, max(held, 0)) if not obs.short_allowed else q82 if q <= 0:83 continue84 if otype is OrderType.MARKET:85 price = None86 elif abs(m) > self.cross_mult * self.threshold and book.best_bid is not None:87 price = int(max(v_hat, book.best_bid))88 else:89 price = int(max(v_hat, (book.best_ask or mid) - self.improve_ticks))90 act.orders.append(OrderIntent(a, Side.SELL, otype, q, price, tag=self.name))91 return act929394@dataclass95class TrendFollower:96 """Momentum: signal = mean(mid, last 5) / mean(mid, last 30) - 1 (simple moving averages); buys when positive."""97 name: str = "trend"98 short_window: int = 599 long_window: int = 30100 threshold: float = 0.002101 size: int = 20102 p_market: float = 0.6103 max_qty: int = 50104105 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:106 act = Action()107 for a, book in obs.books.items():108 if book.halted:109 continue110 h = obs.mid_history[a]111 if len(h) < self.long_window:112 continue113 s = h[-self.short_window:].mean() / h[-self.long_window:].mean() - 1.0114 if abs(s) < self.threshold:115 continue116 tc = obs.tick_cents[a]117 q = clip_qty(self.size * min(abs(s) / self.threshold, 3.0), min(self.max_qty, obs.max_order_qty))118 act.cancels += _cancel_all(obs, a)119 otype = _limit_or_market(rng, self.p_market)120 if s > 0:121 q = min(q, _affordable(obs, book.best_ask or book.mid, tc))122 if q <= 0:123 continue124 price = None if otype is OrderType.MARKET else int(book.best_ask or book.mid)125 act.orders.append(OrderIntent(a, Side.BUY, otype, q, price, tag=self.name))126 else:127 held = obs.positions.get(a, 0)128 q = q if obs.short_allowed else min(q, max(held, 0))129 if q <= 0:130 continue131 price = None if otype is OrderType.MARKET else int(book.best_bid or book.mid)132 act.orders.append(OrderIntent(a, Side.SELL, otype, q, price, tag=self.name))133 return act134135136@dataclass137class MeanReverter:138 """Contrarian: z = (mid - MA_w)/sd_w; sells when z > thr, buys when z < -thr."""139 name: str = "meanrev"140 window: int = 40141 threshold: float = 1.5142 size: int = 15143 p_market: float = 0.2144 max_qty: int = 50145146 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:147 act = Action()148 for a, book in obs.books.items():149 if book.halted:150 continue151 h = obs.mid_history[a]152 if len(h) < self.window:153 continue154 w = h[-self.window:]155 sd = w.std()156 if sd <= 0:157 continue158 z = (book.mid - w.mean()) / sd159 if abs(z) < self.threshold:160 continue161 tc = obs.tick_cents[a]162 q = clip_qty(self.size * min(abs(z) / self.threshold, 3.0), min(self.max_qty, obs.max_order_qty))163 act.cancels += _cancel_all(obs, a)164 otype = _limit_or_market(rng, self.p_market)165 if z < 0:166 q = min(q, _affordable(obs, book.best_ask or book.mid, tc))167 if q <= 0:168 continue169 price = None if otype is OrderType.MARKET else int((book.best_bid or book.mid) + 1)170 act.orders.append(OrderIntent(a, Side.BUY, otype, q, price, tag=self.name))171 else:172 held = obs.positions.get(a, 0)173 q = q if obs.short_allowed else min(q, max(held, 0))174 if q <= 0:175 continue176 price = None if otype is OrderType.MARKET else int((book.best_ask or book.mid) - 1)177 act.orders.append(OrderIntent(a, Side.SELL, otype, q, price, tag=self.name))178 return act179180181@dataclass182class NoiseTrader:183 """Zero-intelligence-plus liquidity demander: random side, size ~ 1+Geometric, limit price at184 mid ± |N(0, spread_sd)| ticks on the passive side (or a market order with prob p_market)."""185 name: str = "noise"186 p_market: float = 0.3187 mean_size: float = 8.0188 price_sd_frac: float = 0.001 # sd of the limit-price offset as a fraction of mid (0.1%)189 max_qty: int = 40190191 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:192 act = Action()193 for a, book in obs.books.items():194 if book.halted:195 continue196 side = Side.BUY if rng.random() < 0.5 else Side.SELL197 q = clip_qty(1 + rng.geometric(1.0 / self.mean_size), min(self.max_qty, obs.max_order_qty))198 tc = obs.tick_cents[a]199 if side is Side.BUY:200 q = min(q, _affordable(obs, book.best_ask or book.mid, tc))201 elif not obs.short_allowed:202 q = min(q, max(obs.positions.get(a, 0), 0))203 if q <= 0:204 continue205 if len(obs.open_orders) > 3:206 act.cancels += _cancel_all(obs, a)207 otype = _limit_or_market(rng, self.p_market)208 if otype is OrderType.MARKET:209 act.orders.append(OrderIntent(a, side, otype, q, None, tag=self.name))210 else:211 off = abs(rng.standard_normal()) * self.price_sd_frac * book.mid212 ref = (book.best_bid if side is Side.BUY else book.best_ask) or book.mid213 price = int(round(ref - off)) if side is Side.BUY else int(round(ref + off))214 act.orders.append(OrderIntent(a, side, otype, q, max(price, 1), tag=self.name))215 return act216217218@dataclass219class MarketMaker:220 """Inventory-aware two-sided quoter (bounded Avellaneda–Stoikov-style rule).221222 Reservation price r = mid * (1 - skew * inv / max_inventory) (inventory skew, fraction of mid)223 Half-spread h = clip(base_half + vol_mult * sigma_ticks, min_half, max_half)224 with sigma_ticks the std of recent tick-to-tick mid changes. The original A–S formulas assume225 Poisson fills and a terminal date; here the skew and spread are bounded fractions of the price so226 that quotes always stay inside the exchange price band. Re-quotes every activation."""227 name: str = "market_maker"228 skew: float = 0.02 # 2% of mid at full inventory deviation229 base_half: float = 8.0 # ticks (8 cents on a 200.00 stock = 4 bp half-spread floor)230 vol_mult: float = 1.0231 quote_qty: int = 20232 max_inventory: int = 200 # inventory deviation (lots) at which the skew is maximal233 min_half_spread: int = 1234 max_half_spread: int = 60235 vol_window: int = 30236 withdrawn: bool = False # liquidity-shock treatment: stop quoting237 _inv0: dict | None = None # inventory target = endowment (set at first decision)238239 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:240 act = Action()241 act.cancels += _cancel_all(obs)242 if self.withdrawn:243 return act244 if self._inv0 is None:245 self._inv0 = dict(obs.positions)246 for a, book in obs.books.items():247 if book.halted:248 continue249 h = obs.mid_history[a]250 sigma = float(np.std(np.diff(h[-self.vol_window:]))) if len(h) > 5 else 2.0251 inv = obs.positions.get(a, 0) - self._inv0.get(a, 0)252 res = book.mid * (1.0 - self.skew * inv / self.max_inventory)253 half = float(np.clip(self.base_half + self.vol_mult * sigma, self.min_half_spread, self.max_half_spread))254 bid = int(np.floor(res - half))255 ask = int(np.ceil(res + half))256 if ask - bid < 2 * self.min_half_spread:257 ask = bid + 2 * self.min_half_spread258 tc = obs.tick_cents[a]259 qb = self.quote_qty if inv < self.max_inventory else 0260 qa = self.quote_qty if inv > -self.max_inventory else 0261 qb = min(qb, _affordable(obs, bid, tc))262 if not obs.short_allowed:263 qa = min(qa, max(obs.positions.get(a, 0), 0))264 if qb > 0:265 act.orders.append(OrderIntent(a, Side.BUY, OrderType.LIMIT, qb, max(bid, 1), tag=self.name))266 if qa > 0:267 act.orders.append(OrderIntent(a, Side.SELL, OrderType.LIMIT, qa, ask, tag=self.name))268 return act269270271@dataclass272class StatArb:273 """Cross-asset arbitrageur: trades the residual of a rolling regression of asset j's mid on asset274 i's mid (pairs trading on the two first assets). Single-asset runs: inactive."""275 name: str = "statarb"276 window: int = 60277 threshold: float = 1.5278 size: int = 10279 max_qty: int = 40280281 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:282 act = Action()283 assets = sorted(obs.books)284 if len(assets) < 2:285 return act286 i, j = assets[0], assets[1]287 hi, hj = obs.mid_history[i], obs.mid_history[j]288 n = min(len(hi), len(hj))289 if n < self.window:290 return act291 x, y = np.log(hi[-self.window:]), np.log(hj[-self.window:])292 beta = np.cov(x, y)[0, 1] / max(np.var(x), 1e-12)293 resid = y - beta * x294 z = (resid[-1] - resid.mean()) / max(resid.std(), 1e-9)295 if abs(z) < self.threshold:296 return act297 act.cancels += _cancel_all(obs)298 q = clip_qty(self.size, min(self.max_qty, obs.max_order_qty))299 bj, bi = obs.books[j], obs.books[i]300 if bj.halted or bi.halted:301 return act302 # z>0: j rich relative to i -> sell j, buy i303 sell_a, buy_a = (j, i) if z > 0 else (i, j)304 held = obs.positions.get(sell_a, 0)305 qs = q if obs.short_allowed else min(q, max(held, 0))306 qb = min(q, _affordable(obs, obs.books[buy_a].best_ask or obs.books[buy_a].mid, obs.tick_cents[buy_a]))307 if qs > 0:308 act.orders.append(OrderIntent(sell_a, Side.SELL, OrderType.LIMIT, qs, int(obs.books[sell_a].best_ask or obs.books[sell_a].mid) - 1, tag=self.name))309 if qb > 0:310 act.orders.append(OrderIntent(buy_a, Side.BUY, OrderType.LIMIT, qb, int(obs.books[buy_a].best_bid or obs.books[buy_a].mid) + 1, tag=self.name))311 return act312313314@dataclass315class Leveraged:316 """A trend follower that may borrow (leverage_cap > 1 on the account). The policy is identical to317 `TrendFollower`; leverage, margin calls and forced liquidation are enforced by the exchange, so318 this class exists to make the treatment explicit in configs."""319 name: str = "leveraged"320 inner: TrendFollower | None = None321322 def __post_init__(self) -> None:323 if self.inner is None:324 self.inner = TrendFollower(name="leveraged", size=30, p_market=0.7)325326 def decide(self, obs: Observation, rng: np.random.Generator) -> Action:327 return self.inner.decide(obs, rng) # type: ignore[union-attr]328329330CLASSICAL_POLICIES = {331 "fundamentalist": Fundamentalist,332 "trend": TrendFollower,333 "meanrev": MeanReverter,334 "noise": NoiseTrader,335 "market_maker": MarketMaker,336 "statarb": StatArb,337 "leveraged": Leveraged,338}339