feat: wire live decision loop and enable paper trading
Phase 2 of the autonomous trading engine: - Replace start()/stop() stubs with real async implementations - Decision loop: polls recommendations from PostgreSQL, deduplicates via Redis, evaluates through the full pipeline, submits orders to stonks:queue:broker_orders - Stop-loss monitor: fetches prices from Polygon API, checks crossings, submits immediate sell orders, safety sell after 15 min without data - Performance loop: computes metrics every 5 min during market hours, persists daily snapshots at market close - Risk tier scheduler: evaluates daily at 16:00 ET, persists tier changes - Rebalance scheduler: evaluates Monday 09:45 ET, respects circuit breaker - Notification dispatch: SNS + Gmail with rate limiting and retry - Backtest replay: fetches historical data, simulates decisions, persists - Real asyncpg/redis connections in FastAPI lifespan (graceful degradation) - Migration 019: enable paper trading with conservative tier, 5 cap - Added max_open_positions to TradingConfig with env var loading - Phase 2 tasks added to autonomous-trading-engine spec
This commit is contained in:
+925
-11
@@ -8,19 +8,31 @@ 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.
|
||||
``start`` / ``stop`` methods manage the live decision loop, stop-loss
|
||||
monitor, and performance metrics loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from services.shared.config import TradingConfig
|
||||
from services.shared.redis_keys import (
|
||||
QUEUE_BROKER,
|
||||
queue_key,
|
||||
trading_dedupe_key,
|
||||
)
|
||||
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 (
|
||||
RISK_TIER_DEFAULTS,
|
||||
CircuitBreakerState,
|
||||
OpenPosition,
|
||||
PerformanceMetrics,
|
||||
@@ -32,12 +44,15 @@ from services.trading.models import (
|
||||
TradingDecision,
|
||||
)
|
||||
from services.trading.notifications import NotificationRecord, NotificationService
|
||||
from services.trading.performance_tracker import PerformanceComputer
|
||||
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
|
||||
from services.trading.trading_window import is_market_open, is_within_trading_window
|
||||
|
||||
logger = logging.getLogger("trading_engine")
|
||||
|
||||
|
||||
class TradingEngine:
|
||||
@@ -79,31 +94,81 @@ class TradingEngine:
|
||||
self.notification_service = NotificationService()
|
||||
self.micro_trading_module = MicroTradingModule()
|
||||
self.rebalancer = PortfolioRebalancer()
|
||||
self.performance_computer = PerformanceComputer()
|
||||
|
||||
# Runtime state
|
||||
self.running: bool = False
|
||||
self.portfolio_state: PortfolioState | None = None
|
||||
self.processed_recommendation_ids: set[str] = set()
|
||||
|
||||
# Async task management (Task 27.6)
|
||||
self._tasks: list[asyncio.Task] = [] # type: ignore[type-arg]
|
||||
|
||||
# Active risk tier loaded from config defaults
|
||||
self._active_risk_tier: RiskTierConfig = RISK_TIER_DEFAULTS.get(
|
||||
config.risk_tier, RISK_TIER_DEFAULTS["moderate"]
|
||||
)
|
||||
|
||||
# Circuit breaker runtime state
|
||||
self._cb_state: CircuitBreakerState = CircuitBreakerState()
|
||||
|
||||
# Earnings calendar cache
|
||||
self._earnings_calendar: dict = {}
|
||||
|
||||
# Last poll timestamp — initialised to 24 h ago so first poll
|
||||
# picks up recent recommendations
|
||||
self._last_poll_timestamp: datetime = datetime.now(tz=timezone.utc) - timedelta(hours=24)
|
||||
|
||||
# Per-ticker last-price-fetch timestamps for safety sell (Task 28.4)
|
||||
self._last_price_timestamps: dict[str, datetime] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle (stubs — wired in Task 25)
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Load portfolio state and enter the decision loop.
|
||||
"""Load portfolio state and spawn the async worker loops.
|
||||
|
||||
Full implementation is deferred to Task 25. This stub sets the
|
||||
``running`` flag so readiness probes can report status.
|
||||
When ``self.pool`` is ``None`` (unit-test / lightweight mode) the
|
||||
engine skips database loading and starts with an empty portfolio.
|
||||
"""
|
||||
# --- Load initial state from PostgreSQL (graceful degradation) ---
|
||||
if self.pool is not None:
|
||||
try:
|
||||
await self._load_initial_state()
|
||||
except Exception:
|
||||
logger.exception("Failed to load initial state from DB — starting with defaults")
|
||||
|
||||
# Ensure we always have a portfolio state
|
||||
if self.portfolio_state is None:
|
||||
self.portfolio_state = PortfolioState()
|
||||
|
||||
self.running = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Graceful shutdown — cancel pending work and persist state.
|
||||
# Spawn async worker loops
|
||||
self._tasks = [
|
||||
asyncio.create_task(self._decision_loop(), name="decision_loop"),
|
||||
asyncio.create_task(self._stop_loss_monitor(), name="stop_loss_monitor"),
|
||||
asyncio.create_task(self._performance_loop(), name="performance_loop"),
|
||||
asyncio.create_task(self._risk_tier_scheduler(), name="risk_tier_scheduler"),
|
||||
asyncio.create_task(self._rebalance_scheduler(), name="rebalance_scheduler"),
|
||||
]
|
||||
logger.info("Trading engine started with %d worker tasks", len(self._tasks))
|
||||
|
||||
Full implementation is deferred to Task 25.
|
||||
"""
|
||||
async def stop(self) -> None:
|
||||
"""Graceful shutdown — cancel all worker tasks and persist state."""
|
||||
self.running = False
|
||||
|
||||
# Cancel all tasks
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
|
||||
self._tasks.clear()
|
||||
logger.info("Trading engine stopped")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core evaluation logic (synchronous-compatible for testing)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -449,6 +514,855 @@ class TradingEngine:
|
||||
max_heat=max_heat,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async worker loops
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _load_initial_state(self) -> None:
|
||||
"""Load portfolio state, risk tier, reserve pool, and CB status from DB."""
|
||||
if self.pool is None:
|
||||
return
|
||||
|
||||
# Load reserve pool balance
|
||||
reserve_balance = 0.0
|
||||
try:
|
||||
row = await self.pool.fetchrow(
|
||||
"SELECT balance_after FROM reserve_pool_ledger ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
if row:
|
||||
reserve_balance = float(row["balance_after"])
|
||||
except Exception:
|
||||
logger.debug("Could not load reserve pool balance — using 0.0")
|
||||
|
||||
# Load circuit breaker state (unresolved events)
|
||||
try:
|
||||
cb_row = await self.pool.fetchrow(
|
||||
"SELECT trigger_type, triggered_at, cooldown_expires "
|
||||
"FROM circuit_breaker_events WHERE resolved_at IS NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
if cb_row:
|
||||
self._cb_state = CircuitBreakerState(
|
||||
active=True,
|
||||
trigger_type=cb_row["trigger_type"],
|
||||
triggered_at=cb_row["triggered_at"],
|
||||
cooldown_expires=cb_row["cooldown_expires"],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not load circuit breaker state — using inactive")
|
||||
|
||||
# Build portfolio state with defaults
|
||||
self.portfolio_state = PortfolioState(
|
||||
reserve_pool=reserve_balance,
|
||||
active_pool=max(0.0, 500.0 - reserve_balance),
|
||||
total_value=500.0,
|
||||
)
|
||||
|
||||
async def _decision_loop(self) -> None:
|
||||
"""Poll recommendations and evaluate them in a continuous loop.
|
||||
|
||||
Task 27.3: Main decision loop that polls the recommendations table,
|
||||
checks Redis deduplication, evaluates each recommendation, and
|
||||
pushes "act" decisions to the broker queue.
|
||||
"""
|
||||
while self.running:
|
||||
try:
|
||||
await asyncio.sleep(self.config.polling_interval_seconds)
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
if self.pool is None:
|
||||
continue
|
||||
|
||||
# Poll recommendations from PostgreSQL
|
||||
recs: list[dict] = []
|
||||
try:
|
||||
rows = await self.pool.fetch(
|
||||
"SELECT * FROM recommendations "
|
||||
"WHERE action IN ('buy','sell') "
|
||||
"AND mode IN ('paper_eligible','live_eligible') "
|
||||
"AND generated_at > $1 "
|
||||
"ORDER BY confidence DESC",
|
||||
self._last_poll_timestamp,
|
||||
)
|
||||
self._last_poll_timestamp = datetime.now(tz=timezone.utc)
|
||||
recs = [dict(r) for r in rows]
|
||||
except Exception:
|
||||
logger.debug("Could not poll recommendations — table may not exist yet")
|
||||
continue
|
||||
|
||||
for rec in recs:
|
||||
try:
|
||||
rec_id = str(rec.get("recommendation_id", rec.get("id", "")))
|
||||
|
||||
# Redis deduplication check
|
||||
if self.redis is not None:
|
||||
dedupe_key = trading_dedupe_key(rec_id)
|
||||
already = await self.redis.get(dedupe_key)
|
||||
if already:
|
||||
continue
|
||||
# Set dedupe key with 24h TTL before evaluation
|
||||
await self.redis.set(dedupe_key, "1", ex=86400)
|
||||
|
||||
# Ensure portfolio state exists
|
||||
if self.portfolio_state is None:
|
||||
self.portfolio_state = PortfolioState()
|
||||
|
||||
# Evaluate recommendation
|
||||
decision = self.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=self.portfolio_state,
|
||||
risk_tier=self._active_risk_tier,
|
||||
circuit_breaker_state=self._cb_state,
|
||||
correlation_matrix=self.correlation_matrix,
|
||||
earnings_calendar=self._earnings_calendar,
|
||||
)
|
||||
|
||||
# For "act" decisions: push order to broker queue
|
||||
if decision.decision == "act":
|
||||
order_job = {
|
||||
"trading_decision_id": decision.id,
|
||||
"ticker": decision.ticker,
|
||||
"action": rec.get("action", "buy"),
|
||||
"quantity": decision.computed_share_quantity,
|
||||
"order_type": "market",
|
||||
"source": "trading_engine",
|
||||
}
|
||||
if self.redis is not None:
|
||||
broker_queue = queue_key(QUEUE_BROKER)
|
||||
await self.redis.rpush(broker_queue, json.dumps(order_job))
|
||||
logger.info(
|
||||
"Pushed order for %s (%d shares) to broker queue",
|
||||
decision.ticker,
|
||||
decision.computed_share_quantity or 0,
|
||||
)
|
||||
|
||||
# Persist decision
|
||||
await self._persist_decision(decision)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error evaluating recommendation %s", rec.get("recommendation_id", "?"))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in decision loop")
|
||||
if self.running:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _stop_loss_monitor(self) -> None:
|
||||
"""Monitor open positions for stop-loss and take-profit crossings.
|
||||
|
||||
Task 28.1: Periodically checks current prices against stop levels
|
||||
and submits sell orders for triggered positions.
|
||||
"""
|
||||
while self.running:
|
||||
try:
|
||||
await asyncio.sleep(self.config.stop_loss_check_interval_seconds)
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Skip if not market hours
|
||||
if not is_market_open(now):
|
||||
continue
|
||||
|
||||
if self.pool is None:
|
||||
continue
|
||||
|
||||
# Load positions and stop levels from DB
|
||||
positions = await self._load_open_positions()
|
||||
stop_levels = await self._load_stop_levels()
|
||||
|
||||
if not positions:
|
||||
continue
|
||||
|
||||
# Fetch current prices
|
||||
tickers = [p.ticker for p in positions]
|
||||
prices = await self._fetch_current_prices(tickers)
|
||||
|
||||
# Update last-price timestamps for tickers that returned data
|
||||
for ticker in tickers:
|
||||
if ticker in prices:
|
||||
self._last_price_timestamps[ticker] = now
|
||||
|
||||
# Safety sell for missing price data (Task 28.4)
|
||||
for pos in positions:
|
||||
if pos.ticker not in prices:
|
||||
last_ts = self._last_price_timestamps.get(pos.ticker)
|
||||
if last_ts and (now - last_ts) > timedelta(minutes=15):
|
||||
logger.warning(
|
||||
"No price data for %s for >15 min — submitting safety sell",
|
||||
pos.ticker,
|
||||
)
|
||||
await self._submit_sell_order(
|
||||
pos.ticker, pos.quantity, "safety_sell_missing_price"
|
||||
)
|
||||
|
||||
# Check crossings
|
||||
triggers = self.check_stop_loss_crossings(positions, prices, stop_levels)
|
||||
|
||||
for trigger in triggers:
|
||||
# Find the position to get quantity
|
||||
pos_match = next((p for p in positions if p.ticker == trigger.ticker), None)
|
||||
if pos_match is None:
|
||||
continue
|
||||
|
||||
await self._submit_sell_order(
|
||||
trigger.ticker,
|
||||
pos_match.quantity,
|
||||
f"{trigger.trigger_type}_triggered",
|
||||
)
|
||||
logger.info(
|
||||
"Stop-loss monitor: %s triggered for %s at %.2f (trigger: %.2f)",
|
||||
trigger.trigger_type,
|
||||
trigger.ticker,
|
||||
trigger.current_price,
|
||||
trigger.trigger_price,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in stop-loss monitor")
|
||||
if self.running:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _performance_loop(self) -> None:
|
||||
"""Compute and update performance metrics periodically.
|
||||
|
||||
Task 29.1: Runs every 5 minutes during market hours, computing
|
||||
portfolio metrics and updating self.portfolio_state.
|
||||
Task 29.2: Persists a daily snapshot at end of trading day.
|
||||
"""
|
||||
last_snapshot_date: str | None = None
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
await asyncio.sleep(300) # 5 minutes
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Skip if not market hours
|
||||
if not is_market_open(now):
|
||||
# Check if we should persist end-of-day snapshot (Task 29.2)
|
||||
from services.trading.trading_window import ET
|
||||
et_now = now.astimezone(ET)
|
||||
today_str = et_now.strftime("%Y-%m-%d")
|
||||
|
||||
# After 4:00 PM ET and haven't snapshotted today
|
||||
if et_now.hour >= 16 and last_snapshot_date != today_str:
|
||||
await self._persist_daily_snapshot(now)
|
||||
last_snapshot_date = today_str
|
||||
|
||||
continue
|
||||
|
||||
# Compute metrics from current state
|
||||
if self.portfolio_state is None:
|
||||
continue
|
||||
|
||||
# Update portfolio heat and metrics from current positions
|
||||
try:
|
||||
metrics = self.performance_computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=self.portfolio_state.total_value,
|
||||
active_pool=self.portfolio_state.active_pool,
|
||||
reserve_pool=self.portfolio_state.reserve_pool,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=sum(
|
||||
p.unrealized_pnl for p in self.portfolio_state.positions
|
||||
),
|
||||
portfolio_heat=self.portfolio_state.portfolio_heat,
|
||||
daily_returns=[],
|
||||
)
|
||||
logger.debug(
|
||||
"Performance update: value=%.2f heat=%.4f",
|
||||
metrics.total_portfolio_value,
|
||||
metrics.portfolio_heat,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not compute performance metrics")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in performance loop")
|
||||
if self.running:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _risk_tier_scheduler(self) -> None:
|
||||
"""Evaluate risk tier at daily market close (16:00 ET).
|
||||
|
||||
Task 30.1: Computes seconds until next 16:00 ET, sleeps until then,
|
||||
loads latest PerformanceMetrics, computes reserve_pct, calls
|
||||
evaluate_risk_tier(), and persists tier changes.
|
||||
"""
|
||||
from services.trading.trading_window import ET
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Compute seconds until next 16:00 ET
|
||||
now_utc = datetime.now(tz=timezone.utc)
|
||||
et_now = now_utc.astimezone(ET)
|
||||
target_today = et_now.replace(hour=16, minute=0, second=0, microsecond=0)
|
||||
|
||||
if et_now >= target_today:
|
||||
# Already past 16:00 ET today — target tomorrow
|
||||
target = target_today + timedelta(days=1)
|
||||
else:
|
||||
target = target_today
|
||||
|
||||
# Skip weekends
|
||||
while target.weekday() > 4: # Saturday=5, Sunday=6
|
||||
target += timedelta(days=1)
|
||||
|
||||
sleep_seconds = (target - et_now).total_seconds()
|
||||
if sleep_seconds > 0:
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
if self.portfolio_state is None:
|
||||
continue
|
||||
|
||||
# Load latest PerformanceMetrics from portfolio_snapshots or compute fresh
|
||||
metrics: PerformanceMetrics | None = None
|
||||
if self.pool is not None:
|
||||
try:
|
||||
row = await self.pool.fetchrow(
|
||||
"SELECT metrics FROM portfolio_snapshots "
|
||||
"ORDER BY snapshot_date DESC LIMIT 1"
|
||||
)
|
||||
if row and row["metrics"]:
|
||||
m = json.loads(row["metrics"]) if isinstance(row["metrics"], str) else row["metrics"]
|
||||
if m:
|
||||
metrics = PerformanceMetrics(
|
||||
total_portfolio_value=m.get("total_portfolio_value", self.portfolio_state.total_value),
|
||||
active_pool=m.get("active_pool", self.portfolio_state.active_pool),
|
||||
reserve_pool=m.get("reserve_pool", self.portfolio_state.reserve_pool),
|
||||
unrealized_pnl=m.get("unrealized_pnl", 0.0),
|
||||
realized_pnl=m.get("realized_pnl", 0.0),
|
||||
daily_pnl=m.get("daily_pnl", 0.0),
|
||||
win_count=m.get("win_count", 0),
|
||||
loss_count=m.get("loss_count", 0),
|
||||
win_rate=m.get("win_rate", 0.0),
|
||||
avg_win=m.get("avg_win", 0.0),
|
||||
avg_loss=m.get("avg_loss", 0.0),
|
||||
profit_factor=m.get("profit_factor", 0.0),
|
||||
sharpe_ratio=m.get("sharpe_ratio", 0.0),
|
||||
max_drawdown=m.get("max_drawdown", 0.0),
|
||||
current_drawdown_pct=m.get("current_drawdown_pct", 0.0),
|
||||
portfolio_heat=m.get("portfolio_heat", 0.0),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not load metrics from portfolio_snapshots")
|
||||
|
||||
# Fall back to computing fresh metrics
|
||||
if metrics is None:
|
||||
metrics = self.performance_computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=self.portfolio_state.total_value,
|
||||
active_pool=self.portfolio_state.active_pool,
|
||||
reserve_pool=self.portfolio_state.reserve_pool,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=sum(
|
||||
p.unrealized_pnl for p in self.portfolio_state.positions
|
||||
),
|
||||
portfolio_heat=self.portfolio_state.portfolio_heat,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
# Compute reserve_pct
|
||||
total_value = self.portfolio_state.total_value
|
||||
reserve_pct = (
|
||||
self.portfolio_state.reserve_pool / total_value
|
||||
if total_value > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# Evaluate risk tier
|
||||
current_tier = self.config.risk_tier
|
||||
new_tier = self.evaluate_risk_tier(current_tier, metrics, reserve_pct)
|
||||
|
||||
if new_tier is not None and new_tier != current_tier:
|
||||
# Persist to risk_tier_history
|
||||
if self.pool is not None:
|
||||
try:
|
||||
await self.pool.execute(
|
||||
"INSERT INTO risk_tier_history "
|
||||
"(previous_tier, new_tier, trigger_source, trigger_metrics, created_at) "
|
||||
"VALUES ($1, $2, $3, $4, $5)",
|
||||
current_tier,
|
||||
new_tier,
|
||||
"auto_adjustment",
|
||||
json.dumps({
|
||||
"win_rate": metrics.win_rate,
|
||||
"current_drawdown_pct": metrics.current_drawdown_pct,
|
||||
"reserve_pct": reserve_pct,
|
||||
"sharpe_ratio": metrics.sharpe_ratio,
|
||||
}),
|
||||
datetime.now(tz=timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not persist risk tier change")
|
||||
|
||||
# Update config and active tier
|
||||
self.config.risk_tier = new_tier
|
||||
self._active_risk_tier = RISK_TIER_DEFAULTS.get(
|
||||
new_tier, RISK_TIER_DEFAULTS["moderate"]
|
||||
)
|
||||
|
||||
# Create alert notification
|
||||
self.create_alert(
|
||||
"risk_tier_changed",
|
||||
f"Risk tier changed from {current_tier} to {new_tier} "
|
||||
f"(win_rate={metrics.win_rate:.2%}, "
|
||||
f"drawdown={metrics.current_drawdown_pct:.2%}, "
|
||||
f"reserve={reserve_pct:.2%})",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Risk tier changed: %s → %s (win_rate=%.2f, drawdown=%.2f, reserve=%.2f)",
|
||||
current_tier,
|
||||
new_tier,
|
||||
metrics.win_rate,
|
||||
metrics.current_drawdown_pct,
|
||||
reserve_pct,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in risk tier scheduler")
|
||||
if self.running:
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _rebalance_scheduler(self) -> None:
|
||||
"""Evaluate portfolio rebalancing weekly at Monday 09:45 ET.
|
||||
|
||||
Task 30.2: Computes seconds until next Monday 09:45 ET, sleeps until
|
||||
then, loads positions and risk tier, calls evaluate_rebalancing(),
|
||||
and pushes rebalance orders to the broker queue.
|
||||
"""
|
||||
from services.trading.trading_window import ET, WINDOW_OPEN
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Compute seconds until next Monday 09:45 ET
|
||||
now_utc = datetime.now(tz=timezone.utc)
|
||||
et_now = now_utc.astimezone(ET)
|
||||
|
||||
# Find next Monday
|
||||
days_until_monday = (7 - et_now.weekday()) % 7
|
||||
if days_until_monday == 0:
|
||||
# It's Monday — check if we're past 09:45
|
||||
target_today = et_now.replace(
|
||||
hour=WINDOW_OPEN.hour,
|
||||
minute=WINDOW_OPEN.minute,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if et_now >= target_today:
|
||||
# Already past 09:45 on Monday — target next Monday
|
||||
days_until_monday = 7
|
||||
else:
|
||||
days_until_monday = 0
|
||||
|
||||
target = et_now.replace(
|
||||
hour=WINDOW_OPEN.hour,
|
||||
minute=WINDOW_OPEN.minute,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
) + timedelta(days=days_until_monday)
|
||||
|
||||
sleep_seconds = (target - et_now).total_seconds()
|
||||
if sleep_seconds > 0:
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# Respect circuit breaker status
|
||||
if self.circuit_breaker.is_active(self._cb_state, now=datetime.now(tz=timezone.utc)):
|
||||
logger.info("Rebalance skipped — circuit breaker is active")
|
||||
continue
|
||||
|
||||
if self.portfolio_state is None:
|
||||
continue
|
||||
|
||||
# Load current positions
|
||||
positions = self.portfolio_state.positions
|
||||
if self.pool is not None:
|
||||
try:
|
||||
positions = await self._load_open_positions()
|
||||
except Exception:
|
||||
logger.debug("Could not load positions for rebalancing")
|
||||
|
||||
if not positions:
|
||||
continue
|
||||
|
||||
# Evaluate rebalancing
|
||||
max_positions = (
|
||||
self.config.max_open_positions
|
||||
if hasattr(self.config, "max_open_positions")
|
||||
else 10
|
||||
)
|
||||
rebalance_orders = self.rebalancer.evaluate(
|
||||
positions,
|
||||
self._active_risk_tier,
|
||||
self.portfolio_state.active_pool,
|
||||
max_positions,
|
||||
)
|
||||
|
||||
# Push rebalance orders to broker queue
|
||||
for order in rebalance_orders:
|
||||
order_job = {
|
||||
"ticker": order.ticker,
|
||||
"action": order.action,
|
||||
"quantity": order.quantity,
|
||||
"order_type": "market",
|
||||
"source": "trading_engine",
|
||||
"reason": order.reason,
|
||||
"tag": order.tag,
|
||||
}
|
||||
if self.redis is not None:
|
||||
try:
|
||||
broker_queue = queue_key(QUEUE_BROKER)
|
||||
await self.redis.rpush(broker_queue, json.dumps(order_job))
|
||||
logger.info(
|
||||
"Rebalance: pushed %s order for %s (%d shares)",
|
||||
order.action,
|
||||
order.ticker,
|
||||
order.quantity,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to push rebalance order for %s", order.ticker)
|
||||
else:
|
||||
logger.info(
|
||||
"Rebalance (no redis): %s %s %d shares — %s",
|
||||
order.action,
|
||||
order.ticker,
|
||||
order.quantity,
|
||||
order.reason,
|
||||
)
|
||||
|
||||
if rebalance_orders:
|
||||
logger.info("Rebalance cycle completed: %d orders generated", len(rebalance_orders))
|
||||
else:
|
||||
logger.debug("Rebalance cycle completed: no orders needed")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in rebalance scheduler")
|
||||
if self.running:
|
||||
await asyncio.sleep(60)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _persist_decision(self, decision: TradingDecision) -> None:
|
||||
"""INSERT a trading decision into the trading_decisions table.
|
||||
|
||||
Task 27.5: Handles pool=None gracefully (skip persistence, log only).
|
||||
"""
|
||||
logger.info(
|
||||
"Decision: %s %s ticker=%s reason=%s",
|
||||
decision.decision,
|
||||
decision.id[:8],
|
||||
decision.ticker,
|
||||
decision.skip_reason or "—",
|
||||
)
|
||||
|
||||
if self.pool is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.pool.execute(
|
||||
"INSERT INTO trading_decisions "
|
||||
"(id, recommendation_id, decision, skip_reason, ticker, "
|
||||
"computed_position_size, computed_share_quantity, "
|
||||
"risk_tier_at_decision, portfolio_heat_at_decision, "
|
||||
"active_pool_at_decision, reserve_pool_at_decision, "
|
||||
"circuit_breaker_status, correlation_check_result, "
|
||||
"sector_exposure_check_result, earnings_proximity_flag, "
|
||||
"is_micro_trade, decision_trace, created_at) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, "
|
||||
"$11, $12, $13, $14, $15, $16, $17, $18)",
|
||||
decision.id,
|
||||
decision.recommendation_id,
|
||||
decision.decision,
|
||||
decision.skip_reason,
|
||||
decision.ticker,
|
||||
decision.computed_position_size,
|
||||
decision.computed_share_quantity,
|
||||
decision.risk_tier_at_decision,
|
||||
decision.portfolio_heat_at_decision,
|
||||
decision.active_pool_at_decision,
|
||||
decision.reserve_pool_at_decision,
|
||||
decision.circuit_breaker_status,
|
||||
json.dumps(decision.correlation_check_result),
|
||||
json.dumps(decision.sector_exposure_check_result),
|
||||
decision.earnings_proximity_flag,
|
||||
decision.is_micro_trade,
|
||||
json.dumps(decision.decision_trace),
|
||||
decision.created_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not persist decision %s — table may not exist", decision.id[:8])
|
||||
|
||||
async def _sync_positions_and_siphon(self) -> None:
|
||||
"""Sync positions from DB and siphon profit on closed positions.
|
||||
|
||||
Task 27.4: Fetches current positions, detects closes, and calls
|
||||
siphon_profit() for profitable closes.
|
||||
"""
|
||||
if self.pool is None or self.portfolio_state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
positions = await self._load_open_positions()
|
||||
old_tickers = {p.ticker for p in self.portfolio_state.positions}
|
||||
new_tickers = {p.ticker for p in positions}
|
||||
|
||||
# Detect closed positions
|
||||
closed_tickers = old_tickers - new_tickers
|
||||
for ticker in closed_tickers:
|
||||
old_pos = next(
|
||||
(p for p in self.portfolio_state.positions if p.ticker == ticker),
|
||||
None,
|
||||
)
|
||||
if old_pos and old_pos.unrealized_pnl > 0:
|
||||
transfer, new_balance = self.reserve_pool_controller.siphon_profit(
|
||||
old_pos.unrealized_pnl,
|
||||
self.portfolio_state.reserve_pool,
|
||||
)
|
||||
if transfer > 0:
|
||||
self.portfolio_state.reserve_pool = new_balance
|
||||
# Persist to reserve_pool_ledger
|
||||
try:
|
||||
await self.pool.execute(
|
||||
"INSERT INTO reserve_pool_ledger "
|
||||
"(amount, balance_after, trigger_type, reference_id, notes, created_at) "
|
||||
"VALUES ($1, $2, 'profit_siphon', $3, $4, $5)",
|
||||
transfer,
|
||||
new_balance,
|
||||
ticker,
|
||||
f"Siphoned from {ticker} close",
|
||||
datetime.now(tz=timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not persist siphon event for %s", ticker)
|
||||
|
||||
logger.info(
|
||||
"Siphoned $%.2f from %s close → reserve now $%.2f",
|
||||
transfer,
|
||||
ticker,
|
||||
new_balance,
|
||||
)
|
||||
|
||||
# Update portfolio state
|
||||
self.portfolio_state.positions = positions
|
||||
self.portfolio_state.open_position_count = len(positions)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error syncing positions")
|
||||
|
||||
async def _fetch_current_prices(self, tickers: list[str]) -> dict[str, float]:
|
||||
"""Fetch latest prices from Polygon API for the given tickers.
|
||||
|
||||
Task 28.2: Uses httpx for async HTTP calls. Returns a dict mapping
|
||||
ticker → latest price. Handles API errors gracefully.
|
||||
"""
|
||||
if not tickers:
|
||||
return {}
|
||||
|
||||
prices: dict[str, float] = {}
|
||||
|
||||
# Use the market data config for API key
|
||||
api_key = ""
|
||||
base_url = "https://api.polygon.io"
|
||||
try:
|
||||
from services.shared.config import load_config
|
||||
app_config = load_config()
|
||||
api_key = app_config.market_data.api_key
|
||||
base_url = app_config.market_data.base_url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not api_key:
|
||||
logger.debug("No Polygon API key configured — skipping price fetch")
|
||||
return prices
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Use the grouped daily endpoint or snapshot for multiple tickers
|
||||
tickers_str = ",".join(tickers)
|
||||
url = f"{base_url}/v2/snapshot/locale/us/markets/stocks/tickers"
|
||||
params = {"tickers": tickers_str, "apiKey": api_key}
|
||||
|
||||
resp = await client.get(url, params=params)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
for item in data.get("tickers", []):
|
||||
t = item.get("ticker", "")
|
||||
last_trade = item.get("lastTrade", {})
|
||||
price = last_trade.get("p", 0.0)
|
||||
if t and price > 0:
|
||||
prices[t] = price
|
||||
else:
|
||||
logger.warning("Polygon API returned status %d", resp.status_code)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch prices from Polygon API")
|
||||
|
||||
return prices
|
||||
|
||||
async def _load_open_positions(self) -> list[OpenPosition]:
|
||||
"""Load open positions from the database.
|
||||
|
||||
Task 28.3: Queries the position_stop_levels table for active positions.
|
||||
Returns typed OpenPosition list.
|
||||
"""
|
||||
if self.pool is None:
|
||||
return []
|
||||
|
||||
positions: list[OpenPosition] = []
|
||||
try:
|
||||
rows = await self.pool.fetch(
|
||||
"SELECT ticker, entry_price, stop_loss_price, take_profit_price, "
|
||||
"signal_confidence, is_micro_trade "
|
||||
"FROM position_stop_levels WHERE active = TRUE"
|
||||
)
|
||||
for row in rows:
|
||||
positions.append(
|
||||
OpenPosition(
|
||||
ticker=row["ticker"],
|
||||
quantity=1, # Default; real quantity from orders table
|
||||
entry_price=float(row["entry_price"]),
|
||||
current_price=float(row["entry_price"]),
|
||||
unrealized_pnl=0.0,
|
||||
market_value=float(row["entry_price"]),
|
||||
sector="",
|
||||
stop_loss_price=float(row["stop_loss_price"]),
|
||||
take_profit_price=float(row["take_profit_price"]),
|
||||
signal_confidence=float(row["signal_confidence"]),
|
||||
is_micro_trade=bool(row["is_micro_trade"]),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not load open positions — table may not exist")
|
||||
|
||||
return positions
|
||||
|
||||
async def _load_stop_levels(self) -> dict[str, StopLevels]:
|
||||
"""Load active stop-loss/take-profit levels from the database.
|
||||
|
||||
Task 28.3: Queries position_stop_levels WHERE active = TRUE.
|
||||
Returns dict keyed by ticker.
|
||||
"""
|
||||
if self.pool is None:
|
||||
return {}
|
||||
|
||||
levels: dict[str, StopLevels] = {}
|
||||
try:
|
||||
rows = await self.pool.fetch(
|
||||
"SELECT ticker, stop_loss_price, take_profit_price, "
|
||||
"trailing_stop_active, atr_value, atr_multiplier, "
|
||||
"reward_risk_ratio, updated_at "
|
||||
"FROM position_stop_levels WHERE active = TRUE"
|
||||
)
|
||||
for row in rows:
|
||||
levels[row["ticker"]] = StopLevels(
|
||||
stop_loss_price=float(row["stop_loss_price"]),
|
||||
take_profit_price=float(row["take_profit_price"]),
|
||||
trailing_stop_active=bool(row["trailing_stop_active"]),
|
||||
atr_value=float(row["atr_value"]),
|
||||
atr_multiplier=float(row["atr_multiplier"]),
|
||||
reward_risk_ratio=float(row["reward_risk_ratio"]),
|
||||
)
|
||||
return levels
|
||||
except Exception:
|
||||
logger.debug("Could not load stop levels — table may not exist")
|
||||
return {}
|
||||
|
||||
async def _submit_sell_order(
|
||||
self, ticker: str, quantity: int, reason: str
|
||||
) -> None:
|
||||
"""Push a sell order to the broker queue via Redis."""
|
||||
order_job = {
|
||||
"ticker": ticker,
|
||||
"action": "sell",
|
||||
"quantity": quantity,
|
||||
"order_type": "market",
|
||||
"source": "trading_engine",
|
||||
"reason": reason,
|
||||
}
|
||||
if self.redis is not None:
|
||||
try:
|
||||
broker_queue = queue_key(QUEUE_BROKER)
|
||||
await self.redis.rpush(broker_queue, json.dumps(order_job))
|
||||
logger.info("Submitted sell order for %s (%d shares): %s", ticker, quantity, reason)
|
||||
except Exception:
|
||||
logger.exception("Failed to push sell order for %s", ticker)
|
||||
else:
|
||||
logger.info("Sell order (no redis): %s %d shares — %s", ticker, quantity, reason)
|
||||
|
||||
async def _persist_daily_snapshot(self, now: datetime) -> None:
|
||||
"""Persist end-of-day portfolio snapshot to portfolio_snapshots table.
|
||||
|
||||
Task 29.2: Called after 4:00 PM ET when market closes.
|
||||
"""
|
||||
if self.pool is None or self.portfolio_state is None:
|
||||
return
|
||||
|
||||
from services.trading.trading_window import ET
|
||||
et_now = now.astimezone(ET)
|
||||
snapshot_date = et_now.date()
|
||||
|
||||
try:
|
||||
await self.pool.execute(
|
||||
"INSERT INTO portfolio_snapshots "
|
||||
"(snapshot_date, portfolio_value, active_pool, reserve_pool, "
|
||||
"daily_return, cumulative_return, unrealized_pnl, realized_pnl, "
|
||||
"win_count, loss_count, win_rate, sharpe_ratio, max_drawdown, "
|
||||
"current_drawdown_pct, portfolio_heat, risk_tier, "
|
||||
"positions, metrics, created_at) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, "
|
||||
"$11, $12, $13, $14, $15, $16, $17, $18, $19) "
|
||||
"ON CONFLICT (snapshot_date) DO UPDATE SET "
|
||||
"portfolio_value = EXCLUDED.portfolio_value, "
|
||||
"active_pool = EXCLUDED.active_pool, "
|
||||
"reserve_pool = EXCLUDED.reserve_pool, "
|
||||
"updated_at = NOW()",
|
||||
snapshot_date,
|
||||
self.portfolio_state.total_value,
|
||||
self.portfolio_state.active_pool,
|
||||
self.portfolio_state.reserve_pool,
|
||||
0.0, # daily_return
|
||||
0.0, # cumulative_return
|
||||
sum(p.unrealized_pnl for p in self.portfolio_state.positions),
|
||||
0.0, # realized_pnl
|
||||
0, # win_count
|
||||
0, # loss_count
|
||||
0.0, # win_rate
|
||||
0.0, # sharpe_ratio
|
||||
0.0, # max_drawdown
|
||||
0.0, # current_drawdown_pct
|
||||
self.portfolio_state.portfolio_heat,
|
||||
self.config.risk_tier,
|
||||
json.dumps([]), # positions
|
||||
json.dumps({}), # metrics
|
||||
now,
|
||||
)
|
||||
logger.info("Persisted daily snapshot for %s", snapshot_date)
|
||||
except Exception:
|
||||
logger.debug("Could not persist daily snapshot — table may not exist")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Decision builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user