SPB Git

spb/wp27_uqo

Public
14.7 KB · 339 lines python
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
Files · main
Menu

Theme