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:
Celes Renata
2026-04-15 20:52:28 +00:00
parent c4b90a5224
commit 70bad7709a
8 changed files with 2159 additions and 28 deletions
+925 -11
View File
@@ -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
# ------------------------------------------------------------------