SPB Git

spb/wp25_uqo

Public
15.4 KB · 264 lines python
1"""Model C period loop (temporary trading equilibrium per period; bounded-rationality simulated outcome).23Timing per period t (docs/model_specification.md §3):4 1. dividend d_t realised:  d_t = dbar + rho (d_{t-1} - dbar) + sigma_d u_t ;  dividends and interest paid5 2. mark-to-market at the last price: margin calls, forced sales, defaults, bail-in6 3. signals about u_{t+1}: private (theta + e_i) or model-m (theta + eta_m); Bayesian expected dividend7 4. agents propose actions; deterministic clipping (cash, holdings, margin, bank capital)8 5. trading under the chosen design; settlement through the ledger (infeasible matches are dropped and counted)9 6. metrics: price, return, order-flow correlation, spread/depth (CDA), price dispersion (bilateral)10No look-ahead: an agent's observation at t contains only t-dated information and its own signal about t+1.11"""12from __future__ import annotations1314from dataclasses import dataclass, field1516import numpy as np1718from age.agents.policies import BayesianMV, LLMAgent, Momentum, Noise, clip_action19from age.banking.ledger import Ledger20from age.markets.designs import bilateral_bargaining, continuous_double_auction, walrasian_call212223@dataclass24class MarketConfig:25    n_agents: int = 2026    T: int = 6027    burn_in: int = 1028    design: str = 'walrasian'          # walrasian | cda | bilateral29    dbar: float = 0.0530    rho_d: float = 0.531    sigma_d: float = 0.0132    rf: float = 0.0133    sigma2: float = 0.01               # agents' perceived variance of next-period payoff p'+d' (goods^2)34    S: float | None = None             # asset supply (default: one share per agent)35    cash0: float = 1.036    bank_equity0: float = 5.037    margin: float = 0.5                # Reg-T style haircut38    fee_rate: float = 0.000539    bank_capital_ratio: float = 0.040    liquidation_impact: float = 0.541    # information42    delegation_share: float = 0.0      # fraction of agents acting on a model signal43    M: int = 1                         # number of distinct foundation models among delegators44    sig_H: float = 0.02                # private noise s.d. (dividend units)45    sig_A: float = 0.005               # model noise s.d.46    # agents47    gamma: float = 2.048    gamma_dispersion: float = 0.5      # agents' gamma_i = gamma * (1 + dispersion * z_i), z_i in [-1,1] (gains from trade)49    policy: str = 'bayesian'           # bayesian | momentum_mix | llm | llm_mix | surrogate50    max_leverage: float = 0.0          # Bayesian agents' borrowing as a multiple of equity (0 = unlevered)51    llm_clients: dict = field(default_factory=dict)   # name -> LLMClient (shared across agents)52    llm_assignment: list = field(default_factory=list) # per-agent client name (for llm policies)53    llm_variants: list = field(default_factory=list)   # per-agent prompt variant id54    llm_temperature: float = 0.055    llm_memory: int = 056    # market maker (liquidity provider): ladder of limit orders on both sides of its reference price57    mm_on: bool = True58    mm_depth: float = 0.5              # shares per level59    mm_tick: float = 0.0005            # relative price step between levels60    mm_levels: int = 1061    mm_cash: float = 50.062    mm_inventory_max: float = 10.0     # beyond this the ladder on the exhausted side is withdrawn (depth under stress)63    mm_inv_sensitivity: float = 0.01   # reference price moves by this fraction per unit of inventory deviation64    shock_period: int | None = None    # period of an out-of-distribution common forecast shock65    shock_size: float = 3.0            # in units of sig_A (common model error)66    shock_persist: int = 1             # number of consecutive periods the common model error persists67    preload_leverage: float = 0.0      # fraction of margin capacity borrowed at t=0 (stress tests: agents start levered)68    auto_repay: bool = True            # repay loans with idle cash before acting (False in stress tests with preloaded leverage)69    seed: int = 1707172def run_market(cfg: MarketConfig) -> dict:73    rng = np.random.default_rng(cfg.seed)74    n = cfg.n_agents75    if cfg.S is None:76        cfg.S = float(n)77    holdings0 = np.full(n, cfg.S / n)78    # steady-state price with a risk premium consistent with supply: p = (dbar + p - gamma_a sigma2 hbar)/(1+rf),79    # gamma_a = gamma/(cash0 + p hbar)  ->  rf hbar p^2 + (rf cash0 - dbar hbar) p - (dbar cash0 - gamma sigma2 hbar) = 080    hbar = cfg.S / n81    a_, b_, c_ = cfg.rf * hbar, cfg.rf * cfg.cash0 - cfg.dbar * hbar, -(cfg.dbar * cfg.cash0 - cfg.gamma * cfg.sigma2 * hbar)82    disc = b_ ** 2 - 4 * a_ * c_83    assert disc > 0 and cfg.dbar * cfg.cash0 > cfg.gamma * cfg.sigma2 * hbar, 'risk premium too large: lower sigma2/gamma'84    p0 = (-b_ + np.sqrt(disc)) / (2 * a_)85    n_led = n + 1 if cfg.mm_on else n86    cash_init = np.full(n_led, cfg.cash0)87    hold_init = np.full(n_led, cfg.S / n)88    if cfg.mm_on:89        cash_init[n] = cfg.mm_cash90        hold_init[n] = cfg.mm_inventory_max / 291    led = Ledger(n_led, cash_init, hold_init, cfg.bank_equity0, cfg.margin, cfg.fee_rate, p0, cfg.rf,92                 cfg.bank_capital_ratio)93    mm_target = cfg.mm_inventory_max / 294    mm_ref = p095    if cfg.preload_leverage > 0:96        for i in range(n):97            led.borrow(i, cfg.preload_leverage * (1 - cfg.margin) * p0 * hold_init[i])98    # information structure: delegators first99    nD = int(round(cfg.delegation_share * n))100    model_of = np.full(n, -1)101    model_of[:nD] = np.arange(nD) % cfg.M102    tau_t = 1.0 / cfg.sigma_d ** 2103    tau_H, tau_A = cfg.sig_H ** -2, cfg.sig_A ** -2104    bH, bA = tau_H / (tau_t + tau_H), tau_A / (tau_t + tau_A)105    # agents106    sigma2 = cfg.sigma2107    W0 = cfg.cash0 + holdings0[0] * p0108    policies = []109    gammas = cfg.gamma * (1 + cfg.gamma_dispersion * np.linspace(-1, 1, n))110    for i in range(n):111        if cfg.policy == 'bayesian':112            policies.append(BayesianMV(gammas[i] / W0, sigma2, p0, aggressiveness=0.8, max_leverage=cfg.max_leverage))113        elif cfg.policy == 'momentum_mix':114            policies.append(BayesianMV(gammas[i] / W0, sigma2, p0, aggressiveness=0.8, max_leverage=cfg.max_leverage) if i % 2 == 0115                            else (Momentum() if i % 4 == 1 else Noise(rng)))116        elif cfg.policy in ('llm', 'llm_mix', 'surrogate'):117            name = cfg.llm_assignment[i] if cfg.llm_assignment else list(cfg.llm_clients)[0]118            var = cfg.llm_variants[i] if cfg.llm_variants else 0119            policies.append(LLMAgent(cfg.llm_clients[name], 'investor', var, cfg.llm_temperature, cfg.seed * 1000 + i,120                                     cfg.llm_memory))121        else:122            raise ValueError(cfg.policy)123    # shocks (common random numbers: drawn up-front from the seed)124    u = rng.standard_normal(cfg.T + 1)125    e = rng.standard_normal((cfg.T + 1, n)) * cfg.sig_H126    eta = rng.standard_normal((cfg.T + 1, max(cfg.M, 1))) * cfg.sig_A127    if cfg.shock_period is not None:128        for k in range(cfg.shock_persist):                                 # signal received from period shock_period on129            if cfg.shock_period + 1 + k <= cfg.T:130                eta[cfg.shock_period + 1 + k, :] = cfg.shock_size * cfg.sig_A131    d = cfg.dbar132    prices, dividends, flows, returns = [p0], [], [], []133    rec = dict(spread=[], depth=[], dispersion=[], defaults=[], forced=[], losses=[], leverage=[], n_trades=[],134               invalid=[], clipped=[], excess=[], expected_div=[], signals=[])135    order_flow = np.zeros((cfg.T, n))136    for t in range(cfg.T):137        d = cfg.dbar + cfg.rho_d * (d - cfg.dbar) + cfg.sigma_d * u[t]138        led.pay_dividend(d)139        led.accrue_interest()140        mm = led.mark_to_market(led.price, cfg.liquidation_impact)141        theta = cfg.sigma_d * u[t + 1]                        # next innovation (unknown to agents)142        # signals and Bayesian expected dividend for next period143        exp_div = np.empty(n)144        for i in range(n):145            if model_of[i] >= 0:146                s = theta + eta[t + 1, model_of[i]]147                exp_div[i] = cfg.dbar + cfg.rho_d * (d - cfg.dbar) + bA * s148            else:149                s = theta + e[t + 1, i]150                exp_div[i] = cfg.dbar + cfg.rho_d * (d - cfg.dbar) + bH * s151        rec['expected_div'].append(exp_div.copy())152        orders = []153        n_invalid = n_clipped = 0154        for i in rng.permutation(n):155            if not led.alive[i]:156                continue157            if cfg.auto_repay and led.loan[i] > 0 and led.cash[i] > 0:   # repay loans with idle cash before acting158                led.repay(i, min(led.loan[i], led.cash[i]))159            eq = led.equity()[i]160            obs = dict(period=t, price=led.price, dividend=d, rf=cfg.rf, expected_dividend=float(exp_div[i]),161                       cash=float(led.cash[i]), holdings=float(led.holdings[i]), loan=float(led.loan[i]),162                       equity=float(eq), margin=cfg.margin, loan_capacity=float(led.loan_capacity(i)),163                       history=prices[-6:], rho_d=cfg.rho_d, dbar=cfg.dbar,164                       own_valuation=float((exp_div[i] + p0 + cfg.rho_d * (exp_div[i] - cfg.dbar) / (1 + cfg.rf - cfg.rho_d)) / (1 + cfg.rf)))165            pol = policies[i]166            if isinstance(pol, LLMAgent):167                # the surrogate/LLM also receives a target so that the surrogate can be a faithful control168                obs['target_holdings'] = float(max((obs['own_valuation'] - led.price) * (1 + cfg.rf) / (cfg.gamma / W0 * sigma2), 0))169                act = pol.act(obs, tags=dict(period=t, agent=int(i), run=cfg.seed))170                n_invalid += int(act.get('invalid', False))171            else:172                act = pol.act(obs)173            act = clip_action(act, obs, led, int(i))174            n_clipped += int(act['clipped'])175            if act['side'] in ('buy', 'sell') and act['quantity'] > 1e-9:176                orders.append(dict(agent=int(i), side=act['side'], qty=act['quantity'],177                                   limit=act['limit_price'] if act['order_type'] == 'limit' else None, type=act['order_type']))178                order_flow[t, i] = act['quantity'] * (1 if act['side'] == 'buy' else -1)179        if cfg.mm_on:180            inv = led.holdings[n]181            mm_ref = mm_ref * (1 - cfg.mm_inv_sensitivity * (inv - mm_target) / max(mm_target, 1e-9)) \182                if False else mm_ref                      # reference anchored; inventory skew applied to quotes below183            skew = cfg.mm_inv_sensitivity * (inv - mm_target) / max(mm_target, 1e-9)184            base = led.price * (1 - skew)185            for k in range(1, cfg.mm_levels + 1):186                ask = base * (1 + k * cfg.mm_tick)187                bid = base * (1 - k * cfg.mm_tick)188                if inv - (k - 1) * cfg.mm_depth > 0:                   # can sell only what it holds189                    orders.append(dict(agent=n, side='sell', qty=min(cfg.mm_depth, max(inv - (k - 1) * cfg.mm_depth, 0)), limit=ask, type='limit'))190                if inv + k * cfg.mm_depth <= cfg.mm_inventory_max and led.cash[n] > bid * cfg.mm_depth * k:191                    orders.append(dict(agent=n, side='buy', qty=cfg.mm_depth, limit=bid, type='limit'))192        if cfg.design == 'walrasian':193            trades, p_new, info = walrasian_call(orders, led.price)194        elif cfg.design == 'cda':195            trades, p_new, info = continuous_double_auction(orders, led.price, rng)196        else:197            trades, p_new, info = bilateral_bargaining(orders, led.price, rng)198        n_ok = 0199        for tr in trades:200            n_ok += int(led.settle_trade(tr['buyer'], tr['seller'], tr['qty'], tr['price']))201        if trades:202            led.price = float(np.average([tr['price'] for tr in trades], weights=[tr['qty'] for tr in trades]))203        prices.append(led.price)204        dividends.append(d)205        returns.append(np.log(prices[-1] / prices[-2]))206        rec['spread'].append(info.get('spread', np.nan)); rec['depth'].append(info.get('depth', np.nan))207        rec['dispersion'].append(info.get('dispersion', np.nan)); rec['excess'].append(info.get('excess', np.nan))208        rec['defaults'].append(mm['defaults']); rec['forced'].append(mm['forced']); rec['losses'].append(mm['losses'])209        rec['leverage'].append(led.snapshot()['leverage']); rec['n_trades'].append(n_ok)210        rec['invalid'].append(n_invalid); rec['clipped'].append(n_clipped)211    # ------------------------------------------------------------------ metrics (post burn-in)212    b = cfg.burn_in213    r = np.array(returns[b:])214    of = order_flow[b:]215    # average pairwise correlation of order flow across agents, *conditional on public information*:216    # each agent's flow is residualised on [1, d_t, p_t] (dividend and price are public), then correlated.217    pub = np.column_stack([np.ones(cfg.T - b), np.array(dividends[b:]), np.array(prices[b:-1])])218    beta_pub, *_ = np.linalg.lstsq(pub, of, rcond=None)219    res_of = of - pub @ beta_pub220    with np.errstate(invalid='ignore', divide='ignore'):221        C = np.corrcoef(res_of.T)222    C = np.nan_to_num(C)223    iu = np.triu_indices(n, 1)224    of_corr = float(C[iu].mean()) if n > 1 else np.nan225    # same correlation within delegators only and within humans only226    def _grp(mask):227        idx = np.where(mask)[0]228        if len(idx) < 2:229            return np.nan230        sub = C[np.ix_(idx, idx)]231        return float(sub[np.triu_indices(len(idx), 1)].mean())232    of_corr_deleg = _grp(model_of >= 0)233    of_corr_human = _grp(model_of < 0)234    # Kyle lambda: regress returns on net order flow235    net = of.sum(axis=1)236    lam = float(np.polyfit(net, r, 1)[0]) if np.std(net) > 1e-12 and len(r) > 3 else np.nan237    # price pressure: regression of returns on the average model error of delegators (common component)238    if nD > 0:239        common = np.array([np.mean([eta[t + 1, model_of[i]] for i in range(nD)]) for t in range(cfg.T)])[b:]240        pressure = float(np.polyfit(common, r, 1)[0]) if np.std(common) > 1e-12 else np.nan241        with np.errstate(invalid='ignore'):242            r2_common = float(np.corrcoef(common, r)[0, 1] ** 2) if np.std(common) > 1e-12 else np.nan243    else:244        pressure, r2_common = np.nan, 0.0245    out = dict(pressure=pressure, r2_common=r2_common, of_corr_deleg=of_corr_deleg, of_corr_human=of_corr_human, mm_inventory=float(led.holdings[n]) if cfg.mm_on else np.nan,prices=np.array(prices), dividends=np.array(dividends), returns=np.array(returns), order_flow=order_flow,246               vol=float(np.std(r)), of_corr=of_corr, kyle_lambda=lam,247               spread=float(np.nanmean(rec['spread'][b:])) if cfg.design == 'cda' else np.nan,248               depth=float(np.nanmean(rec['depth'][b:])) if cfg.design == 'cda' else np.nan,249               dispersion=float(np.nanmean(rec['dispersion'][b:])) if cfg.design == 'bilateral' else np.nan,250               defaults=int(np.sum(rec['defaults'])), forced=float(np.sum(rec['forced'])), losses=float(np.sum(rec['losses'])),251               leverage=float(np.mean(rec['leverage'][b:])), n_trades=float(np.mean(rec['n_trades'][b:])),252               invalid=int(np.sum(rec['invalid'])), clipped=int(np.sum(rec['clipped'])),253               final=led.snapshot(), eta=eta, u=u, model_of=model_of, cfg=cfg)254    if cfg.shock_period is not None and cfg.shock_period < cfg.T:255        sp = cfg.shock_period256        w = cfg.shock_persist + 2257        out['shock_return'] = float(np.sum(returns[sp:sp + w]))258        out['shock_forced'] = float(np.sum(rec['forced'][sp:sp + w]))259        out['shock_defaults'] = int(np.sum(rec['defaults'][sp:sp + w]))260        out['shock_losses'] = float(np.sum(rec['losses'][sp:sp + w]))261        out['shock_min_return'] = float(np.min(returns[sp:sp + w]))262        out['shock_trough'] = float(np.min(prices[sp:sp + w + 1]) / prices[sp] - 1)263    return out264
Files · main
Menu

Theme