diff --git a/polymarket-endcycle-sniper-bot/exports/bot.py b/polymarket-endcycle-sniper-bot/exports/bot.py index 02da672..1b0454c 100644 --- a/polymarket-endcycle-sniper-bot/exports/bot.py +++ b/polymarket-endcycle-sniper-bot/exports/bot.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from typing import Optional @@ -21,7 +21,7 @@ class BotStats: """Runtime statistics for the bot.""" - started_at: datetime = field(default_factory=datetime.utcnow) + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) scan_cycles: int = 0 markets_scanned: int = 0 opportunities_found: int = 0 @@ -178,7 +178,7 @@ async def shutdown(self) -> None: def _log_stats(self) -> None: """Log current statistics.""" - runtime = datetime.utcnow() - self.stats.started_at + runtime = datetime.now(timezone.utc) - self.stats.started_at hours = runtime.total_seconds() / 3600 log.info( @@ -194,7 +194,7 @@ def _log_stats(self) -> None: def get_stats(self) -> dict: """Get current statistics.""" - runtime = datetime.utcnow() - self.stats.started_at + runtime = datetime.now(timezone.utc) - self.stats.started_at return { "runtime_seconds": runtime.total_seconds(), @@ -303,7 +303,7 @@ async def _on_markets_loaded(self, markets: list) -> None: async def _save_near_miss_alert(self, alert, min_required: Decimal) -> None: """Save an illiquid arbitrage alert to the database.""" - from datetime import datetime, timezone + from datetime import datetime, timezone, timezone from rarb.data.repositories import NearMissAlertRepository try: @@ -324,7 +324,7 @@ async def _save_near_miss_alert(self, alert, min_required: Decimal) -> None: async def _save_insufficient_balance_alert(self, alert, required: Decimal, available: Decimal) -> None: """Save an alert for when balance is insufficient.""" - from datetime import datetime, timezone + from datetime import datetime, timezone, timezone from rarb.data.repositories import NearMissAlertRepository try: @@ -575,7 +575,7 @@ async def _auto_redemption_loop(self) -> None: async def _stats_history_loop(self) -> None: """Background task that records hourly stats snapshots for charting.""" - from datetime import datetime, timezone + from datetime import datetime, timezone, timezone from rarb.data.repositories import StatsHistoryRepository # Wait a bit before first record @@ -624,7 +624,7 @@ async def _stats_history_loop(self) -> None: async def _minute_stats_loop(self) -> None: """Background task that records minute-level price update stats.""" - from datetime import datetime, timezone + from datetime import datetime, timezone, timezone from rarb.data.repositories import MinuteStatsRepository # Wait a bit before first record @@ -828,7 +828,7 @@ async def shutdown(self) -> None: def _log_stats(self) -> None: """Log statistics.""" - runtime = datetime.utcnow() - self.stats.started_at + runtime = datetime.now(timezone.utc) - self.stats.started_at hours = runtime.total_seconds() / 3600 scanner_stats = self.scanner.get_stats() diff --git a/polymarket-endcycle-sniper-bot/tests/__init__.py b/polymarket-endcycle-sniper-bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polymarket-endcycle-sniper-bot/tests/test_bot.py b/polymarket-endcycle-sniper-bot/tests/test_bot.py new file mode 100644 index 0000000..a23b401 --- /dev/null +++ b/polymarket-endcycle-sniper-bot/tests/test_bot.py @@ -0,0 +1,376 @@ +""" +Unit tests for BotStats and ArbitrageBot core logic. + +These tests cover pure logic and use mocks for all external +dependencies (scanner, analyzer, executor) so no network or +credentials are required to run them. +""" + +import asyncio +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Helpers to mock the entire rarb.* import tree so tests work even when the +# full package isn't installed / wired up. +# --------------------------------------------------------------------------- + +import sys +import types + + +def _make_module(name: str) -> types.ModuleType: + mod = types.ModuleType(name) + sys.modules[name] = mod + return mod + + +# Stub out every rarb sub-module that bot.py tries to import at the top level. +_STUBS = [ + "rarb", + "rarb.analyzer", + "rarb.analyzer.arbitrage", + "rarb.api", + "rarb.api.models", + "rarb.config", + "rarb.executor", + "rarb.executor.executor", + "rarb.notifications", + "rarb.notifications.slack", + "rarb.scanner", + "rarb.scanner.market_scanner", + "rarb.utils", + "rarb.utils.logging", + "rarb.risk", +] + +for _name in _STUBS: + _make_module(_name) + +# Minimal fakes so attribute access inside bot.py doesn't explode. +sys.modules["rarb.executor.executor"].ExecutionStatus = MagicMock() +sys.modules["rarb.executor.executor"].ExecutionStatus.FILLED = "FILLED" +sys.modules["rarb.executor.executor"].ExecutionResult = MagicMock() +sys.modules["rarb.api.models"].ArbitrageOpportunity = MagicMock() +sys.modules["rarb.utils.logging"].get_logger = lambda _: MagicMock() +sys.modules["rarb.utils.logging"].setup_logging = MagicMock() +sys.modules["rarb.config"].get_settings = MagicMock( + return_value=MagicMock( + dry_run=True, + poll_interval_seconds=5, + min_profit_threshold=0.02, + max_position_size=100, + min_liquidity_usd=10, + log_level="INFO", + ) +) +sys.modules["rarb.notifications.slack"].get_notifier = MagicMock() +sys.modules["rarb.scanner.market_scanner"].MarketScanner = MagicMock() +sys.modules["rarb.scanner.market_scanner"].MarketSnapshot = MagicMock() +sys.modules["rarb.analyzer.arbitrage"].ArbitrageAnalyzer = MagicMock() +sys.modules["rarb.executor.executor"].OrderExecutor = MagicMock() + +# Now we can safely import from the actual source file. +sys.path.insert(0, "exports") +from bot import BotStats, ArbitrageBot # noqa: E402 (after sys.path patch) + + +# --------------------------------------------------------------------------- +# BotStats tests +# --------------------------------------------------------------------------- + + +class TestBotStats: + """Tests for the BotStats dataclass.""" + + def test_default_values(self): + stats = BotStats() + assert stats.scan_cycles == 0 + assert stats.markets_scanned == 0 + assert stats.opportunities_found == 0 + assert stats.trades_executed == 0 + assert stats.trades_successful == 0 + assert stats.total_profit == Decimal("0") + + def test_started_at_is_recent(self): + before = datetime.now(timezone.utc) + stats = BotStats() + after = datetime.now(timezone.utc) + assert before <= stats.started_at <= after + + def test_two_instances_have_independent_state(self): + s1 = BotStats() + s2 = BotStats() + s1.scan_cycles = 5 + s1.total_profit = Decimal("3.14") + assert s2.scan_cycles == 0 + assert s2.total_profit == Decimal("0") + + def test_profit_accumulation(self): + stats = BotStats() + stats.total_profit += Decimal("1.50") + stats.total_profit += Decimal("2.25") + assert stats.total_profit == Decimal("3.75") + + def test_counters_increment(self): + stats = BotStats() + stats.scan_cycles += 3 + stats.markets_scanned += 10 + stats.opportunities_found += 2 + stats.trades_executed += 2 + stats.trades_successful += 1 + assert stats.scan_cycles == 3 + assert stats.markets_scanned == 10 + assert stats.opportunities_found == 2 + assert stats.trades_executed == 2 + assert stats.trades_successful == 1 + + +# --------------------------------------------------------------------------- +# ArbitrageBot tests +# --------------------------------------------------------------------------- + + +def _make_bot(): + """Return an ArbitrageBot with all dependencies mocked.""" + scanner = MagicMock() + scanner.run_once = AsyncMock(return_value=[]) + scanner.close = AsyncMock() + + analyzer = MagicMock() + analyzer.analyze = MagicMock(return_value=None) + analyzer.get_stats = MagicMock(return_value={}) + + executor = MagicMock() + executor.execute = AsyncMock() + executor.close = AsyncMock() + executor.get_stats = MagicMock(return_value={}) + executor.signer = MagicMock(is_configured=True) + + return ArbitrageBot(scanner=scanner, analyzer=analyzer, executor=executor) + + +class TestArbitrageBotInit: + def test_stats_initialised_on_construction(self): + bot = _make_bot() + assert isinstance(bot.stats, BotStats) + assert bot.stats.scan_cycles == 0 + + def test_not_running_on_construction(self): + bot = _make_bot() + assert bot._running is False + + def test_pending_opportunities_empty_on_construction(self): + bot = _make_bot() + assert bot._pending_opportunities == [] + + +class TestProcessSnapshot: + @pytest.mark.asyncio + async def test_no_opportunity_found(self): + bot = _make_bot() + bot.analyzer.analyze.return_value = None + snapshot = MagicMock() + + await bot.process_snapshot(snapshot) + + assert bot.stats.opportunities_found == 0 + assert bot._pending_opportunities == [] + + @pytest.mark.asyncio + async def test_opportunity_appended(self): + bot = _make_bot() + fake_opp = MagicMock(profit_pct=0.05) + bot.analyzer.analyze.return_value = fake_opp + snapshot = MagicMock() + + await bot.process_snapshot(snapshot) + + assert bot.stats.opportunities_found == 1 + assert bot._pending_opportunities == [fake_opp] + + @pytest.mark.asyncio + async def test_multiple_opportunities_accumulate(self): + bot = _make_bot() + bot.analyzer.analyze.side_effect = [ + MagicMock(profit_pct=0.05), + MagicMock(profit_pct=0.03), + ] + for _ in range(2): + await bot.process_snapshot(MagicMock()) + + assert bot.stats.opportunities_found == 2 + assert len(bot._pending_opportunities) == 2 + + +class TestExecuteOpportunities: + @pytest.mark.asyncio + async def test_returns_empty_when_nothing_pending(self): + bot = _make_bot() + results = await bot.execute_opportunities() + assert results == [] + bot.executor.execute.assert_not_called() + + @pytest.mark.asyncio + async def test_clears_pending_after_execution(self): + bot = _make_bot() + opp = MagicMock(profit_pct=0.04) + bot._pending_opportunities = [opp] + + exec_result = MagicMock() + exec_result.status = "FILLED" + exec_result.expected_profit = Decimal("2.00") + bot.executor.execute.return_value = exec_result + + with patch( + "bot.ExecutionStatus", + MagicMock(FILLED="FILLED"), + ): + await bot.execute_opportunities() + + assert bot._pending_opportunities == [] + + @pytest.mark.asyncio + async def test_opportunities_sorted_by_profit_descending(self): + bot = _make_bot() + order = [] + + opp_low = MagicMock(profit_pct=0.01) + opp_high = MagicMock(profit_pct=0.09) + bot._pending_opportunities = [opp_low, opp_high] + + async def capture(opp): + order.append(opp.profit_pct) + r = MagicMock() + r.status = "NOT_FILLED" + r.expected_profit = Decimal("0") + return r + + bot.executor.execute.side_effect = capture + + await bot.execute_opportunities() + + assert order == [0.09, 0.01], "High-profit opportunity should execute first" + + @pytest.mark.asyncio + async def test_trade_counters_updated_on_fill(self): + bot = _make_bot() + opp = MagicMock(profit_pct=0.05) + bot._pending_opportunities = [opp] + + exec_result = MagicMock() + exec_result.status = "FILLED" + exec_result.expected_profit = Decimal("1.50") + bot.executor.execute.return_value = exec_result + + with patch("bot.ExecutionStatus", MagicMock(FILLED="FILLED")): + await bot.execute_opportunities() + + assert bot.stats.trades_executed == 1 + assert bot.stats.trades_successful == 1 + assert bot.stats.total_profit == Decimal("1.50") + + @pytest.mark.asyncio + async def test_trade_counted_but_not_successful_on_non_fill(self): + bot = _make_bot() + opp = MagicMock(profit_pct=0.05) + bot._pending_opportunities = [opp] + + exec_result = MagicMock() + exec_result.status = "PARTIAL" + exec_result.expected_profit = Decimal("0") + bot.executor.execute.return_value = exec_result + + with patch("bot.ExecutionStatus", MagicMock(FILLED="FILLED")): + await bot.execute_opportunities() + + assert bot.stats.trades_executed == 1 + assert bot.stats.trades_successful == 0 + + @pytest.mark.asyncio + async def test_execution_error_does_not_raise(self): + bot = _make_bot() + opp = MagicMock(profit_pct=0.05) + opp.market.question = "Will BTC hit $100k?" + bot._pending_opportunities = [opp] + bot.executor.execute.side_effect = RuntimeError("network error") + + # Should NOT raise — errors are caught and logged internally + await bot.execute_opportunities() + + assert bot.stats.trades_executed == 0 + + +class TestRunCycle: + @pytest.mark.asyncio + async def test_scan_cycle_increments(self): + bot = _make_bot() + bot.scanner.run_once.return_value = [] + await bot.run_cycle() + assert bot.stats.scan_cycles == 1 + + @pytest.mark.asyncio + async def test_markets_scanned_count(self): + bot = _make_bot() + bot.scanner.run_once.return_value = [MagicMock(), MagicMock(), MagicMock()] + bot.analyzer.analyze.return_value = None + await bot.run_cycle() + assert bot.stats.markets_scanned == 3 + + @pytest.mark.asyncio + async def test_executor_not_called_when_no_opportunities(self): + bot = _make_bot() + bot.scanner.run_once.return_value = [MagicMock()] + bot.analyzer.analyze.return_value = None + await bot.run_cycle() + bot.executor.execute.assert_not_called() + + +class TestGetStats: + def test_get_stats_returns_dict(self): + bot = _make_bot() + stats = bot.get_stats() + assert isinstance(stats, dict) + + def test_get_stats_keys_present(self): + bot = _make_bot() + stats = bot.get_stats() + expected_keys = { + "runtime_seconds", + "scan_cycles", + "markets_scanned", + "opportunities_found", + "trades_executed", + "trades_successful", + "total_profit", + } + assert expected_keys.issubset(stats.keys()) + + def test_get_stats_runtime_is_positive(self): + bot = _make_bot() + stats = bot.get_stats() + assert stats["runtime_seconds"] >= 0 + + def test_get_stats_total_profit_is_float(self): + bot = _make_bot() + stats = bot.get_stats() + assert isinstance(stats["total_profit"], float) + + +class TestStopAndShutdown: + def test_stop_sets_running_false(self): + bot = _make_bot() + bot._running = True + bot.stop() + assert bot._running is False + + @pytest.mark.asyncio + async def test_shutdown_calls_close_on_components(self): + bot = _make_bot() + bot._running = True + await bot.shutdown() + bot.scanner.close.assert_called_once() + bot.executor.close.assert_called_once()