summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.example13
-rw-r--r--services/strategy-engine/src/strategy_engine/main.py5
-rw-r--r--shared/src/shared/alpaca.py185
-rw-r--r--shared/src/shared/config.py12
-rw-r--r--shared/src/shared/exchange.py50
-rw-r--r--shared/tests/test_alpaca.py67
-rw-r--r--shared/tests/test_exchange.py55
-rw-r--r--shared/tests/test_models.py10
8 files changed, 264 insertions, 133 deletions
diff --git a/.env.example b/.env.example
index f428104..7a2751f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,8 @@
-EXCHANGE_ID=binance
-EXCHANGE_SANDBOX=false
-BINANCE_API_KEY=
-BINANCE_API_SECRET=
+# Alpaca API (get keys from https://app.alpaca.markets)
+ALPACA_API_KEY=
+ALPACA_API_SECRET=
+ALPACA_PAPER=true
+
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgresql+asyncpg://trading:trading@localhost:5432/trading
LOG_LEVEL=INFO
@@ -21,7 +22,3 @@ HEALTH_PORT=8080
CIRCUIT_BREAKER_THRESHOLD=5
CIRCUIT_BREAKER_TIMEOUT=60
METRICS_AUTH_TOKEN=
-
-# Sentiment APIs (all optional, free)
-CRYPTOPANIC_API_KEY=
-CRYPTOQUANT_API_KEY=
diff --git a/services/strategy-engine/src/strategy_engine/main.py b/services/strategy-engine/src/strategy_engine/main.py
index ac28d6b..8c77ada 100644
--- a/services/strategy-engine/src/strategy_engine/main.py
+++ b/services/strategy-engine/src/strategy_engine/main.py
@@ -74,10 +74,7 @@ async def run() -> None:
engine = StrategyEngine(broker=broker, strategies=strategies)
- provider = SentimentProvider(
- cryptopanic_api_key=config.cryptopanic_api_key,
- cryptoquant_api_key=config.cryptoquant_api_key,
- )
+ provider = SentimentProvider()
health = HealthCheckServer(
"strategy-engine",
diff --git a/shared/src/shared/alpaca.py b/shared/src/shared/alpaca.py
new file mode 100644
index 0000000..3dd752b
--- /dev/null
+++ b/shared/src/shared/alpaca.py
@@ -0,0 +1,185 @@
+"""Alpaca Markets API client for US stock trading."""
+import logging
+from datetime import datetime, timezone
+from decimal import Decimal
+from typing import Any
+
+import aiohttp
+
+logger = logging.getLogger(__name__)
+
+ALPACA_PAPER_URL = "https://paper-api.alpaca.markets"
+ALPACA_LIVE_URL = "https://api.alpaca.markets"
+ALPACA_DATA_URL = "https://data.alpaca.markets"
+
+
+class AlpacaClient:
+ """Async client for Alpaca Trading and Market Data APIs."""
+
+ def __init__(
+ self,
+ api_key: str,
+ api_secret: str,
+ paper: bool = True,
+ ) -> None:
+ self._api_key = api_key
+ self._api_secret = api_secret
+ self._base_url = ALPACA_PAPER_URL if paper else ALPACA_LIVE_URL
+ self._data_url = ALPACA_DATA_URL
+ self._session: aiohttp.ClientSession | None = None
+
+ @property
+ def headers(self) -> dict[str, str]:
+ return {
+ "APCA-API-KEY-ID": self._api_key,
+ "APCA-API-SECRET-KEY": self._api_secret,
+ }
+
+ async def _ensure_session(self) -> aiohttp.ClientSession:
+ if self._session is None or self._session.closed:
+ self._session = aiohttp.ClientSession(headers=self.headers)
+ return self._session
+
+ async def _request(self, method: str, url: str, **kwargs) -> dict | list:
+ session = await self._ensure_session()
+ async with session.request(method, url, **kwargs) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ logger.error("Alpaca API error %d: %s", resp.status, body)
+ raise RuntimeError(f"Alpaca API error {resp.status}: {body}")
+ return await resp.json()
+
+ # --- Account ---
+ async def get_account(self) -> dict:
+ return await self._request("GET", f"{self._base_url}/v2/account")
+
+ async def get_buying_power(self) -> Decimal:
+ account = await self.get_account()
+ return Decimal(str(account.get("buying_power", "0")))
+
+ # --- Orders ---
+ async def submit_order(
+ self,
+ symbol: str,
+ qty: float | None = None,
+ notional: float | None = None,
+ side: str = "buy",
+ type: str = "market",
+ time_in_force: str = "day",
+ ) -> dict:
+ """Submit an order.
+
+ Args:
+ symbol: Stock ticker (e.g., "AAPL")
+ qty: Number of shares (or use notional for dollar amount)
+ notional: Dollar amount to buy (fractional shares)
+ side: "buy" or "sell"
+ type: "market", "limit", "stop", "stop_limit", "market_on_close"
+ time_in_force: "day", "gtc", "opg", "cls"
+ """
+ data: dict[str, Any] = {
+ "symbol": symbol,
+ "side": side,
+ "type": type,
+ "time_in_force": time_in_force,
+ }
+ if qty is not None:
+ data["qty"] = str(qty)
+ elif notional is not None:
+ data["notional"] = str(notional)
+
+ return await self._request("POST", f"{self._base_url}/v2/orders", json=data)
+
+ async def submit_moc_order(
+ self,
+ symbol: str,
+ qty: float,
+ side: str = "buy",
+ ) -> dict:
+ """Submit a Market on Close order."""
+ return await self.submit_order(
+ symbol=symbol,
+ qty=qty,
+ side=side,
+ type="market",
+ time_in_force="cls",
+ )
+
+ async def get_orders(self, status: str = "open", limit: int = 50) -> list:
+ return await self._request(
+ "GET", f"{self._base_url}/v2/orders",
+ params={"status": status, "limit": limit},
+ )
+
+ async def cancel_all_orders(self) -> list:
+ return await self._request("DELETE", f"{self._base_url}/v2/orders")
+
+ # --- Positions ---
+ async def get_positions(self) -> list:
+ return await self._request("GET", f"{self._base_url}/v2/positions")
+
+ async def get_position(self, symbol: str) -> dict | None:
+ try:
+ return await self._request("GET", f"{self._base_url}/v2/positions/{symbol}")
+ except RuntimeError:
+ return None
+
+ async def close_position(self, symbol: str) -> dict:
+ return await self._request("DELETE", f"{self._base_url}/v2/positions/{symbol}")
+
+ async def close_all_positions(self) -> list:
+ return await self._request("DELETE", f"{self._base_url}/v2/positions")
+
+ # --- Market Data ---
+ async def get_bars(
+ self,
+ symbol: str,
+ timeframe: str = "1Day",
+ start: str | None = None,
+ end: str | None = None,
+ limit: int = 100,
+ ) -> list[dict]:
+ """Get historical bars.
+
+ Args:
+ symbol: Stock ticker
+ timeframe: "1Min", "5Min", "15Min", "1Hour", "1Day"
+ start: RFC3339 date string
+ end: RFC3339 date string
+ limit: Max bars to return
+ """
+ params: dict[str, Any] = {"timeframe": timeframe, "limit": limit}
+ if start:
+ params["start"] = start
+ if end:
+ params["end"] = end
+
+ data = await self._request(
+ "GET", f"{self._data_url}/v2/stocks/{symbol}/bars", params=params,
+ )
+ return data.get("bars", [])
+
+ async def get_latest_quote(self, symbol: str) -> dict:
+ data = await self._request(
+ "GET", f"{self._data_url}/v2/stocks/{symbol}/quotes/latest",
+ )
+ return data.get("quote", {})
+
+ async def get_snapshot(self, symbol: str) -> dict:
+ return await self._request(
+ "GET", f"{self._data_url}/v2/stocks/{symbol}/snapshot",
+ )
+
+ # --- Market Status ---
+ async def get_clock(self) -> dict:
+ """Get market clock (is_open, next_open, next_close)."""
+ return await self._request("GET", f"{self._base_url}/v2/clock")
+
+ async def is_market_open(self) -> bool:
+ clock = await self.get_clock()
+ return clock.get("is_open", False)
+
+ # --- Cleanup ---
+ async def close(self) -> None:
+ if self._session and not self._session.closed:
+ await self._session.close()
diff --git a/shared/src/shared/config.py b/shared/src/shared/config.py
index def70b9..4e8e7f1 100644
--- a/shared/src/shared/config.py
+++ b/shared/src/shared/config.py
@@ -4,12 +4,11 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
- binance_api_key: str
- binance_api_secret: str
+ alpaca_api_key: str = ""
+ alpaca_api_secret: str = ""
+ alpaca_paper: bool = True # Use paper trading by default
redis_url: str = "redis://localhost:6379"
database_url: str = "postgresql://trading:trading@localhost:5432/trading"
- exchange_id: str = "binance" # Any ccxt exchange ID
- exchange_sandbox: bool = False # Use sandbox/testnet mode
log_level: str = "INFO"
risk_max_position_size: float = 0.1
risk_stop_loss_pct: float = 5.0
@@ -36,7 +35,4 @@ class Settings(BaseSettings):
circuit_breaker_threshold: int = 5
circuit_breaker_timeout: int = 60
metrics_auth_token: str = "" # If set, /health and /metrics require Bearer token
- cryptopanic_api_key: str = "" # Free key from cryptopanic.com
- cryptoquant_api_key: str = "" # Free key from cryptoquant.com
-
- model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+ model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
diff --git a/shared/src/shared/exchange.py b/shared/src/shared/exchange.py
deleted file mode 100644
index 7afcd92..0000000
--- a/shared/src/shared/exchange.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Exchange factory using ccxt."""
-
-import ccxt.async_support as ccxt
-
-
-def create_exchange(
- exchange_id: str,
- api_key: str,
- api_secret: str,
- sandbox: bool = False,
-) -> ccxt.Exchange:
- """Create a ccxt async exchange instance by ID.
-
- Args:
- exchange_id: ccxt exchange ID (e.g. 'binance', 'bybit', 'okx', 'kraken')
- api_key: API key
- api_secret: API secret
- sandbox: Use sandbox/testnet mode
-
- Returns:
- Configured ccxt async exchange instance
-
- Raises:
- ValueError: If exchange_id is not supported by ccxt
- """
- if not hasattr(ccxt, exchange_id):
- available = [
- x
- for x in dir(ccxt)
- if not x.startswith("_")
- and isinstance(getattr(ccxt, x, None), type)
- and issubclass(getattr(ccxt, x), ccxt.Exchange)
- ]
- raise ValueError(
- f"Unknown exchange '{exchange_id}'. Available: {', '.join(sorted(available)[:20])}..."
- )
-
- exchange_cls = getattr(ccxt, exchange_id)
- exchange = exchange_cls(
- {
- "apiKey": api_key,
- "secret": api_secret,
- "enableRateLimit": True,
- }
- )
-
- if sandbox:
- exchange.set_sandbox_mode(True)
-
- return exchange
diff --git a/shared/tests/test_alpaca.py b/shared/tests/test_alpaca.py
new file mode 100644
index 0000000..7c8eab1
--- /dev/null
+++ b/shared/tests/test_alpaca.py
@@ -0,0 +1,67 @@
+"""Tests for Alpaca API client."""
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from shared.alpaca import AlpacaClient
+
+
+@pytest.fixture
+def client():
+ return AlpacaClient(api_key="test-key", api_secret="test-secret", paper=True)
+
+
+def test_client_uses_paper_url(client):
+ assert "paper" in client._base_url
+
+
+def test_client_uses_live_url():
+ c = AlpacaClient(api_key="k", api_secret="s", paper=False)
+ assert "paper" not in c._base_url
+
+
+def test_client_headers(client):
+ h = client.headers
+ assert h["APCA-API-KEY-ID"] == "test-key"
+ assert h["APCA-API-SECRET-KEY"] == "test-secret"
+
+
+@pytest.mark.asyncio
+async def test_get_buying_power(client):
+ mock_response = AsyncMock()
+ mock_response.status = 200
+ mock_response.json = AsyncMock(return_value={"buying_power": "10000.00"})
+ mock_response.__aenter__ = AsyncMock(return_value=mock_response)
+ mock_response.__aexit__ = AsyncMock(return_value=False)
+
+ mock_session = MagicMock()
+ mock_session.closed = False
+ mock_session.request = MagicMock(return_value=mock_response)
+ mock_session.close = AsyncMock()
+ client._session = mock_session
+
+ result = await client.get_buying_power()
+ from decimal import Decimal
+ assert result == Decimal("10000.00")
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_submit_moc_order(client):
+ mock_response = AsyncMock()
+ mock_response.status = 200
+ mock_response.json = AsyncMock(return_value={"id": "order-1", "status": "accepted"})
+ mock_response.__aenter__ = AsyncMock(return_value=mock_response)
+ mock_response.__aexit__ = AsyncMock(return_value=False)
+
+ mock_session = MagicMock()
+ mock_session.closed = False
+ mock_session.request = MagicMock(return_value=mock_response)
+ mock_session.close = AsyncMock()
+ client._session = mock_session
+
+ result = await client.submit_moc_order("AAPL", qty=10, side="buy")
+ assert result["id"] == "order-1"
+
+ # Verify the request was made with correct params
+ call_args = mock_session.request.call_args
+ assert call_args[0][0] == "POST"
+ await client.close()
diff --git a/shared/tests/test_exchange.py b/shared/tests/test_exchange.py
deleted file mode 100644
index 95dc7d7..0000000
--- a/shared/tests/test_exchange.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""Tests for the exchange factory."""
-
-from unittest.mock import patch
-
-import ccxt.async_support as ccxt
-import pytest
-
-from shared.exchange import create_exchange
-
-
-def test_create_exchange_binance():
- """Verify create_exchange returns a ccxt.binance instance."""
- exchange = create_exchange(
- exchange_id="binance",
- api_key="test-key",
- api_secret="test-secret",
- )
- assert isinstance(exchange, ccxt.binance)
- assert exchange.apiKey == "test-key"
- assert exchange.secret == "test-secret"
- assert exchange.enableRateLimit is True
-
-
-def test_create_exchange_unknown():
- """Verify create_exchange raises ValueError for unknown exchange."""
- with pytest.raises(ValueError, match="Unknown exchange 'not_a_real_exchange'"):
- create_exchange(
- exchange_id="not_a_real_exchange",
- api_key="key",
- api_secret="secret",
- )
-
-
-def test_create_exchange_with_sandbox():
- """Verify sandbox mode is activated when sandbox=True."""
- with patch.object(ccxt.binance, "set_sandbox_mode") as mock_sandbox:
- exchange = create_exchange(
- exchange_id="binance",
- api_key="key",
- api_secret="secret",
- sandbox=True,
- )
- mock_sandbox.assert_called_once_with(True)
- assert isinstance(exchange, ccxt.binance)
-
-
-def test_create_exchange_no_sandbox_by_default():
- """Verify sandbox mode is not set when sandbox=False (default)."""
- with patch.object(ccxt.binance, "set_sandbox_mode") as mock_sandbox:
- create_exchange(
- exchange_id="binance",
- api_key="key",
- api_secret="secret",
- )
- mock_sandbox.assert_not_called()
diff --git a/shared/tests/test_models.py b/shared/tests/test_models.py
index e3b9f12..2b8cd5e 100644
--- a/shared/tests/test_models.py
+++ b/shared/tests/test_models.py
@@ -8,15 +8,9 @@ from unittest.mock import patch
def test_settings_defaults():
"""Test that Settings has correct defaults."""
- with patch.dict(
- os.environ,
- {
- "BINANCE_API_KEY": "test_key",
- "BINANCE_API_SECRET": "test_secret",
- },
- ):
- from shared.config import Settings
+ from shared.config import Settings
+ with patch.dict(os.environ, {}, clear=False):
settings = Settings()
assert settings.redis_url == "redis://localhost:6379"
assert settings.database_url == "postgresql://trading:trading@localhost:5432/trading"