""" Arrival models for generating demand opportunities. This module provides different arrival processes: - PoissonArrivalModel: Constant-rate memoryless arrivals - HawkesArrivalModel: Self-exciting clustered arrivals (market orders) - SessionArrivalModel: Retail browsing sessions with multi-product views Each model implements the ArrivalModel protocol and generates Opportunity objects that flow through the execution pipeline. """ from __future__ import annotations from dataclasses import dataclass from typing import Callable import numpy as np from uuid import uuid4 from ..outlet.types import Opportunity, InstrumentSet, MarketState, HiddenState from ..outlet.constants import Side, OpportunityType from ..outlet.math_util import poisson_arrivals, hawkes_intensity @dataclass class PoissonArrivalConfig: """Configuration for Poisson arrival process. Attributes: base_rate: Expected arrivals per unit time (scaled by hidden.true_demand_intensity) side_probs: Probability distribution over BUY/SELL sides """ base_rate: float = 10.0 side_probs: dict[Side, float] = None def __post_init__(self): if self.side_probs is None: self.side_probs = {Side.BUY: 1.0} class PoissonArrivalModel: """Homogeneous Poisson arrival process. Generates arrivals at a constant rate (modulated by demand intensity). Suitable for stationary demand or as a baseline model. The actual arrival count follows Poisson(rate * dt * intensity). """ def __init__(self, cfg: PoissonArrivalConfig | None = None): self.cfg = cfg or PoissonArrivalConfig() def sample(self, t: float, dt: float, instruments: InstrumentSet, market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]: n_arrivals = poisson_arrivals(self.cfg.base_rate * hidden.true_demand_intensity, dt, rng) opps = [] for _ in range(n_arrivals): inst_id = rng.integers(0, instruments.n) side = rng.choice(list(self.cfg.side_probs.keys()), p=list(self.cfg.side_probs.values())) opps.append(Opportunity( id=str(uuid4())[:8], type=OpportunityType.SESSION, side=side, instrument_id=inst_id, size=1.0, t=t, context={'segment': 'default'} )) return opps @dataclass class HawkesArrivalConfig: """Configuration for Hawkes self-exciting process. Attributes: base_rate: Baseline arrival intensity alpha: Excitation strength (how much each arrival increases intensity) beta: Decay rate (how quickly excitation fades) side_probs: Probability distribution over BUY/SELL sides """ base_rate: float = 5.0 alpha: float = 0.5 beta: float = 1.0 side_probs: dict[Side, float] = None def __post_init__(self): if self.side_probs is None: self.side_probs = {Side.BUY: 0.5, Side.SELL: 0.5} class HawkesArrivalModel: """Self-exciting Hawkes point process for clustered arrivals. Models order flow where arrivals cluster in time (momentum, herding). Intensity: lambda(t) = base + alpha * sum(exp(-beta * (t - t_i))) Used for market making scenarios where orders arrive in bursts. """ def __init__(self, cfg: HawkesArrivalConfig | None = None): self.cfg = cfg or HawkesArrivalConfig() self._history: np.ndarray = np.array([]) def sample(self, t: float, dt: float, instruments: InstrumentSet, market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]: intensity = hawkes_intensity( self.cfg.base_rate * hidden.true_demand_intensity, self._history, self.cfg.alpha, self.cfg.beta, t ) n_arrivals = poisson_arrivals(intensity, dt, rng) opps = [] for i in range(n_arrivals): arr_t = t + rng.uniform(0, dt) self._history = np.append(self._history, arr_t) inst_id = rng.integers(0, instruments.n) side = rng.choice(list(self.cfg.side_probs.keys()), p=list(self.cfg.side_probs.values())) opps.append(Opportunity( id=str(uuid4())[:8], type=OpportunityType.MARKET_ORDER, side=side, instrument_id=inst_id, size=rng.exponential(1.0), t=arr_t, context={'intensity': intensity} )) # decay old history self._history = self._history[self._history > t - 10] return opps @dataclass class SessionArrivalConfig: """Configuration for retail session arrivals. Attributes: sessions_per_step: Number of browsing sessions per step views_per_session: (min, max) product views per session contamination: Fraction of sessions that are scrapers/bots """ sessions_per_step: int = 20 views_per_session: tuple[int, int] = (1, 5) contamination: float = 0.0 class SessionArrivalModel: """Retail browsing session model with multi-product views. Each session views multiple products, generating one opportunity per view. Scraper sessions (controlled by contamination) view more products but convert at lower rates (handled by ExecutionModel). """ def __init__(self, cfg: SessionArrivalConfig | None = None): self.cfg = cfg or SessionArrivalConfig() def sample(self, t: float, dt: float, instruments: InstrumentSet, market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]: n_sessions = self.cfg.sessions_per_step contamination = hidden.contamination if hidden else self.cfg.contamination opps = [] for _ in range(n_sessions): is_scraper = rng.random() < contamination n_views = rng.integers(*self.cfg.views_per_session) sid = str(uuid4())[:8] # scrapers view more products if is_scraper: n_views = min(instruments.n, n_views * 3) viewed = rng.choice(instruments.n, size=min(n_views, instruments.n), replace=False) for inst_id in viewed: opps.append(Opportunity( id=f"{sid}-{inst_id}", type=OpportunityType.SESSION, side=Side.BUY, instrument_id=int(inst_id), size=1.0, t=t, context={'session_id': sid, 'is_scraper': is_scraper, 'n_views': n_views} )) return opps