"""News arrivals and exogenous treatment shocks. * Public news: at random ticks a public item reveals x_t with noise (credible, low noise) or with a deliberate bias (misleading). Every agent can read public news; whether they act on it is policy. * Private signals: drawn per agent at activation time by `private_signal` (see assets.fundamentals) with an agent-specific precision; informed agents receive them at heterogeneous arrival times. * Shocks: scheduled treatment events — a jump in the fundamental mean (macro news), a change in the discount rate (monetary policy), a funding shock (leverage cap cut) or a liquidity shock (market makers withdraw). Shocks are logged so every treatment/control pair shares the same timing. """ from __future__ import annotations from dataclasses import dataclass, field import numpy as np @dataclass(slots=True) class NewsItem: time: int asset: int value: float # reported x (cents) possibly biased credible: bool noise_sd: float text: str = "" @dataclass(slots=True) class NewsConfig: rate: float = 0.02 # per-tick probability of a public news item (per asset) noise_sd: float = 1.0 # noise of credible news (cents) misleading_share: float = 0.0 # share of news items that are biased misleading_bias: float = 10.0 # bias (cents) applied with random sign @dataclass(slots=True) class Shock: time: int kind: str # 'fundamental_jump' | 'rate' | 'funding' | 'liquidity' | 'placebo' size: float = 0.0 asset: int | None = None duration: int = 0 @dataclass(slots=True) class ShockSchedule: shocks: list[Shock] = field(default_factory=list) def at(self, t: int) -> list[Shock]: return [s for s in self.shocks if s.time == t] class NewsFeed: def __init__(self, cfg: NewsConfig, rng: np.random.Generator, n_assets: int) -> None: self.cfg, self.rng, self.K = cfg, rng, n_assets self.items: list[NewsItem] = [] def step(self, t: int, x_true: np.ndarray) -> list[NewsItem]: out = [] for k in range(self.K): if self.rng.random() < self.cfg.rate: misleading = self.rng.random() < self.cfg.misleading_share noise = self.rng.standard_normal() * self.cfg.noise_sd bias = (self.cfg.misleading_bias * (1 if self.rng.random() < 0.5 else -1)) if misleading else 0.0 val = float(x_true[k] + noise + bias) direction = "above" if val > 0 else "below" item = NewsItem(time=t, asset=k, value=val, credible=not misleading, noise_sd=self.cfg.noise_sd, text=f"Analyst note: next dividend expected {abs(val):.1f} cents {direction} " f"its long-run mean.") out.append(item) self.items.extend(out) return out def recent(self, t: int, window: int, asset: int | None = None) -> list[NewsItem]: return [n for n in self.items if t - window <= n.time <= t and (asset is None or n.asset == asset)]