feat: autonomous trading engine — full implementation

- Database migration 018 with 13 tables for trading engine state
- Trading engine service (services/trading/) with 12 pure computation modules:
  position sizer, stop-loss manager, reserve pool, circuit breaker,
  risk tier controller, correlation matrix, tax lots, trading window,
  gradual entry, notifications, micro-trading, backtester
- Core TradingEngine with pre-trade evaluation pipeline and integration wiring
- FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest)
- Performance tracker with Sharpe ratio, drawdown, profit factor computation
- 194 Python tests (165 property-based + 29 integration)
- Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page
- Helm chart entry, network policy, nginx proxy, ingress for trading-engine
- Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
This commit is contained in:
Celes Renata
2026-04-15 16:12:22 +00:00
parent da86132f0c
commit 4ffde8cc06
58 changed files with 14168 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
# Trading Engine - autonomous trading decisions, position sizing, and portfolio management
+295
View File
@@ -0,0 +1,295 @@
"""Trading Engine — FastAPI HTTP service for autonomous trading control.
Feature: autonomous-trading-engine
Exposes health/readiness probes, engine control (pause/resume),
configuration management, decision audit trail, performance metrics,
backtesting, and notification configuration endpoints.
Requirements: 1.7, 5.6, 6.6, 15.5, 16.2, 16.3, 16.4, 17.3, 19.9
"""
from __future__ import annotations
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
from services.shared.config import load_config
from services.trading.engine import TradingEngine
logger = logging.getLogger("trading_engine")
# ---------------------------------------------------------------------------
# Module-level state
# ---------------------------------------------------------------------------
config = load_config()
engine: Optional[TradingEngine] = None
# ---------------------------------------------------------------------------
# Pydantic request/response models
# ---------------------------------------------------------------------------
class ConfigUpdateRequest(BaseModel):
"""Body for PUT /api/trading/config."""
enabled: Optional[bool] = None
risk_tier: Optional[str] = None
reserve_siphon_pct: Optional[float] = None
polling_interval_seconds: Optional[int] = None
absolute_position_cap: Optional[float] = None
active_pool_minimum: Optional[float] = None
micro_trading_enabled: Optional[bool] = None
class BacktestRequest(BaseModel):
"""Body for POST /api/trading/backtest."""
start_date: str
end_date: str
initial_capital: float = 500.0
risk_tier: str = "moderate"
class NotificationConfigRequest(BaseModel):
"""Body for PUT /api/trading/notifications/config."""
sms_enabled: Optional[bool] = None
email_enabled: Optional[bool] = None
phone_number: Optional[str] = None
email_recipient: Optional[str] = None
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start and stop the TradingEngine with the application lifecycle."""
global engine
trading_cfg = config.trading
engine = TradingEngine(pool=None, redis=None, config=trading_cfg)
await engine.start()
logger.info("Trading engine started")
yield
if engine is not None:
await engine.stop()
logger.info("Trading engine stopped")
app = FastAPI(title="Stonks Oracle - Trading Engine", lifespan=lifespan)
# ---------------------------------------------------------------------------
# Health & Readiness
# ---------------------------------------------------------------------------
@app.get("/health")
async def health() -> dict[str, str]:
"""Liveness probe."""
return {"status": "ok"}
@app.get("/ready")
async def ready() -> dict[str, bool]:
"""Readiness probe — reports whether the engine is running."""
is_ready = engine is not None and engine.running
return {"ready": is_ready}
# ---------------------------------------------------------------------------
# Engine Status & Control
# ---------------------------------------------------------------------------
@app.get("/api/trading/status")
async def trading_status() -> dict[str, Any]:
"""Return current engine state."""
if engine is None:
raise HTTPException(503, "Engine not initialised")
return {
"enabled": engine.config.enabled,
"paused": not engine.running,
"risk_tier": engine.config.risk_tier,
"circuit_breaker_status": "inactive",
"active_pool": 0.0,
"reserve_pool": 0.0,
"portfolio_heat": 0.0,
"open_positions": 0,
"last_decision_at": None,
}
@app.put("/api/trading/config")
async def update_config(body: ConfigUpdateRequest) -> dict[str, Any]:
"""Update trading engine configuration.
Returns the previous and new configuration values for audit trail.
Requirements: 16.6
"""
if engine is None:
raise HTTPException(503, "Engine not initialised")
previous: dict[str, Any] = {}
updated: dict[str, Any] = {}
for field_name in body.model_fields_set:
new_value = getattr(body, field_name)
old_value = getattr(engine.config, field_name, None)
previous[field_name] = old_value
updated[field_name] = new_value
setattr(engine.config, field_name, new_value)
return {
"previous": previous,
"updated": updated,
"change_source": "api",
"changed_at": datetime.now(tz=timezone.utc).isoformat(),
}
@app.post("/api/trading/pause")
async def pause_engine() -> dict[str, bool]:
"""Pause the trading engine."""
if engine is not None:
engine.running = False
return {"paused": True}
@app.post("/api/trading/resume")
async def resume_engine() -> dict[str, bool]:
"""Resume the trading engine."""
if engine is not None:
engine.running = True
return {"paused": False}
# ---------------------------------------------------------------------------
# Decision Audit Trail
# ---------------------------------------------------------------------------
@app.get("/api/trading/decisions")
async def list_decisions(
ticker: Optional[str] = None,
decision: Optional[str] = None,
limit: int = Query(default=50, le=200),
offset: int = 0,
) -> list[dict[str, Any]]:
"""Return recent trading decisions (placeholder — paginated)."""
return []
# ---------------------------------------------------------------------------
# Performance Metrics
# ---------------------------------------------------------------------------
@app.get("/api/trading/metrics")
async def current_metrics() -> dict[str, Any]:
"""Return current performance metrics (placeholder)."""
return {
"total_portfolio_value": 0.0,
"active_pool": 0.0,
"reserve_pool": 0.0,
"unrealized_pnl": 0.0,
"realized_pnl": 0.0,
"daily_pnl": 0.0,
"win_rate": 0.0,
"profit_factor": 0.0,
"sharpe_ratio": 0.0,
"max_drawdown": 0.0,
"portfolio_heat": 0.0,
}
@app.get("/api/trading/metrics/history")
async def metrics_history(
limit: int = Query(default=30, le=365),
) -> list[dict[str, Any]]:
"""Return historical daily snapshots (placeholder)."""
return []
# ---------------------------------------------------------------------------
# Backtesting
# ---------------------------------------------------------------------------
@app.post("/api/trading/backtest")
async def launch_backtest(body: BacktestRequest) -> dict[str, str]:
"""Launch a backtest run and return its ID."""
backtest_id = str(uuid.uuid4())
return {"backtest_id": backtest_id}
@app.get("/api/trading/backtest/{backtest_id}")
async def get_backtest(backtest_id: str) -> dict[str, Any]:
"""Retrieve backtest results (placeholder)."""
return {
"backtest_id": backtest_id,
"status": "pending",
"config": None,
"result": None,
}
# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------
@app.get("/api/trading/notifications/config")
async def get_notification_config() -> dict[str, Any]:
"""Return current notification configuration."""
if engine is None:
raise HTTPException(503, "Engine not initialised")
return {
"sms_enabled": bool(engine.config.sns_topic_arn),
"email_enabled": bool(engine.config.gmail_recipient),
"phone_number": engine.config.sns_phone_number,
"email_recipient": engine.config.gmail_recipient,
}
@app.put("/api/trading/notifications/config")
async def update_notification_config(
body: NotificationConfigRequest,
) -> dict[str, Any]:
"""Update notification preferences."""
if engine is None:
raise HTTPException(503, "Engine not initialised")
result: dict[str, Any] = {}
if body.phone_number is not None:
engine.config.sns_phone_number = body.phone_number
result["phone_number"] = body.phone_number
if body.email_recipient is not None:
engine.config.gmail_recipient = body.email_recipient
result["email_recipient"] = body.email_recipient
return {"updated": result}
@app.get("/api/trading/notifications/history")
async def notification_history(
limit: int = Query(default=50, le=200),
) -> list[dict[str, Any]]:
"""Return recent notifications (placeholder)."""
return []
+115
View File
@@ -0,0 +1,115 @@
"""Backtester for the autonomous trading engine.
Pure computation module that assembles backtest results from
pre-computed trade data and daily returns. The actual replay logic
(fetching historical data, simulating decisions) requires DB access
and will be wired in the integration layer. This module provides
the pure computation for result assembly.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import date
from services.trading.models import ClosedTrade
from services.trading.performance_tracker import PerformanceComputer
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class BacktestConfig:
"""Configuration for a backtest run."""
start_date: date
end_date: date
initial_capital: float
risk_tier: str # conservative | moderate | aggressive
@dataclass
class BacktestResult:
"""Output of a completed backtest run."""
backtest_id: str
config: BacktestConfig
total_return: float
sharpe_ratio: float
max_drawdown: float
win_rate: float
profit_factor: float
trade_count: int
trade_log: list[dict] = field(default_factory=list)
equity_curve: list[dict] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Engine
# ---------------------------------------------------------------------------
class BacktestEngine:
"""Assembles a BacktestResult from pre-computed trade data.
Uses :class:`PerformanceComputer` to derive metrics from closed
trades and daily returns, then packages everything into a
:class:`BacktestResult`.
"""
def __init__(self) -> None:
self._perf = PerformanceComputer()
def compute_result(
self,
config: BacktestConfig,
trades: list[ClosedTrade],
daily_returns: list[float],
equity_curve: list[dict],
) -> BacktestResult:
"""Build a :class:`BacktestResult` from raw simulation outputs.
Args:
config: The backtest configuration that was used.
trades: Closed trades produced by the simulation.
daily_returns: Daily return percentages for the simulated period.
equity_curve: List of ``{"date": ..., "portfolio_value": ...}``
dicts representing the equity curve.
Returns:
A fully populated :class:`BacktestResult`.
"""
metrics = self._perf.compute_metrics(
closed_trades=trades,
portfolio_value=config.initial_capital,
active_pool=config.initial_capital,
reserve_pool=0.0,
daily_pnl=0.0,
unrealized_pnl=0.0,
portfolio_heat=0.0,
daily_returns=daily_returns,
)
trade_log = [self._perf.compute_trade_metrics(t) for t in trades]
total_return = (
sum(t.pnl for t in trades) / config.initial_capital
if config.initial_capital > 0
else 0.0
)
return BacktestResult(
backtest_id=str(uuid.uuid4()),
config=config,
total_return=total_return,
sharpe_ratio=metrics.sharpe_ratio,
max_drawdown=metrics.max_drawdown,
win_rate=metrics.win_rate,
profit_factor=metrics.profit_factor,
trade_count=len(trades),
trade_log=trade_log,
equity_curve=equity_curve,
)
+152
View File
@@ -0,0 +1,152 @@
"""Circuit breaker safety mechanism for the autonomous trading engine.
Pure computation module — no DB or Redis access. State management and
persistence are handled by the caller. All methods operate on values
passed in as arguments and return deterministic results.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from services.trading.models import CircuitBreakerState
class CircuitBreaker:
"""Evaluates circuit breaker conditions and computes cooldown expiries.
Parameters
----------
daily_loss_pct:
Maximum allowed daily portfolio loss as a fraction (e.g. 0.05 = 5%).
single_position_loss_pct:
Maximum allowed loss on a single position as a fraction of entry value.
ticker_cooldown_hours:
Hours a ticker is blocked from re-entry after a single-position breach.
volatility_pause_hours:
Hours trading is paused after a volatility (stop-loss cluster) trigger.
stop_loss_hits_threshold:
Number of stop-loss hits within the window that triggers a volatility pause.
stop_loss_window_minutes:
Rolling window (in minutes) for counting clustered stop-loss hits.
"""
def __init__(
self,
daily_loss_pct: float = 0.05,
single_position_loss_pct: float = 0.15,
ticker_cooldown_hours: int = 48,
volatility_pause_hours: int = 2,
stop_loss_hits_threshold: int = 3,
stop_loss_window_minutes: int = 30,
) -> None:
self.daily_loss_pct = daily_loss_pct
self.single_position_loss_pct = single_position_loss_pct
self.ticker_cooldown_hours = ticker_cooldown_hours
self.volatility_pause_hours = volatility_pause_hours
self.stop_loss_hits_threshold = stop_loss_hits_threshold
self.stop_loss_window_minutes = stop_loss_window_minutes
# ------------------------------------------------------------------
# Trigger checks
# ------------------------------------------------------------------
def check_daily_loss(self, daily_pnl: float, portfolio_value: float) -> bool:
"""Return True when the portfolio has dropped more than *daily_loss_pct* today.
Parameters
----------
daily_pnl:
Today's profit/loss — negative values represent losses.
portfolio_value:
Current total portfolio value (must be positive).
"""
if portfolio_value <= 0:
return True # degenerate case — treat as triggered
loss_ratio = abs(daily_pnl) / portfolio_value
return daily_pnl < 0 and loss_ratio > self.daily_loss_pct
def check_single_position(self, position_loss_pct: float) -> bool:
"""Return True when a single position has lost more than the threshold.
Parameters
----------
position_loss_pct:
Loss expressed as a positive fraction of entry value
(e.g. 0.15 means the position is down 15%).
"""
return position_loss_pct > self.single_position_loss_pct
def check_volatility(self, stop_loss_hits: list[datetime]) -> bool:
"""Return True when too many stop-losses fired within the rolling window.
Parameters
----------
stop_loss_hits:
Timestamps of recent stop-loss trigger events.
"""
if len(stop_loss_hits) < self.stop_loss_hits_threshold:
return False
window = timedelta(minutes=self.stop_loss_window_minutes)
sorted_hits = sorted(stop_loss_hits)
# Sliding window: check every contiguous sub-sequence of length
# *stop_loss_hits_threshold* to see if it fits within the window.
for i in range(len(sorted_hits) - self.stop_loss_hits_threshold + 1):
start = sorted_hits[i]
end = sorted_hits[i + self.stop_loss_hits_threshold - 1]
if end - start <= window:
return True
return False
# ------------------------------------------------------------------
# Cooldown helpers
# ------------------------------------------------------------------
def is_ticker_cooled_down(
self,
ticker: str,
ticker_cooldowns: dict[str, datetime],
now: datetime | None = None,
) -> bool:
"""Return True if *ticker* is still in its cooldown period.
Returns False when the cooldown has expired or the ticker has no
active cooldown.
"""
if ticker not in ticker_cooldowns:
return False
now = now or datetime.now(tz=timezone.utc)
return now < ticker_cooldowns[ticker]
def is_active(
self,
state: CircuitBreakerState,
now: datetime | None = None,
) -> bool:
"""Return True if any circuit breaker is currently active (not expired)."""
if not state.active:
return False
if state.cooldown_expires is None:
# Active with no expiry — treat as active.
return True
now = now or datetime.now(tz=timezone.utc)
return now < state.cooldown_expires
def compute_cooldown_expiry(
self,
trigger_type: str,
triggered_at: datetime,
) -> datetime:
"""Compute when the cooldown for *trigger_type* expires.
* ``daily_loss`` / ``volatility`` → *triggered_at* + *volatility_pause_hours*
* ``single_position`` → *triggered_at* + *ticker_cooldown_hours*
"""
if trigger_type == "single_position":
return triggered_at + timedelta(hours=self.ticker_cooldown_hours)
# daily_loss, volatility, and any other type default to the
# volatility pause duration.
return triggered_at + timedelta(hours=self.volatility_pause_hours)
+67
View File
@@ -0,0 +1,67 @@
"""Correlation matrix operations for portfolio diversification.
Feature: autonomous-trading-engine
Pure computation module for price correlation coefficients between
tracked companies. Used by the Position Sizer to prevent
over-concentration in correlated positions.
"""
from __future__ import annotations
from services.trading.models import OpenPosition
class CorrelationMatrix:
"""In-memory correlation matrix for ticker pairs.
Stores pairwise correlation coefficients and provides lookup
methods for individual pairs and weighted portfolio averages.
"""
def __init__(self) -> None:
self._data: dict[tuple[str, str], float] = {}
def load(self, data: dict[tuple[str, str], float]) -> None:
"""Load correlation data from a dict of (ticker_a, ticker_b) -> coefficient."""
self._data = dict(data)
def get_correlation(self, ticker_a: str, ticker_b: str) -> float:
"""Return the correlation coefficient for a ticker pair.
Checks both orderings (a, b) and (b, a). Returns 0.0 if the
pair is not in the matrix.
"""
if ticker_a == ticker_b:
return 1.0
return self._data.get(
(ticker_a, ticker_b),
self._data.get((ticker_b, ticker_a), 0.0),
)
def get_portfolio_correlation(
self,
candidate: str,
positions: list[OpenPosition],
) -> float:
"""Compute weighted average correlation between candidate and positions.
Weights are based on each position's market_value. Returns 0.0
if there are no positions or total weight is zero.
"""
if not positions:
return 0.0
total_weight = 0.0
weighted_corr = 0.0
for pos in positions:
corr = self.get_correlation(candidate, pos.ticker)
weight = pos.market_value
weighted_corr += corr * weight
total_weight += weight
if total_weight == 0.0:
return 0.0
return weighted_corr / total_weight
+512
View File
@@ -0,0 +1,512 @@
"""Core autonomous trading engine — decision loop and pre-trade evaluation.
Feature: autonomous-trading-engine
Coordinates all trading sub-components (PositionSizer, StopLossManager,
CircuitBreaker, ReservePoolController, RiskTierController, CorrelationMatrix)
to evaluate recommendations and produce TradingDecision records.
The ``evaluate_recommendation`` method is deliberately synchronous-compatible
so that it can be tested without real DB/Redis connections. The async
``start`` / ``stop`` methods are thin lifecycle stubs wired up in Task 25.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from services.shared.config import TradingConfig
from services.trading.circuit_breaker import CircuitBreaker
from services.trading.correlation import CorrelationMatrix
from services.trading.micro_trading import MicroTradeConfig, MicroTradingModule
from services.trading.models import (
CircuitBreakerState,
OpenPosition,
PerformanceMetrics,
PortfolioState,
PositionSizeResult,
RiskTierConfig,
StopLevels,
StopTrigger,
TradingDecision,
)
from services.trading.notifications import NotificationRecord, NotificationService
from services.trading.position_sizer import PositionSizer
from services.trading.rebalancer import PortfolioRebalancer
from services.trading.reserve_pool import ReservePoolController
from services.trading.risk_tier_controller import RiskTierController
from services.trading.stop_loss_manager import StopLossManager
from services.trading.trading_window import is_within_trading_window
class TradingEngine:
"""Main autonomous trading engine.
Manages the decision loop, coordinates all sub-components,
and maintains runtime state.
Parameters
----------
pool:
asyncpg connection pool (used by async lifecycle methods).
redis:
Redis client (used for deduplication and pub/sub).
config:
Trading engine configuration.
"""
def __init__(
self,
pool: object,
redis: object,
config: TradingConfig,
) -> None:
self.pool = pool
self.redis = redis
self.config = config
# Sub-components
self.position_sizer = PositionSizer()
self.stop_loss_manager = StopLossManager()
self.circuit_breaker = CircuitBreaker()
self.reserve_pool_controller = ReservePoolController(
siphon_pct=config.reserve_siphon_pct,
high_water_pct=config.reserve_high_water_pct,
)
self.risk_tier_controller = RiskTierController()
self.correlation_matrix = CorrelationMatrix()
self.notification_service = NotificationService()
self.micro_trading_module = MicroTradingModule()
self.rebalancer = PortfolioRebalancer()
# Runtime state
self.running: bool = False
self.portfolio_state: PortfolioState | None = None
self.processed_recommendation_ids: set[str] = set()
# ------------------------------------------------------------------
# Lifecycle (stubs — wired in Task 25)
# ------------------------------------------------------------------
async def start(self) -> None:
"""Load portfolio state and enter the decision loop.
Full implementation is deferred to Task 25. This stub sets the
``running`` flag so readiness probes can report status.
"""
self.running = True
async def stop(self) -> None:
"""Graceful shutdown — cancel pending work and persist state.
Full implementation is deferred to Task 25.
"""
self.running = False
# ------------------------------------------------------------------
# Core evaluation logic (synchronous-compatible for testing)
# ------------------------------------------------------------------
def evaluate_recommendation(
self,
rec: dict,
portfolio_state: PortfolioState,
risk_tier: RiskTierConfig,
circuit_breaker_state: CircuitBreakerState,
correlation_matrix: CorrelationMatrix,
earnings_calendar: dict,
now: datetime | None = None,
) -> TradingDecision:
"""Run all pre-trade checks and produce a TradingDecision.
The checks are applied in order; the first failure short-circuits
with a ``skip`` decision. If all checks pass the PositionSizer
is invoked and its result determines the final decision.
Parameters
----------
rec:
Recommendation dict with at least ``recommendation_id``,
``ticker``, ``confidence``, ``sector``, ``current_price``,
and ``action``.
portfolio_state:
Current portfolio snapshot.
risk_tier:
Active risk tier configuration.
circuit_breaker_state:
Current circuit breaker state.
correlation_matrix:
Correlation matrix instance for diversification checks.
earnings_calendar:
Mapping of ticker → next earnings datetime.
now:
Optional override for the current timestamp (for testing).
"""
now = now or datetime.now(tz=timezone.utc)
rec_id = rec.get("recommendation_id")
ticker = rec.get("ticker", "")
confidence = rec.get("confidence", 0.0)
sector = rec.get("sector", "")
current_price = rec.get("current_price", 0.0)
reasoning: list[str] = []
# --- a. Circuit breaker active? --------------------------------
if self.circuit_breaker.is_active(circuit_breaker_state, now=now):
reasoning.append("Circuit breaker is active")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="circuit_breaker_active",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- b. Trading window? ----------------------------------------
if not is_within_trading_window(now):
reasoning.append("Outside trading window")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="outside_trading_window",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- c. Confidence gate (early check before sizer) -------------
if confidence < risk_tier.min_confidence:
reasoning.append(
f"Confidence {confidence:.4f} below tier minimum "
f"{risk_tier.min_confidence}"
)
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="insufficient_confidence",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- d. Deduplication check ------------------------------------
if rec_id and rec_id in self.processed_recommendation_ids:
reasoning.append(f"Recommendation {rec_id} already processed")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="duplicate_recommendation",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- e. Multiple declining positions check ---------------------
if self.check_declining_positions(portfolio_state.positions):
reasoning.append(
"Multiple declining positions — halting new entries"
)
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="multiple_declining_positions",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- f. Max open positions check -------------------------------
max_positions = self.config.max_open_positions if hasattr(self.config, "max_open_positions") else 10
if self.check_max_positions(
portfolio_state.open_position_count, max_positions
):
reasoning.append(
f"At max open positions ({portfolio_state.open_position_count}/"
f"{max_positions})"
)
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="max_positions_reached",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- g. Position sizing ----------------------------------------
reasoning.append("All pre-trade checks passed — computing position size")
# Build the raw correlation dict expected by PositionSizer
corr_dict: dict[tuple[str, str], float] = correlation_matrix._data
size_result: PositionSizeResult = self.position_sizer.compute(
confidence=confidence,
ticker=ticker,
sector=sector,
current_price=current_price,
active_pool=portfolio_state.active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio_state,
correlation_matrix=corr_dict,
earnings_calendar=earnings_calendar,
absolute_position_cap=self.config.absolute_position_cap,
active_pool_minimum=self.config.active_pool_minimum,
)
reasoning.extend(size_result.adjustments)
if size_result.rejected:
reasoning.append(f"Position sizer rejected: {size_result.rejection_reason}")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason=f"position_sizer_rejected: {size_result.rejection_reason}",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# Mark recommendation as processed
if rec_id:
self.processed_recommendation_ids.add(rec_id)
return self._act_decision(
rec_id=rec_id,
ticker=ticker,
size_result=size_result,
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# ------------------------------------------------------------------
# Helper checks
# ------------------------------------------------------------------
def check_declining_positions(
self,
positions: list[OpenPosition],
threshold_pct: float = 0.50,
decline_pct: float = 0.02,
) -> bool:
"""Return True if > threshold_pct of positions have > decline_pct negative unrealized P&L.
A position is considered "declining" when its unrealized P&L as a
fraction of its entry value is worse than ``-decline_pct``.
Parameters
----------
positions:
List of currently open positions.
threshold_pct:
Fraction of positions that must be declining to trigger
(default 0.50 = 50%).
decline_pct:
Minimum loss fraction to count as declining
(default 0.02 = 2%).
"""
if not positions:
return False
declining_count = 0
for pos in positions:
entry_value = pos.entry_price * pos.quantity
if entry_value <= 0:
continue
loss_pct = -pos.unrealized_pnl / entry_value
if loss_pct > decline_pct:
declining_count += 1
return declining_count > threshold_pct * len(positions)
def check_max_positions(
self,
open_count: int,
max_positions: int = 10,
) -> bool:
"""Return True if the portfolio is at the maximum number of open positions."""
return open_count >= max_positions
# ------------------------------------------------------------------
# Integration wiring — thin wrappers for the decision loop
# ------------------------------------------------------------------
def check_stop_loss_crossings(
self,
positions: list[OpenPosition],
prices: dict[str, float],
stop_levels: dict[str, StopLevels],
) -> list[StopTrigger]:
"""Delegate to StopLossManager.check_price_crossings().
Called by the async decision loop at the configured interval
(5 min default, 60s during high-severity events).
"""
return self.stop_loss_manager.check_price_crossings(
positions, prices, stop_levels
)
def handle_position_close(
self,
realized_profit: float,
reserve_balance: float,
) -> tuple[float, float]:
"""Delegate to ReservePoolController.siphon_profit().
Called when a position close event is detected from broker
service fill events.
"""
return self.reserve_pool_controller.siphon_profit(
realized_profit, reserve_balance
)
def evaluate_risk_tier(
self,
current_tier: str,
metrics: PerformanceMetrics,
reserve_pct: float,
) -> str | None:
"""Delegate to RiskTierController.evaluate().
Scheduled to run at daily market close.
"""
return self.risk_tier_controller.evaluate(
current_tier, metrics, reserve_pct
)
def evaluate_rebalancing(
self,
positions: list[OpenPosition],
risk_tier: RiskTierConfig,
active_pool: float,
) -> list:
"""Delegate to PortfolioRebalancer.evaluate().
Scheduled to run weekly at Monday market open.
"""
return self.rebalancer.evaluate(positions, risk_tier, active_pool)
def create_alert(
self,
event_type: str,
details: str,
) -> NotificationRecord:
"""Create a notification record for a critical event.
Delegates formatting and record creation to NotificationService.
The caller is responsible for actual delivery.
"""
message = self.notification_service.format_alert(event_type, details)
return self.notification_service.create_notification(
channel="email",
event_type=event_type,
message=message,
)
def check_micro_trade_constraints(
self,
daily_count: int,
is_within_window: bool,
circuit_breaker_active: bool,
portfolio_heat_pct: float,
max_heat: float,
) -> tuple[bool, str]:
"""Delegate to MicroTradingModule.check_constraints().
Called by the decision loop when micro-trading is enabled.
"""
micro_config = MicroTradeConfig(
enabled=self.config.micro_trading_enabled,
allocation_cap_pct=self.config.micro_trading_allocation_cap_pct,
max_daily=self.config.micro_trading_max_daily,
max_hold_minutes=self.config.micro_trading_max_hold_minutes,
)
return self.micro_trading_module.check_constraints(
config=micro_config,
daily_count=daily_count,
is_within_window=is_within_window,
circuit_breaker_active=circuit_breaker_active,
portfolio_heat_pct=portfolio_heat_pct,
max_heat=max_heat,
)
# ------------------------------------------------------------------
# Decision builders
# ------------------------------------------------------------------
def _skip_decision(
self,
*,
rec_id: str | None,
ticker: str,
skip_reason: str,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
circuit_breaker_state: CircuitBreakerState,
reasoning: list[str],
now: datetime,
) -> TradingDecision:
return TradingDecision(
id=str(uuid.uuid4()),
recommendation_id=rec_id,
decision="skip",
skip_reason=skip_reason,
ticker=ticker,
computed_position_size=None,
computed_share_quantity=None,
risk_tier_at_decision=risk_tier.name,
portfolio_heat_at_decision=portfolio_state.portfolio_heat,
active_pool_at_decision=portfolio_state.active_pool,
reserve_pool_at_decision=portfolio_state.reserve_pool,
circuit_breaker_status="active" if circuit_breaker_state.active else "inactive",
decision_trace={"reasoning": reasoning},
created_at=now,
)
def _act_decision(
self,
*,
rec_id: str | None,
ticker: str,
size_result: PositionSizeResult,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
circuit_breaker_state: CircuitBreakerState,
reasoning: list[str],
now: datetime,
) -> TradingDecision:
return TradingDecision(
id=str(uuid.uuid4()),
recommendation_id=rec_id,
decision="act",
skip_reason=None,
ticker=ticker,
computed_position_size=size_result.dollar_amount,
computed_share_quantity=size_result.share_quantity,
risk_tier_at_decision=risk_tier.name,
portfolio_heat_at_decision=portfolio_state.portfolio_heat,
active_pool_at_decision=portfolio_state.active_pool,
reserve_pool_at_decision=portfolio_state.reserve_pool,
circuit_breaker_status="active" if circuit_breaker_state.active else "inactive",
decision_trace={"reasoning": reasoning},
created_at=now,
)
+81
View File
@@ -0,0 +1,81 @@
"""Gradual entry logic for the autonomous trading engine.
Pure computation module that determines whether an order should be split
into multiple tranches and performs the splitting. The
``GradualEntryManager`` class (which tracks pending tranches at runtime
and re-evaluates conditions) is intentionally left as a thin wrapper
here — the core logic is in the pure functions below.
"""
from __future__ import annotations
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Tranche dataclass
# ---------------------------------------------------------------------------
@dataclass
class Tranche:
"""A single tranche within a gradual-entry order sequence."""
tranche_index: int
quantity: int
parent_decision_id: str
status: str = "pending"
# ---------------------------------------------------------------------------
# Pure computation helpers
# ---------------------------------------------------------------------------
def should_use_gradual_entry(
position_size_dollars: float,
active_pool: float,
threshold_dollars: float = 30.0,
) -> bool:
"""Return True when the position size exceeds the gradual-entry threshold.
The effective threshold is ``min(threshold_dollars, 5% of active_pool)``.
"""
effective_threshold = min(threshold_dollars, 0.05 * active_pool)
return position_size_dollars > effective_threshold
def split_into_tranches(total_quantity: int, num_tranches: int = 3) -> list[int]:
"""Split *total_quantity* into *num_tranches* approximately equal parts.
The remainder is distributed one unit at a time to the first tranches
so that:
* ``sum(result) == total_quantity``
* All values differ by at most 1
"""
if num_tranches <= 0:
return []
if total_quantity <= 0:
return [0] * num_tranches
base, remainder = divmod(total_quantity, num_tranches)
return [base + (1 if i < remainder else 0) for i in range(num_tranches)]
def create_tranches(
total_quantity: int,
parent_decision_id: str,
num_tranches: int = 3,
) -> list[Tranche]:
"""Create :class:`Tranche` objects linked to *parent_decision_id*.
Uses :func:`split_into_tranches` for the quantity distribution.
"""
quantities = split_into_tranches(total_quantity, num_tranches)
return [
Tranche(
tranche_index=i,
quantity=q,
parent_decision_id=parent_decision_id,
)
for i, q in enumerate(quantities)
]
+137
View File
@@ -0,0 +1,137 @@
"""Micro-trading module for the autonomous trading engine.
Pure computation module for micro-trade evaluation logic. Handles
allocation caps, daily limits, auto-close decisions, and constraint
checking. Actual signal fetching and order submission are deferred
to the engine integration layer.
"""
from __future__ import annotations
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class MicroTradeConfig:
"""Configuration for the micro-trading module."""
enabled: bool = False
allocation_cap_pct: float = 0.03
max_daily: int = 10
max_hold_minutes: int = 120
stop_loss_atr_multiplier: float = 1.0
reward_risk_ratio: float = 1.5
# ---------------------------------------------------------------------------
# Micro-trading module (pure computation)
# ---------------------------------------------------------------------------
class MicroTradingModule:
"""Pure-computation micro-trading evaluator.
All methods are side-effect-free. The engine integration layer is
responsible for fetching signals, submitting orders, and persisting
state.
"""
def should_evaluate(
self,
config: MicroTradeConfig,
daily_count: int,
) -> bool:
"""Check whether micro-trade evaluation should proceed.
Args:
config: Current micro-trade configuration.
daily_count: Number of micro-trades already executed today.
Returns:
``True`` if micro-trading is enabled and the daily limit
has not been reached.
"""
if not config.enabled:
return False
return daily_count < config.max_daily
def compute_allocation_cap(
self,
config: MicroTradeConfig,
active_pool: float,
) -> float:
"""Compute the maximum dollar allocation for a single micro-trade.
Args:
config: Current micro-trade configuration.
active_pool: Current active pool value.
Returns:
Maximum dollar amount for a micro-trade.
"""
return config.allocation_cap_pct * active_pool
def should_auto_close(
self,
config: MicroTradeConfig,
hold_minutes: float,
) -> bool:
"""Determine whether a micro-trade should be auto-closed.
Args:
config: Current micro-trade configuration.
hold_minutes: How long the position has been held, in minutes.
Returns:
``True`` when the hold duration exceeds the configured maximum.
"""
return hold_minutes > config.max_hold_minutes
def check_constraints(
self,
config: MicroTradeConfig,
daily_count: int,
is_within_window: bool,
circuit_breaker_active: bool,
portfolio_heat_pct: float,
max_heat: float,
) -> tuple[bool, str]:
"""Check all constraints for a micro-trade.
Evaluates trading window, circuit breakers, portfolio heat,
daily limit, and enabled state.
Args:
config: Current micro-trade configuration.
daily_count: Number of micro-trades already executed today.
is_within_window: Whether the current time is within the
trading window.
circuit_breaker_active: Whether any circuit breaker is active.
portfolio_heat_pct: Current portfolio heat as a fraction
(e.g. 0.15 for 15%).
max_heat: Maximum allowed portfolio heat fraction.
Returns:
Tuple of ``(allowed, reason)``. When ``allowed`` is ``False``,
``reason`` describes which constraint was violated.
"""
if not config.enabled:
return False, "micro_trading_disabled"
if circuit_breaker_active:
return False, "circuit_breaker_active"
if not is_within_window:
return False, "outside_trading_window"
if daily_count >= config.max_daily:
return False, "daily_limit_reached"
if max_heat > 0 and portfolio_heat_pct >= max_heat:
return False, "portfolio_heat_exceeded"
return True, "ok"
+253
View File
@@ -0,0 +1,253 @@
"""Core data models for the autonomous trading engine.
Defines dataclasses for risk tier configuration, portfolio state,
trading decisions, position sizing results, stop levels, and
performance metrics used across all trading engine components.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
# ---------------------------------------------------------------------------
# Risk Tier Configuration
# ---------------------------------------------------------------------------
@dataclass
class RiskTierConfig:
"""Parameters for a named risk tier (conservative/moderate/aggressive)."""
name: str
min_confidence: float
max_position_pct: float
stop_loss_atr_multiplier: float
reward_risk_ratio: float
max_sector_pct: float
max_portfolio_heat: float
RISK_TIER_DEFAULTS: dict[str, RiskTierConfig] = {
"conservative": RiskTierConfig(
name="conservative",
min_confidence=0.75,
max_position_pct=0.05,
stop_loss_atr_multiplier=1.5,
reward_risk_ratio=2.0,
max_sector_pct=0.20,
max_portfolio_heat=0.10,
),
"moderate": RiskTierConfig(
name="moderate",
min_confidence=0.55,
max_position_pct=0.10,
stop_loss_atr_multiplier=2.0,
reward_risk_ratio=1.5,
max_sector_pct=0.30,
max_portfolio_heat=0.20,
),
"aggressive": RiskTierConfig(
name="aggressive",
min_confidence=0.40,
max_position_pct=0.15,
stop_loss_atr_multiplier=2.5,
reward_risk_ratio=1.2,
max_sector_pct=0.40,
max_portfolio_heat=0.30,
),
}
# ---------------------------------------------------------------------------
# Portfolio State
# ---------------------------------------------------------------------------
@dataclass
class PortfolioState:
"""Snapshot of the current portfolio used for decision-making."""
positions: list = field(default_factory=list)
total_value: float = 0.0
cash: float = 0.0
active_pool: float = 0.0
reserve_pool: float = 0.0
sector_exposure: dict[str, float] = field(default_factory=dict)
portfolio_heat: float = 0.0
open_position_count: int = 0
# ---------------------------------------------------------------------------
# Trading Decision
# ---------------------------------------------------------------------------
@dataclass
class TradingDecision:
"""Record of a trading decision (act or skip), persisted for audit trail."""
id: str
recommendation_id: str | None
decision: str
skip_reason: str | None
ticker: str
computed_position_size: float | None
computed_share_quantity: int | None
risk_tier_at_decision: str
portfolio_heat_at_decision: float | None
active_pool_at_decision: float | None
reserve_pool_at_decision: float | None
circuit_breaker_status: str
correlation_check_result: dict = field(default_factory=dict)
sector_exposure_check_result: dict = field(default_factory=dict)
earnings_proximity_flag: bool = False
is_micro_trade: bool = False
decision_trace: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=datetime.utcnow)
# ---------------------------------------------------------------------------
# Position Sizing
# ---------------------------------------------------------------------------
@dataclass
class PositionSizeResult:
"""Output of the position sizer computation."""
dollar_amount: float
share_quantity: int
allocation_pct: float
adjustments: list[str] = field(default_factory=list)
rejected: bool = False
rejection_reason: str = ""
# ---------------------------------------------------------------------------
# Stop-Loss / Take-Profit Levels
# ---------------------------------------------------------------------------
@dataclass
class StopLevels:
"""Current stop-loss and take-profit levels for an open position."""
stop_loss_price: float
take_profit_price: float
trailing_stop_active: bool
atr_value: float
atr_multiplier: float
reward_risk_ratio: float
last_updated: datetime = field(default_factory=datetime.utcnow)
# ---------------------------------------------------------------------------
# Open / Closed Positions
# ---------------------------------------------------------------------------
@dataclass
class OpenPosition:
"""Representation of a currently held position."""
ticker: str
quantity: int
entry_price: float
current_price: float
unrealized_pnl: float
market_value: float
sector: str
stop_loss_price: float
take_profit_price: float
signal_confidence: float
is_micro_trade: bool = False
@dataclass
class ClosedTrade:
"""Record of a completed (closed) trade."""
ticker: str
entry_price: float
exit_price: float
quantity: int
pnl: float
pnl_pct: float
hold_duration: timedelta
recommendation_id: str | None = None
is_micro_trade: bool = False
# ---------------------------------------------------------------------------
# Performance Metrics
# ---------------------------------------------------------------------------
@dataclass
class PerformanceMetrics:
"""Portfolio-wide performance metrics computed periodically."""
total_portfolio_value: float
active_pool: float
reserve_pool: float
unrealized_pnl: float
realized_pnl: float
daily_pnl: float
win_count: int
loss_count: int
win_rate: float
avg_win: float
avg_loss: float
profit_factor: float
sharpe_ratio: float
max_drawdown: float
current_drawdown_pct: float
portfolio_heat: float
computed_at: datetime = field(default_factory=datetime.utcnow)
# ---------------------------------------------------------------------------
# Circuit Breaker
# ---------------------------------------------------------------------------
@dataclass
class CircuitBreakerState:
"""Current state of the circuit breaker safety mechanism."""
active: bool = False
trigger_type: str | None = None
triggered_at: datetime | None = None
cooldown_expires: datetime | None = None
ticker_cooldowns: dict[str, datetime] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Reserve Pool
# ---------------------------------------------------------------------------
@dataclass
class ReservePoolState:
"""Current state of the reserve pool."""
balance: float = 0.0
total_deposits: float = 0.0
total_withdrawals: float = 0.0
last_updated: datetime = field(default_factory=datetime.utcnow)
# ---------------------------------------------------------------------------
# Stop Trigger
# ---------------------------------------------------------------------------
@dataclass
class StopTrigger:
"""A triggered stop-loss or take-profit event for a position."""
ticker: str
trigger_type: str # "stop_loss" or "take_profit"
current_price: float
trigger_price: float
+163
View File
@@ -0,0 +1,163 @@
"""Notification service for the autonomous trading engine.
Pure computation module for notification logic. Actual delivery (AWS SNS,
Gmail API) is deferred to integration code — this module handles formatting,
rate-limit decisions, and record creation.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from services.trading.models import PerformanceMetrics
# ---------------------------------------------------------------------------
# Supported event types
# ---------------------------------------------------------------------------
SUPPORTED_EVENT_TYPES: frozenset[str] = frozenset(
{
"circuit_breaker_triggered",
"circuit_breaker_resumed",
"risk_tier_changed",
"emergency_liquidation",
"large_trade_pnl",
"daily_summary",
"weekly_digest",
}
)
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class NotificationRecord:
"""A single notification record for audit persistence."""
channel: str
event_type: str
message: str
delivery_status: str = "pending"
retry_count: int = 0
created_at: datetime = field(default_factory=datetime.utcnow)
# ---------------------------------------------------------------------------
# Notification service (pure computation)
# ---------------------------------------------------------------------------
class NotificationService:
"""Pure-computation notification service.
Handles formatting, rate-limit checking, and record creation.
Actual delivery is the caller's responsibility.
"""
def __init__(
self,
sms_enabled: bool = False,
email_enabled: bool = False,
rate_limit_sms_per_hour: int = 10,
rate_limit_email_per_hour: int = 20,
) -> None:
self.sms_enabled = sms_enabled
self.email_enabled = email_enabled
self.rate_limit_sms_per_hour = rate_limit_sms_per_hour
self.rate_limit_email_per_hour = rate_limit_email_per_hour
# ------------------------------------------------------------------
# Rate limiting
# ------------------------------------------------------------------
def should_send(self, channel: str, current_hour_count: int) -> bool:
"""Check whether a notification on *channel* is within the rate limit.
Args:
channel: ``"sms"`` or ``"email"``.
current_hour_count: Number of notifications already sent on this
channel during the current hour window.
Returns:
``True`` if the notification may be sent, ``False`` if it should
be rate-limited.
"""
if channel == "sms":
if not self.sms_enabled:
return False
return current_hour_count < self.rate_limit_sms_per_hour
elif channel == "email":
if not self.email_enabled:
return False
return current_hour_count < self.rate_limit_email_per_hour
return False
# ------------------------------------------------------------------
# Formatting helpers
# ------------------------------------------------------------------
def format_daily_summary(self, metrics: PerformanceMetrics) -> str:
"""Format a daily performance summary message.
Args:
metrics: Current performance metrics snapshot.
Returns:
Human-readable summary string.
"""
total_trades = metrics.win_count + metrics.loss_count
return (
f"Daily Summary — "
f"P&L: ${metrics.daily_pnl:+.2f} | "
f"Portfolio: ${metrics.total_portfolio_value:,.2f} | "
f"Active: ${metrics.active_pool:,.2f} | "
f"Reserve: ${metrics.reserve_pool:,.2f} | "
f"Trades: {total_trades} | "
f"Win Rate: {metrics.win_rate:.0%} | "
f"Heat: {metrics.portfolio_heat:.2%}"
)
def format_alert(self, event_type: str, details: str) -> str:
"""Format an alert message for a specific event type.
Args:
event_type: One of the supported event types.
details: Free-form detail string.
Returns:
Formatted alert string.
"""
label = event_type.replace("_", " ").title()
return f"[Stonks Alert] {label}: {details}"
# ------------------------------------------------------------------
# Record creation
# ------------------------------------------------------------------
def create_notification(
self,
channel: str,
event_type: str,
message: str,
) -> NotificationRecord:
"""Create a ``NotificationRecord`` ready for persistence.
Args:
channel: ``"sms"`` or ``"email"``.
event_type: One of the supported event types.
message: The formatted message body.
Returns:
A new ``NotificationRecord`` with ``delivery_status="pending"``.
"""
return NotificationRecord(
channel=channel,
event_type=event_type,
message=message,
delivery_status="pending",
retry_count=0,
)
+197
View File
@@ -0,0 +1,197 @@
"""Performance tracker for the autonomous trading engine.
Pure computation module that computes portfolio-wide performance metrics
from closed trades and portfolio state data.
"""
from __future__ import annotations
import math
from services.trading.models import ClosedTrade, PerformanceMetrics
class PerformanceComputer:
"""Computes portfolio performance metrics from trade data.
All methods are pure computations with no side effects or I/O.
"""
def compute_metrics(
self,
closed_trades: list[ClosedTrade],
portfolio_value: float,
active_pool: float,
reserve_pool: float,
daily_pnl: float,
unrealized_pnl: float,
portfolio_heat: float,
daily_returns: list[float],
) -> PerformanceMetrics:
"""Compute all performance metrics from trade data and portfolio state.
Args:
closed_trades: List of completed trades.
portfolio_value: Current total portfolio value.
active_pool: Current active pool value.
reserve_pool: Current reserve pool balance.
daily_pnl: Today's P&L.
unrealized_pnl: Unrealized P&L across open positions.
portfolio_heat: Current portfolio heat value.
daily_returns: List of daily return percentages for Sharpe/drawdown.
Returns:
PerformanceMetrics with all computed fields.
"""
wins = [t for t in closed_trades if t.pnl > 0]
losses = [t for t in closed_trades if t.pnl <= 0]
win_count = len(wins)
loss_count = len(losses)
total_trades = len(closed_trades)
win_rate = win_count / total_trades if total_trades > 0 else 0.0
avg_win = (
sum(t.pnl for t in wins) / win_count if win_count > 0 else 0.0
)
avg_loss = (
sum(t.pnl for t in losses) / loss_count if loss_count > 0 else 0.0
)
gross_profits = sum(t.pnl for t in wins)
gross_losses = abs(sum(t.pnl for t in losses))
if gross_losses > 0:
profit_factor = gross_profits / gross_losses
else:
profit_factor = float("inf") if gross_profits > 0 else 0.0
realized_pnl = sum(t.pnl for t in closed_trades)
sharpe_ratio = self._compute_sharpe_ratio(daily_returns)
max_drawdown = self._compute_max_drawdown(daily_returns)
current_drawdown_pct = self._compute_current_drawdown(daily_returns)
return PerformanceMetrics(
total_portfolio_value=portfolio_value,
active_pool=active_pool,
reserve_pool=reserve_pool,
unrealized_pnl=unrealized_pnl,
realized_pnl=realized_pnl,
daily_pnl=daily_pnl,
win_count=win_count,
loss_count=loss_count,
win_rate=win_rate,
avg_win=avg_win,
avg_loss=avg_loss,
profit_factor=profit_factor,
sharpe_ratio=sharpe_ratio,
max_drawdown=max_drawdown,
current_drawdown_pct=current_drawdown_pct,
portfolio_heat=portfolio_heat,
)
def compute_trade_metrics(self, trade: ClosedTrade) -> dict:
"""Compute per-trade metrics for a single closed trade.
Args:
trade: A completed trade.
Returns:
Dictionary with per-trade metrics.
"""
return {
"ticker": trade.ticker,
"entry_price": trade.entry_price,
"exit_price": trade.exit_price,
"quantity": trade.quantity,
"pnl": trade.pnl,
"pnl_pct": trade.pnl_pct,
"hold_duration": str(trade.hold_duration),
"recommendation_id": trade.recommendation_id,
"is_micro_trade": trade.is_micro_trade,
"is_win": trade.pnl > 0,
}
def filter_by_micro_trade(
self,
trades: list[ClosedTrade],
is_micro: bool,
) -> list[ClosedTrade]:
"""Filter trades by micro-trade flag.
Args:
trades: List of closed trades.
is_micro: If True, return only micro-trades; if False, only standard.
Returns:
Filtered list of trades.
"""
return [t for t in trades if t.is_micro_trade == is_micro]
@staticmethod
def _compute_sharpe_ratio(daily_returns: list[float]) -> float:
"""Compute annualized Sharpe ratio from daily returns.
Formula: (mean_daily_return / std_daily_return) * sqrt(252)
Returns 0.0 if fewer than 2 data points or std is 0.
"""
if len(daily_returns) < 2:
return 0.0
n = len(daily_returns)
mean_return = sum(daily_returns) / n
variance = sum((r - mean_return) ** 2 for r in daily_returns) / (n - 1)
std_return = math.sqrt(variance)
if std_return < 1e-12:
return 0.0
return (mean_return / std_return) * math.sqrt(252)
@staticmethod
def _compute_max_drawdown(daily_returns: list[float]) -> float:
"""Compute maximum drawdown from daily returns.
Tracks cumulative returns and finds the largest peak-to-trough decline.
Returns 0.0 if no drawdown or insufficient data.
"""
if not daily_returns:
return 0.0
cumulative = 1.0
peak = 1.0
max_dd = 0.0
for r in daily_returns:
cumulative *= (1.0 + r)
if cumulative > peak:
peak = cumulative
drawdown = (peak - cumulative) / peak if peak > 0 else 0.0
if drawdown > max_dd:
max_dd = drawdown
return max_dd
@staticmethod
def _compute_current_drawdown(daily_returns: list[float]) -> float:
"""Compute current drawdown percentage from daily returns.
Returns the drawdown from the most recent peak to the current value.
"""
if not daily_returns:
return 0.0
cumulative = 1.0
peak = 1.0
for r in daily_returns:
cumulative *= (1.0 + r)
if cumulative > peak:
peak = cumulative
if peak <= 0:
return 0.0
return (peak - cumulative) / peak
+347
View File
@@ -0,0 +1,347 @@
"""Position sizing engine for the autonomous trading system.
Computes dollar allocation and share quantity for a trade by applying
a sequential adjustment pipeline: confidence gate, correlation reduction,
sector exposure, diversification bonus, earnings proximity, portfolio
heat check, active-pool minimum, absolute cap, and share rounding.
"""
from __future__ import annotations
import math
from datetime import datetime
from services.trading.models import (
OpenPosition,
PortfolioState,
PositionSizeResult,
RiskTierConfig,
)
class PositionSizer:
"""Compute position size through a multi-step adjustment pipeline."""
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def compute(
self,
confidence: float,
ticker: str,
sector: str,
current_price: float,
active_pool: float,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
correlation_matrix: dict[tuple[str, str], float],
earnings_calendar: dict[str, datetime],
absolute_position_cap: float = 50.0,
active_pool_minimum: float = 100.0,
) -> PositionSizeResult:
"""Run the full adjustment pipeline and return a sizing result."""
adjustments: list[str] = []
# ---- 1. Active pool minimum check (early reject) -------------
if active_pool < active_pool_minimum:
return self._rejected(
f"Active pool ${active_pool:.2f} below minimum ${active_pool_minimum:.2f}",
adjustments,
)
# ---- 2. Confidence gate --------------------------------------
if confidence < risk_tier.min_confidence:
return self._rejected(
f"Confidence {confidence:.4f} below tier minimum {risk_tier.min_confidence}",
adjustments,
)
# ---- 3. Base sizing formula ----------------------------------
base_allocation_pct = risk_tier.max_position_pct * 0.5
multiplier = 1.0 # default multiplier
raw_pct = (
base_allocation_pct
* (confidence / risk_tier.min_confidence)
* multiplier
)
clamped_pct = min(raw_pct, risk_tier.max_position_pct)
dollar_amount = active_pool * clamped_pct
dollar_amount = min(dollar_amount, absolute_position_cap)
adjustments.append(
f"Base sizing: raw_pct={raw_pct:.6f}, clamped_pct={clamped_pct:.6f}, "
f"dollar=${dollar_amount:.2f}"
)
# ---- 4. Correlation reduction --------------------------------
dollar_amount, clamped_pct = self._apply_correlation_reduction(
ticker,
dollar_amount,
clamped_pct,
portfolio_state,
correlation_matrix,
adjustments,
)
if dollar_amount == 0.0:
return self._rejected(adjustments[-1], adjustments)
# ---- 5. Sector exposure reduction ----------------------------
dollar_amount, clamped_pct = self._apply_sector_exposure_reduction(
sector,
dollar_amount,
clamped_pct,
active_pool,
risk_tier,
portfolio_state,
adjustments,
)
# ---- 6. Diversification bonus --------------------------------
dollar_amount, clamped_pct = self._apply_diversification_bonus(
sector,
dollar_amount,
clamped_pct,
risk_tier,
portfolio_state,
adjustments,
)
# ---- 7. Earnings proximity -----------------------------------
result = self._apply_earnings_proximity(
ticker,
dollar_amount,
clamped_pct,
earnings_calendar,
adjustments,
)
if isinstance(result, PositionSizeResult):
return result
dollar_amount, clamped_pct = result
# ---- 8. Absolute cap enforcement (re-apply after adjustments) -
if dollar_amount > absolute_position_cap:
dollar_amount = absolute_position_cap
clamped_pct = dollar_amount / active_pool if active_pool > 0 else 0.0
adjustments.append(
f"Absolute cap enforced: capped to ${absolute_position_cap:.2f}"
)
# ---- 9. Portfolio heat check ---------------------------------
stop_loss_distance_pct = risk_tier.stop_loss_atr_multiplier * 0.02
new_position_heat = dollar_amount * stop_loss_distance_pct
max_heat_dollars = risk_tier.max_portfolio_heat * active_pool
current_heat = portfolio_state.portfolio_heat
if current_heat + new_position_heat > max_heat_dollars:
return self._rejected(
f"Portfolio heat would exceed limit: current={current_heat:.2f} + "
f"new={new_position_heat:.2f} > max={max_heat_dollars:.2f}",
adjustments,
)
# ---- 10. Share rounding --------------------------------------
if current_price <= 0:
return self._rejected("Invalid current price", adjustments)
share_quantity = math.floor(dollar_amount / current_price)
if share_quantity == 0:
return self._rejected(
f"Zero shares after rounding: ${dollar_amount:.2f} / ${current_price:.2f}",
adjustments,
)
# Final dollar amount based on whole shares
final_dollar = share_quantity * current_price
final_pct = final_dollar / active_pool if active_pool > 0 else 0.0
adjustments.append(
f"Final: {share_quantity} shares @ ${current_price:.2f} = ${final_dollar:.2f} "
f"({final_pct:.4%} of active pool)"
)
return PositionSizeResult(
dollar_amount=final_dollar,
share_quantity=share_quantity,
allocation_pct=final_pct,
adjustments=adjustments,
rejected=False,
rejection_reason="",
)
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _rejected(reason: str, adjustments: list[str]) -> PositionSizeResult:
return PositionSizeResult(
dollar_amount=0.0,
share_quantity=0,
allocation_pct=0.0,
adjustments=adjustments,
rejected=True,
rejection_reason=reason,
)
@staticmethod
def _apply_correlation_reduction(
ticker: str,
dollar_amount: float,
allocation_pct: float,
portfolio_state: PortfolioState,
correlation_matrix: dict[tuple[str, str], float],
adjustments: list[str],
) -> tuple[float, float]:
"""Reduce or reject based on weighted average correlation."""
positions: list[OpenPosition] = portfolio_state.positions
if not positions:
return dollar_amount, allocation_pct
total_weight = 0.0
weighted_corr = 0.0
for pos in positions:
corr = correlation_matrix.get(
(ticker, pos.ticker),
correlation_matrix.get((pos.ticker, ticker), 0.0),
)
weight = pos.market_value
weighted_corr += corr * weight
total_weight += weight
if total_weight == 0.0:
return dollar_amount, allocation_pct
avg_corr = weighted_corr / total_weight
if avg_corr > 0.8:
adjustments.append(
f"Correlation rejection: avg={avg_corr:.4f} > 0.8"
)
return 0.0, 0.0
if avg_corr > 0.5:
# Reduce proportionally: scale factor goes from 1.0 at 0.5 to 0.0 at 0.8
reduction = (avg_corr - 0.5) / (0.8 - 0.5)
factor = 1.0 - reduction
new_dollar = dollar_amount * factor
new_pct = allocation_pct * factor
adjustments.append(
f"Correlation reduction: avg={avg_corr:.4f}, factor={factor:.4f}, "
f"${dollar_amount:.2f} -> ${new_dollar:.2f}"
)
return new_dollar, new_pct
return dollar_amount, allocation_pct
@staticmethod
def _apply_sector_exposure_reduction(
sector: str,
dollar_amount: float,
allocation_pct: float,
active_pool: float,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
adjustments: list[str],
) -> tuple[float, float]:
"""Reduce allocation if sector would exceed max_sector_pct."""
max_sector_dollars = risk_tier.max_sector_pct * active_pool
current_sector_exposure = portfolio_state.sector_exposure.get(sector, 0.0)
if current_sector_exposure + dollar_amount > max_sector_dollars:
available = max(max_sector_dollars - current_sector_exposure, 0.0)
if available <= 0:
adjustments.append(
f"Sector exposure at limit: {sector} "
f"${current_sector_exposure:.2f} >= max ${max_sector_dollars:.2f}"
)
return 0.0, 0.0
new_pct = available / active_pool if active_pool > 0 else 0.0
adjustments.append(
f"Sector exposure reduction: {sector} "
f"${current_sector_exposure:.2f} + ${dollar_amount:.2f} > "
f"max ${max_sector_dollars:.2f}, reduced to ${available:.2f}"
)
return available, new_pct
return dollar_amount, allocation_pct
@staticmethod
def _apply_diversification_bonus(
sector: str,
dollar_amount: float,
allocation_pct: float,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
adjustments: list[str],
) -> tuple[float, float]:
"""Apply 1.2x bonus for under-represented sectors when < 3 sectors held."""
existing_sectors = set(portfolio_state.sector_exposure.keys())
if len(existing_sectors) < 3 and sector not in existing_sectors:
bonus = 1.2
new_dollar = dollar_amount * bonus
new_pct = allocation_pct * bonus
# Re-clamp to max_position_pct after bonus
max_dollar = risk_tier.max_position_pct * (
portfolio_state.active_pool
if portfolio_state.active_pool > 0
else 1.0
)
if new_dollar > max_dollar:
new_dollar = max_dollar
new_pct = risk_tier.max_position_pct
adjustments.append(
f"Diversification bonus: 1.2x applied for new sector '{sector}' "
f"(portfolio has {len(existing_sectors)} sectors), "
f"${dollar_amount:.2f} -> ${new_dollar:.2f}"
)
return new_dollar, new_pct
return dollar_amount, allocation_pct
@staticmethod
def _apply_earnings_proximity(
ticker: str,
dollar_amount: float,
allocation_pct: float,
earnings_calendar: dict[str, datetime],
adjustments: list[str],
) -> tuple[float, float] | PositionSizeResult:
"""Reduce by 50% within 3 trading days; reject within 1 trading day."""
if ticker not in earnings_calendar:
return dollar_amount, allocation_pct
earnings_dt = earnings_calendar[ticker]
now = datetime.utcnow()
delta = earnings_dt - now
# Use total_seconds for precise fractional-day comparison
trading_days_until = delta.total_seconds() / 86400.0
if trading_days_until < 0:
# Earnings already passed
return dollar_amount, allocation_pct
if trading_days_until <= 1:
adjustments.append(
f"Earnings rejection: {ticker} earnings in {trading_days_until:.1f} day(s)"
)
return PositionSizeResult(
dollar_amount=0.0,
share_quantity=0,
allocation_pct=0.0,
adjustments=adjustments,
rejected=True,
rejection_reason=f"Earnings within 1 trading day for {ticker}",
)
if trading_days_until <= 3:
new_dollar = dollar_amount * 0.5
new_pct = allocation_pct * 0.5
adjustments.append(
f"Earnings proximity: {ticker} earnings in {trading_days_until:.1f} days, "
f"50% reduction: ${dollar_amount:.2f} -> ${new_dollar:.2f}"
)
return new_dollar, new_pct
return dollar_amount, allocation_pct
+174
View File
@@ -0,0 +1,174 @@
"""Portfolio rebalancer for the autonomous trading engine.
Evaluates portfolio concentration and generates rebalancing sell orders
when single-stock or sector exposure exceeds configured limits.
"""
from __future__ import annotations
from dataclasses import dataclass
from services.trading.models import OpenPosition, RiskTierConfig
@dataclass
class RebalanceOrder:
"""A sell order generated by the portfolio rebalancer."""
ticker: str
action: str = "sell"
quantity: int = 0
reason: str = ""
tag: str = "rebalance"
class PortfolioRebalancer:
"""Evaluates portfolio concentration and generates rebalancing orders.
Generates partial sell orders when:
- A single stock exceeds max_position_pct of the active pool
- A sector exceeds max_sector_pct of the active pool
- The number of open positions exceeds the configured maximum
"""
def evaluate(
self,
positions: list[OpenPosition],
risk_tier: RiskTierConfig,
active_pool: float,
max_positions: int = 10,
) -> list[RebalanceOrder]:
"""Evaluate portfolio and generate rebalancing sell orders.
Args:
positions: Current open positions.
risk_tier: Active risk tier configuration.
active_pool: Current active pool value in dollars.
max_positions: Maximum allowed open positions.
Returns:
List of RebalanceOrder for positions that need trimming.
"""
orders: list[RebalanceOrder] = []
if not positions or active_pool <= 0:
return orders
# Track which tickers already have orders to avoid duplicates
ordered_tickers: dict[str, RebalanceOrder] = {}
# --- 1. Single-stock concentration check ---
max_position_dollars = risk_tier.max_position_pct * active_pool
for pos in positions:
if pos.market_value > max_position_dollars and pos.current_price > 0:
excess = pos.market_value - max_position_dollars
sell_qty = int(excess / pos.current_price)
if sell_qty > 0:
sell_qty = min(sell_qty, pos.quantity)
order = RebalanceOrder(
ticker=pos.ticker,
action="sell",
quantity=sell_qty,
reason=(
f"Position {pos.ticker} market_value "
f"${pos.market_value:.2f} exceeds "
f"max_position_pct limit "
f"${max_position_dollars:.2f}"
),
tag="rebalance",
)
ordered_tickers[pos.ticker] = order
orders.append(order)
# --- 2. Sector concentration check ---
max_sector_dollars = risk_tier.max_sector_pct * active_pool
# Group positions by sector
sector_positions: dict[str, list[OpenPosition]] = {}
for pos in positions:
sector_positions.setdefault(pos.sector, []).append(pos)
for sector, sector_pos in sector_positions.items():
sector_value = sum(p.market_value for p in sector_pos)
if sector_value > max_sector_dollars:
excess = sector_value - max_sector_dollars
# Sort by confidence ascending — sell lowest confidence first
sorted_pos = sorted(sector_pos, key=lambda p: p.signal_confidence)
remaining_excess = excess
for pos in sorted_pos:
if remaining_excess <= 0:
break
if pos.current_price <= 0:
continue
# Determine how many shares to sell from this position
sell_value = min(remaining_excess, pos.market_value)
sell_qty = int(sell_value / pos.current_price)
if sell_qty <= 0:
continue
sell_qty = min(sell_qty, pos.quantity)
if pos.ticker in ordered_tickers:
# Already have an order for this ticker — take the larger
existing = ordered_tickers[pos.ticker]
if sell_qty > existing.quantity:
existing.quantity = sell_qty
existing.reason += (
f"; also sector {sector} exposure "
f"${sector_value:.2f} exceeds limit "
f"${max_sector_dollars:.2f}"
)
else:
order = RebalanceOrder(
ticker=pos.ticker,
action="sell",
quantity=sell_qty,
reason=(
f"Sector {sector} exposure "
f"${sector_value:.2f} exceeds "
f"max_sector_pct limit "
f"${max_sector_dollars:.2f}"
f"selling lowest-confidence position"
),
tag="rebalance",
)
ordered_tickers[pos.ticker] = order
orders.append(order)
remaining_excess -= sell_qty * pos.current_price
# --- 3. Maximum open positions enforcement ---
if len(positions) > max_positions:
excess_count = len(positions) - max_positions
# Sort by confidence ascending — sell lowest confidence first
sorted_all = sorted(positions, key=lambda p: p.signal_confidence)
sold_count = 0
for pos in sorted_all:
if sold_count >= excess_count:
break
if pos.ticker in ordered_tickers:
# Already selling this ticker — count it toward excess
existing = ordered_tickers[pos.ticker]
if existing.quantity < pos.quantity:
existing.quantity = pos.quantity
existing.reason += "; also exceeds max open positions"
sold_count += 1
else:
order = RebalanceOrder(
ticker=pos.ticker,
action="sell",
quantity=pos.quantity,
reason=(
f"Portfolio has {len(positions)} positions, "
f"exceeding max of {max_positions}"
f"selling lowest-confidence position"
),
tag="rebalance",
)
ordered_tickers[pos.ticker] = order
orders.append(order)
sold_count += 1
return orders
+112
View File
@@ -0,0 +1,112 @@
"""Reserve Pool Controller — pure computation module.
Manages the untouchable cash reserve that grows from realized profits.
All methods are pure computations; persistence is handled by the caller.
"""
from __future__ import annotations
from services.trading.models import ReservePoolState # noqa: F401 — re-export for convenience
class ReservePoolController:
"""Compute reserve-pool operations without touching the database.
Parameters
----------
siphon_pct:
Fraction of realized profit transferred to the reserve on each
profitable position close (default 20 %).
high_water_pct:
When the reserve exceeds this fraction of total portfolio value
the risk-tier controller should consider upgrading (default 30 %).
"""
def __init__(
self,
siphon_pct: float = 0.20,
high_water_pct: float = 0.30,
) -> None:
self.siphon_pct = siphon_pct
self.high_water_pct = high_water_pct
# ------------------------------------------------------------------
# Profit siphoning
# ------------------------------------------------------------------
def siphon_profit(
self,
realized_profit: float,
current_balance: float,
) -> tuple[float, float]:
"""Compute the amount to transfer into the reserve pool.
Only positive profits are siphoned.
Returns
-------
(transfer_amount, new_balance)
*transfer_amount* is ``realized_profit * siphon_pct`` when
the profit is positive, otherwise ``0.0``.
*new_balance* is ``current_balance + transfer_amount``.
"""
if realized_profit <= 0:
return 0.0, current_balance
transfer = realized_profit * self.siphon_pct
return transfer, current_balance + transfer
# ------------------------------------------------------------------
# Emergency liquidation
# ------------------------------------------------------------------
def emergency_liquidate(self, current_balance: float) -> float:
"""Return the full reserve balance to be released into the active pool.
The caller is responsible for zeroing the persisted balance and
recording the ledger entry.
Returns
-------
float
The amount to release (equal to *current_balance*).
"""
return current_balance
# ------------------------------------------------------------------
# Active pool computation
# ------------------------------------------------------------------
def compute_active_pool(
self,
total_portfolio_value: float,
reserve_balance: float,
) -> float:
"""Active Pool = total portfolio value reserve balance."""
return total_portfolio_value - reserve_balance
# ------------------------------------------------------------------
# High-water mark detection
# ------------------------------------------------------------------
def is_high_water(
self,
reserve_balance: float,
total_portfolio_value: float,
) -> bool:
"""Return ``True`` when the reserve exceeds *high_water_pct* of total portfolio."""
if total_portfolio_value <= 0:
return False
return reserve_balance > self.high_water_pct * total_portfolio_value
# ------------------------------------------------------------------
# Emergency liquidation trigger check
# ------------------------------------------------------------------
def should_emergency_liquidate(
self,
current_drawdown_pct: float,
emergency_threshold_pct: float,
) -> bool:
"""Return ``True`` when drawdown exceeds the emergency threshold."""
return current_drawdown_pct > emergency_threshold_pct
+87
View File
@@ -0,0 +1,87 @@
"""Risk tier auto-adjustment controller for the autonomous trading engine.
Pure computation module — no DB access. Persistence of tier changes is
handled by the caller. All methods operate on values passed in as
arguments and return deterministic results.
Tier ordering: conservative → moderate → aggressive
"""
from __future__ import annotations
from services.trading.models import PerformanceMetrics
# Ordered from lowest to highest risk.
TIER_ORDER: list[str] = ["conservative", "moderate", "aggressive"]
class RiskTierController:
"""Evaluates performance metrics and determines whether the active
risk tier should change.
Downgrade conditions (any one triggers a downgrade by one level):
- Trailing 30-day win rate < 40%
- Current drawdown > 15%
Upgrade conditions (ALL must be true):
- Trailing 30-day win rate > 55%
- Reserve pool > 20% of total portfolio
- Current drawdown < 5%
"""
def __init__(self) -> None:
# No configuration needed — uses TIER_ORDER for ordering.
pass
def evaluate(
self,
current_tier: str,
metrics: PerformanceMetrics,
reserve_pct: float,
) -> str | None:
"""Evaluate whether the tier should change based on performance.
Parameters
----------
current_tier:
The currently active tier name (e.g. ``"moderate"``).
metrics:
Latest portfolio performance metrics.
reserve_pct:
Reserve pool balance as a fraction of total portfolio value
(e.g. 0.25 means 25%).
Returns
-------
str | None
The new tier name if a change is needed, or ``None`` if the
current tier should remain.
"""
current_index = TIER_ORDER.index(current_tier)
# --- Downgrade check (any condition triggers) ---
should_downgrade = (
metrics.win_rate < 0.40 or metrics.current_drawdown_pct > 0.15
)
if should_downgrade:
if current_index > 0:
return TIER_ORDER[current_index - 1]
# Already at the lowest tier — no change.
return None
# --- Upgrade check (all conditions must be true) ---
should_upgrade = (
metrics.win_rate > 0.55
and reserve_pct > 0.20
and metrics.current_drawdown_pct < 0.05
)
if should_upgrade:
if current_index < len(TIER_ORDER) - 1:
return TIER_ORDER[current_index + 1]
# Already at the highest tier — no change.
return None
# Neither condition met — stay at current tier.
return None
+256
View File
@@ -0,0 +1,256 @@
"""Stop-loss and take-profit management for the autonomous trading engine.
Computes initial stop/take-profit levels from ATR and risk tier parameters,
re-evaluates levels when volatility or market conditions change, detects
price crossings that should trigger exits, and tightens stops under
high-heat or high-severity-event conditions.
All public methods are synchronous (pure computation, no DB access).
Persistence is handled by the caller (engine.py).
"""
from __future__ import annotations
from datetime import datetime
from services.trading.models import (
OpenPosition,
RiskTierConfig,
StopLevels,
StopTrigger,
)
class StopLossManager:
"""Compute and maintain dynamic stop-loss / take-profit levels."""
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def compute_initial_levels(
self,
entry_price: float,
atr: float,
risk_tier: RiskTierConfig,
is_micro_trade: bool = False,
) -> StopLevels:
"""Compute initial stop-loss and take-profit for a new position.
For standard trades the risk tier's ATR multiplier and reward/risk
ratio are used. Micro-trades use a tighter 1.0x ATR multiplier
and 1.5x stop distance for the take-profit target.
"""
if is_micro_trade:
atr_multiplier = 1.0
reward_risk_ratio = 1.5
else:
atr_multiplier = risk_tier.stop_loss_atr_multiplier
reward_risk_ratio = risk_tier.reward_risk_ratio
stop_distance = atr * atr_multiplier
stop_loss_price = entry_price - stop_distance
if is_micro_trade:
take_profit_price = entry_price + (stop_distance * reward_risk_ratio)
else:
take_profit_price = entry_price + (stop_distance * reward_risk_ratio)
return StopLevels(
stop_loss_price=stop_loss_price,
take_profit_price=take_profit_price,
trailing_stop_active=False,
atr_value=atr,
atr_multiplier=atr_multiplier,
reward_risk_ratio=reward_risk_ratio,
last_updated=datetime.utcnow(),
)
def re_evaluate_levels(
self,
position: OpenPosition,
current_price: float,
atr: float,
risk_tier: RiskTierConfig,
last_levels: StopLevels,
high_severity_event: bool = False,
earnings_within_3_days: bool = False,
portfolio_heat_pct: float = 0.0,
max_portfolio_heat: float = 0.20,
) -> StopLevels | None:
"""Re-evaluate stop/take-profit levels for an open position.
Returns updated ``StopLevels`` when a material change is needed,
or ``None`` when the existing levels are still appropriate.
Material change triggers:
* ATR shift > 10 %
* Trailing stop activation (price moved > 50 % of TP distance)
* Earnings proximity tightening (0.7x ATR multiplier)
* High-severity macro event tightening (0.5x normal multiplier)
* Proactive heat tightening (heat > 80 % of max)
"""
entry_price = position.entry_price
# --- Determine effective ATR multiplier -----------------------
base_multiplier = risk_tier.stop_loss_atr_multiplier
if high_severity_event:
effective_multiplier = base_multiplier * 0.5
elif earnings_within_3_days:
effective_multiplier = base_multiplier * 0.7
else:
effective_multiplier = base_multiplier
# --- Trailing stop check --------------------------------------
trailing_stop_active = last_levels.trailing_stop_active
tp_distance = last_levels.take_profit_price - entry_price
favorable_move = current_price - entry_price
if tp_distance > 0 and favorable_move > 0.5 * tp_distance:
trailing_stop_active = True
# --- Compute candidate stop-loss ------------------------------
candidate_stop = entry_price - (atr * effective_multiplier)
# If trailing stop is active, floor the stop at entry (breakeven)
if trailing_stop_active:
candidate_stop = max(candidate_stop, entry_price)
# --- Proactive heat tightening --------------------------------
# When portfolio heat exceeds 80% of max, tighten further
if max_portfolio_heat > 0 and portfolio_heat_pct > 0.8 * max_portfolio_heat:
heat_tightening_factor = 0.7
tightened_stop = entry_price - (
atr * effective_multiplier * heat_tightening_factor
)
if trailing_stop_active:
tightened_stop = max(tightened_stop, entry_price)
candidate_stop = max(candidate_stop, tightened_stop)
# --- Decide whether the change is material --------------------
atr_change_pct = (
abs(atr - last_levels.atr_value) / last_levels.atr_value
if last_levels.atr_value > 0
else 0.0
)
trailing_changed = trailing_stop_active != last_levels.trailing_stop_active
multiplier_changed = effective_multiplier != last_levels.atr_multiplier
if not trailing_changed and not multiplier_changed and atr_change_pct < 0.10:
return None # no material change
# --- Compute new take-profit ----------------------------------
stop_distance = atr * effective_multiplier
reward_risk_ratio = risk_tier.reward_risk_ratio
candidate_tp = entry_price + (stop_distance * reward_risk_ratio)
return StopLevels(
stop_loss_price=candidate_stop,
take_profit_price=candidate_tp,
trailing_stop_active=trailing_stop_active,
atr_value=atr,
atr_multiplier=effective_multiplier,
reward_risk_ratio=reward_risk_ratio,
last_updated=datetime.utcnow(),
)
def check_price_crossings(
self,
positions: list[OpenPosition],
prices: dict[str, float],
stop_levels: dict[str, StopLevels],
) -> list[StopTrigger]:
"""Return triggers for positions whose price has crossed a level.
A ``StopTrigger`` is emitted when:
* current price <= stop_loss_price → ``"stop_loss"``
* current price >= take_profit_price → ``"take_profit"``
"""
triggers: list[StopTrigger] = []
for pos in positions:
current_price = prices.get(pos.ticker)
if current_price is None:
continue
levels = stop_levels.get(pos.ticker)
if levels is None:
continue
if current_price <= levels.stop_loss_price:
triggers.append(
StopTrigger(
ticker=pos.ticker,
trigger_type="stop_loss",
current_price=current_price,
trigger_price=levels.stop_loss_price,
)
)
elif current_price >= levels.take_profit_price:
triggers.append(
StopTrigger(
ticker=pos.ticker,
trigger_type="take_profit",
current_price=current_price,
trigger_price=levels.take_profit_price,
)
)
return triggers
def tighten_for_heat(
self,
positions: list[OpenPosition],
stop_levels: dict[str, StopLevels],
portfolio_heat: float,
max_heat: float,
active_pool: float,
) -> dict[str, StopLevels]:
"""Tighten stops on lowest-confidence positions when heat is high.
When ``portfolio_heat > 0.8 * max_heat``, the lowest-confidence
positions get their stops tightened first (moved closer to current
price) to reduce overall portfolio heat.
Returns a *new* dict containing only the tickers whose levels
were actually changed.
"""
if max_heat <= 0 or portfolio_heat <= 0.8 * max_heat:
return {}
# Sort positions by confidence ascending (lowest first)
sorted_positions = sorted(positions, key=lambda p: p.signal_confidence)
updated: dict[str, StopLevels] = {}
for pos in sorted_positions:
levels = stop_levels.get(pos.ticker)
if levels is None:
continue
# Tighten by reducing the stop distance by 30 %
heat_factor = 0.7
new_stop_distance = levels.atr_value * levels.atr_multiplier * heat_factor
new_stop = pos.entry_price - new_stop_distance
# Never move stop further away from current price
new_stop = max(new_stop, levels.stop_loss_price)
# If trailing stop is active, floor at entry
if levels.trailing_stop_active:
new_stop = max(new_stop, pos.entry_price)
if new_stop != levels.stop_loss_price:
updated[pos.ticker] = StopLevels(
stop_loss_price=new_stop,
take_profit_price=levels.take_profit_price,
trailing_stop_active=levels.trailing_stop_active,
atr_value=levels.atr_value,
atr_multiplier=levels.atr_multiplier,
reward_risk_ratio=levels.reward_risk_ratio,
last_updated=datetime.utcnow(),
)
return updated
+132
View File
@@ -0,0 +1,132 @@
"""Tax lot tracking for cost basis and wash sale detection.
Feature: autonomous-trading-engine
Pure computation module for FIFO tax lot closing and wash sale
detection within the 30-day window. Used by the Trading Engine
for tax-loss harvesting awareness.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
@dataclass
class TaxLot:
"""A single tax lot representing a purchase of shares."""
ticker: str
quantity: int
cost_basis_per_share: float
acquisition_date: date
status: str = "open" # open | closed | washed
@dataclass
class ClosedLot:
"""Result of closing a tax lot via FIFO."""
ticker: str
quantity: int
cost_basis_per_share: float
exit_price: float
realized_pnl: float
acquisition_date: date
closed_date: date
class TaxLotTracker:
"""Pure computation for FIFO lot closing and wash sale detection."""
def close_lots_fifo(
self,
lots: list[TaxLot],
quantity: int,
exit_price: float,
exit_date: date,
) -> list[ClosedLot]:
"""Close lots in FIFO order (earliest acquired first).
Processes open lots sorted by acquisition_date ascending,
closing shares until the requested quantity is fulfilled.
Returns a list of ClosedLot records with realized P&L.
Parameters
----------
lots:
All tax lots for the ticker (open ones will be selected).
quantity:
Number of shares to close.
exit_price:
Price per share at exit.
exit_date:
Date the lots are being closed.
Returns
-------
list[ClosedLot]
Closed lot records in FIFO order.
"""
open_lots = sorted(
[lot for lot in lots if lot.status == "open"],
key=lambda lot: lot.acquisition_date,
)
closed: list[ClosedLot] = []
remaining = quantity
for lot in open_lots:
if remaining <= 0:
break
close_qty = min(lot.quantity, remaining)
realized_pnl = (exit_price - lot.cost_basis_per_share) * close_qty
closed.append(
ClosedLot(
ticker=lot.ticker,
quantity=close_qty,
cost_basis_per_share=lot.cost_basis_per_share,
exit_price=exit_price,
realized_pnl=realized_pnl,
acquisition_date=lot.acquisition_date,
closed_date=exit_date,
)
)
remaining -= close_qty
return closed
def check_wash_sale(
self,
loss_date: date,
purchases: list[TaxLot],
) -> bool:
"""Check whether any purchase falls within the 30-day wash sale window.
A wash sale occurs when the same ticker is purchased within
30 days before or after a loss-closing date.
Parameters
----------
loss_date:
The date the loss was realized.
purchases:
Tax lots representing purchases of the same ticker.
Returns
-------
bool
True if any purchase is within the 30-day window.
"""
window_start = loss_date - timedelta(days=30)
window_end = loss_date + timedelta(days=30)
for lot in purchases:
if window_start <= lot.acquisition_date <= window_end:
return True
return False
+82
View File
@@ -0,0 +1,82 @@
"""Trading window utilities for the autonomous trading engine.
Pure computation module that determines whether a given timestamp falls
within the allowed trading window (9:45 AM 3:45 PM ET on weekdays),
whether the US market is open, and when the next trading window opens.
Uses ``zoneinfo.ZoneInfo("America/New_York")`` for Eastern Time handling.
Does not check market holidays (simplified).
"""
from __future__ import annotations
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
# US Eastern timezone
ET = ZoneInfo("America/New_York")
# Trading window boundaries (excludes first/last 15 min of market hours)
WINDOW_OPEN = time(9, 45)
WINDOW_CLOSE = time(15, 45)
# Full US market hours
MARKET_OPEN = time(9, 30)
MARKET_CLOSE = time(16, 0)
# Weekday range: Monday=0 … Friday=4
_WEEKDAYS = range(0, 5)
def is_within_trading_window(dt: datetime) -> bool:
"""Return True if *dt* is between 9:45 AM ET and 3:45 PM ET on a weekday.
The timestamp is first converted to US/Eastern time. Weekends are
always outside the window. Market holidays are **not** checked
(simplified implementation).
"""
et_dt = dt.astimezone(ET)
if et_dt.weekday() not in _WEEKDAYS:
return False
t = et_dt.time()
return WINDOW_OPEN <= t < WINDOW_CLOSE
def next_window_open(dt: datetime) -> datetime:
"""Return the next datetime when the trading window opens (9:45 AM ET).
If *dt* is before 9:45 AM ET on a weekday the same day's open is
returned. Otherwise the next weekday's 9:45 AM ET is returned.
"""
et_dt = dt.astimezone(ET)
today_open = et_dt.replace(
hour=WINDOW_OPEN.hour,
minute=WINDOW_OPEN.minute,
second=0,
microsecond=0,
)
# If we haven't reached today's open yet and it's a weekday, return today
if et_dt < today_open and et_dt.weekday() in _WEEKDAYS:
return today_open
# Otherwise advance to the next weekday
candidate = et_dt + timedelta(days=1)
candidate = candidate.replace(
hour=WINDOW_OPEN.hour,
minute=WINDOW_OPEN.minute,
second=0,
microsecond=0,
)
while candidate.weekday() not in _WEEKDAYS:
candidate += timedelta(days=1)
return candidate
def is_market_open(dt: datetime) -> bool:
"""Return True if *dt* is during US market hours (9:30 AM 4:00 PM ET) on a weekday."""
et_dt = dt.astimezone(ET)
if et_dt.weekday() not in _WEEKDAYS:
return False
t = et_dt.time()
return MARKET_OPEN <= t < MARKET_CLOSE