diff options
Diffstat (limited to 'services')
37 files changed, 109 insertions, 904 deletions
diff --git a/services/api/tests/test_portfolio_router.py b/services/api/tests/test_portfolio_router.py index f2584ea..3bd1b2c 100644 --- a/services/api/tests/test_portfolio_router.py +++ b/services/api/tests/test_portfolio_router.py @@ -45,7 +45,7 @@ def test_get_positions_with_data(app, mock_db): app.state.db = db mock_row = MagicMock() - mock_row.symbol = "BTCUSDT" + mock_row.symbol = "AAPL" mock_row.quantity = Decimal("0.1") mock_row.avg_entry_price = Decimal("50000") mock_row.current_price = Decimal("55000") @@ -59,7 +59,7 @@ def test_get_positions_with_data(app, mock_db): assert response.status_code == 200 data = response.json() assert len(data) == 1 - assert data[0]["symbol"] == "BTCUSDT" + assert data[0]["symbol"] == "AAPL" def test_get_snapshots_empty(app, mock_db): diff --git a/services/backtester/src/backtester/config.py b/services/backtester/src/backtester/config.py index f7897da..57ee1fb 100644 --- a/services/backtester/src/backtester/config.py +++ b/services/backtester/src/backtester/config.py @@ -5,7 +5,7 @@ from shared.config import Settings class BacktestConfig(Settings): backtest_initial_balance: float = 10000.0 - symbol: str = "BTCUSDT" + symbol: str = "AAPL" timeframe: str = "1h" strategy_name: str = "rsi_strategy" candle_limit: int = 500 diff --git a/services/backtester/tests/test_engine.py b/services/backtester/tests/test_engine.py index 743a43b..4794e63 100644 --- a/services/backtester/tests/test_engine.py +++ b/services/backtester/tests/test_engine.py @@ -23,14 +23,14 @@ def make_candle(symbol: str, price: float, timeframe: str = "1h") -> Candle: ) -def make_candles(prices: list[float], symbol: str = "BTCUSDT") -> list[Candle]: +def make_candles(prices: list[float], symbol: str = "AAPL") -> list[Candle]: return [make_candle(symbol, p) for p in prices] def make_signal(side: OrderSide, price: str, quantity: str = "0.1") -> Signal: return Signal( strategy="test", - symbol="BTCUSDT", + symbol="AAPL", side=side, price=Decimal(price), quantity=Decimal(quantity), diff --git a/services/backtester/tests/test_metrics.py b/services/backtester/tests/test_metrics.py index 582309a..55f5b6c 100644 --- a/services/backtester/tests/test_metrics.py +++ b/services/backtester/tests/test_metrics.py @@ -12,7 +12,7 @@ from backtester.metrics import TradeRecord, compute_detailed_metrics def _make_trade(side: str, price: str, minutes_offset: int = 0) -> TradeRecord: return TradeRecord( time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=minutes_offset), - symbol="BTCUSDT", + symbol="AAPL", side=side, price=Decimal(price), quantity=Decimal("1"), @@ -127,39 +127,39 @@ def test_risk_free_rate_affects_sharpe(): base = datetime(2025, 1, 1, tzinfo=timezone.utc) trades = [ TradeRecord( - time=base, symbol="BTCUSDT", side="BUY", price=Decimal("100"), quantity=Decimal("1") + time=base, symbol="AAPL", side="BUY", price=Decimal("100"), quantity=Decimal("1") ), TradeRecord( time=base + timedelta(days=1), - symbol="BTCUSDT", + symbol="AAPL", side="SELL", price=Decimal("110"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=2), - symbol="BTCUSDT", + symbol="AAPL", side="BUY", price=Decimal("105"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=3), - symbol="BTCUSDT", + symbol="AAPL", side="SELL", price=Decimal("115"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=4), - symbol="BTCUSDT", + symbol="AAPL", side="BUY", price=Decimal("110"), quantity=Decimal("1"), ), TradeRecord( time=base + timedelta(days=5), - symbol="BTCUSDT", + symbol="AAPL", side="SELL", price=Decimal("108"), quantity=Decimal("1"), diff --git a/services/backtester/tests/test_reporter.py b/services/backtester/tests/test_reporter.py index 2ea49c0..5199b68 100644 --- a/services/backtester/tests/test_reporter.py +++ b/services/backtester/tests/test_reporter.py @@ -32,7 +32,7 @@ def _make_result(with_detailed: bool = False) -> BacktestResult: ) return BacktestResult( strategy_name="sma_crossover", - symbol="BTCUSDT", + symbol="AAPL", total_trades=10, initial_balance=Decimal("10000"), final_balance=Decimal("11500"), @@ -48,7 +48,7 @@ def test_format_report_contains_key_metrics(): report = format_report(result) assert "sma_crossover" in report - assert "BTCUSDT" in report + assert "AAPL" in report assert "10000" in report assert "11500" in report assert "1500" in report @@ -89,7 +89,7 @@ def test_export_json(): data = json.loads(json_output) assert data["strategy_name"] == "sma_crossover" - assert data["symbol"] == "BTCUSDT" + assert data["symbol"] == "AAPL" assert "detailed" in data assert data["detailed"]["sharpe_ratio"] == 1.5 assert data["detailed"]["monthly_returns"]["2025-01"] == 500.0 diff --git a/services/backtester/tests/test_simulator.py b/services/backtester/tests/test_simulator.py index a407c21..62e2cdb 100644 --- a/services/backtester/tests/test_simulator.py +++ b/services/backtester/tests/test_simulator.py @@ -36,20 +36,20 @@ def test_simulator_initial_balance(): def test_simulator_buy_reduces_balance(): sim = OrderSimulator(Decimal("10000")) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") result = sim.execute(signal) assert result is True assert sim.balance == Decimal("5000") - assert sim.positions["BTCUSDT"] == Decimal("0.1") + assert sim.positions["AAPL"] == Decimal("0.1") def test_simulator_sell_increases_balance(): sim = OrderSimulator(Decimal("10000")) - buy_signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + buy_signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(buy_signal) balance_after_buy = sim.balance - sell_signal = make_signal("BTCUSDT", OrderSide.SELL, "55000", "0.1") + sell_signal = make_signal("AAPL", OrderSide.SELL, "55000", "0.1") result = sim.execute(sell_signal) assert result is True assert sim.balance > balance_after_buy @@ -59,20 +59,20 @@ def test_simulator_sell_increases_balance(): def test_simulator_reject_buy_insufficient_balance(): sim = OrderSimulator(Decimal("100")) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") result = sim.execute(signal) assert result is False assert sim.balance == Decimal("100") - assert sim.positions.get("BTCUSDT", Decimal("0")) == Decimal("0") + assert sim.positions.get("AAPL", Decimal("0")) == Decimal("0") def test_simulator_trade_history(): sim = OrderSimulator(Decimal("10000")) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(signal) assert len(sim.trades) == 1 trade = sim.trades[0] - assert trade.symbol == "BTCUSDT" + assert trade.symbol == "AAPL" assert trade.side == OrderSide.BUY assert trade.price == Decimal("50000") assert trade.quantity == Decimal("0.1") @@ -86,7 +86,7 @@ def test_simulator_trade_history(): def test_slippage_on_buy(): """Buy price should increase by slippage_pct.""" sim = OrderSimulator(Decimal("100000"), slippage_pct=0.01) # 1% - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(signal) trade = sim.trades[0] expected_price = Decimal("50000") * Decimal("1.01") # 50500 @@ -97,10 +97,10 @@ def test_slippage_on_sell(): """Sell price should decrease by slippage_pct.""" sim = OrderSimulator(Decimal("100000"), slippage_pct=0.01) # Buy first (no slippage check here, just need a position) - buy = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + buy = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(buy) # Sell - sell = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + sell = make_signal("AAPL", OrderSide.SELL, "50000", "0.1") sim.execute(sell) trade = sim.trades[1] expected_price = Decimal("50000") * Decimal("0.99") # 49500 @@ -116,7 +116,7 @@ def test_fee_deducted_from_balance(): """Fees should reduce balance beyond the raw cost.""" fee_pct = 0.001 # 0.1% sim = OrderSimulator(Decimal("100000"), taker_fee_pct=fee_pct) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(signal) # cost = 50000 * 0.1 = 5000, fee = 5000 * 0.001 = 5 expected_balance = Decimal("100000") - Decimal("5000") - Decimal("5") @@ -132,7 +132,7 @@ def test_fee_deducted_from_balance(): def test_stop_loss_triggers(): """Long position auto-closed when candle_low <= stop_loss.""" sim = OrderSimulator(Decimal("100000")) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(signal, stop_loss=Decimal("48000")) ts = datetime(2025, 1, 1, tzinfo=timezone.utc) @@ -150,7 +150,7 @@ def test_stop_loss_triggers(): def test_take_profit_triggers(): """Long position auto-closed when candle_high >= take_profit.""" sim = OrderSimulator(Decimal("100000")) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(signal, take_profit=Decimal("55000")) ts = datetime(2025, 1, 1, tzinfo=timezone.utc) @@ -168,7 +168,7 @@ def test_take_profit_triggers(): def test_stop_not_triggered_within_range(): """No auto-close when price stays within stop/tp range.""" sim = OrderSimulator(Decimal("100000")) - signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.BUY, "50000", "0.1") sim.execute(signal, stop_loss=Decimal("48000"), take_profit=Decimal("55000")) ts = datetime(2025, 1, 1, tzinfo=timezone.utc) @@ -189,10 +189,10 @@ def test_stop_not_triggered_within_range(): def test_short_sell_allowed(): """Can open short position with allow_short=True.""" sim = OrderSimulator(Decimal("100000"), allow_short=True) - signal = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.SELL, "50000", "0.1") result = sim.execute(signal) assert result is True - assert sim.positions["BTCUSDT"] == Decimal("-0.1") + assert sim.positions["AAPL"] == Decimal("-0.1") assert len(sim.open_positions) == 1 assert sim.open_positions[0].side == OrderSide.SELL @@ -200,16 +200,16 @@ def test_short_sell_allowed(): def test_short_sell_rejected(): """Short rejected when allow_short=False (default).""" sim = OrderSimulator(Decimal("100000"), allow_short=False) - signal = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.SELL, "50000", "0.1") result = sim.execute(signal) assert result is False - assert sim.positions.get("BTCUSDT", Decimal("0")) == Decimal("0") + assert sim.positions.get("AAPL", Decimal("0")) == Decimal("0") def test_short_stop_loss(): """Short position stop-loss triggers on candle high >= stop_loss.""" sim = OrderSimulator(Decimal("100000"), allow_short=True) - signal = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + signal = make_signal("AAPL", OrderSide.SELL, "50000", "0.1") sim.execute(signal, stop_loss=Decimal("52000")) ts = datetime(2025, 1, 1, tzinfo=timezone.utc) diff --git a/services/backtester/tests/test_walk_forward.py b/services/backtester/tests/test_walk_forward.py index 5ab2e7b..96abb6e 100644 --- a/services/backtester/tests/test_walk_forward.py +++ b/services/backtester/tests/test_walk_forward.py @@ -21,7 +21,7 @@ def _generate_candles(n=100, base_price=100.0): price = base_price + (i % 20) - 10 candles.append( Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1h", open_time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(hours=i), open=Decimal(str(price)), diff --git a/services/data-collector/src/data_collector/binance_rest.py b/services/data-collector/src/data_collector/binance_rest.py deleted file mode 100644 index eaf4e30..0000000 --- a/services/data-collector/src/data_collector/binance_rest.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Binance REST API helpers for fetching historical candle data.""" - -from datetime import datetime, timezone -from decimal import Decimal - -from shared.models import Candle - - -def _normalize_symbol(symbol: str) -> str: - """Convert 'BTC/USDT' to 'BTCUSDT'.""" - return symbol.replace("/", "") - - -async def fetch_historical_candles( - exchange, - symbol: str, - timeframe: str, - since: int, - limit: int = 500, -) -> list[Candle]: - """Fetch historical OHLCV candles from the exchange and return Candle models. - - Args: - exchange: An async ccxt exchange instance. - symbol: Market symbol, e.g. 'BTC/USDT'. - timeframe: Candle timeframe, e.g. '1m'. - since: Start timestamp in milliseconds. - limit: Maximum number of candles to fetch. - - Returns: - A list of Candle model instances. - """ - rows = await exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=limit) - - normalized = _normalize_symbol(symbol) - candles: list[Candle] = [] - - for row in rows: - ts_ms, o, h, low, c, v = row - open_time = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc) - candles.append( - Candle( - symbol=normalized, - timeframe=timeframe, - open_time=open_time, - open=Decimal(str(o)), - high=Decimal(str(h)), - low=Decimal(str(low)), - close=Decimal(str(c)), - volume=Decimal(str(v)), - ) - ) - - return candles diff --git a/services/data-collector/src/data_collector/binance_ws.py b/services/data-collector/src/data_collector/binance_ws.py deleted file mode 100644 index e25e7a6..0000000 --- a/services/data-collector/src/data_collector/binance_ws.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Binance WebSocket client for real-time kline/candle data. - -NOTE: This module is Binance-specific (uses Binance WebSocket URL and message format). -Multi-exchange WebSocket support would require exchange-specific implementations. -""" - -import asyncio -import json -import logging -from datetime import datetime, timezone -from decimal import Decimal -from typing import Callable, Awaitable - -import websockets - -from shared.models import Candle - -logger = logging.getLogger(__name__) - -BINANCE_WS_URL = "wss://stream.binance.com:9443/ws" -RECONNECT_DELAY = 5 # seconds - - -def _normalize_symbol(symbol: str) -> str: - """Convert 'BTC/USDT' to 'BTCUSDT'.""" - return symbol.replace("/", "") - - -def _stream_name(symbol: str, timeframe: str) -> str: - """Build Binance stream name, e.g. 'btcusdt@kline_1m'.""" - return f"{_normalize_symbol(symbol).lower()}@kline_{timeframe}" - - -class BinanceWebSocket: - """Connects to Binance WebSocket streams and emits closed candles.""" - - def __init__( - self, - symbols: list[str], - timeframe: str, - on_candle: Callable[[Candle], Awaitable[None]], - ) -> None: - self._symbols = symbols - self._timeframe = timeframe - self._on_candle = on_candle - self._running = False - - def _build_subscribe_message(self) -> dict: - streams = [_stream_name(s, self._timeframe) for s in self._symbols] - return { - "method": "SUBSCRIBE", - "params": streams, - "id": 1, - } - - def _parse_candle(self, message: dict) -> Candle | None: - """Parse a kline WebSocket message into a Candle, or None if not closed.""" - k = message.get("k") - if k is None: - return None - if not k.get("x"): # only closed candles - return None - - symbol = k["s"] # already normalized, e.g. 'BTCUSDT' - open_time = datetime.fromtimestamp(k["t"] / 1000, tz=timezone.utc) - return Candle( - symbol=symbol, - timeframe=self._timeframe, - open_time=open_time, - open=Decimal(k["o"]), - high=Decimal(k["h"]), - low=Decimal(k["l"]), - close=Decimal(k["c"]), - volume=Decimal(k["v"]), - ) - - async def _run_once(self) -> None: - """Single connection attempt; processes messages until disconnected.""" - async with websockets.connect(BINANCE_WS_URL) as ws: - subscribe_msg = self._build_subscribe_message() - await ws.send(json.dumps(subscribe_msg)) - logger.info("Subscribed to Binance streams: %s", subscribe_msg["params"]) - - async for raw in ws: - if not self._running: - break - try: - message = json.loads(raw) - candle = self._parse_candle(message) - if candle is not None: - await self._on_candle(candle) - except Exception: - logger.exception("Error processing WebSocket message: %s", raw) - - async def start(self) -> None: - """Connect to Binance WebSocket and process messages, auto-reconnecting.""" - self._running = True - while self._running: - try: - await self._run_once() - except Exception: - if not self._running: - break - logger.warning("WebSocket disconnected. Reconnecting in %ds…", RECONNECT_DELAY) - await asyncio.sleep(RECONNECT_DELAY) - - def stop(self) -> None: - """Signal the WebSocket loop to stop after the current message.""" - self._running = False diff --git a/services/data-collector/src/data_collector/config.py b/services/data-collector/src/data_collector/config.py index 4761013..dd430e6 100644 --- a/services/data-collector/src/data_collector/config.py +++ b/services/data-collector/src/data_collector/config.py @@ -1,4 +1,5 @@ """Data Collector configuration.""" + from shared.config import Settings diff --git a/services/data-collector/src/data_collector/main.py b/services/data-collector/src/data_collector/main.py index 38f8759..b42b34c 100644 --- a/services/data-collector/src/data_collector/main.py +++ b/services/data-collector/src/data_collector/main.py @@ -1,9 +1,9 @@ """Data Collector Service — fetches US stock data from Alpaca.""" + import asyncio from shared.alpaca import AlpacaClient from shared.broker import RedisBroker -from shared.config import Settings from shared.db import Database from shared.events import CandleEvent from shared.healthcheck import HealthCheckServer @@ -33,6 +33,7 @@ async def fetch_latest_bars( bar = bars[-1] from datetime import datetime from decimal import Decimal + candle = Candle( symbol=symbol, timeframe=timeframe, diff --git a/services/data-collector/src/data_collector/ws_factory.py b/services/data-collector/src/data_collector/ws_factory.py deleted file mode 100644 index e068399..0000000 --- a/services/data-collector/src/data_collector/ws_factory.py +++ /dev/null @@ -1,34 +0,0 @@ -"""WebSocket factory for exchange-specific connections.""" - -import logging - -from data_collector.binance_ws import BinanceWebSocket - -logger = logging.getLogger(__name__) - -# Supported exchanges for WebSocket streaming -SUPPORTED_WS = {"binance": BinanceWebSocket} - - -def create_websocket(exchange_id: str, **kwargs): - """Create an exchange-specific WebSocket handler. - - Args: - exchange_id: Exchange identifier (e.g. 'binance') - **kwargs: Passed to the WebSocket constructor (symbols, timeframe, on_candle) - - Returns: - WebSocket handler instance - - Raises: - ValueError: If exchange is not supported for WebSocket streaming - """ - ws_cls = SUPPORTED_WS.get(exchange_id) - if ws_cls is None: - supported = ", ".join(sorted(SUPPORTED_WS.keys())) - raise ValueError( - f"WebSocket streaming not supported for '{exchange_id}'. " - f"Supported: {supported}. " - f"Use REST polling as fallback for unsupported exchanges." - ) - return ws_cls(**kwargs) diff --git a/services/data-collector/tests/test_binance_rest.py b/services/data-collector/tests/test_binance_rest.py deleted file mode 100644 index bf88210..0000000 --- a/services/data-collector/tests/test_binance_rest.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for binance_rest module.""" - -import pytest -from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock -from datetime import datetime, timezone - -from data_collector.binance_rest import fetch_historical_candles - - -@pytest.mark.asyncio -async def test_fetch_historical_candles_parses_response(): - """Verify that OHLCV rows are correctly parsed into Candle models.""" - ts = 1700000000000 # milliseconds - mock_exchange = MagicMock() - mock_exchange.fetch_ohlcv = AsyncMock( - return_value=[ - [ts, 30000.0, 30100.0, 29900.0, 30050.0, 1.5], - [ts + 60000, 30050.0, 30200.0, 30000.0, 30150.0, 2.0], - ] - ) - - candles = await fetch_historical_candles(mock_exchange, "BTC/USDT", "1m", since=ts, limit=500) - - assert len(candles) == 2 - - c = candles[0] - assert c.symbol == "BTCUSDT" - assert c.timeframe == "1m" - assert c.open_time == datetime.fromtimestamp(ts / 1000, tz=timezone.utc) - assert c.open == Decimal("30000.0") - assert c.high == Decimal("30100.0") - assert c.low == Decimal("29900.0") - assert c.close == Decimal("30050.0") - assert c.volume == Decimal("1.5") - - mock_exchange.fetch_ohlcv.assert_called_once_with("BTC/USDT", "1m", since=ts, limit=500) - - -@pytest.mark.asyncio -async def test_fetch_historical_candles_empty_response(): - """Verify that an empty exchange response returns an empty list.""" - mock_exchange = MagicMock() - mock_exchange.fetch_ohlcv = AsyncMock(return_value=[]) - - candles = await fetch_historical_candles(mock_exchange, "BTC/USDT", "1m", since=1700000000000) - - assert candles == [] diff --git a/services/data-collector/tests/test_storage.py b/services/data-collector/tests/test_storage.py index be85578..ffffa40 100644 --- a/services/data-collector/tests/test_storage.py +++ b/services/data-collector/tests/test_storage.py @@ -9,7 +9,7 @@ from shared.models import Candle from data_collector.storage import CandleStorage -def _make_candle(symbol: str = "BTCUSDT") -> Candle: +def _make_candle(symbol: str = "AAPL") -> Candle: return Candle( symbol=symbol, timeframe="1m", @@ -39,11 +39,11 @@ async def test_storage_saves_to_db_and_publishes(): mock_broker.publish.assert_called_once() stream_arg = mock_broker.publish.call_args[0][0] - assert stream_arg == "candles.BTCUSDT" + assert stream_arg == "candles.AAPL" data_arg = mock_broker.publish.call_args[0][1] assert data_arg["type"] == "CANDLE" - assert data_arg["data"]["symbol"] == "BTCUSDT" + assert data_arg["data"]["symbol"] == "AAPL" @pytest.mark.asyncio diff --git a/services/data-collector/tests/test_ws_factory.py b/services/data-collector/tests/test_ws_factory.py deleted file mode 100644 index cdddcca..0000000 --- a/services/data-collector/tests/test_ws_factory.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Tests for WebSocket factory.""" - -import pytest -from data_collector.ws_factory import create_websocket, SUPPORTED_WS -from data_collector.binance_ws import BinanceWebSocket - - -def test_create_binance_ws(): - ws = create_websocket("binance", symbols=["BTCUSDT"], timeframe="1m", on_candle=lambda c: None) - assert isinstance(ws, BinanceWebSocket) - - -def test_create_unsupported_exchange(): - with pytest.raises(ValueError, match="not supported"): - create_websocket( - "unsupported_exchange", symbols=["BTCUSDT"], timeframe="1m", on_candle=lambda c: None - ) - - -def test_supported_exchanges(): - assert "binance" in SUPPORTED_WS diff --git a/services/order-executor/src/order_executor/main.py b/services/order-executor/src/order_executor/main.py index 3e098c3..51ab286 100644 --- a/services/order-executor/src/order_executor/main.py +++ b/services/order-executor/src/order_executor/main.py @@ -1,4 +1,5 @@ """Order Executor Service entry point.""" + import asyncio from decimal import Decimal diff --git a/services/order-executor/tests/test_risk_manager.py b/services/order-executor/tests/test_risk_manager.py index 00a9ab4..3d5175b 100644 --- a/services/order-executor/tests/test_risk_manager.py +++ b/services/order-executor/tests/test_risk_manager.py @@ -7,7 +7,7 @@ from shared.models import OrderSide, Position, Signal from order_executor.risk_manager import RiskManager -def make_signal(side: OrderSide, price: str, quantity: str, symbol: str = "BTC/USDT") -> Signal: +def make_signal(side: OrderSide, price: str, quantity: str, symbol: str = "AAPL") -> Signal: return Signal( strategy="test", symbol=symbol, @@ -93,7 +93,7 @@ def test_risk_check_rejects_insufficient_balance(): def test_trailing_stop_set_and_trigger(): """Trailing stop should trigger when price drops below stop level.""" rm = make_risk_manager(trailing_stop_pct="5") - rm.set_trailing_stop("BTC/USDT", Decimal("100")) + rm.set_trailing_stop("AAPL", Decimal("100")) signal = make_signal(side=OrderSide.BUY, price="94", quantity="0.01") result = rm.check(signal, balance=Decimal("10000"), positions={}, daily_pnl=Decimal("0")) @@ -104,10 +104,10 @@ def test_trailing_stop_set_and_trigger(): def test_trailing_stop_updates_highest_price(): """Trailing stop should track the highest price seen.""" rm = make_risk_manager(trailing_stop_pct="5") - rm.set_trailing_stop("BTC/USDT", Decimal("100")) + rm.set_trailing_stop("AAPL", Decimal("100")) # Price rises to 120 => stop at 114 - rm.update_price("BTC/USDT", Decimal("120")) + rm.update_price("AAPL", Decimal("120")) # Price at 115 is above stop (114), should be allowed signal = make_signal(side=OrderSide.BUY, price="115", quantity="0.01") @@ -124,7 +124,7 @@ def test_trailing_stop_updates_highest_price(): def test_trailing_stop_not_triggered_above_stop(): """Trailing stop should not trigger when price is above stop level.""" rm = make_risk_manager(trailing_stop_pct="5") - rm.set_trailing_stop("BTC/USDT", Decimal("100")) + rm.set_trailing_stop("AAPL", Decimal("100")) # Price at 96 is above stop (95), should be allowed signal = make_signal(side=OrderSide.BUY, price="96", quantity="0.01") @@ -140,11 +140,11 @@ def test_max_open_positions_check(): rm = make_risk_manager(max_open_positions=2) positions = { - "BTC/USDT": make_position("BTC/USDT", "1", "100", "100"), - "ETH/USDT": make_position("ETH/USDT", "10", "50", "50"), + "AAPL": make_position("AAPL", "1", "100", "100"), + "MSFT": make_position("MSFT", "10", "50", "50"), } - signal = make_signal(side=OrderSide.BUY, price="10", quantity="1", symbol="SOL/USDT") + signal = make_signal(side=OrderSide.BUY, price="10", quantity="1", symbol="TSLA") result = rm.check(signal, balance=Decimal("10000"), positions=positions, daily_pnl=Decimal("0")) assert result.allowed is False assert result.reason == "Max open positions reached" @@ -158,14 +158,14 @@ def test_volatility_calculation(): rm = make_risk_manager(volatility_lookback=5) # No history yet - assert rm.get_volatility("BTC/USDT") is None + assert rm.get_volatility("AAPL") is None # Feed prices prices = [100, 102, 98, 105, 101] for p in prices: - rm.update_price("BTC/USDT", Decimal(str(p))) + rm.update_price("AAPL", Decimal(str(p))) - vol = rm.get_volatility("BTC/USDT") + vol = rm.get_volatility("AAPL") assert vol is not None assert vol > 0 @@ -177,9 +177,9 @@ def test_position_size_with_volatility_scaling(): # Feed volatile prices prices = [100, 120, 80, 130, 70] for p in prices: - rm.update_price("BTC/USDT", Decimal(str(p))) + rm.update_price("AAPL", Decimal(str(p))) - size = rm.calculate_position_size("BTC/USDT", Decimal("10000")) + size = rm.calculate_position_size("AAPL", Decimal("10000")) base = Decimal("10000") * Decimal("0.1") # High volatility should reduce size below base @@ -192,9 +192,9 @@ def test_position_size_without_scaling(): prices = [100, 120, 80, 130, 70] for p in prices: - rm.update_price("BTC/USDT", Decimal(str(p))) + rm.update_price("AAPL", Decimal(str(p))) - size = rm.calculate_position_size("BTC/USDT", Decimal("10000")) + size = rm.calculate_position_size("AAPL", Decimal("10000")) base = Decimal("10000") * Decimal("0.1") assert size == base @@ -211,8 +211,8 @@ def test_portfolio_exposure_check_passes(): max_portfolio_exposure=0.8, ) positions = { - "BTCUSDT": Position( - symbol="BTCUSDT", + "AAPL": Position( + symbol="AAPL", quantity=Decimal("0.01"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"), @@ -230,8 +230,8 @@ def test_portfolio_exposure_check_rejects(): max_portfolio_exposure=0.3, ) positions = { - "BTCUSDT": Position( - symbol="BTCUSDT", + "AAPL": Position( + symbol="AAPL", quantity=Decimal("1"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"), @@ -263,10 +263,10 @@ def test_var_calculation(): daily_loss_limit_pct=Decimal("10"), ) for i in range(30): - rm.update_price("BTCUSDT", Decimal(str(100 + (i % 5) - 2))) + rm.update_price("AAPL", Decimal(str(100 + (i % 5) - 2))) positions = { - "BTCUSDT": Position( - symbol="BTCUSDT", + "AAPL": Position( + symbol="AAPL", quantity=Decimal("1"), avg_entry_price=Decimal("100"), current_price=Decimal("100"), @@ -357,7 +357,7 @@ def test_drawdown_check_rejects_in_check(): rm.update_balance(Decimal("10000")) signal = Signal( strategy="test", - symbol="BTC/USDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), diff --git a/services/portfolio-manager/tests/test_portfolio.py b/services/portfolio-manager/tests/test_portfolio.py index 768e071..365dc1a 100644 --- a/services/portfolio-manager/tests/test_portfolio.py +++ b/services/portfolio-manager/tests/test_portfolio.py @@ -10,7 +10,7 @@ def make_order(side: OrderSide, price: str, quantity: str) -> Order: """Helper to create a filled Order.""" return Order( signal_id="test-signal", - symbol="BTC/USDT", + symbol="AAPL", side=side, type=OrderType.MARKET, price=Decimal(price), @@ -24,7 +24,7 @@ def test_portfolio_add_buy_order() -> None: order = make_order(OrderSide.BUY, "50000", "0.1") tracker.apply_order(order) - position = tracker.get_position("BTC/USDT") + position = tracker.get_position("AAPL") assert position is not None assert position.quantity == Decimal("0.1") assert position.avg_entry_price == Decimal("50000") @@ -35,7 +35,7 @@ def test_portfolio_add_multiple_buys() -> None: tracker.apply_order(make_order(OrderSide.BUY, "50000", "0.1")) tracker.apply_order(make_order(OrderSide.BUY, "52000", "0.1")) - position = tracker.get_position("BTC/USDT") + position = tracker.get_position("AAPL") assert position is not None assert position.quantity == Decimal("0.2") assert position.avg_entry_price == Decimal("51000") @@ -46,7 +46,7 @@ def test_portfolio_sell_reduces_position() -> None: tracker.apply_order(make_order(OrderSide.BUY, "50000", "0.2")) tracker.apply_order(make_order(OrderSide.SELL, "55000", "0.1")) - position = tracker.get_position("BTC/USDT") + position = tracker.get_position("AAPL") assert position is not None assert position.quantity == Decimal("0.1") assert position.avg_entry_price == Decimal("50000") @@ -54,7 +54,7 @@ def test_portfolio_sell_reduces_position() -> None: def test_portfolio_no_position_returns_none() -> None: tracker = PortfolioTracker() - position = tracker.get_position("ETH/USDT") + position = tracker.get_position("MSFT") assert position is None @@ -66,7 +66,7 @@ def test_realized_pnl_on_sell() -> None: tracker.apply_order( Order( signal_id="s1", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, type=OrderType.MARKET, price=Decimal("50000"), @@ -80,7 +80,7 @@ def test_realized_pnl_on_sell() -> None: tracker.apply_order( Order( signal_id="s2", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.SELL, type=OrderType.MARKET, price=Decimal("55000"), @@ -98,7 +98,7 @@ def test_realized_pnl_on_loss() -> None: tracker.apply_order( Order( signal_id="s1", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, type=OrderType.MARKET, price=Decimal("50000"), @@ -109,7 +109,7 @@ def test_realized_pnl_on_loss() -> None: tracker.apply_order( Order( signal_id="s2", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.SELL, type=OrderType.MARKET, price=Decimal("45000"), @@ -128,7 +128,7 @@ def test_realized_pnl_accumulates() -> None: tracker.apply_order( Order( signal_id="s1", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, type=OrderType.MARKET, price=Decimal("50000"), @@ -141,7 +141,7 @@ def test_realized_pnl_accumulates() -> None: tracker.apply_order( Order( signal_id="s2", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.SELL, type=OrderType.MARKET, price=Decimal("55000"), @@ -154,7 +154,7 @@ def test_realized_pnl_accumulates() -> None: tracker.apply_order( Order( signal_id="s3", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.SELL, type=OrderType.MARKET, price=Decimal("60000"), diff --git a/services/portfolio-manager/tests/test_snapshot.py b/services/portfolio-manager/tests/test_snapshot.py index a464599..ec5e92d 100644 --- a/services/portfolio-manager/tests/test_snapshot.py +++ b/services/portfolio-manager/tests/test_snapshot.py @@ -13,7 +13,7 @@ class TestSaveSnapshot: from portfolio_manager.main import save_snapshot pos = Position( - symbol="BTCUSDT", + symbol="AAPL", quantity=Decimal("0.5"), avg_entry_price=Decimal("50000"), current_price=Decimal("52000"), diff --git a/services/strategy-engine/src/strategy_engine/config.py b/services/strategy-engine/src/strategy_engine/config.py index e3a49c2..9fd9c49 100644 --- a/services/strategy-engine/src/strategy_engine/config.py +++ b/services/strategy-engine/src/strategy_engine/config.py @@ -4,6 +4,6 @@ from shared.config import Settings class StrategyConfig(Settings): - symbols: list[str] = ["BTC/USDT"] + symbols: list[str] = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA"] timeframes: list[str] = ["1m"] strategy_params: dict = {} diff --git a/services/strategy-engine/src/strategy_engine/main.py b/services/strategy-engine/src/strategy_engine/main.py index d62f886..30de528 100644 --- a/services/strategy-engine/src/strategy_engine/main.py +++ b/services/strategy-engine/src/strategy_engine/main.py @@ -8,7 +8,6 @@ from shared.healthcheck import HealthCheckServer from shared.logging import setup_logging from shared.metrics import ServiceMetrics from shared.notifier import TelegramNotifier -from shared.sentiment import SentimentProvider from strategy_engine.config import StrategyConfig from strategy_engine.engine import StrategyEngine @@ -22,28 +21,6 @@ STRATEGIES_DIR = Path(__file__).parent.parent.parent.parent / "strategies" # order-executor: +2 (8082), portfolio-manager: +3 (8083) HEALTH_PORT_OFFSET = 1 -SENTIMENT_REFRESH_INTERVAL = 300 # 5 minutes - - -async def sentiment_loop(provider: SentimentProvider, strategies: list, log) -> None: - """Periodically fetch sentiment and update strategies that support it.""" - while True: - try: - sentiment = await provider.get_sentiment("SOL") - log.info( - "sentiment_updated", - fear_greed=sentiment.fear_greed_value, - news=sentiment.news_sentiment, - netflow=sentiment.exchange_netflow, - should_block=sentiment.should_block, - ) - for strategy in strategies: - if hasattr(strategy, "update_sentiment"): - strategy.update_sentiment(sentiment) - except Exception as exc: - log.warning("sentiment_fetch_failed", error=str(exc)) - await asyncio.sleep(SENTIMENT_REFRESH_INTERVAL) - async def process_symbol(engine: StrategyEngine, stream: str, log) -> None: """Process candles for a single symbol stream.""" @@ -74,8 +51,6 @@ async def run() -> None: engine = StrategyEngine(broker=broker, strategies=strategies) - provider = SentimentProvider() - health = HealthCheckServer( "strategy-engine", port=config.health_port + HEALTH_PORT_OFFSET, @@ -87,15 +62,11 @@ async def run() -> None: tasks = [] try: - # Sentiment updater - tasks.append(asyncio.create_task(sentiment_loop(provider, strategies, log))) - # Symbol processors for symbol in config.symbols: stream = f"candles.{symbol.replace('/', '_')}" task = asyncio.create_task(process_symbol(engine, stream, log)) tasks.append(task) - # Wait for all tasks (they run forever until cancelled) await asyncio.gather(*tasks) except Exception as exc: log.error("fatal_error", error=str(exc)) @@ -106,7 +77,6 @@ async def run() -> None: task.cancel() metrics.service_up.labels(service="strategy-engine").set(0) await notifier.close() - await provider.close() await broker.close() diff --git a/services/strategy-engine/strategies/asian_session_rsi.py b/services/strategy-engine/strategies/asian_session_rsi.py deleted file mode 100644 index 1874591..0000000 --- a/services/strategy-engine/strategies/asian_session_rsi.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Asian Session RSI Strategy — 한국시간 9:00~11:00 단타. - -규칙: -- SOL/USDT 5분봉 -- 매수: RSI(14) < 25 + 볼륨 > 평균 + 센티먼트 OK -- 익절: +1.5%, 손절: -0.7%, 시간청산: 11:00 KST (02:00 UTC) -- 하루 최대 3회, 2연패 시 중단 -- 센티먼트 필터: Fear & Greed > 80이면 매수 차단, 뉴스 극도 부정이면 차단 -""" - -from collections import deque -from decimal import Decimal -from datetime import datetime - -import pandas as pd - -from shared.models import Candle, Signal, OrderSide -from shared.sentiment import SentimentData -from strategies.base import BaseStrategy - - -class AsianSessionRsiStrategy(BaseStrategy): - name: str = "asian_session_rsi" - - def __init__(self) -> None: - super().__init__() - self._rsi_period: int = 14 - self._rsi_oversold: float = 25.0 - self._rsi_overbought: float = 75.0 - self._quantity: Decimal = Decimal("0.1") - self._take_profit_pct: float = 1.5 - self._stop_loss_pct: float = 0.7 - # Session: 00:00~02:00 UTC = 09:00~11:00 KST - self._session_start_utc: int = 0 - self._session_end_utc: int = 2 - self._max_trades_per_day: int = 3 - self._max_consecutive_losses: int = 2 - self._use_sentiment: bool = True - self._ema_period: int = 20 - self._require_bullish_candle: bool = True - self._prev_candle_bullish: bool = False - # Sentiment (updated externally before each session) - self._sentiment: SentimentData | None = None - # State - self._closes: deque[float] = deque(maxlen=200) - self._volumes: deque[float] = deque(maxlen=50) - self._today: str | None = None - self._trades_today: int = 0 - self._consecutive_losses: int = 0 - self._in_position: bool = False - self._entry_price: float = 0.0 - - @property - def warmup_period(self) -> int: - return self._rsi_period + 1 - - def configure(self, params: dict) -> None: - self._rsi_period = int(params.get("rsi_period", 14)) - self._rsi_oversold = float(params.get("rsi_oversold", 25.0)) - self._rsi_overbought = float(params.get("rsi_overbought", 75.0)) - self._quantity = Decimal(str(params.get("quantity", "0.1"))) - self._take_profit_pct = float(params.get("take_profit_pct", 1.5)) - self._stop_loss_pct = float(params.get("stop_loss_pct", 0.7)) - self._session_start_utc = int(params.get("session_start_utc", 0)) - self._session_end_utc = int(params.get("session_end_utc", 2)) - self._max_trades_per_day = int(params.get("max_trades_per_day", 3)) - self._max_consecutive_losses = int(params.get("max_consecutive_losses", 2)) - self._use_sentiment = bool(params.get("use_sentiment", True)) - self._ema_period = int(params.get("ema_period", 20)) - self._require_bullish_candle = bool(params.get("require_bullish_candle", True)) - - if self._quantity <= 0: - raise ValueError(f"Quantity must be positive, got {self._quantity}") - if self._stop_loss_pct <= 0: - raise ValueError(f"Stop loss must be positive, got {self._stop_loss_pct}") - if self._take_profit_pct <= 0: - raise ValueError(f"Take profit must be positive, got {self._take_profit_pct}") - - self._init_filters( - require_trend=False, - adx_threshold=25.0, - min_volume_ratio=0.5, - atr_stop_multiplier=1.5, - atr_tp_multiplier=2.0, - ) - - def reset(self) -> None: - super().reset() - self._closes.clear() - self._volumes.clear() - self._today = None - self._trades_today = 0 - self._consecutive_losses = 0 - self._in_position = False - self._entry_price = 0.0 - self._sentiment = None - self._prev_candle_bullish = False - - def update_sentiment(self, sentiment: SentimentData) -> None: - """Update sentiment data. Call before each trading session.""" - self._sentiment = sentiment - - def _check_sentiment(self) -> bool: - """Check if sentiment allows buying. Returns True if OK.""" - if not self._use_sentiment or self._sentiment is None: - return True # No sentiment data, allow by default - return not self._sentiment.should_block - - def _is_session_active(self, dt: datetime) -> bool: - """Check if current time is within trading session.""" - hour = dt.hour - if self._session_start_utc <= self._session_end_utc: - return self._session_start_utc <= hour < self._session_end_utc - # Wrap around midnight - return hour >= self._session_start_utc or hour < self._session_end_utc - - def _compute_rsi(self) -> float | None: - if len(self._closes) < self._rsi_period + 1: - return None - series = pd.Series(list(self._closes)) - delta = series.diff() - gain = delta.clip(lower=0) - loss = -delta.clip(upper=0) - avg_gain = gain.ewm(com=self._rsi_period - 1, min_periods=self._rsi_period).mean() - avg_loss = loss.ewm(com=self._rsi_period - 1, min_periods=self._rsi_period).mean() - rs = avg_gain / avg_loss.replace(0, float("nan")) - rsi = 100 - (100 / (1 + rs)) - val = rsi.iloc[-1] - if pd.isna(val): - return None - return float(val) - - def _volume_above_average(self) -> bool: - if len(self._volumes) < 20: - return True # Not enough data, allow - avg = sum(self._volumes) / len(self._volumes) - return self._volumes[-1] >= avg - - def _price_above_ema(self) -> bool: - """Check if current price is above short-term EMA.""" - if len(self._closes) < self._ema_period: - return True # Not enough data, allow by default - series = pd.Series(list(self._closes)) - ema_val = series.ewm(span=self._ema_period, adjust=False).mean().iloc[-1] - return self._closes[-1] >= ema_val - - def on_candle(self, candle: Candle) -> Signal | None: - self._update_filter_data(candle) - - close = float(candle.close) - self._closes.append(close) - self._volumes.append(float(candle.volume)) - - # Track candle direction for bullish confirmation - is_bullish = float(candle.close) >= float(candle.open) - - # Daily reset - day = candle.open_time.strftime("%Y-%m-%d") - if self._today != day: - self._today = day - self._trades_today = 0 - # Don't reset consecutive_losses — carries across days - - # Check exit conditions first (if in position) - if self._in_position: - pnl_pct = (close - self._entry_price) / self._entry_price * 100 - - # Take profit - if pnl_pct >= self._take_profit_pct: - self._in_position = False - self._consecutive_losses = 0 - return self._apply_filters( - Signal( - strategy=self.name, - symbol=candle.symbol, - side=OrderSide.SELL, - price=candle.close, - quantity=self._quantity, - conviction=0.9, - reason=f"Take profit {pnl_pct:.2f}% >= {self._take_profit_pct}%", - ) - ) - - # Stop loss - if pnl_pct <= -self._stop_loss_pct: - self._in_position = False - self._consecutive_losses += 1 - return self._apply_filters( - Signal( - strategy=self.name, - symbol=candle.symbol, - side=OrderSide.SELL, - price=candle.close, - quantity=self._quantity, - conviction=1.0, - reason=f"Stop loss {pnl_pct:.2f}% <= -{self._stop_loss_pct}%", - ) - ) - - # Time exit: session ended while in position - if not self._is_session_active(candle.open_time): - self._in_position = False - if pnl_pct < 0: - self._consecutive_losses += 1 - else: - self._consecutive_losses = 0 - return self._apply_filters( - Signal( - strategy=self.name, - symbol=candle.symbol, - side=OrderSide.SELL, - price=candle.close, - quantity=self._quantity, - conviction=0.5, - reason=f"Time exit (session ended), PnL {pnl_pct:.2f}%", - ) - ) - - return None # Still in position, no action - - # Entry conditions - if not self._is_session_active(candle.open_time): - return None # Outside trading hours - - if self._trades_today >= self._max_trades_per_day: - return None # Daily limit reached - - if self._consecutive_losses >= self._max_consecutive_losses: - return None # Consecutive loss limit - - if not self._check_sentiment(): - return None # Sentiment blocked (extreme greed or very negative news) - - rsi = self._compute_rsi() - if rsi is None: - return None - - if rsi < self._rsi_oversold and self._volume_above_average() and self._price_above_ema(): - if self._require_bullish_candle and not is_bullish: - return None # Wait for bullish candle confirmation - self._in_position = True - self._entry_price = close - self._trades_today += 1 - - # Conviction: lower RSI = stronger signal - conv = min((self._rsi_oversold - rsi) / self._rsi_oversold, 1.0) - conv = max(conv, 0.3) - - sl = candle.close * (1 - Decimal(str(self._stop_loss_pct / 100))) - tp = candle.close * (1 + Decimal(str(self._take_profit_pct / 100))) - - return self._apply_filters( - Signal( - strategy=self.name, - symbol=candle.symbol, - side=OrderSide.BUY, - price=candle.close, - quantity=self._quantity, - conviction=conv, - stop_loss=sl, - take_profit=tp, - reason=f"RSI {rsi:.1f} < {self._rsi_oversold} (session active, vol OK)", - ) - ) - - return None diff --git a/services/strategy-engine/strategies/config/asian_session_rsi.yaml b/services/strategy-engine/strategies/config/asian_session_rsi.yaml deleted file mode 100644 index bc7c5c9..0000000 --- a/services/strategy-engine/strategies/config/asian_session_rsi.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Asian Session RSI — SOL/USDT 5분봉 단타 -# 한국시간 9:00~11:00 (UTC 0:00~2:00) -rsi_period: 14 -rsi_oversold: 25 -rsi_overbought: 75 -quantity: "0.5" # SOL 0.5개 (~$75, 100만원의 10%) -take_profit_pct: 1.5 # 익절 1.5% -stop_loss_pct: 0.7 # 손절 0.7% -session_start_utc: 0 # UTC 0시 = KST 9시 -session_end_utc: 2 # UTC 2시 = KST 11시 -max_trades_per_day: 3 # 하루 최대 3회 -max_consecutive_losses: 2 # 2연패 시 중단 -ema_period: 20 -require_bullish_candle: true diff --git a/services/strategy-engine/strategies/config/grid_strategy.yaml b/services/strategy-engine/strategies/config/grid_strategy.yaml index 607f3df..338bb4c 100644 --- a/services/strategy-engine/strategies/config/grid_strategy.yaml +++ b/services/strategy-engine/strategies/config/grid_strategy.yaml @@ -1,4 +1,4 @@ -lower_price: 60000 -upper_price: 70000 +lower_price: 170 +upper_price: 190 grid_count: 5 -quantity: "0.01" +quantity: "1" diff --git a/services/strategy-engine/tests/test_asian_session_rsi.py b/services/strategy-engine/tests/test_asian_session_rsi.py deleted file mode 100644 index db031f0..0000000 --- a/services/strategy-engine/tests/test_asian_session_rsi.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for Asian Session RSI strategy.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from datetime import datetime, timezone -from decimal import Decimal - -from shared.models import Candle, OrderSide -from strategies.asian_session_rsi import AsianSessionRsiStrategy - - -def _candle(price, hour=0, minute=30, volume=100.0, day=1): - return Candle( - symbol="SOLUSDT", - timeframe="5m", - open_time=datetime(2025, 1, day, hour, minute, tzinfo=timezone.utc), - open=Decimal(str(price)), - high=Decimal(str(price + 1)), - low=Decimal(str(price - 1)), - close=Decimal(str(price)), - volume=Decimal(str(volume)), - ) - - -def _make_strategy(**overrides): - s = AsianSessionRsiStrategy() - params = { - "rsi_period": 5, - "rsi_oversold": 30, - "rsi_overbought": 70, - "quantity": "0.5", - "take_profit_pct": 1.5, - "stop_loss_pct": 0.7, - "session_start_utc": 0, - "session_end_utc": 2, - "max_trades_per_day": 3, - "max_consecutive_losses": 2, - } - params.update(overrides) - s.configure(params) - return s - - -def test_no_signal_outside_session(): - s = _make_strategy() - # Hour 5 UTC = outside session (0-2 UTC) - for i in range(10): - sig = s.on_candle(_candle(100 - i * 3, hour=5)) - assert sig is None - - -def test_buy_signal_during_session_on_oversold(): - s = AsianSessionRsiStrategy() - s._rsi_period = 5 - s._rsi_oversold = 30 - s._quantity = Decimal("0.5") - s._take_profit_pct = 1.5 - s._stop_loss_pct = 0.7 - s._session_start_utc = 0 - s._session_end_utc = 2 - s._max_trades_per_day = 3 - s._max_consecutive_losses = 10 # High limit so test isn't blocked - - # Feed declining prices — collect all signals - signals = [] - for i in range(10): - sig = s.on_candle(_candle(100 - i * 3, hour=0, minute=i * 5)) - if sig is not None: - signals.append(sig) - - # Should have generated at least one BUY signal - buy_signals = [s for s in signals if s.side == OrderSide.BUY] - assert len(buy_signals) > 0 - assert buy_signals[0].strategy == "asian_session_rsi" - - -def test_take_profit_exit(): - s = _make_strategy(rsi_period=5, rsi_oversold=40) - # Force entry - for i in range(8): - s.on_candle(_candle(100 - i * 2, hour=0, minute=i * 5)) - - # Should be in position now — push price up for TP - sig = s.on_candle(_candle(100, hour=0, minute=50)) # entry ~around 84-86 - if s._in_position: - tp_price = s._entry_price * (1 + s._take_profit_pct / 100) - sig = s.on_candle(_candle(tp_price + 1, hour=1, minute=0)) - if sig is not None: - assert sig.side == OrderSide.SELL - assert "Take profit" in sig.reason - - -def test_stop_loss_exit(): - s = _make_strategy(rsi_period=5, rsi_oversold=40) - for i in range(8): - s.on_candle(_candle(100 - i * 2, hour=0, minute=i * 5)) - - if s._in_position: - sl_price = s._entry_price * (1 - s._stop_loss_pct / 100) - sig = s.on_candle(_candle(sl_price - 1, hour=1, minute=0)) - if sig is not None: - assert sig.side == OrderSide.SELL - assert "Stop loss" in sig.reason - - -def test_time_exit_when_session_ends(): - s = _make_strategy(rsi_period=5, rsi_oversold=40) - for i in range(8): - s.on_candle(_candle(100 - i * 2, hour=0, minute=i * 5)) - - if s._in_position: - # Session ends at hour 2 - sig = s.on_candle(_candle(s._entry_price, hour=3, minute=0)) - if sig is not None: - assert sig.side == OrderSide.SELL - assert "Time exit" in sig.reason - - -def test_max_trades_per_day(): - s = _make_strategy(rsi_period=3, rsi_oversold=40, max_trades_per_day=1) - # Force one trade - for i in range(6): - s.on_candle(_candle(100 - i * 5, hour=0, minute=i * 5)) - # Exit - if s._in_position: - s.on_candle(_candle(200, hour=0, minute=35)) # TP exit - # Try to enter again — should be blocked - for i in range(6): - s.on_candle(_candle(100 - i * 5, hour=1, minute=i * 5)) - # After 1 trade, no more allowed - assert not s._in_position or s._trades_today >= 1 - - -def test_consecutive_losses_stop(): - s = _make_strategy(rsi_period=3, rsi_oversold=40, max_consecutive_losses=2) - # Simulate 2 losses - s._consecutive_losses = 2 - # Even with valid conditions, should not enter - for i in range(6): - sig = s.on_candle(_candle(100 - i * 5, hour=0, minute=i * 5)) - assert sig is None - - -def test_reset_clears_all(): - s = _make_strategy() - s.on_candle(_candle(100, hour=0)) - s._in_position = True - s._trades_today = 2 - s._consecutive_losses = 1 - s.reset() - assert not s._in_position - assert s._trades_today == 0 - assert len(s._closes) == 0 - - -def test_warmup_period(): - s = _make_strategy(rsi_period=14) - assert s.warmup_period == 15 - - -def test_ema_filter_blocks_below_ema(): - """Entry blocked when price is below EMA.""" - s = AsianSessionRsiStrategy() - s._rsi_period = 5 - s._rsi_oversold = 40 - s._quantity = Decimal("0.5") - s._take_profit_pct = 1.5 - s._stop_loss_pct = 0.7 - s._session_start_utc = 0 - s._session_end_utc = 2 - s._max_trades_per_day = 3 - s._max_consecutive_losses = 10 - s._ema_period = 5 - s._require_bullish_candle = False # Test EMA only - - # Feed rising prices to set EMA high, then sharp drop - for i in range(10): - s.on_candle(_candle(200 + i * 5, hour=0, minute=i * 5)) - # Now feed low price -- below EMA, RSI should be low - signals = [] - for i in range(5): - sig = s.on_candle(_candle(100 - i * 5, hour=0, minute=(15 + i) * 5 % 60)) - if sig is not None: - signals.append(sig) - # Should have no BUY signals because price is way below EMA - buy_sigs = [s for s in signals if s.side == OrderSide.BUY] - assert len(buy_sigs) == 0 diff --git a/services/strategy-engine/tests/test_base_filters.py b/services/strategy-engine/tests/test_base_filters.py index 3e55973..ae9ca05 100644 --- a/services/strategy-engine/tests/test_base_filters.py +++ b/services/strategy-engine/tests/test_base_filters.py @@ -43,7 +43,7 @@ def _candle(price=100.0, volume=10.0, high=None, low=None): h = high if high is not None else price + 5 lo = low if low is not None else price - 5 return Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1h", open_time=datetime(2025, 1, 1, tzinfo=timezone.utc), open=Decimal(str(price)), diff --git a/services/strategy-engine/tests/test_bollinger_strategy.py b/services/strategy-engine/tests/test_bollinger_strategy.py index 7761f2d..8261377 100644 --- a/services/strategy-engine/tests/test_bollinger_strategy.py +++ b/services/strategy-engine/tests/test_bollinger_strategy.py @@ -10,7 +10,7 @@ from strategies.bollinger_strategy import BollingerStrategy def make_candle(close: float) -> Candle: return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal(str(close)), diff --git a/services/strategy-engine/tests/test_combined_strategy.py b/services/strategy-engine/tests/test_combined_strategy.py index 20a572e..8a4dc74 100644 --- a/services/strategy-engine/tests/test_combined_strategy.py +++ b/services/strategy-engine/tests/test_combined_strategy.py @@ -72,7 +72,7 @@ class NeutralStrategy(BaseStrategy): def _candle(price=100.0): return Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2025, 1, 1, tzinfo=timezone.utc), open=Decimal(str(price)), diff --git a/services/strategy-engine/tests/test_ema_crossover_strategy.py b/services/strategy-engine/tests/test_ema_crossover_strategy.py index 67a20bf..7028eb0 100644 --- a/services/strategy-engine/tests/test_ema_crossover_strategy.py +++ b/services/strategy-engine/tests/test_ema_crossover_strategy.py @@ -10,7 +10,7 @@ from strategies.ema_crossover_strategy import EmaCrossoverStrategy def make_candle(close: float) -> Candle: return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal(str(close)), diff --git a/services/strategy-engine/tests/test_engine.py b/services/strategy-engine/tests/test_engine.py index ac9a596..2623027 100644 --- a/services/strategy-engine/tests/test_engine.py +++ b/services/strategy-engine/tests/test_engine.py @@ -13,7 +13,7 @@ from strategy_engine.engine import StrategyEngine def make_candle_event() -> dict: candle = Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal("50000"), @@ -28,7 +28,7 @@ def make_candle_event() -> dict: def make_signal() -> Signal: return Signal( strategy="test", - symbol="BTC/USDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50050"), quantity=Decimal("0.01"), @@ -46,12 +46,12 @@ async def test_engine_dispatches_candle_to_strategies(): strategy.on_candle = MagicMock(return_value=None) engine = StrategyEngine(broker=broker, strategies=[strategy]) - await engine.process_once("candles.BTC_USDT", "0") + await engine.process_once("candles.AAPL", "0") strategy.on_candle.assert_called_once() candle_arg = strategy.on_candle.call_args[0][0] assert isinstance(candle_arg, Candle) - assert candle_arg.symbol == "BTC/USDT" + assert candle_arg.symbol == "AAPL" @pytest.mark.asyncio @@ -64,7 +64,7 @@ async def test_engine_publishes_signal_when_strategy_returns_one(): strategy.on_candle = MagicMock(return_value=make_signal()) engine = StrategyEngine(broker=broker, strategies=[strategy]) - await engine.process_once("candles.BTC_USDT", "0") + await engine.process_once("candles.AAPL", "0") broker.publish.assert_called_once() call_args = broker.publish.call_args diff --git a/services/strategy-engine/tests/test_grid_strategy.py b/services/strategy-engine/tests/test_grid_strategy.py index 9823f98..878b900 100644 --- a/services/strategy-engine/tests/test_grid_strategy.py +++ b/services/strategy-engine/tests/test_grid_strategy.py @@ -10,7 +10,7 @@ from strategies.grid_strategy import GridStrategy def make_candle(close: float) -> Candle: return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal(str(close)), diff --git a/services/strategy-engine/tests/test_macd_strategy.py b/services/strategy-engine/tests/test_macd_strategy.py index 17dd2cf..556fd4c 100644 --- a/services/strategy-engine/tests/test_macd_strategy.py +++ b/services/strategy-engine/tests/test_macd_strategy.py @@ -10,7 +10,7 @@ from strategies.macd_strategy import MacdStrategy def _candle(price: float) -> Candle: return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal(str(price)), diff --git a/services/strategy-engine/tests/test_multi_symbol.py b/services/strategy-engine/tests/test_multi_symbol.py index cb8088c..671a9d3 100644 --- a/services/strategy-engine/tests/test_multi_symbol.py +++ b/services/strategy-engine/tests/test_multi_symbol.py @@ -22,7 +22,7 @@ async def test_engine_processes_multiple_streams(): broker = AsyncMock() candle_btc = Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2025, 1, 1, tzinfo=timezone.utc), open=Decimal("50000"), @@ -32,7 +32,7 @@ async def test_engine_processes_multiple_streams(): volume=Decimal("10"), ) candle_eth = Candle( - symbol="ETHUSDT", + symbol="MSFT", timeframe="1m", open_time=datetime(2025, 1, 1, tzinfo=timezone.utc), open=Decimal("3000"), @@ -45,16 +45,16 @@ async def test_engine_processes_multiple_streams(): btc_events = [CandleEvent(data=candle_btc).to_dict()] eth_events = [CandleEvent(data=candle_eth).to_dict()] - # First call returns BTC event, second ETH, then empty - call_count = {"btc": 0, "eth": 0} + # First call returns AAPL event, second MSFT, then empty + call_count = {"aapl": 0, "msft": 0} async def mock_read(stream, **kwargs): - if "BTC" in stream: - call_count["btc"] += 1 - return btc_events if call_count["btc"] == 1 else [] - elif "ETH" in stream: - call_count["eth"] += 1 - return eth_events if call_count["eth"] == 1 else [] + if "AAPL" in stream: + call_count["aapl"] += 1 + return btc_events if call_count["aapl"] == 1 else [] + elif "MSFT" in stream: + call_count["msft"] += 1 + return eth_events if call_count["msft"] == 1 else [] return [] broker.read = AsyncMock(side_effect=mock_read) @@ -65,8 +65,8 @@ async def test_engine_processes_multiple_streams(): engine = StrategyEngine(broker=broker, strategies=[strategy]) # Process both streams - await engine.process_once("candles.BTCUSDT", "$") - await engine.process_once("candles.ETHUSDT", "$") + await engine.process_once("candles.AAPL", "$") + await engine.process_once("candles.MSFT", "$") # Strategy should have been called with both candles assert strategy.on_candle.call_count == 2 diff --git a/services/strategy-engine/tests/test_rsi_strategy.py b/services/strategy-engine/tests/test_rsi_strategy.py index b2aecc9..6d31fd5 100644 --- a/services/strategy-engine/tests/test_rsi_strategy.py +++ b/services/strategy-engine/tests/test_rsi_strategy.py @@ -10,7 +10,7 @@ from strategies.rsi_strategy import RsiStrategy def make_candle(close: float, idx: int = 0) -> Candle: return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal(str(close)), diff --git a/services/strategy-engine/tests/test_sentiment_wiring.py b/services/strategy-engine/tests/test_sentiment_wiring.py deleted file mode 100644 index e0052cb..0000000 --- a/services/strategy-engine/tests/test_sentiment_wiring.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Test sentiment is wired into strategy engine.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from shared.sentiment import SentimentData -from strategies.asian_session_rsi import AsianSessionRsiStrategy - - -def test_strategy_accepts_sentiment(): - s = AsianSessionRsiStrategy() - data = SentimentData(fear_greed_value=20, fear_greed_label="Extreme Fear") - s.update_sentiment(data) - assert s._sentiment is not None - assert s._sentiment.fear_greed_value == 20 - - -def test_strategy_blocks_on_extreme_greed(): - s = AsianSessionRsiStrategy() - data = SentimentData(fear_greed_value=85) - s.update_sentiment(data) - assert not s._check_sentiment() - - -def test_strategy_allows_on_fear(): - s = AsianSessionRsiStrategy() - data = SentimentData(fear_greed_value=20) - s.update_sentiment(data) - assert s._check_sentiment() diff --git a/services/strategy-engine/tests/test_volume_profile_strategy.py b/services/strategy-engine/tests/test_volume_profile_strategy.py index f40261c..65ee2e8 100644 --- a/services/strategy-engine/tests/test_volume_profile_strategy.py +++ b/services/strategy-engine/tests/test_volume_profile_strategy.py @@ -10,7 +10,7 @@ from strategies.volume_profile_strategy import VolumeProfileStrategy def make_candle(close: float, volume: float = 1.0) -> Candle: return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal(str(close)), diff --git a/services/strategy-engine/tests/test_vwap_strategy.py b/services/strategy-engine/tests/test_vwap_strategy.py index 0312972..2c34b01 100644 --- a/services/strategy-engine/tests/test_vwap_strategy.py +++ b/services/strategy-engine/tests/test_vwap_strategy.py @@ -22,7 +22,7 @@ def make_candle( if open_time is None: open_time = datetime(2024, 1, 1, tzinfo=timezone.utc) return Candle( - symbol="BTC/USDT", + symbol="AAPL", timeframe="1m", open_time=open_time, open=Decimal(str(close)), |
