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