Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(
risk_manager: Any,
execution_broker: Any,
journal: Any = None,
trade_tracker: Any = None,
) -> None:
"""Initialise the event engine.

Expand All @@ -44,12 +45,15 @@ def __init__(
execution_broker: PaperBroker or live broker for order
execution.
journal: Optional Journal instance for structured trade logging.
trade_tracker: Optional TradeTracker instance for closed-trade
recording and metrics.
"""
self.clock = clock
self.bus = bus
self.risk_manager = risk_manager
self.execution_broker = execution_broker
self.journal = journal
self.trade_tracker = trade_tracker
self.cells: list[Any] = []
self._running = False

Expand Down Expand Up @@ -260,6 +264,24 @@ async def _process_event(self, event: dict[str, Any]) -> None:
trade_id=_trade_id,
)

# -- TradeTracker recording (T3.2) ------------------------------------
if self.trade_tracker is not None and signal_action == "SELL":
_entry = approved.get("entry_price", 0.0)
_exit_px = approved.get("price", 0.0)
_qty = approved.get("qty", 0)
_pnl = (_exit_px - _entry) * _qty
self.trade_tracker.record_trade(
symbol=approved.get("symbol", ""),
direction="long",
entry_price=_entry,
exit_price=_exit_px,
qty=_qty,
pnl=_pnl,
strategy_name=cell_name,
exit_time=str(self.clock.now()),
exit_reason="signal",
)

# Reset cell state after successful SELL execution.
# This mirrors enter_position() — state only changes
# AFTER the trade is confirmed, not before.
Expand All @@ -283,14 +305,13 @@ async def _process_event(self, event: dict[str, Any]) -> None:
result.get("order_id", ""),
)

# Mark-to-market on every tick for peak-equity drawdown
_symbol = event.get("symbol", "")
_price = event.get("price", None)
if _symbol and _price is not None:
try:
self.risk_manager.portfolio.update_price(_symbol, _price)
except Exception:
pass
# -- Mark-to-market update (T2.2) ------------------------------------
try:
self.risk_manager.portfolio.update_price(
event.get("symbol", ""), event.get("price", 0.0),
)
except Exception:
logger.exception("Error al actualizar precio en portfolio")

def stop(self) -> None:
"""Gracefully stop the event processing loop."""
Expand Down
193 changes: 193 additions & 0 deletions core/trade_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""In-memory closed-trade accumulator with computed performance metrics.

Tracks every closed trade via a ``Trade`` dataclass and a ``TradeTracker``
with a ring-buffer eviction policy (max 100 trades by default). Exposes
computed properties (win rate, profit factor, Sharpe ratio, etc.) for
real-time dashboard consumption without database persistence.
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from statistics import mean, stdev
from typing import Any


@dataclass
class Trade:
"""A single closed trade record.

Attributes:
symbol: Trading pair symbol (e.g. ``"BTCUSDT"``).
direction: Trade direction — ``"long"`` or ``"short"``.
entry_price: Price at which the position was opened.
exit_price: Price at which the position was closed.
qty: Quantity traded (positive for both long and short entries).
pnl: Realised profit/loss in quote currency.
pnl_pct: Realised P&L as a percentage of the trade's cost basis.
strategy_name: Name of the cell / strategy that generated the trade.
entry_time: ISO-format timestamp when the position was opened.
exit_time: ISO-format timestamp when the position was closed.
duration_seconds: Wall-clock duration of the trade in seconds.
exit_reason: Reason for the exit (e.g. ``"signal"``, ``"stop_loss"``).
"""

symbol: str
direction: str = "long"
entry_price: float = 0.0
exit_price: float = 0.0
qty: float = 0.0
pnl: float = 0.0
pnl_pct: float = 0.0
strategy_name: str = ""
entry_time: str | None = None
exit_time: str | None = None
duration_seconds: float = 0.0
exit_reason: str = "signal"


class TradeTracker:
"""In-memory accumulator of closed trades with computed metrics.

Stores up to *max_trades* trades in a ring buffer (oldest discarded
when capacity is reached). All computed properties derive from the
internal trade list and require no external state.

Args:
max_trades: Maximum number of trades to retain (default 100).
"""

def __init__(self, max_trades: int = 100) -> None:
self.max_trades: int = max_trades
self._trades: list[Trade] = []

# -- Trade recording ---------------------------------------------------

def record_trade(self, **kwargs: Any) -> Trade:
"""Record a closed trade and enforce the ring-buffer capacity.

Accepts all ``Trade`` dataclass fields as keyword arguments.
If the number of stored trades already equals *max_trades*, the
oldest trade is removed before appending the new one.

Returns:
The newly created ``Trade`` instance.
"""
# Compute pnl_pct from pnl and cost basis when pnl_pct is not
# explicitly provided and we have enough data to calculate it.
if "pnl_pct" not in kwargs and kwargs.get("pnl") is not None:
entry_price = kwargs.get("entry_price", 0.0)
qty = kwargs.get("qty", 0.0)
cost_basis = entry_price * qty
if cost_basis != 0.0:
kwargs["pnl_pct"] = (kwargs["pnl"] / cost_basis) * 100.0

trade = Trade(**kwargs)

if len(self._trades) >= self.max_trades:
self._trades.pop(0) # discard oldest

self._trades.append(trade)
return trade

# -- Computed properties -----------------------------------------------

@property
def total_trades(self) -> int:
"""Total number of trades currently stored."""
return len(self._trades)

@property
def trades(self) -> list[Trade]:
"""Read-only access to the internal trade list."""
return list(self._trades)

@property
def win_rate(self) -> float:
"""Fraction of trades with positive P&L (0.0 .. 1.0).

Returns 0.0 when there are no trades.
"""
if not self._trades:
return 0.0
wins = sum(1 for t in self._trades if t.pnl > 0.0)
return wins / len(self._trades)

@property
def profit_factor(self) -> float:
"""Ratio of gross profits to gross losses.

Returns ``float('inf')`` when there are no losing trades.
Returns 0.0 when there are no winning trades.
"""
gross_profit = sum(t.pnl for t in self._trades if t.pnl > 0.0)
gross_loss = abs(sum(t.pnl for t in self._trades if t.pnl < 0.0))

if gross_loss == 0.0:
return float("inf") if gross_profit > 0.0 else 0.0
return gross_profit / gross_loss

@property
def expectancy(self) -> float:
"""Average P&L per trade.

Returns 0.0 when there are no trades.
"""
if not self._trades:
return 0.0
return sum(t.pnl for t in self._trades) / len(self._trades)

@property
def best_trade(self) -> Trade | None:
"""Trade with the highest P&L, or ``None`` if no trades."""
if not self._trades:
return None
return max(self._trades, key=lambda t: t.pnl)

@property
def worst_trade(self) -> Trade | None:
"""Trade with the lowest P&L, or ``None`` if no trades."""
if not self._trades:
return None
return min(self._trades, key=lambda t: t.pnl)

@property
def total_pnl(self) -> float:
"""Sum of P&L across all stored trades."""
return sum(t.pnl for t in self._trades)

@property
def sharpe_ratio(self) -> float:
"""Annualised Sharpe ratio based on trade P&L.

Formula: ``mean(pnl) / stdev(pnl) * sqrt(252)``.

Returns 0.0 when fewer than 2 trades are stored (standard
deviation is undefined for a single data point).
"""
if len(self._trades) < 2:
return 0.0

pnls = [t.pnl for t in self._trades]
_mean = mean(pnls)
_stdev = stdev(pnls)

if _stdev == 0.0:
return 0.0

return (_mean / _stdev) * math.sqrt(252)

@property
def avg_holding_time(self) -> float:
"""Average trade duration in seconds.

Returns 0.0 when there are no trades or no trades have
duration data.
"""
if not self._trades:
return 0.0
durations = [t.duration_seconds for t in self._trades if t.duration_seconds > 0.0]
if not durations:
return 0.0
return mean(durations)
5 changes: 2 additions & 3 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ async def _run():

self.loop.run_until_complete(_run())

cell.enter_position.assert_called_once_with(50000)
self.broker.submit_order.assert_awaited_once_with(approved_signal)

def test_signal_rejected_by_risk(self):
Expand All @@ -165,7 +164,7 @@ def test_signal_rejected_by_risk(self):
cell.handle = AsyncMock(return_value=signal)
self.engine.register(cell)

self.risk_manager.approve.return_value = None # reject
self.risk_manager.approve.return_value = None

event = {"type": "tick", "symbol": "BTCUSDT", "price": 50000}
self.bus.queue.put_nowait(event)
Expand Down Expand Up @@ -193,7 +192,7 @@ def test_engine_emits_signal_and_trade_events(self):
cell.handle = AsyncMock(return_value=signal)
self.engine.register(cell)

self.risk_manager.approve.return_value = dict(signal)
self.risk_manager.approve.return_value = {"approved": True, **signal}

event = {"type": "tick", "symbol": "BTCUSDT", "price": 50000}
self.bus.queue.put_nowait(event)
Expand Down
Loading