Repository navigation
PnL Engine + Tests + CI/CD #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e681b1f
cb0932e
50862bb
251e5e6
270902f
47b3728
318d587
c5cd8ff
09f5db7
d6af5d0
28c472f
0f4a26b
8e1232e
3a1460d
63af7fc
f7ac722
3a67701
e8a4ecc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Core trading modules.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] = {} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The def _cleanup_closed_positions(self) -> None:
"""Remove positions with zero qty to prevent memory bloat"""
self.positions = {sym: pos for sym, pos in self.positions.items() if pos.qty != 0}Call this periodically or after position closes. For a long-running system, this can become significant. Prompt for AgentCall this periodically or after position closes. For a long-running system, this can become significant. |
||
| 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validation silently ignores invalid trades with only a warning. For a financial system, invalid trades should either raise an exception or return a validation result so the caller can handle the error appropriately. Consider: def on_trade(self, trade: Trade) -> bool:
"""Update position and realized PnL. Returns True if trade was processed."""
if trade.qty <= 0:
raise ValueError(f"Invalid trade {trade.trade_id}: qty must be positive")
if trade.price < 0:
raise ValueError(f"Invalid trade {trade.trade_id}: price cannot be negative")
# ... rest of logic
return TrueThis allows upstream systems to detect and handle validation failures. Prompt for AgentThis allows upstream systems to detect and handle validation failures. |
||
| 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) | ||
|
inditilve marked this conversation as resolved.
Comment on lines
+126
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Unrealized PnL calculated with price=0 when no market price has been set When Click to expandHow the bug is triggered
Exampleengine = RealTimePnLEngine()
engine.on_trade(Trade(symbol='AAPL', side=Side.BUY, qty=100, price=150.0))
unrealized = engine.get_unrealized_pnl('AAPL') # Returns -15000.0 instead of 0 or errorImpact
For a long position bought at $150, the unrealized PnL would be reported as -$15,000 (a massive loss) when no price update has been received, rather than indicating that the price is unknown. Recommendation: Either check if the symbol exists in def get_unrealized_pnl(self, symbol: str) -> float:
pos = self.get_position(symbol)
if pos.qty == 0:
return 0.0
if symbol not in self.last_prices:
raise ValueError(f"No price available for {symbol}")
return pos.unrealized_pnl(self.last_prices[symbol])Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| name: trading-primitives | ||
| channels: | ||
| - conda-forge | ||
| - defaults | ||
| dependencies: | ||
| - python=3.12 | ||
| - pip | ||
| - pip: | ||
| - -r requirements.txt |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Models package: domain models and API schemas.""" | ||
|
|
||
| from models.domain import Position, Side, Trade | ||
|
|
||
| __all__ = ["Position", "Side", "Trade"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PnL engine lacks thread safety mechanisms, making it unsafe for concurrent access in real-time trading systems where multiple threads may process trades simultaneously. Consider adding thread synchronization:
Without this, race conditions can corrupt position state and PnL calculations.
Agent: 🏛 Architecture •
• 
Prompt for Agent
Without this, race conditions can corrupt position state and PnL calculations.