mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
shock: defining new lab environment and formulation
This commit is contained in:
174
lab/population/execution.py
Normal file
174
lab/population/execution.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Execution models for computing acceptance/fill probabilities.
|
||||
|
||||
This module provides different models for how opportunities convert to executions:
|
||||
- ElasticityExecutionModel: Price elasticity with competitor cross-effects (retail)
|
||||
- IntensityExecutionModel: Distance-based fill intensity (market making)
|
||||
- LogitExecutionModel: Discrete choice model
|
||||
|
||||
Each model implements the ExecutionModel protocol.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from ..outlet.types import Opportunity, Quote, InstrumentSet, MarketState
|
||||
from ..outlet.constants import Side
|
||||
from ..outlet.math_util import sigmoid, safe_log, intensity_decay, EPS
|
||||
|
||||
@dataclass
|
||||
class ElasticityConfig:
|
||||
"""Configuration for price elasticity execution model.
|
||||
|
||||
Attributes:
|
||||
base_prob: Baseline purchase probability at reference price
|
||||
price_sensitivity: Own-price elasticity coefficient
|
||||
cross_elasticity: Competitor price cross-elasticity
|
||||
scraper_conversion: Multiplier for scraper conversion (typically << 1)
|
||||
"""
|
||||
base_prob: float = 0.3
|
||||
price_sensitivity: float = 2.0
|
||||
cross_elasticity: float = 0.5
|
||||
scraper_conversion: float = 0.01
|
||||
|
||||
class ElasticityExecutionModel:
|
||||
"""Price elasticity model for retail dynamic pricing.
|
||||
|
||||
P(buy) = base_prob * exp(-sensitivity * log(price/ref)) * cross_effect * scraper_mult
|
||||
|
||||
Higher prices reduce purchase probability exponentially.
|
||||
Competitor undercutting shifts demand away from the platform.
|
||||
Scrapers convert at a much lower rate (reconnaissance, not purchase).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: ElasticityConfig | None = None):
|
||||
self.cfg = cfg or ElasticityConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
idx = int(opp.instrument_id)
|
||||
price = quote.prices[idx]
|
||||
ref = instruments.refs[idx]
|
||||
|
||||
# base probability adjusted by price ratio
|
||||
log_ratio = safe_log(price / ref)
|
||||
prob = self.cfg.base_prob * np.exp(-self.cfg.price_sensitivity * log_ratio)
|
||||
|
||||
# cross-elasticity: competitor undercutting increases their share
|
||||
if market and market.competitor_quotes is not None:
|
||||
comp_price = market.competitor_quotes[idx]
|
||||
if comp_price < price:
|
||||
prob *= np.exp(-self.cfg.cross_elasticity * (price - comp_price) / ref)
|
||||
|
||||
# scrapers convert at much lower rate
|
||||
if opp.context.get('is_scraper', False):
|
||||
prob *= self.cfg.scraper_conversion
|
||||
|
||||
return float(np.clip(prob, 0, 1))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
# simple imputation: assume fills = prob * exposures, invert
|
||||
exposures = context.get('exposures', fills) if context else fills
|
||||
avg_prob = self.cfg.base_prob
|
||||
return fills / (avg_prob + EPS)
|
||||
|
||||
@dataclass
|
||||
class IntensityConfig:
|
||||
"""Configuration for intensity-based execution model.
|
||||
|
||||
Attributes:
|
||||
base_intensity: Baseline fill intensity
|
||||
kappa: Decay rate with distance from mid-price
|
||||
vol_scale: Volatility multiplier for fill intensity
|
||||
"""
|
||||
base_intensity: float = 1.0
|
||||
kappa: float = 1.5
|
||||
vol_scale: float = 0.5
|
||||
|
||||
class IntensityExecutionModel:
|
||||
"""Avellaneda-Stoikov style fill intensity for market making.
|
||||
|
||||
Fill probability decays exponentially with distance from mid-price:
|
||||
P(fill) = base * exp(-kappa * |quote - mid|) * (1 + vol_scale * sigma)
|
||||
|
||||
Tighter spreads (closer to mid) have higher fill probability.
|
||||
Higher volatility increases fill probability (more aggressive traders).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: IntensityConfig | None = None):
|
||||
self.cfg = cfg or IntensityConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
idx = int(opp.instrument_id)
|
||||
|
||||
# get mid price from market or use quote price
|
||||
if market and market.mid_prices is not None:
|
||||
mid = market.mid_prices[idx]
|
||||
else:
|
||||
mid = quote.prices[idx]
|
||||
|
||||
# compute distance from mid
|
||||
if opp.side == Side.BUY:
|
||||
exec_price = quote.asks[idx] if quote.asks is not None else quote.prices[idx]
|
||||
distance = exec_price - mid
|
||||
else:
|
||||
exec_price = quote.bids[idx] if quote.bids is not None else quote.prices[idx]
|
||||
distance = mid - exec_price
|
||||
|
||||
# intensity decays with distance
|
||||
intensity = self.cfg.base_intensity * intensity_decay(abs(distance), self.cfg.kappa)
|
||||
|
||||
# volatility increases fill probability
|
||||
if market and market.volatility is not None:
|
||||
vol = market.volatility[idx]
|
||||
intensity *= (1 + self.cfg.vol_scale * vol)
|
||||
|
||||
return float(np.clip(intensity, 0, 1))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
return fills # market making doesn't have same censorship concept
|
||||
|
||||
@dataclass
|
||||
class LogitConfig:
|
||||
"""Configuration for logit discrete choice model.
|
||||
|
||||
Attributes:
|
||||
beta_0: Intercept (base utility)
|
||||
beta_price: Price coefficient (typically negative)
|
||||
beta_quality: Quality attribute coefficient
|
||||
"""
|
||||
beta_0: float = 0.5
|
||||
beta_price: float = -1.5
|
||||
beta_quality: float = 0.3
|
||||
|
||||
class LogitExecutionModel:
|
||||
"""Discrete choice logit model for purchase probability.
|
||||
|
||||
Utility: U = beta_0 + beta_price * (price/ref) + beta_quality * quality
|
||||
P(buy) = sigmoid(U)
|
||||
|
||||
Provides a theoretically grounded demand model from economics literature.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: LogitConfig | None = None):
|
||||
self.cfg = cfg or LogitConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
idx = int(opp.instrument_id)
|
||||
price = quote.prices[idx]
|
||||
ref = instruments.refs[idx]
|
||||
quality = instruments.instruments[idx].attrs.get('quality', 0.5)
|
||||
|
||||
# utility
|
||||
u = self.cfg.beta_0 + self.cfg.beta_price * (price / ref) + self.cfg.beta_quality * quality
|
||||
|
||||
# choice probability via sigmoid
|
||||
return float(sigmoid(u))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
return fills / (self.cfg.beta_0 + EPS)
|
||||
Reference in New Issue
Block a user