Files
stonks-oracle/.kiro/specs/autonomous-trading-engine/design.md
T
Celes Renata 4ffde8cc06 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
2026-04-15 16:12:22 +00:00

57 KiB

Autonomous Trading Engine — Design

Overview

This design adds a fully autonomous trading engine to the Stonks Oracle platform. The engine is a new service (services/trading/) that consumes actionable recommendations from the existing three-layer signal aggregation pipeline, applies confidence-based position sizing with reserve pool management, enforces dynamic stop-loss/take-profit levels, manages circuit breakers, and submits orders through the existing Broker Service queue.

The engine is designed for a developer allocating ~$500 in paper trading capital. All buy/sell decisions are made autonomously based on signal confidence, risk tier configuration, portfolio state, and market conditions. The architecture supports switching from paper to live trading by changing a single configuration flag.

Design Rationale

  • Extend, don't replace: The trading engine sits on top of the existing recommendation engine, broker service, and risk engine. It consumes recommendations via PostgreSQL polling and submits orders to the existing stonks:queue:broker_orders Redis queue.
  • Fail-safe by default: Circuit breakers halt trading on abnormal losses. The reserve pool is untouchable by normal trading. Stop-losses are mandatory on every position. The engine fails closed — if it can't determine portfolio state, it stops trading.
  • Full audit trail: Every decision (act or skip) is persisted with the complete reasoning chain — recommendation ID, position sizing inputs, risk tier, portfolio heat, correlation checks, sector exposure, and circuit breaker status.
  • Incremental entry: Positions above a threshold are built gradually via tranches, allowing the engine to abort if conditions change between fills.
  • Adaptive risk: Risk tiers auto-adjust based on trailing performance metrics and reserve pool health, shifting conservative after losses and aggressive after sustained wins.
  • Notification-driven awareness: Critical events (circuit breaker triggers, risk tier changes, large trades) push SMS via AWS SNS and email via Gmail API so the operator doesn't need to watch the dashboard.

Architecture

The trading engine introduces one new Kubernetes deployment (trading-engine) and extends the existing Query API and Dashboard. It communicates with existing services through PostgreSQL, Redis queues, and the Alpaca broker adapter.

flowchart TD
    subgraph Existing["Existing Services"]
        REC[Recommendation Engine]
        BS[Broker Service]
        BA[Broker Adapter / Alpaca]
        RE[Risk Engine]
        AGG[Aggregation Engine]
    end

    subgraph TradingEngine["Trading Engine Service (new)"]
        DL[Decision Loop]
        PS[Position Sizer]
        SLM[Stop-Loss Manager]
        CB[Circuit Breaker]
        RPC[Reserve Pool Controller]
        RTC[Risk Tier Controller]
        PR[Portfolio Rebalancer]
        PT[Performance Tracker]
        BT[Backtester]
        NS[Notification Service]
        MT[Micro-Trading Module]
    end

    subgraph Storage["PostgreSQL"]
        TEC[trading_engine_config]
        RPL[reserve_pool_ledger]
        RTH[risk_tier_history]
        CBE[circuit_breaker_events]
        TD[trading_decisions]
        PSL[position_stop_levels]
        PSN[portfolio_snapshots]
        BTR[backtest_runs / backtest_trades]
        TL[tax_lots]
        EC[earnings_calendar]
        CMC[correlation_matrix_cache]
        NTF[notifications]
    end

    subgraph External["External Services"]
        SNS[AWS SNS]
        GMAIL[Gmail API]
    end

    subgraph Dashboard["Dashboard (extended)"]
        TCP[Trading Control Panel]
        PCP[Portfolio Composition]
        THP[Trade History]
        PFP[Performance Charts]
        BTP[Backtesting Panel]
        MTP[Micro-Trading Panel]
        NTP[Notification Preferences]
    end

    REC -->|recommendations table| DL
    AGG -->|macro events| DL
    DL -->|order jobs| BS
    BS -->|orders| BA
    DL --> PS
    DL --> SLM
    DL --> CB
    DL --> RPC
    DL --> RTC
    DL --> PR
    DL --> PT
    DL --> NS
    DL --> MT
    NS --> SNS
    NS --> GMAIL
    PT --> PSN
    BT --> BTR
    DL --> TD
    SLM --> PSL
    RPC --> RPL
    RTC --> RTH
    CB --> CBE

Data Flow

  1. Recommendation Polling: The Decision Loop polls the recommendations table for new actionable recommendations (action = buy/sell, mode = paper_eligible/live_eligible). Each recommendation ID is checked against a Redis deduplication set (TTL 24h) to ensure at-most-once processing.

  2. Pre-Trade Evaluation: For each new recommendation, the engine evaluates: circuit breaker status, trading window constraints, risk tier confidence threshold, portfolio heat capacity, sector exposure limits, correlation constraints, and earnings calendar proximity.

  3. Position Sizing: The Position Sizer computes the dollar allocation using base_allocation_pct * (confidence / confidence_threshold) * risk_tier_multiplier, clamped to the risk tier's max_position_pct of the Active Pool. Correlation and sector adjustments reduce the allocation further if needed.

  4. Order Submission: The engine generates an order job payload (matching the existing broker queue schema) and pushes it to stonks:queue:broker_orders. The existing Broker Service handles risk evaluation, Alpaca submission, and persistence.

  5. Stop-Loss/Take-Profit Management: The Stop-Loss Manager computes initial levels on position open (ATR-based) and re-evaluates every 5 minutes during market hours. Price crossings trigger immediate sell orders.

  6. Reserve Pool Siphoning: When the Broker Service reports a filled sell order that closes a position at a profit, the engine transfers a configurable percentage of the realized profit into the Reserve Pool.

  7. Performance Tracking: The Performance Tracker computes portfolio metrics every 5 minutes during market hours and persists daily snapshots at market close.

  8. Adaptive Adjustment: The Risk Tier Controller evaluates performance daily at market close and adjusts the active tier based on win rate, Sharpe ratio, drawdown, and reserve pool health.

  9. Notifications: Critical events (circuit breaker triggers, tier changes, large trades) are pushed via AWS SNS (SMS) and Gmail API (email). Daily and weekly summaries are sent on schedule.

Components and Interfaces

Decision Loop

Location: services/trading/engine.py

The core autonomous loop that drives all trading activity. Runs as an asyncio task within the trading engine service.

class TradingEngine:
    """Main autonomous trading engine.
    
    Manages the decision loop, coordinates all sub-components,
    and maintains runtime state.
    """
    def __init__(
        self,
        pool: asyncpg.Pool,
        redis: aioredis.Redis,
        config: TradingEngineConfig,
    ) -> None: ...

    async def start(self) -> None:
        """Load portfolio state, enter decision loop."""
        
    async def stop(self) -> None:
        """Graceful shutdown — cancel pending tranches, persist state."""

    async def decision_loop(self) -> None:
        """Main loop: poll recommendations, evaluate, size, submit."""

    async def poll_recommendations(self) -> list[dict]:
        """Fetch new actionable recommendations not yet processed."""

    async def evaluate_recommendation(self, rec: dict) -> TradingDecision:
        """Run all pre-trade checks and produce a decision record."""

    async def execute_decision(self, decision: TradingDecision) -> None:
        """Submit order(s) to broker queue, handle gradual entry."""

Polling Query: Fetches from recommendations where action IN ('buy', 'sell') AND mode IN ('paper_eligible', 'live_eligible') AND generated_at > last_poll_timestamp, ordered by confidence DESC.

Deduplication: Each recommendation ID is checked against Redis key stonks:dedupe:trading:{recommendation_id} with a 24-hour TTL. Processed recommendations are marked immediately before evaluation to prevent double-processing on restart.

Startup Sequence:

  1. Load trading_engine_config from PostgreSQL
  2. Load active risk tier parameters
  3. Sync portfolio state from Broker Service (positions, account balance)
  4. Load reserve pool balance from reserve_pool_ledger
  5. Load circuit breaker status from circuit_breaker_events
  6. Load open stop-loss/take-profit levels from position_stop_levels
  7. Enter decision loop

Position Sizer

Location: services/trading/position_sizer.py

Computes the dollar allocation and share quantity for a trade.

@dataclass
class PositionSizeResult:
    dollar_amount: float
    share_quantity: int          # whole shares, rounded down
    allocation_pct: float       # percentage of Active Pool
    adjustments: list[str]      # reasons for any reductions
    rejected: bool              # True if allocation is zero
    rejection_reason: str       # why rejected, if applicable

class PositionSizer:
    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],
    ) -> PositionSizeResult: ...

Sizing Formula:

raw_pct = base_allocation_pct * (confidence / risk_tier.min_confidence) * risk_tier.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)

Adjustment Pipeline (applied sequentially):

  1. Confidence gate: If confidence < risk_tier.min_confidence, reject with zero allocation.
  2. Correlation reduction: Compute weighted average correlation with existing positions. If avg > 0.5, reduce proportionally. If avg > 0.8, reject entirely.
  3. Sector exposure reduction: If adding this position would push sector exposure above risk_tier.max_sector_pct, reduce to fit within limit.
  4. Diversification bonus: If portfolio holds < 3 sectors, apply 1.2x multiplier for under-represented sectors.
  5. Earnings proximity: If earnings within 3 trading days, reduce by 50%. If within 1 day, reject new entries.
  6. Portfolio heat check: If current heat + new position heat would exceed risk_tier.max_portfolio_heat, reject.
  7. Active pool minimum: If Active Pool < $100, reject new entries (exits only).
  8. Absolute cap: Enforce configurable absolute dollar cap (default $50 for $500 portfolio, scales linearly).
  9. Share rounding: Round down to whole shares. If quantity = 0, reject.

Stop-Loss Manager

Location: services/trading/stop_loss_manager.py

Computes and maintains dynamic stop-loss and take-profit levels for every open position.

@dataclass
class StopLevels:
    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

class StopLossManager:
    async def compute_initial_levels(
        self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
    ) -> StopLevels: ...

    async def re_evaluate_levels(
        self, position: OpenPosition, current_price: float, atr: float,
        risk_tier: RiskTierConfig,
    ) -> StopLevels: ...

    async def check_price_crossings(
        self, positions: list[OpenPosition], prices: dict[str, float],
    ) -> list[StopTrigger]: ...

Initial Stop-Loss: entry_price - (ATR_14 * risk_tier.stop_loss_atr_multiplier) Initial Take-Profit: entry_price + (stop_distance * risk_tier.reward_risk_ratio) Trailing Stop: When price moves favorably by > 50% of take-profit distance, move stop-loss to entry price (breakeven).

Re-evaluation: Every 5 minutes during market hours (configurable). Adjusts if ATR has changed materially (> 10% shift) or if signal conditions have changed.

Price Data Unavailability: If price data is unavailable for > 15 minutes during market hours, close the position as a safety measure.

Circuit Breaker

Location: services/trading/circuit_breaker.py

Safety mechanism that halts trading under abnormal conditions.

@dataclass
class CircuitBreakerState:
    active: bool
    trigger_type: str | None     # daily_loss | single_position | volatility
    triggered_at: datetime | None
    cooldown_expires: datetime | None
    ticker_cooldowns: dict[str, datetime]  # per-ticker re-entry cooldowns

class CircuitBreaker:
    def check_daily_loss(self, daily_pnl: float, portfolio_value: float) -> bool: ...
    def check_single_position(self, position_loss_pct: float, ticker: str) -> bool: ...
    def check_volatility(self, stop_loss_hits: list[datetime]) -> bool: ...
    def is_ticker_cooled_down(self, ticker: str) -> bool: ...

Triggers:

  1. Daily loss: Portfolio drops > 5% (configurable) in a single day → halt all new orders.
  2. Single position loss: Position loses > 15% (configurable) of entry value → immediate close + 48h ticker cooldown.
  3. Volatility spike: 3+ positions hit stop-losses within 30 minutes → pause all trading for 2 hours (configurable).

Cooldown: Circuit breakers auto-expire after their configured duration. Resumption is logged and notified.

Reserve Pool Controller

Location: services/trading/reserve_pool.py

Manages the untouchable cash reserve that grows from realized profits.

@dataclass
class ReservePoolState:
    balance: float
    total_deposits: float
    total_withdrawals: float
    last_updated: datetime

class ReservePoolController:
    async def siphon_profit(self, realized_profit: float) -> float:
        """Transfer configured percentage of profit to reserve. Returns amount transferred."""

    async def emergency_liquidate(self, reason: str) -> float:
        """Release reserve into active pool during emergency. Returns amount released."""

    def compute_active_pool(self, total_portfolio_value: float) -> float:
        """Active Pool = total_portfolio_value - reserve_pool_balance."""

Siphon Rate: Default 20% of realized profit on each profitable position close. High-Water Mark: When reserve exceeds 30% of total portfolio, signal Risk Tier Controller to consider upgrading. Emergency Liquidation: When drawdown exceeds 40% of initial capital, liquidate reserve into active pool, log event, shift to conservative tier.

Risk Tier Controller

Location: services/trading/risk_tier_controller.py

Evaluates performance metrics and auto-adjusts the active risk tier.

@dataclass
class RiskTierConfig:
    name: str                    # conservative | moderate | aggressive
    min_confidence: float
    max_position_pct: float
    stop_loss_atr_multiplier: float
    reward_risk_ratio: float
    max_sector_pct: float
    max_portfolio_heat: float

class RiskTierController:
    async def evaluate(self, metrics: PerformanceMetrics, reserve_pct: float) -> RiskTierConfig | None:
        """Evaluate whether tier should change. Returns new tier or None."""

Downgrade Conditions (any triggers downgrade):

  • 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%

Evaluation Frequency: Daily at market close (configurable).

Portfolio Rebalancer

Location: services/trading/rebalancer.py

Periodically evaluates portfolio concentration and generates rebalancing orders.

class PortfolioRebalancer:
    async def evaluate(
        self, positions: list[OpenPosition], risk_tier: RiskTierConfig,
        active_pool: float,
    ) -> list[RebalanceOrder]: ...

Schedule: Weekly at market open on Monday (configurable). Actions: Generate partial sell orders for over-concentrated positions (single stock > max_position_pct, sector > max_sector_pct). Sells lowest-confidence positions first within over-exposed sectors. Constraints: Respects trading window and circuit breaker status. Maximum 10 open positions (configurable).

Performance Tracker

Location: services/trading/performance_tracker.py

Computes and persists real-time portfolio metrics.

@dataclass
class PerformanceMetrics:
    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          # annualized, trailing 30d daily returns
    max_drawdown: float
    current_drawdown_pct: float
    portfolio_heat: float
    computed_at: datetime

class PerformanceTracker:
    async def compute_metrics(self) -> PerformanceMetrics: ...
    async def record_trade(self, trade: ClosedTrade) -> None: ...
    async def persist_daily_snapshot(self) -> None: ...

Computation Interval: Every 5 minutes during market hours. Sharpe Ratio: Annualized using daily returns over trailing 30 days: (mean_daily_return / std_daily_return) * sqrt(252). Per-Trade Metrics: Entry price, exit price, hold duration, P&L amount/percentage, recommendation ID.

Backtester

Location: services/trading/backtester.py

Replays historical recommendations to simulate trading strategy performance.

@dataclass
class BacktestConfig:
    start_date: date
    end_date: date
    initial_capital: float
    risk_tier: str               # conservative | moderate | aggressive

@dataclass
class BacktestResult:
    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]
    equity_curve: list[dict]     # [{date, portfolio_value}]

class Backtester:
    async def run(self, config: BacktestConfig) -> BacktestResult: ...

Simulation: Replays the full decision logic (position sizing, stop-loss/take-profit, circuit breakers, reserve pool, rebalancing) using historical recommendations and price data from existing tables.

API: POST /api/trading/backtest to launch, returns backtest_id. Results retrievable via GET /api/trading/backtest/{backtest_id}.

Notification Service

Location: services/trading/notifications.py

Sends alerts via SMS (AWS SNS) and email (Gmail API).

class NotificationService:
    async def send_alert(self, event_type: str, message: str) -> None:
        """Send via all enabled channels."""

    async def send_daily_summary(self, metrics: PerformanceMetrics) -> None:
    async def send_weekly_digest(self, weekly_metrics: dict) -> None:

Channels:

  • SMS: AWS SNS with configurable topic ARN and phone number. Auth via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars.
  • Email: Gmail API with OAuth2 (client ID, client secret, refresh token). Configurable sender and recipient.

Rate Limiting: Max 10 SMS/hour, 20 emails/hour (configurable). Prevents notification storms during volatile markets.

Retry: Up to 3 retries with exponential backoff on delivery failure. Failures are logged but never block trading operations.

Event Types: circuit_breaker_triggered, circuit_breaker_resumed, risk_tier_changed, emergency_liquidation, large_trade_pnl, daily_summary, weekly_digest.

Micro-Trading Module

Location: services/trading/micro_trading.py

Optional fast-cycle trading that evaluates intraday signals.

class MicroTradingModule:
    async def poll_intraday_signals(self) -> list[dict]:
        """Fetch intraday and 1d trend window signals."""

    async def evaluate_micro_trade(self, signal: dict) -> TradingDecision: ...

Polling: Every 5 minutes during market hours when enabled (configurable). Allocation Cap: 3% of Active Pool per micro-trade (lower than standard). Daily Limit: Max 10 micro-trades per day (configurable). Stop-Loss: 1.0x ATR (tighter than standard tier multiplier). Max Hold: 2 hours — auto-close at market price after expiry. Metrics: Tracked separately from standard trades.

Trading Engine HTTP Service

Location: services/trading/app.py

FastAPI application exposing health, status, configuration, and trading API endpoints.

# Health and readiness
GET  /health                          # liveness probe
GET  /ready                           # readiness (portfolio loaded, loop active)

# Engine control
GET  /api/trading/status              # current engine state
PUT  /api/trading/config              # update configuration
POST /api/trading/pause               # pause trading
POST /api/trading/resume              # resume trading

# Decision audit
GET  /api/trading/decisions           # recent decisions (paginated, filterable)

# Performance
GET  /api/trading/metrics             # current performance metrics
GET  /api/trading/metrics/history     # historical daily snapshots

# Backtesting
POST /api/trading/backtest            # launch backtest
GET  /api/trading/backtest/{id}       # retrieve results

# Notifications
GET  /api/trading/notifications/config    # notification preferences
PUT  /api/trading/notifications/config    # update preferences
GET  /api/trading/notifications/history   # recent notifications

Correlation Matrix

Location: services/trading/correlation.py

Computes and caches price correlation coefficients between tracked companies.

class CorrelationMatrix:
    async def refresh(self, pool: asyncpg.Pool) -> None:
        """Recompute from trailing 90-day price history. Run daily."""

    def get_correlation(self, ticker_a: str, ticker_b: str) -> float:
        """Return correlation coefficient, 0.0 if unknown."""

    def get_portfolio_correlation(
        self, candidate: str, positions: list[str],
    ) -> float:
        """Weighted average correlation with existing portfolio."""

Data Source: Market data tables (daily close prices) for all tracked companies. Refresh: Daily, cached in correlation_matrix_cache table and in-memory.

Tax-Loss Harvesting Tracker

Location: services/trading/tax_lots.py

Tracks cost basis and flags wash sale risks.

class TaxLotTracker:
    async def record_entry(self, ticker: str, quantity: int, price: float, date: date) -> str:
        """Create a tax lot record. Returns tax_lot_id."""

    async def close_lots_fifo(self, ticker: str, quantity: int, exit_price: float, date: date) -> list[ClosedLot]:
        """Close lots using FIFO method. Returns closed lot details with P&L."""

    async def check_wash_sale(self, ticker: str, loss_date: date) -> bool:
        """Check 30-day window before and after for same-ticker purchases."""

Data Models

New PostgreSQL Tables (Migration 018)

trading_engine_config

CREATE TABLE trading_engine_config (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    enabled BOOLEAN NOT NULL DEFAULT FALSE,
    paused BOOLEAN NOT NULL DEFAULT FALSE,
    risk_tier VARCHAR(20) NOT NULL DEFAULT 'moderate',
    reserve_siphon_pct FLOAT NOT NULL DEFAULT 0.20,
    polling_interval_seconds INTEGER NOT NULL DEFAULT 60,
    gradual_entry_tranches INTEGER NOT NULL DEFAULT 3,
    gradual_entry_threshold_dollars FLOAT NOT NULL DEFAULT 30.0,
    gradual_entry_interval_minutes INTEGER NOT NULL DEFAULT 15,
    trading_window_start_minutes INTEGER NOT NULL DEFAULT 15,
    trading_window_end_minutes INTEGER NOT NULL DEFAULT 15,
    max_open_positions INTEGER NOT NULL DEFAULT 10,
    circuit_breaker_daily_loss_pct FLOAT NOT NULL DEFAULT 0.05,
    circuit_breaker_single_position_loss_pct FLOAT NOT NULL DEFAULT 0.15,
    circuit_breaker_ticker_cooldown_hours INTEGER NOT NULL DEFAULT 48,
    circuit_breaker_volatility_pause_hours INTEGER NOT NULL DEFAULT 2,
    circuit_breaker_stop_loss_hits_threshold INTEGER NOT NULL DEFAULT 3,
    circuit_breaker_stop_loss_window_minutes INTEGER NOT NULL DEFAULT 30,
    active_pool_minimum FLOAT NOT NULL DEFAULT 100.0,
    emergency_drawdown_threshold_pct FLOAT NOT NULL DEFAULT 0.40,
    reserve_high_water_pct FLOAT NOT NULL DEFAULT 0.30,
    absolute_position_cap FLOAT NOT NULL DEFAULT 50.0,
    correlation_reduction_threshold FLOAT NOT NULL DEFAULT 0.5,
    correlation_rejection_threshold FLOAT NOT NULL DEFAULT 0.8,
    earnings_pre_window_days INTEGER NOT NULL DEFAULT 3,
    earnings_post_cooldown_days INTEGER NOT NULL DEFAULT 1,
    micro_trading_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    micro_trading_interval_seconds INTEGER NOT NULL DEFAULT 300,
    micro_trading_allocation_cap_pct FLOAT NOT NULL DEFAULT 0.03,
    micro_trading_max_daily INTEGER NOT NULL DEFAULT 10,
    micro_trading_max_hold_minutes INTEGER NOT NULL DEFAULT 120,
    notification_sms_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    notification_email_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    notification_phone_number VARCHAR(20),
    notification_email_recipient VARCHAR(200),
    notification_sns_topic_arn VARCHAR(300),
    notification_rate_limit_sms_per_hour INTEGER NOT NULL DEFAULT 10,
    notification_rate_limit_email_per_hour INTEGER NOT NULL DEFAULT 20,
    notification_daily_summary_time TIME NOT NULL DEFAULT '16:30',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

reserve_pool_ledger

CREATE TABLE reserve_pool_ledger (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    amount FLOAT NOT NULL,
    balance_after FLOAT NOT NULL,
    trigger_type VARCHAR(30) NOT NULL,
    reference_id UUID,
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_trigger_type CHECK (
        trigger_type IN ('profit_siphon', 'emergency_liquidation', 'manual_adjustment', 'initial')
    )
);
CREATE INDEX idx_reserve_pool_ledger_created ON reserve_pool_ledger(created_at DESC);

risk_tier_history

CREATE TABLE risk_tier_history (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    previous_tier VARCHAR(20) NOT NULL,
    new_tier VARCHAR(20) NOT NULL,
    trigger_source VARCHAR(30) NOT NULL,
    trigger_metrics JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_risk_tier_history_created ON risk_tier_history(created_at DESC);

circuit_breaker_events

CREATE TABLE circuit_breaker_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    trigger_type VARCHAR(30) NOT NULL,
    threshold_value FLOAT,
    actual_value FLOAT,
    ticker VARCHAR(20),
    cooldown_expires TIMESTAMPTZ,
    resolved_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_cb_trigger_type CHECK (
        trigger_type IN ('daily_loss', 'single_position', 'volatility', 'manual')
    )
);
CREATE INDEX idx_circuit_breaker_events_active ON circuit_breaker_events(created_at DESC)
    WHERE resolved_at IS NULL;

trading_decisions

CREATE TABLE trading_decisions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    recommendation_id UUID REFERENCES recommendations(id),
    decision VARCHAR(10) NOT NULL,
    skip_reason TEXT,
    ticker VARCHAR(20) NOT NULL,
    computed_position_size FLOAT,
    computed_share_quantity INTEGER,
    risk_tier_at_decision VARCHAR(20) NOT NULL,
    portfolio_heat_at_decision FLOAT,
    active_pool_at_decision FLOAT,
    reserve_pool_at_decision FLOAT,
    circuit_breaker_status VARCHAR(20) NOT NULL DEFAULT 'inactive',
    correlation_check_result JSONB DEFAULT '{}',
    sector_exposure_check_result JSONB DEFAULT '{}',
    earnings_proximity_flag BOOLEAN NOT NULL DEFAULT FALSE,
    is_micro_trade BOOLEAN NOT NULL DEFAULT FALSE,
    decision_trace JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_trading_decisions_ticker ON trading_decisions(ticker, created_at DESC);
CREATE INDEX idx_trading_decisions_rec ON trading_decisions(recommendation_id);
CREATE INDEX idx_trading_decisions_decision ON trading_decisions(decision, created_at DESC);

position_stop_levels

CREATE TABLE position_stop_levels (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticker VARCHAR(20) NOT NULL,
    entry_price FLOAT NOT NULL,
    stop_loss_price FLOAT NOT NULL,
    take_profit_price FLOAT NOT NULL,
    trailing_stop_active BOOLEAN NOT NULL DEFAULT FALSE,
    atr_value FLOAT NOT NULL,
    atr_multiplier FLOAT NOT NULL,
    reward_risk_ratio FLOAT NOT NULL,
    signal_confidence FLOAT,
    is_micro_trade BOOLEAN NOT NULL DEFAULT FALSE,
    active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_position_stop_levels_ticker ON position_stop_levels(ticker) WHERE active = TRUE;

portfolio_snapshots

CREATE TABLE portfolio_snapshots (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    snapshot_date DATE NOT NULL UNIQUE,
    portfolio_value FLOAT NOT NULL,
    active_pool FLOAT NOT NULL,
    reserve_pool FLOAT NOT NULL,
    daily_return FLOAT,
    cumulative_return FLOAT,
    unrealized_pnl FLOAT,
    realized_pnl FLOAT,
    win_count INTEGER NOT NULL DEFAULT 0,
    loss_count INTEGER NOT NULL DEFAULT 0,
    win_rate FLOAT,
    sharpe_ratio FLOAT,
    max_drawdown FLOAT,
    current_drawdown_pct FLOAT,
    portfolio_heat FLOAT,
    risk_tier VARCHAR(20),
    positions JSONB NOT NULL DEFAULT '[]',
    metrics JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_portfolio_snapshots_date ON portfolio_snapshots(snapshot_date DESC);

backtest_runs

CREATE TABLE backtest_runs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    initial_capital FLOAT NOT NULL,
    risk_tier VARCHAR(20) NOT NULL,
    config JSONB NOT NULL DEFAULT '{}',
    total_return FLOAT,
    sharpe_ratio FLOAT,
    max_drawdown FLOAT,
    win_rate FLOAT,
    profit_factor FLOAT,
    trade_count INTEGER,
    equity_curve JSONB DEFAULT '[]',
    status VARCHAR(20) NOT NULL DEFAULT 'running',
    completed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

backtest_trades

CREATE TABLE backtest_trades (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    backtest_id UUID NOT NULL REFERENCES backtest_runs(id) ON DELETE CASCADE,
    ticker VARCHAR(20) NOT NULL,
    side VARCHAR(10) NOT NULL,
    entry_price FLOAT NOT NULL,
    exit_price FLOAT,
    quantity INTEGER NOT NULL,
    pnl FLOAT,
    pnl_pct FLOAT,
    entry_date DATE NOT NULL,
    exit_date DATE,
    hold_duration_days INTEGER,
    recommendation_id UUID,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backtest_trades_run ON backtest_trades(backtest_id);

tax_lots

CREATE TABLE tax_lots (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticker VARCHAR(20) NOT NULL,
    quantity INTEGER NOT NULL,
    cost_basis_per_share FLOAT NOT NULL,
    acquisition_date DATE NOT NULL,
    status VARCHAR(10) NOT NULL DEFAULT 'open',
    closed_date DATE,
    exit_price FLOAT,
    realized_pnl FLOAT,
    wash_sale_flag BOOLEAN NOT NULL DEFAULT FALSE,
    wash_sale_details TEXT,
    order_id UUID REFERENCES orders(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_tax_lot_status CHECK (status IN ('open', 'closed', 'washed'))
);
CREATE INDEX idx_tax_lots_ticker ON tax_lots(ticker, acquisition_date DESC);
CREATE INDEX idx_tax_lots_status ON tax_lots(status) WHERE status = 'open';

earnings_calendar

CREATE TABLE earnings_calendar (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticker VARCHAR(20) NOT NULL,
    earnings_date DATE NOT NULL,
    source VARCHAR(30) NOT NULL DEFAULT 'manual',
    confirmed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(ticker, earnings_date)
);
CREATE INDEX idx_earnings_calendar_date ON earnings_calendar(earnings_date);
CREATE INDEX idx_earnings_calendar_ticker ON earnings_calendar(ticker);

correlation_matrix_cache

CREATE TABLE correlation_matrix_cache (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticker_a VARCHAR(20) NOT NULL,
    ticker_b VARCHAR(20) NOT NULL,
    correlation_coefficient FLOAT NOT NULL,
    lookback_days INTEGER NOT NULL DEFAULT 90,
    computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(ticker_a, ticker_b)
);
CREATE INDEX idx_correlation_cache_tickers ON correlation_matrix_cache(ticker_a, ticker_b);

notifications

CREATE TABLE notifications (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    channel VARCHAR(10) NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    message TEXT NOT NULL,
    delivery_status VARCHAR(20) NOT NULL DEFAULT 'pending',
    retry_count INTEGER NOT NULL DEFAULT 0,
    error_message TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    delivered_at TIMESTAMPTZ,
    CONSTRAINT chk_channel CHECK (channel IN ('sms', 'email')),
    CONSTRAINT chk_delivery_status CHECK (
        delivery_status IN ('pending', 'delivered', 'failed', 'rate_limited')
    )
);
CREATE INDEX idx_notifications_created ON notifications(created_at DESC);
CREATE INDEX idx_notifications_event ON notifications(event_type, created_at DESC);

New Pydantic Schemas

Added to services/shared/schemas.py:

class TradingDecisionType(str, Enum):
    ACT = "act"
    SKIP = "skip"

class CircuitBreakerTriggerType(str, Enum):
    DAILY_LOSS = "daily_loss"
    SINGLE_POSITION = "single_position"
    VOLATILITY = "volatility"
    MANUAL = "manual"

class ReservePoolTriggerType(str, Enum):
    PROFIT_SIPHON = "profit_siphon"
    EMERGENCY_LIQUIDATION = "emergency_liquidation"
    MANUAL_ADJUSTMENT = "manual_adjustment"
    INITIAL = "initial"

class NotificationChannel(str, Enum):
    SMS = "sms"
    EMAIL = "email"

class RiskTierName(str, Enum):
    CONSERVATIVE = "conservative"
    MODERATE = "moderate"
    AGGRESSIVE = "aggressive"

New TradingConfig in services/shared/config.py

@dataclass
class TradingConfig:
    enabled: bool = False
    risk_tier: str = "moderate"
    reserve_siphon_pct: float = 0.20
    polling_interval_seconds: int = 60
    stop_loss_check_interval_seconds: int = 300
    fast_stop_loss_interval_seconds: int = 60
    gradual_entry_tranches: int = 3
    gradual_entry_threshold_dollars: float = 30.0
    absolute_position_cap: float = 50.0
    active_pool_minimum: float = 100.0
    emergency_drawdown_threshold_pct: float = 0.40
    reserve_high_water_pct: float = 0.30
    micro_trading_enabled: bool = False
    micro_trading_interval_seconds: int = 300
    micro_trading_allocation_cap_pct: float = 0.03
    micro_trading_max_daily: int = 10
    micro_trading_max_hold_minutes: int = 120
    sns_topic_arn: str = ""
    sns_phone_number: str = ""
    gmail_sender: str = ""
    gmail_recipient: str = ""

New Redis Keys

Added to services/shared/redis_keys.py:

QUEUE_TRADING_DECISIONS = "trading_decisions"

# Trading engine deduplication prefix
TRADING_DEDUPE_PREFIX = f"{PREFIX}:dedupe:trading"

# Circuit breaker state
TRADING_CB_PREFIX = f"{PREFIX}:trading:circuit_breaker"

# Notification rate limiting
TRADING_NOTIFICATION_RATE = f"{PREFIX}:trading:notification_rate"

Kubernetes Deployment

New service entry in infra/helm/stonks-oracle/values.yaml:

trading-engine:
  image: ghcr.io/celesrenata/stonks-oracle/trading-engine:latest
  replicas: 1
  port: 8000
  env:
    SERVICE_CMD: "services.trading.app"
  resources:
    requests:
      memory: "256Mi"
      cpu: "100m"
    limits:
      memory: "512Mi"
      cpu: "500m"
  labels:
    stonks-oracle/tier: trading

Network policy: Allow ingress from query-api and dashboard on port 8000. Allow egress to PostgreSQL, Redis, and external (SNS, Gmail API).

Dashboard proxy: Add /trading/trading-engine:8000 to the nginx frontend proxy config.

Risk Tier Default Parameters

Parameter Conservative Moderate Aggressive
min_confidence 0.75 0.55 0.40
max_position_pct 0.05 0.10 0.15
stop_loss_atr_multiplier 1.5 2.0 2.5
reward_risk_ratio 2.0 1.5 1.2
max_sector_pct 0.20 0.30 0.40
max_portfolio_heat 0.10 0.20 0.30

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: Position sizing formula and invariants

For any valid confidence value, Active Pool balance, current stock price, and Risk Tier configuration, the Position Sizer SHALL:

  • Return zero allocation when confidence is below the Risk Tier's min_confidence threshold
  • Compute the raw allocation as base_allocation_pct * (confidence / min_confidence) * multiplier
  • Clamp the allocation percentage to never exceed the Risk Tier's max_position_pct
  • Clamp the dollar amount to never exceed the absolute position cap
  • Round the share quantity down to whole shares
  • Reject the trade (zero allocation) when the rounded share quantity is zero

Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.7

Property 2: Correlation-based allocation adjustment

For any candidate stock, existing portfolio positions, and correlation matrix, the Position Sizer SHALL:

  • Reduce the allocation proportionally when the weighted average correlation between the candidate and existing positions exceeds 0.5
  • Reject the trade entirely when the weighted average correlation exceeds 0.8
  • Leave the allocation unchanged when the weighted average correlation is at or below 0.5

Furthermore, increasing the weighted average correlation (while holding other inputs constant) SHALL produce an allocation that is less than or equal to the previous allocation (monotonic non-increase).

Validates: Requirements 2.5, 9.2, 9.3

Property 3: Sector exposure computation and enforcement

For any set of open positions with sector labels, the sector exposure for each sector SHALL equal the sum of market values of all positions in that sector. When adding a new position would cause a sector to exceed the Risk Tier's max_sector_pct of the Active Pool, the Position Sizer SHALL reduce the allocation to fit within the limit.

Validates: Requirements 2.6, 9.4

Property 4: Diversification bonus for under-represented sectors

For any portfolio holding positions in fewer than 3 sectors, the Position Sizer SHALL apply a 1.2x allocation multiplier to trades in sectors not currently represented in the portfolio. When the portfolio holds 3 or more sectors, no diversification bonus SHALL be applied.

Validates: Requirements 9.5

Property 5: Active Pool computation invariant

For any total portfolio value and reserve pool balance where reserve pool balance is non-negative and does not exceed total portfolio value, the Active Pool SHALL equal total_portfolio_value - reserve_pool_balance.

Validates: Requirements 3.3

Property 6: Reserve pool siphon computation

For any realized profit amount and siphon percentage, the amount transferred to the Reserve Pool SHALL equal realized_profit * siphon_pct, and the reserve pool balance after the transfer SHALL equal the previous balance plus the transferred amount.

Validates: Requirements 3.1, 3.2

Property 7: Active Pool minimum halts new entries but allows exits

For any portfolio state where the Active Pool is below the configured minimum threshold, the Trading Engine SHALL reject all new position entries (buy orders) but SHALL allow position exits (sell orders) to proceed.

Validates: Requirements 3.5

Property 8: Emergency drawdown triggers reserve liquidation

For any portfolio experiencing a drawdown exceeding the emergency threshold percentage of initial capital, the Trading Engine SHALL liquidate the entire Reserve Pool into the Active Pool, and the resulting risk tier SHALL be conservative.

Validates: Requirements 3.6

Property 9: Stop-loss and take-profit initial computation

For any entry price, ATR value, and Risk Tier configuration, the Stop Loss Manager SHALL compute:

  • Stop-loss price = entry_price - (ATR * stop_loss_atr_multiplier) (for long positions)
  • Take-profit price = entry_price + (stop_distance * reward_risk_ratio) where stop_distance = entry_price - stop_loss_price

The stop-loss price SHALL always be below the entry price, and the take-profit price SHALL always be above the entry price.

Validates: Requirements 4.1, 4.2

Property 10: Price crossing triggers immediate sell

For any open position with defined stop-loss and take-profit levels, when the current price is at or below the stop-loss price OR at or above the take-profit price, the Trading Engine SHALL generate a market sell order for that position.

Validates: Requirements 4.4, 4.5

Property 11: Trailing stop activation at 50% of take-profit distance

For any open position where the current price has moved favorably by more than 50% of the distance from entry price to take-profit price, the Stop Loss Manager SHALL move the stop-loss to the entry price (breakeven). The trailing stop SHALL NOT activate when the favorable move is 50% or less.

Validates: Requirements 4.6

Property 12: Risk tier auto-adjustment conditions

For any set of performance metrics (trailing 30-day win rate, current drawdown, reserve pool percentage):

  • The Risk Tier Controller SHALL downgrade by one level when win rate < 40% OR drawdown > 15%
  • The Risk Tier Controller SHALL upgrade by one level when win rate > 55% AND reserve pool > 20% of total portfolio AND drawdown < 5%
  • The Risk Tier Controller SHALL make no change when neither condition is met
  • The tier SHALL never go below conservative or above aggressive

Validates: Requirements 5.3, 5.4

Property 13: Circuit breaker activation

For any portfolio state:

  • When daily portfolio loss exceeds the configured daily_loss_pct threshold, the circuit breaker SHALL activate with trigger_type = 'daily_loss'
  • When a single position's loss exceeds the configured single_position_loss_pct of its entry value, the circuit breaker SHALL close that position and apply a ticker cooldown
  • When 3 or more positions hit stop-losses within a 30-minute window, the circuit breaker SHALL activate with trigger_type = 'volatility'

When any circuit breaker is active, the Trading Engine SHALL reject all new order submissions.

Validates: Requirements 6.1, 6.2, 6.3

Property 14: Circuit breaker cooldown expiry

For any circuit breaker event with a configured cooldown duration, the circuit breaker SHALL transition from active to resolved when the current time exceeds triggered_at + cooldown_duration. Before expiry, the circuit breaker SHALL remain active.

Validates: Requirements 6.5

Property 15: Stop tightening during high-severity events

For any open position during an active high-severity macro event, the Stop Loss Manager SHALL compute tightened stop-loss levels using a reduced ATR multiplier (default: 0.5x the normal multiplier). The tightened stop-loss SHALL be closer to the current price than the normal stop-loss.

Validates: Requirements 7.2

Property 16: Multiple declining positions halts new entries

For any portfolio state where more than 50% of open positions have negative unrealized P&L exceeding 2% of their entry value, the Trading Engine SHALL reduce new position entries to zero while continuing to manage existing positions.

Validates: Requirements 7.5

Property 17: Portfolio rebalancing generates correct sell orders

For any portfolio where a single stock exceeds the Risk Tier's max_position_pct of the Active Pool, the Portfolio Rebalancer SHALL generate a partial sell order that, if executed, would bring the position within the limit. When a sector exceeds max_sector_pct, the Rebalancer SHALL generate sell orders targeting the lowest-confidence positions in that sector first.

Validates: Requirements 8.2, 8.3

Property 18: Maximum open positions enforcement

For any portfolio at the configured maximum number of open positions, the Trading Engine SHALL reject new position entries. The portfolio SHALL never hold more than the configured maximum number of simultaneous open positions.

Validates: Requirements 8.4

Property 19: Earnings proximity adjustments

For any stock with an earnings date within 3 trading days, the Position Sizer SHALL reduce the maximum position size by 50% and the Stop Loss Manager SHALL tighten stop-losses by the configured factor (default 0.7x ATR multiplier). When earnings are within 1 trading day, the Trading Engine SHALL reject all new position entries for that stock.

Validates: Requirements 10.2, 10.3

Property 20: Trading window determination

For any timestamp during US market hours, the Trading Engine SHALL classify it as within the Trading Window if and only if it falls between 9:45 AM ET (15 minutes after open) and 3:45 PM ET (15 minutes before close). Timestamps outside this range SHALL be classified as outside the Trading Window, and new orders SHALL not be submitted.

Validates: Requirements 11.1

Property 21: Gradual entry tranche splitting

For any position size exceeding the gradual entry threshold (default: min($30, 5% of Active Pool)), the Trading Engine SHALL split the order into the configured number of tranches (default: 3), each of approximately equal size, and all tranches SHALL reference the same parent trading decision ID.

Validates: Requirements 11.3, 11.5

Property 22: Tax lot FIFO ordering

For any sequence of buy and sell transactions for the same ticker, closing lots SHALL follow FIFO order — the earliest-acquired open lot is closed first. The realized P&L for each closed lot SHALL equal (exit_price - cost_basis_per_share) * quantity.

Validates: Requirements 12.4

Property 23: Wash sale detection within 30-day window

For any position closed at a loss, the Tax Lot Tracker SHALL flag it as a potential wash sale if the same ticker was purchased within 30 days before the loss date or if a pending recommendation suggests purchase within 30 days after the loss date.

Validates: Requirements 12.2, 12.3

Property 24: Portfolio heat computation and threshold enforcement

For any set of open positions with entry prices and stop-loss levels, Portfolio Heat SHALL equal the sum of position_value * (entry_price - stop_loss_price) / entry_price across all positions. When Portfolio Heat exceeds the Risk Tier's max_portfolio_heat percentage of the Active Pool, the Position Sizer SHALL reject new position entries.

Validates: Requirements 13.1, 13.2

Property 25: Proactive stop tightening at 80% heat threshold

For any portfolio state where Portfolio Heat exceeds 80% of the Risk Tier's max_portfolio_heat threshold, the Stop Loss Manager SHALL tighten stop-losses on the lowest-confidence positions first, reducing overall heat. Positions with lower confidence SHALL have their stops tightened before positions with higher confidence.

Validates: Requirements 13.3

Property 26: Performance metrics computation

For any set of closed trades with entry prices, exit prices, and hold durations, the Performance Tracker SHALL compute: win_rate = wins / total_trades, profit_factor = gross_profits / gross_losses (or infinity if no losses), and Sharpe ratio = (mean_daily_return / std_daily_return) * sqrt(252). All computed metrics SHALL be consistent with the underlying trade data.

Validates: Requirements 14.1, 14.2

Property 27: Recommendation deduplication (idempotence)

For any recommendation ID, processing it through the Trading Engine a second time SHALL be a no-op — no new trading decision record SHALL be created and no order SHALL be submitted. The deduplication check SHALL use the Redis key stonks:dedupe:trading:{recommendation_id}.

Validates: Requirements 1.5

Property 28: Trading decision record completeness and traceability

For any recommendation evaluated by the Trading Engine, a trading_decision record SHALL be persisted containing: recommendation_id, decision (act/skip), skip_reason (if skipped), computed_position_size, risk_tier_at_decision, portfolio_heat_at_decision, active_pool_at_decision, reserve_pool_at_decision, circuit_breaker_status, and timestamp. When the decision is "act", the resulting order job SHALL contain the trading_decision_id.

Validates: Requirements 1.4, 17.1, 17.2

Property 29: Persistence round-trip for trading engine state

For any valid trading engine configuration, reserve pool ledger entry, risk tier history record, circuit breaker event, portfolio snapshot, or backtest result, persisting it to PostgreSQL and reading it back SHALL produce an equivalent object with all fields preserved.

Validates: Requirements 3.2, 4.7, 5.5, 6.4, 14.3, 15.4, 16.1

Property 30: Notification rate limiting

For any sequence of notification requests within a one-hour window, the Notification Service SHALL deliver at most the configured maximum per channel (default: 10 SMS, 20 emails per hour). Notifications exceeding the rate limit SHALL be marked as 'rate_limited' and not delivered.

Validates: Requirements 19.7

Property 31: Micro-trade parameter constraints

For any micro-trade, the Position Sizer SHALL enforce: allocation does not exceed micro_trading_allocation_cap_pct of Active Pool (default 3%), stop-loss is computed at 1.0x ATR (not the standard tier multiplier), and take-profit is at 1.5x the stop distance. The daily micro-trade count SHALL not exceed the configured maximum (default 10).

Validates: Requirements 20.3, 20.4, 20.5

Property 32: Micro-trade auto-close after max hold duration

For any micro-trade position that has been open longer than the configured maximum hold duration (default: 2 hours), the Trading Engine SHALL close the position at market price regardless of current P&L.

Validates: Requirements 20.6

Property 33: Micro-trade metrics tracked separately

For any set of trades containing both standard and micro-trades, the Performance Tracker SHALL compute micro-trade metrics (win rate, average P&L, count, contribution to total P&L) independently from standard trade metrics. The two sets of metrics SHALL not contaminate each other.

Validates: Requirements 20.7

Property 34: Micro-trades respect all existing constraints

For any micro-trade evaluation, the Trading Engine SHALL enforce the same constraints as standard trades: Trading Window, Circuit Breakers, Portfolio Heat limits, correlation checks, sector exposure limits, and earnings proximity rules.

Validates: Requirements 20.10

Property 35: Configuration change audit trail

For any change to the trading engine configuration (whether via API, auto-adjustment, or manual override), the system SHALL persist an audit event containing the previous configuration, new configuration, and the source of the change.

Validates: Requirements 16.6

Property 36: Backtester produces equivalent metrics

For any set of historical trades, the Backtester's metric computation (total return, Sharpe ratio, max drawdown, win rate, profit factor) SHALL produce the same results as the Performance Tracker when given the same trade data.

Validates: Requirements 15.3

Error Handling

Decision Loop Failures

  • If the recommendation polling query fails, the engine logs the error and retries on the next polling cycle. No decisions are made during the failed cycle.
  • If portfolio state cannot be loaded from the Broker Service on startup, the engine enters a degraded state (readiness probe returns unhealthy) and retries every 30 seconds until state is available.
  • If a single recommendation evaluation fails (exception in position sizing, correlation lookup, etc.), the engine logs the error, persists a skip decision with reason "evaluation_error", and continues to the next recommendation.

Position Sizing Failures

  • If the correlation matrix is unavailable (cache miss, DB error), the Position Sizer treats all correlations as 0.0 (no reduction) and logs a warning. This is a fail-open decision for correlation only — all other checks still apply.
  • If sector data is missing for a ticker, the sector exposure check is skipped with a warning. The position is still subject to all other constraints.
  • If current price data is unavailable for share quantity computation, the trade is skipped with reason "price_unavailable".

Stop-Loss Manager Failures

  • If ATR data cannot be fetched for a position, the Stop Loss Manager uses the last known ATR value and logs a warning.
  • If price data is unavailable for more than 15 minutes during market hours, the position is closed as a safety measure (Req 4.8).
  • If the stop-loss re-evaluation query fails, existing levels are preserved and the next re-evaluation cycle retries.

Circuit Breaker Failures

  • Circuit breaker checks are fail-closed: if the daily P&L cannot be computed (DB error), the circuit breaker activates as a precaution.
  • If circuit breaker event persistence fails, the in-memory state still prevents trading. The persistence is retried on the next cycle.

Reserve Pool Failures

  • If the reserve pool ledger write fails during profit siphoning, the siphon is retried on the next profitable close. The trade itself is not affected.
  • If the reserve pool balance cannot be loaded on startup, the engine treats the reserve as $0 (conservative — all capital is active pool) and logs a critical warning.

Notification Failures

  • Notification delivery failures (SNS publish error, Gmail API error) trigger up to 3 retries with exponential backoff.
  • Failed notifications are persisted with delivery_status = 'failed' and the error message.
  • Notification failures never block or delay trading operations — they run in a separate asyncio task.
  • Rate-limited notifications are persisted with delivery_status = 'rate_limited'.

Backtester Failures

  • If historical price data is missing for a date range, the backtester skips those dates and notes the gap in the result.
  • If the backtester encounters an error mid-run, it persists partial results with status = 'failed' and the error message.

Broker Queue Failures

  • If pushing an order job to the Redis broker queue fails, the engine retries up to 3 times. If all retries fail, the decision is persisted as "act" with a note that queue submission failed, and the engine logs a critical error.

Graceful Degradation

  • The trading engine is designed to degrade gracefully. Individual component failures (correlation matrix, earnings calendar, notification service) do not halt the core decision loop. Each component failure results in that specific check being skipped or defaulted, with appropriate logging.

Testing Strategy

Property-Based Testing

This feature is well-suited for property-based testing. The core logic — position sizing formulas, stop-loss/take-profit computation, circuit breaker threshold detection, portfolio heat calculation, risk tier evaluation, FIFO tax lot ordering, and trading window determination — consists of pure functions with clear input/output behavior and large input spaces.

Library: Hypothesis for Python property-based testing.

Configuration: Minimum 100 iterations per property test (@settings(max_examples=100)).

Tag format: Feature: autonomous-trading-engine, Property {number}: {property_text}

Each correctness property maps to one property-based test. Generators will produce:

  • Random RiskTierConfig objects with valid parameter ranges
  • Random confidence values in [0, 1]
  • Random Active Pool balances ($50 - $10,000)
  • Random stock prices ($1 - $1,000)
  • Random ATR values (proportional to price)
  • Random correlation matrices with coefficients in [-1, 1]
  • Random portfolio states with varying position counts, sectors, and P&L
  • Random sequences of buy/sell transactions for FIFO testing
  • Random timestamps across market hours for trading window testing
  • Random performance metrics (win rates, drawdowns, Sharpe ratios)
  • Random notification sequences for rate limiting testing
  • Random earnings dates relative to current date

Unit Testing

Unit tests complement property tests for specific examples and edge cases:

  • API endpoint response codes and error handling (status, config, pause/resume, decisions, backtest)
  • Dashboard component rendering with mock data (trading control panel, portfolio composition, trade history, performance charts, backtesting panel, micro-trading panel, notification preferences)
  • Health and readiness endpoint behavior in various engine states
  • Risk tier default parameter values match specification
  • Startup sequence with missing/partial state
  • Gradual entry with exactly 1 tranche (below threshold)
  • Circuit breaker with exactly 3 stop-losses at the 30-minute boundary
  • Earnings calendar CRUD operations
  • Notification daily summary and weekly digest content

Integration Testing

Integration tests verify end-to-end flows:

  • Full decision cycle: recommendation → evaluation → position sizing → order submission to broker queue
  • Stop-loss crossing detection → immediate sell order generation
  • Reserve pool siphoning on profitable position close
  • Risk tier auto-adjustment after simulated 30-day performance
  • Circuit breaker trigger → trading halt → cooldown expiry → resume
  • Portfolio rebalancing generating correct sell orders
  • Backtester producing results from historical data
  • Notification delivery via mocked SNS and Gmail clients
  • Engine startup state reconstruction from PostgreSQL
  • Micro-trading mode polling and evaluation cycle
  • Gradual entry with re-evaluation between tranches