diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0b1f296 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: Build, Lint, and Run Tests + +on: + pull_request: + branches: [master] + +jobs: + build-lint-test: + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Lint with ruff check + run: ruff check . + + - name: Format check with ruff + run: ruff format . --check + + - name: Type check with mypy + run: mypy . --ignore-missing-imports + + - name: Run tests with pytest + run: pytest tests/ --junitxml=junit/test-results.xml --html=htmlcov/report.html --self-contained-html + + - name: Upload test results artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: junit/test-results.xml + + - name: Upload HTML report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: htmlcov/report.html + + - name: Publish test results to PR + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: junit/test-results.xml + check_name: Test Results + comment_mode: always \ No newline at end of file diff --git a/.gitignore b/.gitignore index d463451..452f347 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Python __pycache__/ *.py[cod] *$py.class @@ -15,11 +16,42 @@ parts/ sdist/ var/ wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +junit/ +.tox/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.ruff_cache/ + +# Pre-commit +.pre-commit/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment .env .venv +env/ venv/ -.vscode/ -.idea/ +conda-env/ + +# Conda .conda/ -*.db -.DS_Store + +# Dev Notes +notes/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..241a689 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.13 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.1 + hooks: + - id: mypy + args: [--ignore-missing-imports] + pass_filenames: true + stages: [commit] \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..86fd34d --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""Core trading modules.""" diff --git a/core/pnl_engine.py b/core/pnl_engine.py index e69de29..8200b83 100644 --- a/core/pnl_engine.py +++ b/core/pnl_engine.py @@ -0,0 +1,182 @@ +import logging +from collections import defaultdict + +from models import Position, Trade + +logger = logging.getLogger(__name__) + + +class RealTimePnLEngine: + """Real-time PnL attribution with realized/unrealized split""" + + def __init__(self, account_id: str = "system"): + self.account_id = account_id + self.positions: dict[str, Position] = {} + self.realized_pnl: dict[str, float] = defaultdict(float) + self.last_prices: dict[str, float] = defaultdict(float) + logger.debug(f"RealTimePnLEngine initialized for account {account_id}") + + def on_trade(self, trade: Trade) -> None: + """Update position and realized PnL""" + + # Validate trade + if trade.qty <= 0: + logger.warning(f"Ignoring trade {trade.trade_id} with qty={trade.qty}") + return + + if trade.price < 0: + logger.warning(f"Ignoring trade {trade.trade_id} with negative price") + return + + sym = trade.symbol + signed_qty = trade.signed_qty() + trade_price = trade.price + + logger.info( + f"Trade {trade.trade_id}: {sym} {trade.side.value} qty={trade.qty} " + f"price={trade_price:.2f} notional={trade.notional_value():.2f}" + ) + + if sym not in self.positions: + self.positions[sym] = Position(account_id=self.account_id, symbol=sym) + + pos = self.positions[sym] + prev_qty = pos.qty + + # Determine if trade is same direction or opposite + if prev_qty * signed_qty >= 0: + self._handle_same_direction_trade(pos, signed_qty, trade_price) + else: + self._handle_opposite_direction_trade(pos, signed_qty, trade_price) + + pos.qty += signed_qty + logger.debug(f"{sym}: Position updated to qty={pos.qty}") + + def _handle_same_direction_trade( + self, pos: Position, signed_qty: int, trade_price: float + ) -> None: + """Handle weighted average cost updates for same-direction trades""" + sym = pos.symbol + prev_qty = pos.qty + prev_cost = pos.avg_cost + new_qty = prev_qty + signed_qty + + if new_qty != 0: + pos.avg_cost = (prev_qty * prev_cost + signed_qty * trade_price) / new_qty + logger.debug(f"{sym}: avg_cost updated to {pos.avg_cost:.2f}") + + def _handle_opposite_direction_trade( + self, pos: Position, signed_qty: int, trade_price: float + ) -> None: + """Handle opposite-direction trade: realize PnL and update position""" + sym = pos.symbol + prev_qty = pos.qty + prev_cost = pos.avg_cost + closing_qty = min(abs(prev_qty), abs(signed_qty)) + + self._realize_pnl(sym, closing_qty, trade_price, prev_cost, prev_qty) + + # After closing, check remaining qty + new_qty = prev_qty + signed_qty + + if new_qty == 0: + pos.avg_cost = 0.0 + logger.debug(f"{sym}: Position fully closed") + elif prev_qty * new_qty > 0: + # Partial close, same direction continues + logger.debug(f"{sym}: Partial close, avg_cost remains {prev_cost:.2f}") + else: + # Direction flipped: open new position at trade price + pos.avg_cost = trade_price + logger.debug( + f"{sym}: Direction flipped, new position opened at {trade_price:.2f} " + f"(qty={new_qty})" + ) + + def _realize_pnl( + self, + symbol: str, + closing_qty: int, + trade_price: float, + prev_cost: float, + prev_qty: int, + ) -> None: + """Calculate and log realized PnL""" + sign = 1 if prev_qty > 0 else -1 + pnl_increment = closing_qty * (trade_price - prev_cost) * sign + self.realized_pnl[symbol] += pnl_increment + logger.info( + f"{symbol}: Realized PnL {self.realized_pnl[symbol]:.2f} " + f"(closed {closing_qty} @ {trade_price:.2f} vs cost {prev_cost:.2f})" + ) + + def on_price(self, symbol: str, price: float) -> None: + """Update latest market price""" + self.last_prices[symbol] = price + + def get_position(self, symbol: str) -> Position: + """Get position for symbol, or empty position if none exists""" + return self.positions.get(symbol, Position(account_id=self.account_id, symbol=symbol)) + + def get_unrealized_pnl(self, symbol: str) -> float: + """Compute unrealized PnL for symbol""" + pos = self.get_position(symbol) + if pos.qty == 0: + return 0.0 + last_px = self.last_prices[symbol] + return pos.unrealized_pnl(last_px) + + def get_total_pnl(self) -> float: + """Total realized + unrealized PnL across all symbols""" + return sum(self.realized_pnl[sym] + self.get_unrealized_pnl(sym) for sym in self.positions) + + def get_long_positions(self) -> dict[str, Position]: + """Get all long positions""" + return {sym: pos for sym, pos in self.positions.items() if pos.is_long()} + + def get_short_positions(self) -> dict[str, Position]: + """Get all short positions""" + return {sym: pos for sym, pos in self.positions.items() if pos.is_short()} + + def get_open_positions(self) -> dict[str, Position]: + """Get all open positions (non-zero qty)""" + return {sym: pos for sym, pos in self.positions.items() if pos.is_open()} + + def get_pnl_by_symbol(self, symbol: str) -> dict[str, float]: + """Get both realized and unrealized PnL for a symbol""" + return { + "realized": self.realized_pnl[symbol], + "unrealized": self.get_unrealized_pnl(symbol), + "total": self.realized_pnl[symbol] + self.get_unrealized_pnl(symbol), + } + + def get_total_notional(self, last_prices: dict[str, float]) -> float: + """Total notional value across all positions""" + return sum( + pos.notional_value(last_prices.get(pos.symbol, 0.0)) for pos in self.positions.values() + ) + + def log_summary(self, symbol: str | None = None) -> str: + """Log engine summary for debugging""" + if symbol: + pos = self.get_position(symbol) + realized = self.realized_pnl[symbol] + unrealized = self.get_unrealized_pnl(symbol) + summary = ( + f"{symbol}: qty={pos.qty}, avg_cost={pos.avg_cost:.2f}, " + f"realized={realized:.2f}, unrealized={unrealized:.2f}, " + f"total={realized + unrealized:.2f}" + ) + else: + # All symbols + positions_count = len(self.positions) + total_realized = sum(self.realized_pnl.values()) + total_unrealized = sum(self.get_unrealized_pnl(sym) for sym in self.positions) + summary = ( + f"Account {self.account_id}: {positions_count} symbols, " + f"realized={total_realized:.2f}, unrealized={total_unrealized:.2f}, " + f"total={self.get_total_pnl():.2f}" + ) + + logger.info(summary) + return summary diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..3af664c --- /dev/null +++ b/environment.yml @@ -0,0 +1,9 @@ +name: trading-primitives +channels: + - conda-forge + - defaults +dependencies: + - python=3.12 + - pip + - pip: + - -r requirements.txt \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..71b0e86 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +"""Models package: domain models and API schemas.""" + +from models.domain import Position, Side, Trade + +__all__ = ["Position", "Side", "Trade"] diff --git a/models/domain.py b/models/domain.py new file mode 100644 index 0000000..ead1c67 --- /dev/null +++ b/models/domain.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from uuid import uuid4 + + +def generate_id() -> str: + return str(uuid4()) + + +class Side(Enum): + BUY = "BUY" + SELL = "SELL" + + +@dataclass +class Trade: + """ + A single trade execution + """ + + symbol: str + side: Side + qty: int # Always positive; side determines direction + price: float + trade_id: str = field(default_factory=generate_id) + order_id: str | None = None # Link to parent order + timestamp: datetime = field(default_factory=lambda: datetime.now().astimezone()) + + def notional_value(self) -> float: + """Notional value of the trade (always positive)""" + return self.qty * self.price + + def signed_qty(self) -> int: + """Returns qty with sign based on side (-ve for SELL, +ve for BUY)""" + return self.qty if self.side == Side.BUY else -self.qty + + +@dataclass +class Position: + """ + Account position on a symbol + """ + + account_id: str + symbol: str + qty: int = 0 # +ve for long, -ve for short + avg_cost: float = 0.0 + position_id: str = field(default_factory=generate_id) + updated_at: datetime = field(default_factory=lambda: datetime.now().astimezone()) + + def is_long(self) -> bool: + return self.qty > 0 + + def is_short(self) -> bool: + return self.qty < 0 + + def notional_value(self, current_price: float) -> float: + """Current market value of the position""" + return self.qty * current_price + + def unrealized_pnl(self, current_price: float) -> float: + """Unrealized PnL at current price""" + if self.qty == 0: + return 0.0 + return self.qty * (current_price - self.avg_cost) + + def is_open(self) -> bool: + """Check if position has any qty""" + return self.qty != 0 + + def is_closed(self) -> bool: + """Check if position is flat""" + return self.qty == 0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..872a88a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=65.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "trading-primitives" +version = "0.1.0" +description = "Real-time PnL engine for trading positions" +requires-python = ">=3.12" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # import sorting + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line-too-long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["core", "models", "api", "persistence"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +ignore_missing_imports = true +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --strict-markers" +markers = [ + "unit: unit tests", + "integration: integration tests", + "acceptance: acceptance tests", + "slow: slow tests", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b7ee022..de5f3e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,15 @@ pytest==7.4.3 pytest-cov==4.1.0 pytest-asyncio==0.21.1 pytest-mock==3.12.0 +pytest-html==4.1.1 faker==20.1.0 sqlalchemy==2.0.23 psycopg2-binary==2.9.9 testcontainers==3.7.0 redis==5.0.1 -fastapi[all]==0.104.1 +fastapi[all]==0.109.1 uvicorn[standard]==0.24.0 -pydantic[email]==2.5.0 \ No newline at end of file +pydantic[email]==2.5.0 +ruff==0.14.13 +mypy==1.7.1 +pre-commit==3.5.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f4e7197 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from models import Side, Trade + + +@pytest.fixture +def sample_trade() -> Trade: + """Standard buy trade for testing""" + return Trade( + symbol="AAPL", + side=Side.BUY, + qty=100, + price=150.0, + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..ea3f8b9 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" diff --git a/tests/unit/test_pnl_engine.py b/tests/unit/test_pnl_engine.py new file mode 100644 index 0000000..d8a5f69 --- /dev/null +++ b/tests/unit/test_pnl_engine.py @@ -0,0 +1,334 @@ +import pytest + +from core.pnl_engine import RealTimePnLEngine +from models import Position, Side, Trade + + +@pytest.mark.unit +class TestRealTimePnLEngine: + # --- Unit tests for RealTimePnLEngine --- + + @pytest.fixture + def engine(self) -> RealTimePnLEngine: + return RealTimePnLEngine(account_id="test_account") + + # --- Basic Tests --- + + def test_initial_state_empty(self, engine: RealTimePnLEngine): + # --- Engine starts with zero positions and PnL --- + assert engine.get_total_pnl() == 0.0 + assert len(engine.positions) == 0 + assert len(engine.realized_pnl) == 0 + assert len(engine.last_prices) == 0 + + def test_on_trade_buy_creates_position(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Processing Trade creates Position object --- + engine.on_trade(sample_trade) + + pos: Position = engine.get_position("AAPL") + assert pos.symbol == "AAPL" + assert pos.qty == sample_trade.qty # BUY is positive + assert pos.avg_cost == sample_trade.price + assert pos.is_long() + assert not pos.is_short() + + def test_position_object_attributes(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Position object attributes are set correctly --- + engine.on_trade(sample_trade) + + pos: Position = engine.get_position("AAPL") + assert pos.account_id == "test_account" + assert pos.symbol == "AAPL" + assert pos.position_id is not None + assert pos.updated_at is not None + + def test_get_position_nonexistent_symbol(self, engine: RealTimePnLEngine): + # --- Getting non-existent position returns empty Position --- + pos = engine.get_position("NONEXIST") + assert pos.qty == 0 + assert pos.avg_cost == 0.0 + + # --- Same Direction Tests --- + + def test_on_trade_same_direction_updates_wavg_cost( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Buying more increases qty and updates avg cost --- + trade_buy_more = Trade(symbol="AAPL", side=Side.BUY, qty=50, price=160.0) + + engine.on_trade(sample_trade) + engine.on_trade(trade_buy_more) + + pos: Position = engine.get_position("AAPL") + assert pos.qty == 150 + assert pos.avg_cost == (sample_trade.notional_value() + 50 * 160.0) / 150 + + def test_same_direction_multiple_buys(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Multiple buys at different prices update weighted avg correctly --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="AAPL", side=Side.BUY, qty=50, price=160.0)) + engine.on_trade(Trade(symbol="AAPL", side=Side.BUY, qty=50, price=140.0)) + + pos = engine.get_position("AAPL") + assert pos.qty == 200 + # (100*150 + 50*160 + 50*140) / 200 = 30000 / 200 = 150 + assert pos.avg_cost == 150.0 + + def test_same_direction_short_sell_more(self, engine: RealTimePnLEngine): + # --- Selling more when short updates weighted avg --- + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=100, price=160.0)) + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=50, price=155.0)) + + pos = engine.get_position("AAPL") + assert pos.qty == -150 + assert pos.is_short() + assert pos.avg_cost == (100 * 160.0 + 50 * 155.0) / 150 + + def test_same_direction_no_realized_pnl(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Same direction trades don't realize PnL --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="AAPL", side=Side.BUY, qty=50, price=160.0)) + assert engine.realized_pnl["AAPL"] == 0.0 + + # --- Opposite Direction Tests --- + + def test_on_trade_opposite_direction_realizes_pnl( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Selling at profit realizes PnL and closes position --- + trade_sell = Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=160.0) + + engine.on_trade(sample_trade) + engine.on_trade(trade_sell) + + pos: Position = engine.get_position("AAPL") + assert pos.qty == 0 + + realized_pnl = engine.realized_pnl["AAPL"] + assert realized_pnl == sample_trade.qty * (160.0 - sample_trade.price) + + def test_opposite_direction_sell_at_loss(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Selling at loss realizes negative PnL --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=140.0)) + + assert engine.realized_pnl["AAPL"] == sample_trade.qty * (140.0 - 150.0) + + def test_opposite_direction_short_cover_at_profit( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Covering short at lower price realizes profit --- + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=160.0)) + engine.on_trade(sample_trade) + + assert engine.realized_pnl["AAPL"] == sample_trade.qty * (160.0 - sample_trade.price) + + def test_opposite_direction_partial_close(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Partial close realizes PnL on closed portion only --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=50, price=160.0)) + + pos = engine.get_position("AAPL") + assert pos.qty == 50 + assert pos.avg_cost == sample_trade.price # Avg cost stays same + assert engine.realized_pnl["AAPL"] == 50 * (160.0 - sample_trade.price) + + def test_opposite_direction_short_partial_cover( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Partially covering short position --- + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=100, price=150.0)) + engine.on_trade(Trade(symbol="AAPL", side=Side.BUY, qty=50, price=145.0)) + + pos = engine.get_position("AAPL") + assert pos.qty == -50 + assert pos.avg_cost == 150.0 # Avg cost stays same + assert engine.realized_pnl["AAPL"] == 50 * (150.0 - 145.0) + + # --- Direction Flip Tests --- + + def test_on_trade_flips_position_direction( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Selling more than current position flips direction and updates avg cost --- + trade_sell_more = Trade(symbol="AAPL", side=Side.SELL, qty=150, price=160.0) + + engine.on_trade(sample_trade) # Buy 100@150 + engine.on_trade(trade_sell_more) # Sell 150@160 + + pos: Position = engine.get_position("AAPL") + assert pos.qty == -50 # Short 50 shares + assert pos.is_short() + assert pos.avg_cost == 160.0 # New avg cost is sell price + + realized_pnl = engine.realized_pnl["AAPL"] + assert realized_pnl == sample_trade.qty * (160.0 - sample_trade.price) + + trade_buy_more = Trade(symbol="AAPL", side=Side.BUY, qty=100, price=155.0) + engine.on_trade(trade_buy_more) # Buy 100@155 to cover and go long + pos = engine.get_position("AAPL") + assert pos.qty == 50 # Long 50 shares + assert pos.is_long() + assert pos.avg_cost == 155.0 # New avg cost is buy price + realized_pnl = engine.realized_pnl["AAPL"] + assert realized_pnl == sample_trade.qty * (160.0 - sample_trade.price) + 50 * ( + 160.0 - 155.0 + ) + + # --- Price Update Tests --- + + def test_on_price_updates_last_price_and_unrealized_pnl( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Updating price computes unrealized PnL correctly --- + engine.on_trade(sample_trade) + engine.on_price("AAPL", 160.0) + + unrealized_pnl = engine.get_unrealized_pnl("AAPL") + assert unrealized_pnl == sample_trade.qty * (160.0 - sample_trade.price) + assert engine.last_prices["AAPL"] == 160.0 + + def test_on_price_short_position_unrealized_pnl( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Unrealized PnL for short position --- + engine.on_trade( + Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=sample_trade.price) + ) + engine.on_price("AAPL", 140.0) + + unrealized_pnl = engine.get_unrealized_pnl("AAPL") + assert unrealized_pnl == -sample_trade.qty * (140.0 - sample_trade.price) + assert unrealized_pnl == sample_trade.qty * (sample_trade.price - 140.0) + + def test_on_price_zero_position_zero_unrealized_pnl( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Closed position has zero unrealized PnL --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=160.0)) + engine.on_price("AAPL", 200.0) # Price doesn't matter + + unrealized_pnl = engine.get_unrealized_pnl("AAPL") + assert unrealized_pnl == 0.0 + + # --- Total PnL Tests --- + + def test_get_total_pnl_combines_realized_and_unrealized( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Total PnL sums realized and unrealized correctly --- + trade_sell_partial = Trade(symbol="AAPL", side=Side.SELL, qty=50, price=160.0) + + engine.on_trade(sample_trade) # Buy 100@150 + engine.on_trade(trade_sell_partial) # Sell 50@160 + engine.on_price("AAPL", 155.0) # Update price to 155 + + realized_pnl = engine.realized_pnl["AAPL"] + expected_realized_pnl = 50 * (160.0 - sample_trade.price) + assert realized_pnl == expected_realized_pnl + + unrealized_pnl = engine.get_unrealized_pnl("AAPL") + expected_unrealized_pnl = 50 * (155.0 - sample_trade.price) + assert unrealized_pnl == expected_unrealized_pnl + + total_pnl = engine.get_total_pnl() + expected_total_pnl = expected_realized_pnl + expected_unrealized_pnl + assert total_pnl == expected_total_pnl + + def test_get_total_pnl_multiple_symbols(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Total PnL across multiple symbols --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="MSFT", side=Side.BUY, qty=50, price=300.0)) + engine.on_trade( + Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=160.0) + ) # Close AAPL + engine.on_price("MSFT", 310.0) + + aapl_realized = engine.realized_pnl["AAPL"] + msft_unrealized = engine.get_unrealized_pnl("MSFT") + total = engine.get_total_pnl() + + assert aapl_realized == 1000.0 + assert msft_unrealized == 500.0 + assert total == 1500.0 + + def test_get_total_pnl_zero_when_no_positions(self, engine: RealTimePnLEngine): + # --- Total PnL is zero with no positions --- + assert engine.get_total_pnl() == 0.0 + + # --- Multi-Symbol Tests --- + + def test_multiple_symbols_independent_positions( + self, engine: RealTimePnLEngine, sample_trade: Trade + ): + # --- Positions for different symbols are independent --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="MSFT", side=Side.BUY, qty=50, price=300.0)) + engine.on_trade(Trade(symbol="GOOGL", side=Side.SELL, qty=25, price=2000.0)) + + aapl_pos = engine.get_position("AAPL") + msft_pos = engine.get_position("MSFT") + googl_pos = engine.get_position("GOOGL") + + assert aapl_pos.qty == sample_trade.qty + assert msft_pos.qty == 50 + assert googl_pos.qty == -25 + assert aapl_pos.avg_cost == sample_trade.price + assert msft_pos.avg_cost == 300.0 + assert googl_pos.avg_cost == 2000.0 + + def test_multiple_symbols_independent_pnl(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- PnL calculations independent per symbol --- + engine.on_trade(sample_trade) + engine.on_trade(Trade(symbol="MSFT", side=Side.BUY, qty=100, price=300.0)) + + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=sample_trade.qty, price=160.0)) + engine.on_price("MSFT", 310.0) + + assert engine.realized_pnl["AAPL"] == 1000.0 + assert engine.realized_pnl["MSFT"] == 0.0 + assert engine.get_unrealized_pnl("MSFT") == 1000.0 + + # --- Filter and Summary Tests --- + + def test_get_long_positions(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Filter long positions only --- + engine.on_trade(sample_trade) # AAPL long + engine.on_trade(Trade(symbol="MSFT", side=Side.SELL, qty=50, price=300.0)) # MSFT short + + long_pos = engine.get_long_positions() + assert "AAPL" in long_pos + assert "MSFT" not in long_pos + + def test_get_short_positions(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Filter short positions only --- + engine.on_trade(sample_trade) # AAPL long + engine.on_trade(Trade(symbol="MSFT", side=Side.SELL, qty=50, price=300.0)) # MSFT short + + short_pos = engine.get_short_positions() + assert "MSFT" in short_pos + assert "AAPL" not in short_pos + + def test_get_pnl_by_symbol(self, engine: RealTimePnLEngine, sample_trade: Trade): + # --- Get realized + unrealized breakdown for one symbol --- + engine.on_trade(sample_trade) # Buy 100@150 + engine.on_trade(Trade(symbol="AAPL", side=Side.SELL, qty=50, price=160.0)) # Sell 50@160 + engine.on_price("AAPL", 155.0) # Mark at 155 + + pnl = engine.get_pnl_by_symbol("AAPL") + assert pnl["realized"] == 500.0 # 50 * (160 - 150) + assert pnl["unrealized"] == 250.0 # 50 * (155 - 150) + assert pnl["total"] == 750.0 + + def test_get_total_notional(self, engine: RealTimePnLEngine): + # --- Calculate total notional value across all positions --- + engine.on_trade(Trade(symbol="AAPL", side=Side.BUY, qty=100, price=150.0)) + engine.on_trade(Trade(symbol="MSFT", side=Side.BUY, qty=50, price=300.0)) + + last_prices = {"AAPL": 160.0, "MSFT": 310.0} + total = engine.get_total_notional(last_prices) + # AAPL: 100 * 160 = 16000 + # MSFT: 50 * 310 = 15500 + # Total: 31500 + assert total == 31500.0