sentinel-backend / src /agents /base_agent.py
Penguindrum920's picture
Deploy SENTINEL backend to Hugging Face Spaces
6a09e49 verified
Raw
History Blame Contribute Delete
6.2 kB
"""Abstract base agent for the SENTINEL market simulator."""
from abc import ABC, abstractmethod
from typing import List, Dict
import math
from ..market.order import Order
from ..market.trade import Trade
class BaseAgent(ABC):
"""
Abstract base class for all trading agents.
Every agent has capital, a position, and tracks its own PnL.
Subclasses must implement decide_action() to produce orders
based on the current market state.
"""
def __init__(
self,
agent_id: str,
agent_type: str,
initial_capital: float = 100_000.0,
latency_seconds: float = 0.0,
) -> None:
self.agent_id = agent_id
self.agent_type = agent_type
self.initial_capital = initial_capital
self.capital = initial_capital
self.cash = initial_capital
self.latency_seconds = latency_seconds
# Position tracking
self.position: int = 0 # net shares held (positive=long, negative=short)
self.avg_entry_price: float = 0.0
self.realized_pnl: float = 0.0
self.num_trades: int = 0
self._trade_returns: List[float] = []
self.active_orders: Dict[str, Order] = {}
@abstractmethod
def decide_action(self, market_state: Dict) -> List[Order]:
"""
Given the current market state, return a list of orders to submit.
Must be implemented by every agent subclass.
"""
...
def consume_cancellations(self) -> List[str]:
"""Return any outstanding order IDs the simulator should cancel."""
return []
def cancel_all_active_orders(self) -> List[str]:
"""Cancel and clear any currently tracked resting orders."""
order_ids = list(self.active_orders.keys())
self.active_orders.clear()
return order_ids
def update_position(self, trade: Trade) -> None:
"""Update position and PnL after a trade fills."""
if trade.buyer_agent_id == self.agent_id:
self._apply_fill(trade.quantity, trade.price, is_buy=True)
elif trade.seller_agent_id == self.agent_id:
self._apply_fill(trade.quantity, trade.price, is_buy=False)
self.num_trades += 1
def _apply_fill(self, quantity: int, price: float, is_buy: bool) -> None:
"""Apply a fill to the position, tracking average entry and realized PnL."""
if not math.isfinite(price):
return
direction = 1 if is_buy else -1
new_qty = direction * quantity
cash_delta = price * quantity
if is_buy:
self.cash -= cash_delta
else:
self.cash += cash_delta
if (self.position >= 0 and is_buy) or (self.position <= 0 and not is_buy):
# Adding to position: update average entry
total_cost = self.avg_entry_price * abs(self.position) + price * quantity
self.position += new_qty
if self.position != 0:
self.avg_entry_price = total_cost / abs(self.position)
else:
# Reducing or flipping position: realize PnL
close_qty = min(quantity, abs(self.position))
if is_buy:
pnl = (self.avg_entry_price - price) * close_qty # closing short
else:
pnl = (price - self.avg_entry_price) * close_qty # closing long
self.realized_pnl += pnl
self._trade_returns.append(pnl)
self.position += new_qty
# If flipped, the remainder is a new position at the trade price
if abs(new_qty) > close_qty:
self.avg_entry_price = price
def reset(self) -> None:
"""Reset mutable state for a fresh simulation episode."""
self.capital = self.initial_capital
self.cash = self.initial_capital
self.position = 0
self.avg_entry_price = 0.0
self.realized_pnl = 0.0
self.num_trades = 0
self._trade_returns.clear()
self.active_orders.clear()
def get_unrealized_pnl(self, current_price: float) -> float:
"""Mark-to-market unrealized PnL."""
if self.position == 0:
return 0.0
if not math.isfinite(current_price) or not math.isfinite(self.avg_entry_price):
return 0.0
return (current_price - self.avg_entry_price) * self.position
def get_metrics(self, current_price: float = 0.0) -> Dict:
"""Return agent performance metrics."""
realized = self.realized_pnl if math.isfinite(self.realized_pnl) else 0.0
unrealized = self.get_unrealized_pnl(current_price)
if not math.isfinite(unrealized):
unrealized = 0.0
total_pnl = realized + unrealized
if not math.isfinite(total_pnl):
total_pnl = 0.0
return_pct = (total_pnl / self.initial_capital) * 100 if self.initial_capital else 0.0
sharpe = self._compute_sharpe()
if not math.isfinite(return_pct):
return_pct = 0.0
if not math.isfinite(sharpe):
sharpe = 0.0
return {
"agent_id": self.agent_id,
"agent_type": self.agent_type,
"position": self.position,
"total_pnl": round(total_pnl, 2),
"realized_pnl": round(realized, 2),
"unrealized_pnl": round(unrealized, 2),
"return_pct": round(return_pct, 4),
"sharpe_ratio": round(sharpe, 4),
"num_trades": self.num_trades,
}
def _compute_sharpe(self) -> float:
"""Compute Sharpe ratio from trade returns."""
returns = [value for value in self._trade_returns if math.isfinite(value)]
if len(returns) < 2:
return 0.0
mean = sum(returns) / len(returns)
variance = sum((r - mean) ** 2 for r in returns) / (len(returns) - 1)
std = math.sqrt(variance) if variance > 0 else 0.0
if std == 0:
return 0.0
return (mean / std) * math.sqrt(252) # annualized
def __repr__(self) -> str:
return f"{self.agent_type}({self.agent_id}, pos={self.position}, pnl={self.realized_pnl:.2f})"