mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
152 lines
5.8 KiB
Python
152 lines
5.8 KiB
Python
"""
|
|
Inventory/position management and instrument factories.
|
|
|
|
This module provides:
|
|
- PositionConfig: Configuration for position constraints and costs
|
|
- PositionModel: Manages inventory (retail) or position (finance)
|
|
- make_instruments: Factory for creating instrument sets
|
|
|
|
The PositionModel handles demand censorship by limiting executions
|
|
to available inventory, computing holding costs, and managing replenishment.
|
|
"""
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass, field
|
|
import numpy as np
|
|
from .types import Instrument, InstrumentSet, Execution
|
|
from .constants import Side, InstrumentType
|
|
|
|
@dataclass
|
|
class PositionConfig:
|
|
"""Configuration for position/inventory management.
|
|
|
|
Attributes:
|
|
initial_position: Starting inventory (None = unlimited, float = same for all)
|
|
max_position: Maximum long position per instrument
|
|
min_position: Maximum short position (negative, for finance)
|
|
holding_cost_rate: Cost per unit per step for holding inventory
|
|
shortage_cost_rate: Opportunity cost rate for stockouts
|
|
lead_time: Steps until replenishment orders arrive
|
|
"""
|
|
initial_position: np.ndarray | float | None = None
|
|
max_position: float = 1000.0
|
|
min_position: float = -1000.0
|
|
holding_cost_rate: float = 0.001
|
|
shortage_cost_rate: float = 0.05
|
|
lead_time: int = 0
|
|
|
|
@dataclass
|
|
class PositionModel:
|
|
"""Manages inventory (retail) or position (finance) with censorship.
|
|
|
|
Key responsibilities:
|
|
- Track current position per instrument
|
|
- Censor executions when position is insufficient
|
|
- Compute holding costs per step
|
|
- Track shortage/stockout costs
|
|
- Handle replenishment orders with lead time
|
|
|
|
For retail: position is inventory (positive), selling reduces it
|
|
For finance: position can be positive (long) or negative (short)
|
|
"""
|
|
cfg: PositionConfig
|
|
n: int = 0
|
|
_position: np.ndarray = field(default_factory=lambda: np.array([]))
|
|
_pending_orders: list[tuple[int, np.ndarray]] = field(default_factory=list)
|
|
_step_holding_cost: float = 0.0
|
|
_step_shortage_cost: float = 0.0
|
|
|
|
def reset(self, instruments: InstrumentSet, rng: np.random.Generator) -> None:
|
|
self.n = instruments.n
|
|
if self.cfg.initial_position is None:
|
|
self._position = np.full(self.n, np.inf) # unlimited
|
|
elif isinstance(self.cfg.initial_position, (int, float)):
|
|
self._position = np.full(self.n, float(self.cfg.initial_position))
|
|
else:
|
|
self._position = self.cfg.initial_position.copy().astype(np.float64)
|
|
self._pending_orders = []
|
|
self._step_holding_cost = 0.0
|
|
self._step_shortage_cost = 0.0
|
|
|
|
def available(self, instrument_id: int, side: Side) -> float:
|
|
pos = self._position[instrument_id]
|
|
if np.isinf(pos): return np.inf
|
|
if side == Side.BUY:
|
|
return max(0, pos) # can sell up to current inventory
|
|
else:
|
|
return max(0, self.cfg.max_position - pos) # can buy up to max
|
|
|
|
def apply_execution(self, exe: Execution) -> Execution:
|
|
idx = int(exe.instrument_id)
|
|
avail = self.available(idx, exe.side)
|
|
filled = min(exe.size_requested, avail)
|
|
shortage = exe.size_requested - filled
|
|
|
|
if exe.side == Side.BUY:
|
|
self._position[idx] -= filled # sold from inventory
|
|
else:
|
|
self._position[idx] += filled # bought into inventory
|
|
|
|
if shortage > 0:
|
|
self._step_shortage_cost += shortage * exe.price * self.cfg.shortage_cost_rate
|
|
|
|
return Execution(
|
|
opportunity_id=exe.opportunity_id, instrument_id=exe.instrument_id,
|
|
side=exe.side, size_requested=exe.size_requested,
|
|
size_filled=filled, price=exe.price, propensity=exe.propensity, t=exe.t
|
|
)
|
|
|
|
def order(self, quantity: np.ndarray) -> None:
|
|
if self.cfg.lead_time > 0:
|
|
self._pending_orders.append((self.cfg.lead_time, quantity.copy()))
|
|
else:
|
|
self._position += quantity
|
|
|
|
def step(self, t: float) -> None:
|
|
# compute holding cost
|
|
pos = np.where(np.isinf(self._position), 0, self._position)
|
|
self._step_holding_cost = float(np.sum(np.abs(pos)) * self.cfg.holding_cost_rate)
|
|
|
|
# receive pending orders
|
|
new_pending = []
|
|
for (remaining, qty) in self._pending_orders:
|
|
if remaining <= 1:
|
|
self._position += qty
|
|
else:
|
|
new_pending.append((remaining - 1, qty))
|
|
self._pending_orders = new_pending
|
|
|
|
@property
|
|
def position(self) -> np.ndarray:
|
|
return np.where(np.isinf(self._position), -1, self._position)
|
|
|
|
@property
|
|
def holding_cost(self) -> float:
|
|
return self._step_holding_cost
|
|
|
|
@property
|
|
def shortage_cost(self) -> float:
|
|
return self._step_shortage_cost
|
|
|
|
def make_instruments(n: int, cost_range: tuple[float, float] = (1.0, 10.0),
|
|
margin_range: tuple[float, float] = (0.2, 0.5),
|
|
inst_type: InstrumentType = InstrumentType.SKU,
|
|
rng: np.random.Generator | None = None) -> InstrumentSet:
|
|
"""Factory function to create a random instrument set.
|
|
|
|
Args:
|
|
n: Number of instruments to create
|
|
cost_range: (min, max) for uniform cost sampling
|
|
margin_range: (min, max) for uniform margin sampling
|
|
inst_type: Type of instruments (SKU, ASSET, etc.)
|
|
rng: Random generator (uses default if None)
|
|
|
|
Returns:
|
|
InstrumentSet with n instruments having random costs and margins
|
|
"""
|
|
rng = rng or np.random.default_rng()
|
|
costs = rng.uniform(*cost_range, n)
|
|
margins = rng.uniform(*margin_range, n)
|
|
items = [Instrument(id=i, type=inst_type, cost_basis=c, reference_price=c*(1+m))
|
|
for i, (c, m) in enumerate(zip(costs, margins))]
|
|
return InstrumentSet(instruments=items)
|