1"""Agent policies for Model C. A policy maps an observation to a *proposed* action; the simulation validates and clips2it against the ledger (cash, holdings, margin). Policies never touch the ledger.34Observation (dict): price, dividend, rf, signal (forecast of next dividend innovation, already Bayes-weighted into5`expected_dividend`), own cash/holdings/loan/equity, margin, history (last prices), target (for Bayesian agents),6own_valuation, period.78Action (dict): side ('buy'|'sell'|'hold'), quantity (>=0), order_type ('limit'|'market'), limit_price, borrow (>=0),9rationale (string; ignored by accounting).10"""11from __future__ import annotations1213import json1415import numpy as np1617ACTION_SCHEMA = {18 "type": "object",19 "properties": {20 "side": {"type": "string", "enum": ["buy", "sell", "hold"]},21 "quantity": {"type": "number", "minimum": 0},22 "order_type": {"type": "string", "enum": ["limit", "market"]},23 "limit_price": {"type": "number", "minimum": 0},24 "borrow": {"type": "number", "minimum": 0},25 "rationale": {"type": "string"},26 },27 "required": ["side", "quantity", "order_type"],28 "additionalProperties": True,29}303132# --------------------------------------------------------------------------- Bayesian mean-variance (control)33class BayesianMV:34 """h* = (E[p' + d'] - (1 + rf) p) / (gamma_a sigma^2); limit order halfway between price and valuation."""3536 def __init__(self, gamma_a: float, sigma2: float, p_anchor: float, aggressiveness: float = 0.5,37 max_leverage: float = 0.0):38 self.gamma_a, self.sigma2, self.p_anchor = gamma_a, sigma2, p_anchor39 self.aggr = aggressiveness40 self.max_leverage = max_leverage4142 def valuation(self, obs):43 # next payoff = expected dividend + resale value; the resale value prices the persistence of the expected44 # dividend deviation: p_anchor + rho_d (E[d'] - dbar)/(1 + rf - rho_d)45 rho, dbar = obs.get('rho_d', 0.0), obs.get('dbar', obs['expected_dividend'])46 resale = self.p_anchor + rho * (obs['expected_dividend'] - dbar) / (1 + obs['rf'] - rho)47 return (obs['expected_dividend'] + resale) / (1 + obs['rf'])4849 def act(self, obs):50 p = obs['price']51 v = self.valuation(obs)52 h_star = (v * (1 + obs['rf']) - (1 + obs['rf']) * p) / (self.gamma_a * self.sigma2)53 h_star = max(h_star, 0.0)54 q = h_star - obs['holdings']55 side = 'buy' if q > 1e-9 else ('sell' if q < -1e-9 else 'hold')56 limit = p + self.aggr * (v - p) if side == 'buy' else p - self.aggr * (p - v)57 limit = max(limit, 1e-6)58 borrow = 0.059 if side == 'buy' and self.max_leverage > 0:60 need = abs(q) * limit - obs['cash']61 borrow = max(min(need, self.max_leverage * obs['equity']), 0.0)62 return dict(side=side, quantity=abs(q), order_type='limit', limit_price=limit, borrow=borrow,63 rationale='bayesian mean-variance')646566# --------------------------------------------------------------------------- rule-based67class Momentum:68 def __init__(self, lookback=3, size=0.1):69 self.lb, self.size = lookback, size7071 def act(self, obs):72 hist = obs['history']73 if len(hist) <= self.lb:74 return dict(side='hold', quantity=0.0, order_type='limit', limit_price=obs['price'], borrow=0.0, rationale='warm-up')75 ret = hist[-1] / hist[-1 - self.lb] - 176 side = 'buy' if ret > 0 else 'sell'77 q = self.size * (obs['holdings'] if side == 'sell' else max(obs['cash'] / obs['price'], 0))78 return dict(side=side, quantity=q, order_type='market', limit_price=obs['price'], borrow=0.0, rationale='momentum')798081class Noise:82 def __init__(self, rng, size=0.05):83 self.rng, self.size = rng, size8485 def act(self, obs):86 side = 'buy' if self.rng.random() < 0.5 else 'sell'87 q = self.size * (obs['holdings'] if side == 'sell' else max(obs['cash'] / obs['price'], 0))88 lim = obs['price'] * (1 + 0.02 * self.rng.standard_normal())89 return dict(side=side, quantity=q, order_type='limit', limit_price=max(lim, 1e-6), borrow=0.0, rationale='noise')909192# --------------------------------------------------------------------------- LLM agent93SYSTEM_PROMPTS = {94 'investor': ("You are an AI portfolio agent acting on behalf of a human principal in a simulated market with one risky "95 "asset (pays a stochastic dividend each period) and a safe deposit paying rate rf. Your mandate: maximise the "96 "principal's expected wealth next period while keeping risk moderate; you may borrow against holdings only "97 "within the margin limit. You must reply with ONLY a JSON object with keys side (buy|sell|hold), quantity "98 "(number of shares, >= 0), order_type (limit|market), limit_price (number), borrow (number >= 0, cash to "99 "borrow), rationale (short string). Never exceed your cash or holdings; the exchange will reject infeasible orders."),100 'market_maker': ("You are a market-maker agent. Quote a limit order on the side where your inventory deviates most from "101 "its target, with a price inside the recent range. Reply with ONLY the JSON object described."),102 'lender': ("You are a bank credit officer. Given the borrower's balance sheet, decide the fraction of the requested loan "103 "to grant (0..1). Reply ONLY with JSON {\"grant_fraction\": number, \"rationale\": string}."),104}105106PROMPT_VARIANTS = [107 "", " Be concise.", " Think like a careful long-term investor.", " Trade actively when the signal is strong.",108 " Prefer limit orders near the current price.",109]110111112class LLMAgent:113 def __init__(self, client, role: str = 'investor', variant: int = 0, temperature: float = 0.0, seed: int = 0,114 memory: int = 0):115 self.client, self.role, self.variant, self.T, self.seed, self.memory = client, role, variant, temperature, seed, memory116 self.history = []117118 def observation_text(self, obs):119 o = {k: (round(v, 4) if isinstance(v, float) else v) for k, v in obs.items() if k != 'target'}120 o['history'] = [round(x, 4) for x in obs['history'][-5:]]121 return json.dumps(o)122123 def act(self, obs, tags=None):124 sys_prompt = SYSTEM_PROMPTS[self.role] + PROMPT_VARIANTS[self.variant % len(PROMPT_VARIANTS)]125 msgs = [{'role': 'system', 'content': sys_prompt}]126 if self.memory and self.history:127 msgs.append({'role': 'user', 'content': 'Your previous actions: ' + json.dumps(self.history[-self.memory:])})128 msgs.append({'role': 'user', 'content': 'Observation: ' + self.observation_text(obs) + '\nReply with the JSON action.'})129 res = self.client.complete_json(msgs, ACTION_SCHEMA, temperature=self.T, seed=self.seed, tags=tags)130 if res.parsed is None:131 act = dict(side='hold', quantity=0.0, order_type='limit', limit_price=obs['price'], borrow=0.0,132 rationale='INVALID-LLM-OUTPUT -> no action', invalid=True)133 else:134 act = dict(res.parsed)135 act.setdefault('limit_price', obs['price'])136 act.setdefault('borrow', 0.0)137 act['invalid'] = False138 if self.memory:139 self.history.append({k: act[k] for k in ('side', 'quantity', 'limit_price')})140 return act141142143def clip_action(act: dict, obs: dict, ledger, i: int) -> dict:144 """Deterministic enforcement: borrow within capacity, quantities within cash/holdings, prices positive."""145 a = dict(act)146 a['side'] = a.get('side', 'hold')147 a['quantity'] = float(max(a.get('quantity', 0.0) or 0.0, 0.0))148 a['order_type'] = a.get('order_type', 'limit') if a.get('order_type') in ('limit', 'market') else 'limit'149 lp = a.get('limit_price', obs['price'])150 a['limit_price'] = float(lp) if (lp is not None and lp > 0) else obs['price']151 a['borrow'] = float(max(a.get('borrow', 0.0) or 0.0, 0.0))152 if a['borrow'] > 0:153 a['borrow'] = ledger.borrow(i, a['borrow']) # actually granted (margin + bank capital)154 if a['side'] == 'buy':155 px = a['limit_price'] if a['order_type'] == 'limit' else obs['price'] * 1.05156 a['quantity'] = min(a['quantity'], ledger.max_affordable(i, px))157 elif a['side'] == 'sell':158 a['quantity'] = min(a['quantity'], ledger.holdings[i])159 else:160 a['quantity'] = 0.0161 a['clipped'] = (a['quantity'] < float(act.get('quantity', 0.0) or 0.0) - 1e-9)162 return a163