Files
Celes Renata 88ad1e8d99 feat: comprehensive docs, unit tests, docker-compose app services
- Add scheduler and ingestion unit tests (test_scheduler_unit.py, test_ingestion_unit.py)
- Add all 13 app services + dashboard to docker-compose.yml
- Add full documentation suite: API reference, Helm reference, Docker deployment guide,
  3 architecture diagrams (K8s, Docker Compose, data pipeline), AI agent guide,
  backup/restore guide, observability/metrics reference, per-service docs
- Add intelligence pipeline deep-dive docs with Mermaid diagrams
- Update README with documentation index and links
- Add specs for comprehensive-quality-docs, intelligence-pipeline-deep-dive,
  sanitized-pipeline-docs
2026-04-22 02:56:41 +00:00

26 KiB
Raw Permalink Blame History

Page 6 — Trading Decisions and Execution

The recommendation engine described in Page 5 produces Recommendation objects with an action, execution mode, position sizing guideline, thesis, and risk classification. Recommendations marked as paper_eligible or live_eligible are persisted to the recommendations table and are now available for the final stage of the pipeline: autonomous trade execution. The trading engine in services/trading/engine.py is where intelligence becomes action. It polls eligible recommendations, subjects each one to a strict sequence of pre-trade safety checks, computes a portfolio-aware position size, and — if every gate passes — submits an order through the broker adapter to Alpaca's paper trading API. Every evaluation, whether it results in a trade or a skip, is recorded as a TradingDecision in the trading_decisions table, creating a complete audit trail from the original document signal through to the broker response.

For a visual overview of the decision flow, see the Trading Engine Decision Loop diagram.


The Trading Engine Decision Loop

The TradingEngine class in services/trading/engine.py is the orchestrator. When start() is called, it loads the current portfolio state from PostgreSQL — open positions, reserve pool balance, sector exposure, portfolio heat — and then spawns five concurrent asyncio tasks that run for the lifetime of the engine:

  1. _decision_loop() — The core polling loop. Every 60 seconds (configurable via polling_interval_seconds), it queries the recommendations table for rows where action IN ('buy', 'sell'), mode IN ('paper_eligible', 'live_eligible'), and generated_at is within the last two hours. Recommendations are ordered by confidence descending and capped at 50 per cycle. For each recommendation, the engine fetches the current market price (first from market_snapshots, falling back to the Polygon API), then runs the full pre-trade evaluation pipeline described below.

  2. _stop_loss_monitor() — Periodically checks current prices against the stop-loss and take-profit levels maintained by the StopLossManager in services/trading/stop_loss_manager.py. When a price crosses a stop-loss or take-profit threshold, the monitor submits a sell order to the broker queue. The StopLossManager computes initial levels from ATR and risk tier parameters, re-evaluates them when volatility shifts materially (ATR change > 10%), activates trailing stops when the price moves more than 50% toward the take-profit target, and tightens stops proactively when portfolio heat exceeds 80% of the maximum.

  3. _performance_loop() — Computes portfolio-wide performance metrics (total value, unrealized and realized P&L, win rate, Sharpe ratio, drawdown, portfolio heat), persists daily snapshots to portfolio_snapshots, checks for daily-loss circuit breaker triggers, evaluates profit-taking opportunities, and synchronizes positions with the database to detect closed positions and trigger reserve pool siphoning.

  4. _risk_tier_scheduler() — Runs once daily at 16:00 ET (market close). It loads the latest PerformanceMetrics from portfolio_snapshots, computes the reserve pool as a fraction of total portfolio value, and delegates to the RiskTierController in services/trading/risk_tier_controller.py to determine whether the active risk tier should change. Tier changes are persisted to risk_tier_history and take effect immediately for subsequent decision cycles.

  5. _rebalance_scheduler() — Runs weekly on Monday at 09:45 ET (shortly after market open). It loads current positions, evaluates them against the active risk tier's constraints using the PortfolioRebalancer, and pushes any rebalance sell orders to stonks:queue:broker_orders. The rebalancer respects the circuit breaker — if any breaker is active, the rebalance cycle is skipped entirely.

All five tasks run concurrently within a single asyncio event loop. Graceful shutdown via stop() cancels all tasks and awaits their completion. If any task encounters an unexpected exception, it logs the error and retries after a brief sleep rather than crashing the engine.


Pre-Trade Check Sequence

When the decision loop picks up a buy recommendation, it calls evaluate_recommendation() — a synchronous method that runs the full pre-trade check sequence. The checks are applied in a strict order, and the first failure short-circuits the evaluation with a skip decision. This fail-fast design ensures that expensive downstream computations (like position sizing and correlation analysis) are never reached when a simple gate would have rejected the trade.

The six checks, in order:

a. Circuit breaker check. The engine calls self.circuit_breaker.is_active() on the current CircuitBreakerState. If any circuit breaker is active and its cooldown has not expired, the recommendation is skipped with reason circuit_breaker_active. The circuit breaker mechanism is described in detail below.

b. Trading window check. The is_within_trading_window() function verifies that the current time falls within US market hours. Outside the trading window, no orders are submitted — the recommendation is skipped with reason outside_trading_window.

c. Confidence gate. The recommendation's confidence score is compared against the active risk tier's min_confidence threshold. A conservative tier requires confidence ≥ 0.75, moderate requires ≥ 0.55, and aggressive requires ≥ 0.40. If the recommendation's confidence falls below the tier minimum, it is skipped with reason insufficient_confidence. This gate ensures that the risk tier's conservatism is enforced before any capital allocation is considered.

d. Deduplication check. The engine maintains an in-memory set of processed recommendation IDs (processed_recommendation_ids) and also checks Redis via stonks:dedupe:trading:* keys (with a 24-hour TTL). If the recommendation has already been evaluated in this engine session or by a previous instance, it is skipped with reason duplicate_recommendation. This prevents the same recommendation from generating multiple orders across polling cycles.

e. Declining positions check. The check_declining_positions() method examines all open positions. If more than 50% of positions have unrealized losses exceeding 2% of their entry value, the engine halts new entries with reason multiple_declining_positions. This is a portfolio-level safety valve — when the majority of existing positions are underwater, adding new exposure compounds the risk.

f. Max open positions check. The engine enforces a configurable maximum number of concurrent positions (default 10). If the portfolio is already at capacity, the recommendation is skipped with reason max_positions_reached.

For sell recommendations, the engine follows a separate, simpler path: it verifies the trading window, looks up the existing position for the ticker, and submits a market sell order for the full position quantity without running the position sizer. Sell decisions still generate a TradingDecision audit record and set the Redis deduplication key.

If all six checks pass for a buy recommendation, the engine proceeds to position sizing.


Position Sizing

The PositionSizer in services/trading/position_sizer.py translates a recommendation's signal quality into a concrete dollar amount and share count, applying a sequential pipeline of adjustments that account for confidence, portfolio composition, sector concentration, correlation, and upcoming earnings events. The sizer operates on the active pool — the portion of the portfolio available for trading after subtracting the reserve pool balance.

Base Sizing

The computation begins with a base allocation percentage derived from the risk tier:

base_allocation_pct = risk_tier.max_position_pct × 0.5
raw_pct = base_allocation_pct × (confidence / min_confidence)

The base starts at half the tier's maximum position percentage, then scales linearly with how far the recommendation's confidence exceeds the tier minimum. A moderate-tier recommendation with confidence 0.70 against a minimum of 0.55 would produce a raw percentage of 0.05 × (0.70 / 0.55) ≈ 0.0636, or about 6.4% of the active pool. The raw percentage is clamped to max_position_pct (5% for conservative, 10% for moderate, 15% for aggressive) and then converted to a dollar amount against the active pool. An absolute position cap (default $50) provides a hard ceiling regardless of pool size — a safety measure for the paper trading environment.

Correlation-Aware Diversification

The sizer computes a weighted average correlation between the candidate ticker and all existing positions, using the pairwise correlation matrix that the engine refreshes from 30 days of daily close prices in market_snapshots. Each existing position's correlation is weighted by its market value, so larger positions have more influence on the diversification check.

If the weighted average correlation exceeds 0.8, the position is rejected outright — the portfolio already has too much exposure to correlated assets. Between 0.5 and 0.8, the dollar amount is reduced proportionally: a correlation of 0.65 produces a scale factor of 1.0 (0.65 0.5) / (0.8 0.5) = 0.5, halving the position size. Below 0.5, no reduction is applied.

Sector Exposure Reduction

The sizer checks whether adding the new position would push the sector's total exposure beyond the risk tier's max_sector_pct (20% for conservative, 30% for moderate, 40% for aggressive). If the sector is already at its limit, the position is rejected. If the new position would exceed the limit, the dollar amount is reduced to exactly fill the remaining sector capacity.

Diversification Bonus

When the portfolio holds fewer than three distinct sectors and the candidate ticker belongs to a new sector, the sizer applies a 1.2× bonus to the dollar amount. This incentivizes early diversification — the first few positions are encouraged to spread across sectors rather than concentrating in a single one. The bonus is re-clamped to max_position_pct after application to prevent oversized positions.

Earnings Proximity Adjustment

The sizer checks the earnings calendar for the candidate ticker. If earnings are within one trading day, the position is rejected entirely — the binary risk of an earnings surprise is too high for automated entry. If earnings are within three trading days, the dollar amount is reduced by 50%. Beyond three days, no adjustment is applied.

Portfolio Heat Check and Share Rounding

After all adjustments, the sizer estimates the new position's contribution to portfolio heat (the aggregate risk from stop-loss distances across all positions). If adding the position would push total heat beyond max_portfolio_heat × active_pool (10% for conservative, 20% for moderate, 30% for aggressive), the position is rejected.

Finally, the dollar amount is converted to whole shares via floor(dollar_amount / current_price). If rounding produces zero shares (the position is too small for even one share at the current price), the position is rejected. The final dollar amount is recalculated from the whole-share quantity to reflect the actual capital deployed.

The PositionSizeResult returned to the engine includes the dollar amount, share quantity, allocation percentage, a list of human-readable adjustment notes, and a rejected flag with reason if any step failed. These adjustment notes are embedded in the TradingDecision's decision_trace for full auditability.


Circuit Breaker

The CircuitBreaker in services/trading/circuit_breaker.py is a pure computation module that evaluates three independent trigger conditions. It carries no state of its own — the engine manages the CircuitBreakerState dataclass and persists trigger events to the circuit_breaker_events table and Redis keys under stonks:trading:circuit_breaker:*.

Three Trigger Types

Daily loss trigger. When the portfolio's daily P&L loss exceeds 5% of total portfolio value (daily_loss_pct = 0.05), the circuit breaker activates. The check_daily_loss() method compares the absolute loss ratio against the threshold. The cooldown duration is set to volatility_pause_hours (default 2 hours). The performance loop in the engine calls _check_circuit_breaker_daily_loss() periodically to evaluate this condition against the latest portfolio metrics. In extreme cases where the drawdown exceeds an emergency threshold, the reserve pool's emergency liquidation mechanism may also be triggered.

Single position loss trigger. When any individual position loses more than 15% of its entry value (single_position_loss_pct = 0.15), the circuit breaker activates with a ticker-specific cooldown. The check_single_position() method evaluates the loss percentage. The cooldown for the affected ticker is set to ticker_cooldown_hours (default 48 hours), during which the engine will not re-enter that ticker. The is_ticker_cooled_down() method checks whether a specific ticker is still within its cooldown window by consulting the ticker_cooldowns dictionary in the CircuitBreakerState.

Volatility trigger (stop-loss clustering). When three or more stop-losses fire within a 30-minute rolling window (stop_loss_hits_threshold = 3, stop_loss_window_minutes = 30), the circuit breaker activates. The check_volatility() method uses a sliding window algorithm: it sorts the stop-loss timestamps and checks every contiguous subsequence of length stop_loss_hits_threshold to see if it fits within the window. This detects rapid-fire stop-loss cascades that indicate extreme market volatility. The cooldown is volatility_pause_hours (default 2 hours).

Cooldown Computation

The compute_cooldown_expiry() method calculates when a triggered breaker expires. For daily_loss and volatility triggers, the expiry is triggered_at + volatility_pause_hours. For single_position triggers, the expiry is triggered_at + ticker_cooldown_hours, giving the affected ticker a longer cooling-off period. The is_active() method returns True when the breaker is flagged active and the current time has not yet passed the cooldown expiry.

Redis State Tracking

The engine persists circuit breaker state to Redis under the stonks:trading:circuit_breaker:* key pattern (constructed by trading_cb_key() in services/shared/redis_keys.py). Each trigger type gets its own key — for example, stonks:trading:circuit_breaker:daily_loss — storing the activation timestamp and cooldown expiry. This allows the state to survive engine restarts and enables external monitoring tools to query breaker status without accessing the engine's memory.


Reserve Pool

The ReservePoolController in services/trading/reserve_pool.py manages an untouchable cash reserve that grows from realized trading profits. The reserve serves two purposes: it provides a buffer against drawdowns, and its size relative to the portfolio influences risk tier upgrade decisions.

Profit Siphoning

When the engine detects a closed position with positive unrealized P&L (via _sync_positions_and_siphon() in the performance loop), it calls siphon_profit() on the controller. The method transfers a configurable fraction of the realized profit into the reserve — by default 20% (siphon_pct = 0.20). Only positive profits are siphoned; losses do not reduce the reserve balance. Each siphon event is recorded in the reserve_pool_ledger table with the transfer amount, resulting balance, trigger type (profit_siphon), the ticker as reference, and a timestamp.

High-Water Mark Rebalancing

The is_high_water() method returns True when the reserve balance exceeds 30% of total portfolio value (high_water_pct = 0.30). This signal is consumed by the risk tier scheduler — when the reserve is healthy and other performance criteria are met, the controller may recommend upgrading to a more aggressive tier. The high-water mark acts as a confidence indicator: a large reserve means the system has been consistently profitable and can afford to take on more risk.

Emergency Liquidation

The should_emergency_liquidate() method checks whether the current drawdown exceeds an emergency threshold. When triggered, emergency_liquidate() returns the full reserve balance for release back into the active pool. The caller (the engine) is responsible for zeroing the persisted balance and recording the ledger entry. Emergency liquidation is a last resort — it sacrifices the safety buffer to prevent the portfolio from hitting a catastrophic loss level.

Active Pool Computation

The compute_active_pool() method calculates the capital available for trading: active_pool = total_portfolio_value reserve_balance. All position sizing computations use the active pool rather than the total portfolio value, ensuring that the reserve is never inadvertently deployed into new positions.


Risk Tier Auto-Adjustment

The RiskTierController in services/trading/risk_tier_controller.py evaluates portfolio performance and determines whether the active risk tier should shift. The system supports three tiers — conservative, moderate, and aggressive — each defined by a RiskTierConfig dataclass in services/trading/models.py with distinct parameter values:

Parameter Conservative Moderate Aggressive
min_confidence 0.75 0.55 0.40
max_position_pct 5% 10% 15%
stop_loss_atr_multiplier 1.5× 2.0× 2.5×
reward_risk_ratio 2.0 1.5 1.2
max_sector_pct 20% 30% 40%
max_portfolio_heat 10% 20% 30%

The tier controller's evaluate() method checks two conditions:

Downgrade (any one triggers). If the trailing 30-day win rate drops below 40% or the current drawdown exceeds 15%, the tier steps down by one level (e.g., aggressive → moderate). If the system is already at conservative, no further downgrade is possible.

Upgrade (all must be true). If the win rate exceeds 55%, the reserve pool exceeds 20% of total portfolio value, and the current drawdown is below 5%, the tier steps up by one level. The triple requirement ensures that upgrades only happen when the system is performing well, has built a safety cushion, and is not in a drawdown.

The risk tier scheduler in the engine evaluates these conditions daily at market close. When a tier change occurs, it is persisted to the risk_tier_history table with the previous tier, new tier, trigger source (auto_adjustment), and the metrics that drove the decision (win rate, drawdown, reserve percentage, Sharpe ratio). The new tier takes effect immediately — the engine updates its _active_risk_tier reference, and all subsequent decision cycles use the new tier's parameters for confidence gates, position sizing, stop-loss computation, and sector exposure limits.


Order Submission Flow

When evaluate_recommendation() returns an act decision, the engine constructs an order job and pushes it through a multi-stage submission pipeline that spans two services.

TradingDecision Persistence

Every evaluation — whether it results in act or skip — produces a TradingDecision dataclass that is persisted to the trading_decisions table via _persist_decision(). The record captures the recommendation ID, decision outcome, skip reason (if applicable), ticker, computed position size and share quantity, the risk tier at the time of decision, portfolio heat, active pool and reserve pool balances, circuit breaker status, correlation and sector exposure check results, earnings proximity flag, and a decision_trace JSONB field containing the full reasoning chain. This creates a complete audit record of every recommendation the engine evaluated and why it acted or declined.

Order Enqueue

For act decisions, the engine builds an order job dictionary containing the trading decision ID, ticker, action (buy or sell), quantity, and order type (market). This job is pushed via rpush to the stonks:queue:broker_orders Redis queue (constructed by queue_key(QUEUE_BROKER) from services/shared/redis_keys.py). The engine immediately deducts the estimated order cost from the in-memory active pool to prevent over-allocation across concurrent recommendation evaluations within the same polling cycle.

Broker Service Processing

The broker service in services/adapters/broker_service.py runs as a standalone worker that polls stonks:queue:broker_orders via blpop. For each order job, process_order_job() executes a multi-step pipeline:

  1. Idempotency check. A deterministic idempotency key is generated from the job's ticker, action, quantity, and trading decision ID. The service checks Redis first (fast path) and then the orders table (durable fallback) to prevent duplicate submissions. If a matching key exists, the job is silently dropped.

  2. Risk evaluation. The service loads the current PortfolioRiskConfig from the database and the account's risk state (open positions, daily P&L, sector exposure) from both the database and the Alpaca API. The evaluate_order() function runs the proposed order through a set of risk checks — position limits, sector concentration, daily loss thresholds — and produces an evaluation result. The evaluation is persisted to the risk_evaluations table regardless of outcome.

  3. Alpaca submission. If the risk evaluation passes, the service calls submit_order() on the AlpacaBrokerAdapter in services/adapters/broker_adapter.py. The adapter constructs the Alpaca REST API payload (symbol, quantity, side, order type, time in force) and submits it to paper-api.alpaca.markets/v2/orders with an idempotency key header. The adapter follows a fail-closed policy: any network error or ambiguous response returns a rejected OrderResponse rather than risking duplicate orders.

  4. Persistence and audit trail. The persist_order() function writes the order to the orders table with the full request and response details, risk evaluation results, and the recommendation ID for traceability. When the order is filled, the fill details (price, quantity) are recorded. Order events are published to the analytical lakehouse via MinIO for downstream analysis. The Redis idempotency marker is set after successful persistence to prevent reprocessing.

The result is a complete chain of custody: from the original document that produced a signal (Pages 12), through signal scoring (Page 3) and trend aggregation (Page 4), to the recommendation (Page 5), the trading decision, the risk evaluation, and the broker response — every step is persisted and linked by foreign keys. The trading_decisions table links to recommendations via recommendation_id, the orders table links back to both, and the positions and portfolio_snapshots tables capture the portfolio impact over time.

For additional reference on the trading engine's configuration, queue topology, and database tables, see docs/services.md.


Conclusion: From Raw Data to Trade Execution

This six-page series has traced the full intelligence-to-decision pipeline in Stonks Oracle, from the moment raw data enters the system to the moment an order reaches the broker.

It began with Page 1, where the scheduler orchestrates ingestion cycles across four data sources — Polygon news, SEC EDGAR filings, Polygon market data, and macro news APIs — and the parser normalizes raw content into structured documents ready for AI processing. Page 2 described how the Document Intelligence Extractor and Global Event Classifier agents use LLM inference to produce structured JSON intelligence, with hot-swappable model configurations and a robust JSON repair pipeline. Page 3 explained how raw extraction output is transformed into WeightedSignal objects through a composite formula that balances recency, credibility, novelty, and market context across three independent signal layers. Page 4 showed how the aggregation engine merges these signals across five time windows, detecting contradictions, ranking evidence, and computing trend projections — with consecutive same-direction signals accumulating to escalate the system's response from neutral through watch and hold to buy or sell. Page 5 covered the translation of trend assessments into actionable recommendations through data quality suppression, eligibility evaluation, position sizing, thesis generation, and risk classification.

And here in Page 6, the pipeline reached its terminus: the trading engine's decision loop polling those recommendations, subjecting each to circuit breaker checks, confidence gates, deduplication, portfolio health assessments, and a multi-step position sizer — then submitting approved orders through the broker adapter to Alpaca's paper trading API, with every decision recorded in a fully auditable trail from signal to execution.

The pipeline is designed to be conservative by default and transparent throughout. Every stage applies its own safety checks — deduplication at ingestion, confidence gates at extraction, contradiction detection at aggregation, suppression at recommendation, and circuit breakers at trading. The system can be tuned through runtime configuration (risk tier parameters, suppression thresholds, signal layer toggles in risk_configs) without code changes or restarts. And the complete audit trail — from documents through document_intelligence, document_impact_records, trend_windows, recommendations, trading_decisions, and orders — means that any trade can be traced back to the specific documents, signals, and decisions that produced it.