diff --git a/setup.py b/setup.py index 1eb358b..a42ce0e 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,8 @@ setup( name="wata", version="0.1", - packages=find_packages(), + package_dir={'': 'src'}, + packages=find_packages(where='src'), entry_points={ 'console_scripts': [ 'watasaxoauth=src.saxo_authen.cli:main', diff --git a/src/trade/api_actions.py b/src/trade/api_actions.py index 0fa1321..212432b 100644 --- a/src/trade/api_actions.py +++ b/src/trade/api_actions.py @@ -853,10 +853,12 @@ def get_spending_power(self): if not resp_balance or "SpendingPower" not in resp_balance: logging.error(f"Invalid balance response: {resp_balance}") raise SaxoApiError("Invalid balance response received, missing SpendingPower.") - spending_power = resp_balance["SpendingPower"] - if not isinstance(spending_power, (int, float)): - logging.error(f"Invalid SpendingPower value: {spending_power}") - raise SaxoApiError(f"Invalid SpendingPower value received: {spending_power}") + spending_power_raw = resp_balance["SpendingPower"] + try: + spending_power = float(spending_power_raw) + except (ValueError, TypeError): + logging.error(f"Invalid SpendingPower value: {spending_power_raw}") + raise SaxoApiError(f"Invalid SpendingPower value received: {spending_power_raw}") logging.info(f"Spending Power retrieved: {spending_power}") return spending_power diff --git a/src/wata.egg-info/PKG-INFO b/src/wata.egg-info/PKG-INFO new file mode 100644 index 0000000..449a9c0 --- /dev/null +++ b/src/wata.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: wata +Version: 0.1 +License-File: LICENSE +Requires-Dist: requests +Requires-Dist: python-dotenv +Dynamic: license-file +Dynamic: requires-dist diff --git a/src/wata.egg-info/SOURCES.txt b/src/wata.egg-info/SOURCES.txt new file mode 100644 index 0000000..7901a64 --- /dev/null +++ b/src/wata.egg-info/SOURCES.txt @@ -0,0 +1,160 @@ +LICENSE +README.md +setup.py +src/configuration/__init__.py +src/database/__init__.py +src/database/show_data.py +src/logging_helper/__init__.py +src/message_helper/__init__.py +src/mq_telegram/__init__.py +src/mq_telegram/tools.py +src/saxo_authen/__init__.py +src/saxo_authen/cli.py +src/saxo_openapi/__init__.py +src/saxo_openapi/exceptions.py +src/saxo_openapi/saxo_openapi.py +src/saxo_openapi/contrib/__init__.py +src/saxo_openapi/contrib/session.py +src/saxo_openapi/contrib/orders/__init__.py +src/saxo_openapi/contrib/orders/baseorder.py +src/saxo_openapi/contrib/orders/helper.py +src/saxo_openapi/contrib/orders/limitorder.py +src/saxo_openapi/contrib/orders/marketorder.py +src/saxo_openapi/contrib/orders/mixin.py +src/saxo_openapi/contrib/orders/onfill.py +src/saxo_openapi/contrib/orders/stoporder.py +src/saxo_openapi/contrib/util/__init__.py +src/saxo_openapi/contrib/util/instrument_to_uic.py +src/saxo_openapi/contrib/ws/__init__.py +src/saxo_openapi/contrib/ws/stream.py +src/saxo_openapi/definitions/__init__.py +src/saxo_openapi/definitions/accounthistory.py +src/saxo_openapi/definitions/activities.py +src/saxo_openapi/definitions/orders.py +src/saxo_openapi/definitions/reportformats.py +src/saxo_openapi/endpoints/__init__.py +src/saxo_openapi/endpoints/apirequest.py +src/saxo_openapi/endpoints/decorators.py +src/saxo_openapi/endpoints/accounthistory/__init__.py +src/saxo_openapi/endpoints/accounthistory/accountvalues.py +src/saxo_openapi/endpoints/accounthistory/base.py +src/saxo_openapi/endpoints/accounthistory/historicalpositions.py +src/saxo_openapi/endpoints/accounthistory/performance.py +src/saxo_openapi/endpoints/accounthistory/responses/__init__.py +src/saxo_openapi/endpoints/accounthistory/responses/accountvalues.py +src/saxo_openapi/endpoints/accounthistory/responses/historicalpositions.py +src/saxo_openapi/endpoints/accounthistory/responses/performance.py +src/saxo_openapi/endpoints/chart/__init__.py +src/saxo_openapi/endpoints/chart/base.py +src/saxo_openapi/endpoints/chart/charts.py +src/saxo_openapi/endpoints/chart/responses/__init__.py +src/saxo_openapi/endpoints/chart/responses/charts.py +src/saxo_openapi/endpoints/eventnotificationservices/__init__.py +src/saxo_openapi/endpoints/eventnotificationservices/base.py +src/saxo_openapi/endpoints/eventnotificationservices/clientactivities.py +src/saxo_openapi/endpoints/eventnotificationservices/responses/__init__.py +src/saxo_openapi/endpoints/eventnotificationservices/responses/clientactivities.py +src/saxo_openapi/endpoints/portfolio/__init__.py +src/saxo_openapi/endpoints/portfolio/accountgroups.py +src/saxo_openapi/endpoints/portfolio/accounts.py +src/saxo_openapi/endpoints/portfolio/balances.py +src/saxo_openapi/endpoints/portfolio/base.py +src/saxo_openapi/endpoints/portfolio/clients.py +src/saxo_openapi/endpoints/portfolio/closedpositions.py +src/saxo_openapi/endpoints/portfolio/exposure.py +src/saxo_openapi/endpoints/portfolio/netpositions.py +src/saxo_openapi/endpoints/portfolio/orders.py +src/saxo_openapi/endpoints/portfolio/positions.py +src/saxo_openapi/endpoints/portfolio/users.py +src/saxo_openapi/endpoints/portfolio/responses/__init__.py +src/saxo_openapi/endpoints/portfolio/responses/accountgroups.py +src/saxo_openapi/endpoints/portfolio/responses/accounts.py +src/saxo_openapi/endpoints/portfolio/responses/balances.py +src/saxo_openapi/endpoints/portfolio/responses/clients.py +src/saxo_openapi/endpoints/portfolio/responses/closedpositions.py +src/saxo_openapi/endpoints/portfolio/responses/exposure.py +src/saxo_openapi/endpoints/portfolio/responses/netpositions.py +src/saxo_openapi/endpoints/portfolio/responses/orders.py +src/saxo_openapi/endpoints/portfolio/responses/positions.py +src/saxo_openapi/endpoints/portfolio/responses/users.py +src/saxo_openapi/endpoints/referencedata/__init__.py +src/saxo_openapi/endpoints/referencedata/algostrategies.py +src/saxo_openapi/endpoints/referencedata/base.py +src/saxo_openapi/endpoints/referencedata/countries.py +src/saxo_openapi/endpoints/referencedata/cultures.py +src/saxo_openapi/endpoints/referencedata/currencies.py +src/saxo_openapi/endpoints/referencedata/currencypairs.py +src/saxo_openapi/endpoints/referencedata/exchanges.py +src/saxo_openapi/endpoints/referencedata/instruments.py +src/saxo_openapi/endpoints/referencedata/languages.py +src/saxo_openapi/endpoints/referencedata/standarddates.py +src/saxo_openapi/endpoints/referencedata/timezones.py +src/saxo_openapi/endpoints/referencedata/responses/__init__.py +src/saxo_openapi/endpoints/referencedata/responses/algostrategies.py +src/saxo_openapi/endpoints/referencedata/responses/countries.py +src/saxo_openapi/endpoints/referencedata/responses/cultures.py +src/saxo_openapi/endpoints/referencedata/responses/currencies.py +src/saxo_openapi/endpoints/referencedata/responses/currencypairs.py +src/saxo_openapi/endpoints/referencedata/responses/exchanges.py +src/saxo_openapi/endpoints/referencedata/responses/instruments.py +src/saxo_openapi/endpoints/referencedata/responses/languages.py +src/saxo_openapi/endpoints/referencedata/responses/standarddates.py +src/saxo_openapi/endpoints/referencedata/responses/timezones.py +src/saxo_openapi/endpoints/rootservices/__init__.py +src/saxo_openapi/endpoints/rootservices/base.py +src/saxo_openapi/endpoints/rootservices/diagnostics.py +src/saxo_openapi/endpoints/rootservices/features.py +src/saxo_openapi/endpoints/rootservices/sessions.py +src/saxo_openapi/endpoints/rootservices/subscriptions.py +src/saxo_openapi/endpoints/rootservices/user.py +src/saxo_openapi/endpoints/rootservices/responses/__init__.py +src/saxo_openapi/endpoints/rootservices/responses/features.py +src/saxo_openapi/endpoints/rootservices/responses/sessions.py +src/saxo_openapi/endpoints/rootservices/responses/subscriptions.py +src/saxo_openapi/endpoints/rootservices/responses/user.py +src/saxo_openapi/endpoints/trading/__init__.py +src/saxo_openapi/endpoints/trading/allocationkeys.py +src/saxo_openapi/endpoints/trading/base.py +src/saxo_openapi/endpoints/trading/infoprices.py +src/saxo_openapi/endpoints/trading/messages.py +src/saxo_openapi/endpoints/trading/optionschain.py +src/saxo_openapi/endpoints/trading/orders.py +src/saxo_openapi/endpoints/trading/positions.py +src/saxo_openapi/endpoints/trading/prices.py +src/saxo_openapi/endpoints/trading/screener.py +src/saxo_openapi/endpoints/trading/responses/__init__.py +src/saxo_openapi/endpoints/trading/responses/allocationkeys.py +src/saxo_openapi/endpoints/trading/responses/infoprices.py +src/saxo_openapi/endpoints/trading/responses/messages.py +src/saxo_openapi/endpoints/trading/responses/optionschain.py +src/saxo_openapi/endpoints/trading/responses/orders.py +src/saxo_openapi/endpoints/trading/responses/positions.py +src/saxo_openapi/endpoints/trading/responses/prices.py +src/saxo_openapi/endpoints/valueadd/__init__.py +src/saxo_openapi/endpoints/valueadd/base.py +src/saxo_openapi/endpoints/valueadd/pricealerts.py +src/saxo_openapi/endpoints/valueadd/responses/__init__.py +src/saxo_openapi/endpoints/valueadd/responses/pricealerts.py +src/scheduler/__init__.py +src/schema/__init__.py +src/wata.egg-info/PKG-INFO +src/wata.egg-info/SOURCES.txt +src/wata.egg-info/dependency_links.txt +src/wata.egg-info/entry_points.txt +src/wata.egg-info/requires.txt +src/wata.egg-info/top_level.txt +src/web_server/__init__.py +src/web_server/auth_token.py +src/web_server/cli.py +tests/test_configuration_manager.py +tests/test_data_factory.py +tests/test_db_position_manager.py +tests/test_instrument_service.py +tests/test_order_service.py +tests/test_performance_monitor.py +tests/test_position_service.py +tests/test_saxo_api_client.py +tests/test_trade_rules.py +tests/test_trading_orchestrator.py +tests/test_web_server.py +tests/test_web_server_advanced.py \ No newline at end of file diff --git a/src/wata.egg-info/dependency_links.txt b/src/wata.egg-info/dependency_links.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/wata.egg-info/entry_points.txt b/src/wata.egg-info/entry_points.txt new file mode 100644 index 0000000..0b4ee61 --- /dev/null +++ b/src/wata.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +watasaxoauth = src.saxo_authen.cli:main +watawebtoken = src.web_server.cli:main diff --git a/src/wata.egg-info/requires.txt b/src/wata.egg-info/requires.txt new file mode 100644 index 0000000..df7458c --- /dev/null +++ b/src/wata.egg-info/requires.txt @@ -0,0 +1,2 @@ +requests +python-dotenv diff --git a/src/wata.egg-info/top_level.txt b/src/wata.egg-info/top_level.txt new file mode 100644 index 0000000..06b3671 --- /dev/null +++ b/src/wata.egg-info/top_level.txt @@ -0,0 +1,10 @@ +configuration +database +logging_helper +message_helper +mq_telegram +saxo_authen +saxo_openapi +scheduler +schema +web_server diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5cc1315 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +import pytest +from unittest.mock import MagicMock + +from src.configuration import ConfigurationManager +from src.database import DbOrderManager, DbPositionManager +from src.trade.rules import TradingRule +from src.saxo_authen import SaxoAuth +from src.trade.api_actions import SaxoApiClient + +@pytest.fixture +def mock_config_manager(): + """A mock for ConfigurationManager with a flexible side_effect.""" + manager = MagicMock(spec=ConfigurationManager) + + def get_config_value(key, default=None): + configs = { + "saxo_auth.env": "simulation", + "trade.config.general.api_limits": {"top_instruments": 200, "top_positions": 200, "top_closed_positions": 500}, + "trade.config.turbo_preference.price_range": {"min": 4, "max": 15}, + "trade.config.general.retry_config": {"max_retries": 5, "retry_sleep_seconds": 2}, + "trade.config.general.websocket": {"refresh_rate_ms": 10000}, + "trade.config.buying_power": {"safety_margins": {"bid_calculation": 1}, "max_account_funds_to_use_percentage": 100}, + "trade.config.position_management": {"performance_thresholds": {"stoploss_percent": -20, "max_profit_percent": 60}}, + "trade.config.general": {"timezone": "Europe/Paris"}, + "logging.persistant": {"log_path": "/tmp/logs"} + } + # Return specific value if key exists, otherwise the provided default + return configs.get(key, default) + + manager.get_config_value.side_effect = get_config_value + manager.get_logging_config.return_value = {"persistant": {"log_path": "/tmp/logs"}} + return manager + +@pytest.fixture +def mock_saxo_auth(): + """A mock for SaxoAuth.""" + auth = MagicMock(spec=SaxoAuth) + auth.get_token.return_value = "test_token" + return auth + +@pytest.fixture +def mock_db_order_manager(): + """A mock for DbOrderManager.""" + return MagicMock(spec=DbOrderManager) + +@pytest.fixture +def mock_db_position_manager(): + """A mock for DbPositionManager.""" + return MagicMock(spec=DbPositionManager) + +@pytest.fixture +def mock_trading_rule(): + """A mock for TradingRule.""" + rule = MagicMock(spec=TradingRule) + rule.get_rule_config.return_value = {"percent_profit_wanted_per_days": 1.0} + return rule + +@pytest.fixture +def mock_api_client(): + """ + A mock for the low-level SaxoApiClient. + This fixture mocks the *wrapper* client, not the underlying SaxoOpenApiLib. + This is useful for testing the services that *use* the client. + """ + client = MagicMock(spec=SaxoApiClient) + return client diff --git a/tests/test_data_factory.py b/tests/test_data_factory.py new file mode 100644 index 0000000..9b68f20 --- /dev/null +++ b/tests/test_data_factory.py @@ -0,0 +1,178 @@ +from copy import deepcopy + +class TestDataFactory: + """ + Factory for creating consistent and reusable test data fixtures for testing + the trading application components. + """ + + @staticmethod + def create_saxo_instrument( + uic=123, + identifier=456, + description="TURBO LONG DAX 15000 CITI", + asset_type="WarrantKnockOut", + **overrides + ): + """Creates a basic instrument as returned from the initial instrument search.""" + instrument = { + "Uic": uic, + "Identifier": identifier, + "Description": description, + "AssetType": asset_type, + "ExchangeId": "XEUR", + "Symbol": "DE000CD4PTZ4", + "Status": "Tradable", + "CurrencyCode": "EUR", + "PrimaryListing": identifier, + "TradableOn": ["ATS", "SAXO"], + } + instrument.update(overrides) + return instrument + + @staticmethod + def create_saxo_infoprice( + uic=123, + asset_type="WarrantKnockOut", + bid=10.0, + ask=10.1, + market_state="Open", + price_type_ask="Tradable", + price_type_bid="Tradable", + **overrides + ): + """Creates a detailed InfoPrice object for an instrument.""" + infoprice = { + "Uic": uic, + "AssetType": asset_type, + "LastUpdated": "2023-10-27T10:00:00.000Z", + "PriceSource": "Calculated", + "Quote": { + "Amount": 1000, + "Ask": ask, + "Bid": bid, + "DelayedByMinutes": 0, + "MarketState": market_state, + "PriceTypeAsk": price_type_ask, + "PriceTypeBid": price_type_bid, + }, + "DisplayAndFormat": { + "Currency": "EUR", + "Decimals": 3, + "Description": "TURBO LONG DAX 15000 CITI", + "Symbol": "DE000CD4PTZ4" + } + } + # Deep merge overrides for nested structures like 'Quote' + if overrides: + base = deepcopy(infoprice) + # A simple deep merge for 1-level nesting + for key, value in overrides.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + base[key].update(value) + else: + base[key] = value + return base + return infoprice + + @staticmethod + def create_price_subscription_snapshot( + uic=123, + description="Final TURBO LONG DAX 15000 CITI", + ask=10.05, + bid=9.95, + **overrides + ): + """Creates a snapshot as returned from a price subscription.""" + snapshot = { + "Uic": uic, + "DisplayAndFormat": {"Description": description, "Symbol": "Sym", "Currency": "EUR", "Decimals": 2}, + "Quote": {"Ask": ask, "Bid": bid}, + "Commissions": {"CostBuy": 1.5}, + } + if overrides: + base = deepcopy(snapshot) + for key, value in overrides.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + base[key].update(value) + else: + base[key] = value + return base + return snapshot + + @staticmethod + def create_saxo_position( + position_id="pos1", + order_id="order1", + uic=123, + amount=100, + open_price=10.0, + current_bid=9.5, + can_be_closed=True, + **overrides + ): + """Creates a mock open position from the Saxo API.""" + position = { + "PositionId": position_id, + "PositionBase": { + "AccountId": "acc1", + "Amount": amount, + "AssetType": "WarrantKnockOut", + "CanBeClosed": can_be_closed, + "ClientId": "client1", + "OpenPrice": open_price, + "SourceOrderId": order_id, + "Status": "Open", + "Uic": uic, + }, + "PositionView": { + "Bid": current_bid, + "CurrentPrice": current_bid, + "MarketValue": current_bid * amount, + "ProfitLossOnTrade": (current_bid - open_price) * amount, + }, + "DisplayAndFormat": {"Description": "Test Position", "Currency": "EUR"}, + } + if overrides: + base = deepcopy(position) + for key, value in overrides.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + base[key].update(value) + else: + base[key] = value + return base + return position + + @staticmethod + def create_saxo_closed_position( + opening_position_id="pos1", + closing_price=9.5, + open_price=10.0, + amount=100, + **overrides + ): + """Creates a mock closed position from the Saxo API.""" + closed_position = { + "ClosedPosition": { + "OpeningPositionId": opening_position_id, + "ClosingPrice": closing_price, + "OpenPrice": open_price, + "Amount": amount, + "ProfitLossOnTrade": (closing_price - open_price) * amount, + "ExecutionTimeClose": "2023-10-27T12:00:00.000Z", + }, + "DisplayAndFormat": {"Description": "Test Closed Position"}, + } + closed_position.update(overrides) + return closed_position + + @staticmethod + def create_order_response(order_id="order123", **overrides): + """Creates a mock successful order placement response.""" + response = { + "OrderId": order_id, + "Duration": {"DurationType": "DayOrder"}, + # ... other fields as needed + } + response.update(overrides) + return response diff --git a/tests/test_instrument_service.py b/tests/test_instrument_service.py new file mode 100644 index 0000000..6274423 --- /dev/null +++ b/tests/test_instrument_service.py @@ -0,0 +1,281 @@ +import pytest +from unittest.mock import patch, MagicMock + +from src.trade.api_actions import InstrumentService +from src.trade.exceptions import NoTurbosAvailableException, NoMarketAvailableException, ApiRequestException +from tests.test_data_factory import TestDataFactory + +@pytest.fixture +def instrument_service(mock_api_client, mock_config_manager): + """Fixture for InstrumentService.""" + return InstrumentService(mock_api_client, mock_config_manager, "account_key") + +class TestInstrumentService: + + def test_find_turbos_happy_path(self, instrument_service, mock_api_client): + # Arrange + initial_instrument = TestDataFactory.create_saxo_instrument(identifier=1) + infoprice = TestDataFactory.create_saxo_infoprice(uic=101, identifier=1, bid=10.0, ask=10.1) + price_snapshot = TestDataFactory.create_price_subscription_snapshot(uic=101, ask=10.05, bid=9.95) + + mock_api_client.request.side_effect = [ + {"Data": [initial_instrument]}, + {"Data": [infoprice]}, + {"Snapshot": price_snapshot} + ] + + # Act + result = instrument_service.find_turbos("exchange1", "underlying1", "long") + + # Assert + assert result['selected_instrument']['uic'] == 101 + assert result['selected_instrument']['latest_ask'] == 10.05 + assert result['selected_instrument']['latest_bid'] == 9.95 + assert mock_api_client.request.call_count == 3 + + def test_find_turbos_no_initial_instruments(self, instrument_service, mock_api_client): + mock_api_client.request.return_value = {"Data": []} + with pytest.raises(NoTurbosAvailableException): + instrument_service.find_turbos("e1", "u1", "long") + + @patch('src.trade.api_actions.parse_saxo_turbo_description') + def test_should_handle_malformed_instrument_data(self, mock_parse, instrument_service, mock_api_client): + """Test that instruments with unparsable descriptions are ignored.""" + # Arrange + # The first instrument has a description that our mock parser will reject + instrument1 = TestDataFactory.create_saxo_instrument(identifier=1, description="INVALID") + # The second one is valid + instrument2 = TestDataFactory.create_saxo_instrument(identifier=2, description="TURBO LONG DAX 15000 CITI") + infoprice = TestDataFactory.create_saxo_infoprice(uic=102, identifier=2) + price_snapshot = TestDataFactory.create_price_subscription_snapshot(uic=102) + + mock_api_client.request.side_effect = [ + {"Data": [instrument1, instrument2]}, + {"Data": [infoprice]}, + {"Snapshot": price_snapshot} + ] + # Make parse_saxo_turbo_description return None for the invalid description + valid_parsed_data = {"name": "TURBO", "kind": "LONG", "buysell": "DAX", "price": "15000", "from": "CITI"} + mock_parse.side_effect = [ + None, # For instrument1 + valid_parsed_data, # For instrument2 + valid_parsed_data, # For the final result construction + ] + + # Act + result = instrument_service.find_turbos("e1", "u1", "long") + + # Assert + # It should have skipped instrument 1 and selected instrument 2 + assert result['selected_instrument']['uic'] == 102 + # Called for instrument1, instrument2, and the final result construction + assert mock_parse.call_count == 3 + assert mock_api_client.request.call_count == 3 + + + @patch('time.sleep', return_value=None) + def test_should_handle_partial_bid_data_within_tolerance(self, mock_sleep, instrument_service, mock_api_client): + """Test bid retry logic with partial missing bid data under 50%.""" + # Arrange + initial_instruments = [TestDataFactory.create_saxo_instrument(identifier=i) for i in range(4)] + # 3 items have full quote, 1 is missing 'Bid' + infoprices = [ + TestDataFactory.create_saxo_infoprice(uic=1, identifier=1), + TestDataFactory.create_saxo_infoprice(uic=2, identifier=2), + TestDataFactory.create_saxo_infoprice(uic=3, identifier=3), + TestDataFactory.create_saxo_infoprice(uic=4, identifier=4, overrides={"Quote": {"Ask": 12.0}}) # No Bid + ] + price_snapshot = TestDataFactory.create_price_subscription_snapshot(uic=1) # Assume it selects the first one + + mock_api_client.request.side_effect = [ + {"Data": initial_instruments}, + {"Data": infoprices}, + {"Snapshot": price_snapshot} + ] + + # Act + result = instrument_service.find_turbos("e1", "u1", "long") + + # Assert + # The process should continue as <50% of items are missing bids + assert result is not None + assert result['selected_instrument']['uic'] == 1 + # The InfoPrice call should only happen once, no retry needed + # Call 1: Instruments, Call 2: InfoPrices, Call 3: Subscription + assert mock_api_client.request.call_count == 3 + mock_sleep.assert_not_called() + + + @patch('time.sleep', return_value=None) + def test_should_retry_bid_check_on_websocket_refresh_rate_delay(self, mock_sleep, instrument_service, mock_api_client): + """Test bid retry logic when >50% of bid data is missing initially.""" + # Arrange + initial_instruments = [TestDataFactory.create_saxo_instrument(identifier=i) for i in range(4)] + + # First response: 3 out of 4 items are missing bids. Be explicit. + infoprice_with_bid = TestDataFactory.create_saxo_infoprice(uic=1, identifier=1) + infoprice_no_bid_1 = TestDataFactory.create_saxo_infoprice(uic=2, identifier=2) + del infoprice_no_bid_1['Quote']['Bid'] + infoprice_no_bid_2 = TestDataFactory.create_saxo_infoprice(uic=3, identifier=3) + del infoprice_no_bid_2['Quote']['Bid'] + infoprice_no_bid_3 = TestDataFactory.create_saxo_infoprice(uic=4, identifier=4) + del infoprice_no_bid_3['Quote']['Bid'] + + infoprices_missing_bids = [infoprice_with_bid, infoprice_no_bid_1, infoprice_no_bid_2, infoprice_no_bid_3] + + # Second response: All bids are present + infoprices_full = [TestDataFactory.create_saxo_infoprice(uic=i, identifier=i-1) for i in range(1, 5)] + price_snapshot = TestDataFactory.create_price_subscription_snapshot(uic=1) + + mock_api_client.request.side_effect = [ + {"Data": initial_instruments}, + {"Data": infoprices_missing_bids}, # First attempt fails bid check + {"Data": infoprices_full}, # Second attempt passes + {"Snapshot": price_snapshot} + ] + + # Act + result = instrument_service.find_turbos("e1", "u1", "long") + + # Assert + assert result is not None + assert result['selected_instrument']['uic'] == 1 + # Call 1: Instruments, Call 2: InfoPrices (fail), Call 3: InfoPrices (success), Call 4: Subscription + assert mock_api_client.request.call_count == 4 + mock_sleep.assert_called_once() + + + def test_should_handle_price_subscription_partial_failure(self, instrument_service, mock_api_client): + """Test when price subscription returns a response but is missing the 'Snapshot'.""" + # Arrange + initial_instrument = TestDataFactory.create_saxo_instrument(identifier=1) + infoprice = TestDataFactory.create_saxo_infoprice(uic=101, identifier=1, bid=10.0, ask=10.1) + # The subscription response is valid but has no 'Snapshot' key + price_sub_response_no_snapshot = {"ContextId": "ctx123"} + + mock_api_client.request.side_effect = [ + {"Data": [initial_instrument]}, + {"Data": [infoprice]}, + price_sub_response_no_snapshot + ] + + # Act + result = instrument_service.find_turbos("e1", "u1", "long") + + # Assert + # Should fall back to using the InfoPrice data + assert result is not None + assert result['selected_instrument']['uic'] == 101 + # Prices should be from the infoprice response, not the failed subscription + assert result['selected_instrument']['latest_ask'] == 10.1 + assert result['selected_instrument']['latest_bid'] == 10.0 + # Subscription details should be None since it failed + assert result['selected_instrument']['subscription_context_id'] is None + + @patch('time.sleep', return_value=None) + def test_find_turbos_no_infoprice_data_after_retries(self, mock_sleep, instrument_service, mock_api_client): + initial_instrument = TestDataFactory.create_saxo_instrument() + # All calls to get infoprices fail. The outer while loop runs 5 times. + mock_api_client.request.side_effect = [ + {"Data": [initial_instrument]}, + None, None, None, None, None + ] + with pytest.raises(NoMarketAvailableException, match="Failed to obtain valid InfoPrice data"): + instrument_service.find_turbos("e1", "u1", "long") + # Instruments (1) + InfoPrices (5 retries) = 6 calls + assert mock_api_client.request.call_count == 6 + assert mock_sleep.call_count == 4 # Sleeps between retries + + @pytest.mark.parametrize("bid_price, should_be_found", [ + (4.0, True), # Exactly at min price + (15.0, True), # Exactly at max price + (9.5, True), # Comfortably in the middle + (3.99, False), # Just below min price + (15.01, False) # Just above max price + ]) + def test_should_validate_turbo_price_range_boundaries(self, bid_price, should_be_found, instrument_service, mock_api_client, mock_config_manager): + """Test behavior at exact min/max price range boundaries.""" + # Arrange + # The config fixture sets the range to min: 4, max: 15 + initial_instrument = TestDataFactory.create_saxo_instrument(identifier=1) + infoprice = TestDataFactory.create_saxo_infoprice(uic=101, identifier=1, bid=bid_price, ask=bid_price + 0.1) + price_snapshot = TestDataFactory.create_price_subscription_snapshot(uic=101) + + mock_api_client.request.side_effect = [ + {"Data": [initial_instrument]}, + {"Data": [infoprice]}, + {"Snapshot": price_snapshot} + ] + + # Act & Assert + if should_be_found: + result = instrument_service.find_turbos("exchange1", "underlying1", "long") + assert result is not None + assert result['selected_instrument']['uic'] == 101 + else: + with pytest.raises(NoTurbosAvailableException, match="No turbos found in price range"): + instrument_service.find_turbos("exchange1", "underlying1", "long") + + def test_should_filter_out_closed_market_instruments(self, instrument_service, mock_api_client): + """Test that instruments with MarketState 'Closed' are filtered out.""" + # Arrange + initial_instrument = TestDataFactory.create_saxo_instrument(identifier=1) + # This instrument is tradable, but the market is closed. + infoprice_closed = TestDataFactory.create_saxo_infoprice(uic=101, identifier=1, market_state="Closed") + + mock_api_client.request.side_effect = [ + {"Data": [initial_instrument]}, + {"Data": [infoprice_closed]}, + ] + + # Act & Assert + with pytest.raises(NoMarketAvailableException, match="No markets available"): + instrument_service.find_turbos("exchange1", "underlying1", "long") + + @patch('time.sleep', return_value=None) + def test_should_proceed_if_exactly_50_percent_bids_missing(self, mock_sleep, instrument_service, mock_api_client): + """Test bid retry logic proceeds without retrying if exactly 50% of bids are missing.""" + # Arrange + initial_instruments = [TestDataFactory.create_saxo_instrument(identifier=i) for i in range(4)] + + # Exactly 50% (2/4) are missing bids + infoprice1 = TestDataFactory.create_saxo_infoprice(uic=1, identifier=1) + infoprice2 = TestDataFactory.create_saxo_infoprice(uic=2, identifier=2) + infoprice_no_bid_1 = TestDataFactory.create_saxo_infoprice(uic=3, identifier=3) + del infoprice_no_bid_1['Quote']['Bid'] + infoprice_no_bid_2 = TestDataFactory.create_saxo_infoprice(uic=4, identifier=4) + del infoprice_no_bid_2['Quote']['Bid'] + infoprices = [infoprice1, infoprice2, infoprice_no_bid_1, infoprice_no_bid_2] + + price_snapshot = TestDataFactory.create_price_subscription_snapshot(uic=1) + + mock_api_client.request.side_effect = [ + {"Data": initial_instruments}, + {"Data": infoprices}, + {"Snapshot": price_snapshot} + ] + + # Act + result = instrument_service.find_turbos("exchange1", "underlying1", "long") + + # Assert + # The process should continue as the condition is >50% + assert result is not None + assert result['selected_instrument']['uic'] == 1 + # No retry should be triggered + mock_sleep.assert_not_called() + assert mock_api_client.request.call_count == 3 + + @patch('src.trade.api_actions.parse_saxo_turbo_description') + def test_find_turbos_handles_key_error_on_sort(self, mock_parse, instrument_service, mock_api_client): + """Test that a KeyError during sorting is handled gracefully.""" + # Arrange + instrument = TestDataFactory.create_saxo_instrument() + # The parsed data is missing the 'price' key required for sorting + malformed_parsed_data = {"name": "TURBO", "kind": "LONG"} + mock_parse.return_value = malformed_parsed_data + mock_api_client.request.return_value = {"Data": [instrument]} + + # Act & Assert + with pytest.raises(ValueError, match="Could not sort instruments by parsed price"): + instrument_service.find_turbos("e1", "u1", "long") diff --git a/tests/test_order_service.py b/tests/test_order_service.py new file mode 100644 index 0000000..93577d6 --- /dev/null +++ b/tests/test_order_service.py @@ -0,0 +1,103 @@ +import pytest +from unittest.mock import patch, MagicMock + +from src.trade.api_actions import OrderService +from src.trade.exceptions import OrderPlacementError, ApiRequestException +from tests.test_data_factory import TestDataFactory + +@pytest.fixture +def order_service(mock_api_client): + """Fixture for OrderService.""" + return OrderService(mock_api_client, "account_key", "client_key") + +class TestOrderService: + + def test_place_market_order_success(self, order_service, mock_api_client): + # Arrange + order_response = TestDataFactory.create_order_response(order_id="12345") + mock_api_client.request.return_value = order_response + + # Act + result = order_service.place_market_order(uic=1, asset_type="FxSpot", amount=100, buy_sell="Buy") + + # Assert + assert result == order_response + mock_api_client.request.assert_called_once() + # Better validation: check the payload sent to the API + call_args = mock_api_client.request.call_args[0][0] + assert call_args.data['Uic'] == 1 + assert call_args.data['Amount'] == 100 + assert call_args.data['BuySell'] == "Buy" + assert call_args.data['AccountKey'] == "account_key" + + def test_place_market_order_api_error(self, order_service, mock_api_client): + # Arrange + mock_api_client.request.side_effect = OrderPlacementError("API rejected order") + + # Act & Assert + with pytest.raises(OrderPlacementError): + order_service.place_market_order(uic=1, asset_type="FxSpot", amount=100, buy_sell="Buy") + + def test_place_market_order_missing_order_id(self, order_service, mock_api_client): + """Test that an error is raised if the API response is missing the OrderId.""" + # Arrange + # The response is successful, but malformed (missing OrderId) + malformed_response = {"Status": "Success", "SomeOtherKey": "value"} + mock_api_client.request.return_value = malformed_response + + # Act & Assert + with pytest.raises(OrderPlacementError, match="Order placement response missing OrderId"): + order_service.place_market_order(uic=1, asset_type="FxSpot", amount=100, buy_sell="Buy") + + def test_cancel_order_success(self, order_service, mock_api_client): + # Arrange + mock_api_client.request.return_value = {"Status": "Cancelled"} # Simulate a success response + + # Act + result = order_service.cancel_order("123") + + # Assert + assert result is True + mock_api_client.request.assert_called_once() + # The first argument of the first call to request() is the request object + request_object = mock_api_client.request.call_args[0][0] + # The OrderId is formatted into the endpoint URL + assert "123" in request_object._endpoint + assert request_object.params['AccountKey'] == "account_key" + + def test_cancel_order_failure_api_error(self, order_service, mock_api_client): + # Arrange + mock_api_client.request.side_effect = ApiRequestException("Failed to cancel") + + # Act + result = order_service.cancel_order("123") + + # Assert + assert result is False + + def test_get_single_order_success(self, order_service, mock_api_client): + """Test that getting a single order works correctly.""" + # Arrange + expected_order = {"OrderId": "order1", "Status": "Working"} + mock_api_client.request.return_value = expected_order + + # Act + result = order_service.get_single_order("order1") + + # Assert + assert result == expected_order + mock_api_client.request.assert_called_once() + request_object = mock_api_client.request.call_args[0][0] + # Both OrderId and ClientKey are part of the endpoint URL + assert "order1" in request_object._endpoint + assert "client_key" in request_object._endpoint + + def test_cancel_order_unexpected_exception(self, order_service, mock_api_client): + # Arrange + mock_api_client.request.side_effect = Exception("Unexpected error") + + # Act + result = order_service.cancel_order("123") + + # Assert + assert result is False diff --git a/tests/test_performance_monitor.py b/tests/test_performance_monitor.py new file mode 100644 index 0000000..537f0a7 --- /dev/null +++ b/tests/test_performance_monitor.py @@ -0,0 +1,425 @@ +import pytest +from unittest.mock import patch, MagicMock, call, ANY, mock_open + +from src.trade.api_actions import PerformanceMonitor, PositionService, OrderService +from src.database import DbPositionManager +from src.trade.rules import TradingRule +from src.trade.exceptions import OrderPlacementError, ApiRequestException +from tests.test_data_factory import TestDataFactory + +@pytest.fixture +def mock_position_service(): + return MagicMock(spec=PositionService) + +@pytest.fixture +def mock_order_service(): + return MagicMock(spec=OrderService) + +@pytest.fixture +def mock_rabbit_connection(): + return MagicMock() + +@pytest.fixture +def performance_monitor( + mock_position_service, + mock_order_service, + mock_config_manager, + mock_db_position_manager, + mock_trading_rule, + mock_rabbit_connection +): + """Fixture for PerformanceMonitor.""" + return PerformanceMonitor( + position_service=mock_position_service, + order_service=mock_order_service, + config_manager=mock_config_manager, + db_position_manager=mock_db_position_manager, + trading_rule=mock_trading_rule, + rabbit_connection=mock_rabbit_connection, + ) + +class TestPerformanceMonitor: + + @patch('time.sleep', return_value=None) + @patch('src.trade.api_actions.send_message_to_mq_for_telegram') + def test_fetch_and_update_closed_position_in_db_success(self, mock_send_message, mock_sleep, performance_monitor, mock_db_position_manager, mock_position_service): + # Arrange + closed_position = TestDataFactory.create_saxo_closed_position(opening_position_id="pos1") + mock_position_service.get_closed_positions.return_value = {"Data": [closed_position]} + mock_db_position_manager.get_max_position_percent.return_value = 5.0 + mock_db_position_manager.get_percent_of_the_day.return_value = 1.5 + + # Act + result = performance_monitor._fetch_and_update_closed_position_in_db("pos1", "Test Close") + + # Assert + assert result is True + mock_db_position_manager.update_turbo_position_data.assert_called_once_with("pos1", ANY) + mock_send_message.assert_called_once() + # Check that the message contains key info. The message is the second argument. + message_arg = mock_send_message.call_args[0][1] + assert "CLOSED POSITION" in message_arg + assert "pos1" in message_arg + + @patch.object(PerformanceMonitor, '_log_performance_detail') + @patch.object(PerformanceMonitor, '_fetch_and_update_closed_position_in_db') + def test_check_all_positions_performance_triggers_stoploss(self, mock_update_db, mock_log_perf, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + # Arrange + mock_db_position_manager.get_open_positions_ids_actions.return_value = [{"position_id": "pos1"}] + # Position has lost 21%, and stoploss is at -20% + position = TestDataFactory.create_saxo_position(position_id="pos1", open_price=100, current_bid=79, can_be_closed=True) + mock_position_service.get_open_positions.return_value = {"Data": [position]} + mock_db_position_manager.get_max_position_percent.return_value = 1.0 + mock_update_db.return_value = True + + # Act + result = performance_monitor.check_all_positions_performance() + + # Assert + assert len(result["closed_positions_processed"]) == 1 + assert result["closed_positions_processed"][0]["status"] == "Closed" + mock_order_service.place_market_order.assert_called_once() + mock_update_db.assert_called_once_with("pos1", ANY) + + def test_check_all_positions_performance_no_positions(self, performance_monitor, mock_db_position_manager): + mock_db_position_manager.get_open_positions_ids_actions.return_value = [] + result = performance_monitor.check_all_positions_performance() + assert result == {"closed_positions_processed": [], "db_updates": [], "errors": 0} + + @patch.object(PerformanceMonitor, '_fetch_and_update_closed_position_in_db') + def test_should_handle_multiple_positions_hitting_thresholds_simultaneously(self, mock_update_db, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + # Arrange + db_positions = [{"position_id": "pos_stoploss"}, {"position_id": "pos_takeprofit"}] + # pos1 hits stoploss (-25%), pos2 hits takeprofit (+65%) + pos1 = TestDataFactory.create_saxo_position(position_id="pos_stoploss", open_price=100, current_bid=75) + pos2 = TestDataFactory.create_saxo_position(position_id="pos_takeprofit", open_price=100, current_bid=165) + mock_db_position_manager.get_open_positions_ids_actions.return_value = db_positions + mock_position_service.get_open_positions.return_value = {"Data": [pos1, pos2]} + mock_db_position_manager.get_max_position_percent.return_value = 1.0 + mock_update_db.return_value = True + + # Act + result = performance_monitor.check_all_positions_performance() + + # Assert + assert len(result["closed_positions_processed"]) == 2 + assert mock_order_service.place_market_order.call_count == 2 + assert mock_update_db.call_count == 2 + # Check that different reasons were passed + assert "Stoploss" in mock_update_db.call_args_list[0][0][1] + assert "Takeprofit" in mock_update_db.call_args_list[1][0][1] + + @patch('src.trade.api_actions.send_message_to_mq_for_telegram') + @patch.object(PerformanceMonitor, '_fetch_and_update_closed_position_in_db') + def test_should_handle_partial_close_order_failures(self, mock_update_db, mock_send_message, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + # Arrange + db_positions = [{"position_id": "pos_ok"}, {"position_id": "pos_fail"}] + pos_ok = TestDataFactory.create_saxo_position(position_id="pos_ok", open_price=100, current_bid=70) # Hits stoploss + pos_fail = TestDataFactory.create_saxo_position(position_id="pos_fail", open_price=100, current_bid=70) # Hits stoploss + mock_db_position_manager.get_open_positions_ids_actions.return_value = db_positions + mock_position_service.get_open_positions.return_value = {"Data": [pos_ok, pos_fail]} + mock_db_position_manager.get_max_position_percent.return_value = 1.0 + + # The first order succeeds, the second fails + mock_order_service.place_market_order.side_effect = [ + TestDataFactory.create_order_response(), + OrderPlacementError("Order failed") + ] + mock_update_db.return_value = True + + # Act + result = performance_monitor.check_all_positions_performance() + + # Assert + assert len(result["closed_positions_processed"]) == 2 + assert result["errors"] == 1 + assert result["closed_positions_processed"][0]["status"] == "Closed" + assert result["closed_positions_processed"][1]["status"] == "Close Order Failed" + + # One order placed, one failed + assert mock_order_service.place_market_order.call_count == 2 + # Only one position was successfully updated in the DB + mock_update_db.assert_called_once_with("pos_ok", ANY) + # A notification should be sent for the failure + mock_send_message.assert_called_once() + assert "ERROR: Failed closing pos_fail" in mock_send_message.call_args[0][1] + + @patch('src.trade.api_actions.send_message_to_mq_for_telegram') + def test_sync_db_positions_with_api_success(self, mock_send_message, performance_monitor, mock_db_position_manager, mock_position_service): + # Arrange + mock_db_position_manager.get_open_positions_ids.return_value = ["pos1_closed", "pos2_open"] + mock_position_service.get_open_positions.return_value = {"Data": [TestDataFactory.create_saxo_position("pos2_open")]} + closed_pos = TestDataFactory.create_saxo_closed_position(opening_position_id="pos1_closed") + mock_position_service.get_closed_positions.return_value = {"Data": [closed_pos]} + + # Act + result = performance_monitor.sync_db_positions_with_api() + + # Assert + assert len(result["updates_for_db"]) == 1 + update_tuple = result["updates_for_db"][0] + assert update_tuple[0] == "pos1_closed" + assert update_tuple[1]["position_status"] == "Closed" + assert update_tuple[1]["position_close_reason"] == "SaxoAPI" + # Check for notification + mock_send_message.assert_called_once() + assert "SYNC CLOSE: Position pos1_closed" in mock_send_message.call_args[0][1] + + @patch.object(PerformanceMonitor, '_log_performance_detail') + def test_should_calculate_daily_profit_with_multiple_open_positions(self, mock_log_perf, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + """ + Tests the daily profit calculation with multiple positions. + NOTE: This test characterizes the *current* logic, which is known to be + a simplistic and potentially incorrect way to calculate portfolio profit. + It serves as a baseline for future refactoring. + """ + # Arrange + # Daily profit target is 5% + performance_monitor.percent_profit_wanted_per_days = 5.0 + # DB reports 2% of realized profit for the day so far + mock_db_position_manager.get_percent_of_the_day.return_value = 2.0 + + db_positions = [{"position_id": "pos1"}, {"position_id": "pos2"}] + # Both positions are up 2% + pos1 = TestDataFactory.create_saxo_position(position_id="pos1", open_price=100, current_bid=102) + pos2 = TestDataFactory.create_saxo_position(position_id="pos2", open_price=100, current_bid=102) + + mock_db_position_manager.get_open_positions_ids_actions.return_value = db_positions + mock_position_service.get_open_positions.return_value = {"Data": [pos1, pos2]} + mock_db_position_manager.get_max_position_percent.return_value = 2.0 + + # Act + result = performance_monitor.check_all_positions_performance() + + # Assert + # The current flawed logic is: (1 + realized_profit) * (1 + position_profit) - 1 + # (1.02 * 1.02) - 1 = 0.0404, or 4.04%, which is less than the 5% target. + # Therefore, no positions should be closed. + assert len(result["closed_positions_processed"]) == 0 + mock_order_service.place_market_order.assert_not_called() + + @patch('src.trade.api_actions.send_message_to_mq_for_telegram') + def test_should_handle_api_position_sync_race_conditions(self, mock_send_message, performance_monitor, mock_db_position_manager, mock_position_service): + """ + Tests the sync logic when a position is closed on the API while the sync + is in progress. + """ + # Arrange + # DB thinks pos1 and pos2 are open + mock_db_position_manager.get_open_positions_ids.return_value = ["pos1", "pos2"] + + # By the time we check, the API only reports pos2 as open (pos1 was just closed) + mock_position_service.get_open_positions.return_value = { + "Data": [TestDataFactory.create_saxo_position("pos2")] + } + # And pos1 now appears in the list of recently closed positions + closed_pos1 = TestDataFactory.create_saxo_closed_position(opening_position_id="pos1") + mock_position_service.get_closed_positions.return_value = {"Data": [closed_pos1]} + + # Act + result = performance_monitor.sync_db_positions_with_api() + + # Assert + # The sync logic should have found one position to update + assert len(result["updates_for_db"]) == 1 + position_id, update_data = result["updates_for_db"][0] + + # It should be pos1 + assert position_id == "pos1" + # It should be marked as Closed + assert update_data["position_status"] == "Closed" + assert update_data["position_close_reason"] == "SaxoAPI" + # A notification should have been sent + mock_send_message.assert_called_once() + assert "SYNC CLOSE: Position pos1" in mock_send_message.call_args[0][1] + + @patch.object(PerformanceMonitor, '_log_performance_detail') + def test_should_skip_closing_if_canbeclosed_is_false(self, mock_log_perf, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + """Test that a position is not closed if it hits a threshold but CanBeClosed is False.""" + # Arrange + mock_db_position_manager.get_open_positions_ids_actions.return_value = [{"position_id": "pos1"}] + # Position hits stoploss, but is marked as not closable + position = TestDataFactory.create_saxo_position(position_id="pos1", open_price=100, current_bid=70, can_be_closed=False) + mock_position_service.get_open_positions.return_value = {"Data": [position]} + mock_db_position_manager.get_max_position_percent.return_value = 1.0 + + # Act + result = performance_monitor.check_all_positions_performance() + + # Assert + # No close order should have been attempted + assert len(result["closed_positions_processed"]) == 1 + assert result["closed_positions_processed"][0]['status'] == "Skipped (Cannot Be Closed)" + mock_order_service.place_market_order.assert_not_called() + + def test_sync_anomaly_position_is_not_in_open_or_closed(self, performance_monitor, mock_db_position_manager, mock_position_service): + """Test the sync anomaly case where a position from the DB is not found anywhere in the API.""" + # Arrange + # DB thinks pos_vanished is open + mock_db_position_manager.get_open_positions_ids.return_value = ["pos_vanished"] + # API returns no open positions + mock_position_service.get_open_positions.return_value = {"Data": []} + # And the position is also not in the recently closed list + mock_position_service.get_closed_positions.return_value = {"Data": []} + + # Act + result = performance_monitor.sync_db_positions_with_api() + + # Assert + # No updates should be generated for the DB + assert len(result["updates_for_db"]) == 0 + + def test_check_performance_handles_position_not_in_api(self, performance_monitor, mock_db_position_manager, mock_position_service): + """Test that a position open in the DB but not in the API is skipped during performance checks.""" + # Arrange + # DB thinks pos1 is open + mock_db_position_manager.get_open_positions_ids_actions.return_value = [{"position_id": "pos1"}] + # But the API returns no open positions + mock_position_service.get_open_positions.return_value = {"Data": []} + + # Act + result = performance_monitor.check_all_positions_performance() + + # Assert + # Nothing should be processed or closed + assert len(result["closed_positions_processed"]) == 0 + assert len(result["db_updates"]) == 0 + assert result["errors"] == 0 + + def test_sync_db_handles_api_failure(self, performance_monitor, mock_db_position_manager, mock_position_service): + """Test that the sync process handles an API failure gracefully.""" + # Arrange + mock_db_position_manager.get_open_positions_ids.return_value = ["pos1"] + mock_position_service.get_open_positions.side_effect = ApiRequestException("API Error") + + # Act + result = performance_monitor.sync_db_positions_with_api() + + # Assert + # The process should return an empty list of updates without crashing + assert result["updates_for_db"] == [] + + +class TestCloseManagedPositions: + + @patch.object(PerformanceMonitor, '_fetch_and_update_closed_position_in_db', return_value=True) + def test_close_all_positions_no_filter(self, mock_update_db, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + """Test that all managed positions are closed when no filter is provided.""" + # Arrange + db_positions = [ + {"position_id": "pos_long", "action": "long"}, + {"position_id": "pos_short", "action": "short"}, + ] + api_positions = [ + TestDataFactory.create_saxo_position(position_id="pos_long", amount=100), + TestDataFactory.create_saxo_position(position_id="pos_short", amount=-100), + ] + mock_db_position_manager.get_open_positions_ids_actions.return_value = db_positions + mock_position_service.get_open_positions.return_value = {"Data": api_positions} + mock_order_service.place_market_order.return_value = TestDataFactory.create_order_response() + + # Act + result = performance_monitor.close_managed_positions_by_criteria(action_filter=None) + + # Assert + assert result["closed_initiated_count"] == 2 + assert mock_order_service.place_market_order.call_count == 2 + # Called once for each closed position + assert mock_update_db.call_count == 2 + + @patch.object(PerformanceMonitor, '_fetch_and_update_closed_position_in_db', return_value=True) + def test_close_positions_with_long_filter(self, mock_update_db, performance_monitor, mock_db_position_manager, mock_position_service, mock_order_service): + """Test that only 'long' positions are closed when the filter is 'long'.""" + # Arrange + db_positions = [ + {"position_id": "pos_long", "action": "long"}, + {"position_id": "pos_short", "action": "short"}, + ] + api_positions = [ + TestDataFactory.create_saxo_position(position_id="pos_long", amount=100), + TestDataFactory.create_saxo_position(position_id="pos_short", amount=-100), + ] + mock_db_position_manager.get_open_positions_ids_actions.return_value = db_positions + mock_position_service.get_open_positions.return_value = {"Data": api_positions} + mock_order_service.place_market_order.return_value = TestDataFactory.create_order_response() + + # Act + result = performance_monitor.close_managed_positions_by_criteria(action_filter="long") + + # Assert + assert result["closed_initiated_count"] == 1 + # Should only be called for the 'long' position + assert mock_order_service.place_market_order.call_count == 1 + # The argument passed to place_market_order should be for the long position (a Sell order) + call_kwargs = mock_order_service.place_market_order.call_args.kwargs + assert call_kwargs['uic'] == api_positions[0]['PositionBase']['Uic'] + assert call_kwargs['buy_sell'] == 'Sell' + assert mock_update_db.call_count == 1 + + +class TestPerformanceMonitorHelpers: + + @patch('time.sleep', return_value=None) + def test_fetch_and_update_closed_position_in_db_api_fails(self, mock_sleep, performance_monitor, mock_position_service): + """Test the error handling when fetching closed positions fails.""" + # Arrange + mock_position_service.get_closed_positions.side_effect = ApiRequestException("API Error") + + # Act + result = performance_monitor._fetch_and_update_closed_position_in_db("pos1", "Test Close") + + # Assert + # The method should fail gracefully and return False + assert result is False + + @patch('time.sleep', return_value=None) + def test_fetch_and_update_closed_position_in_db_no_positions_found(self, mock_sleep, performance_monitor, mock_position_service): + """Test the case where the API returns no closed positions.""" + # Arrange + mock_position_service.get_closed_positions.return_value = {"Data": []} + + # Act + result = performance_monitor._fetch_and_update_closed_position_in_db("pos1", "Test Close") + + # Assert + assert result is False + + def test_log_performance_detail_handles_bad_date(self, performance_monitor): + """Test that the logger handles an invalid date format without crashing.""" + # Arrange + api_pos = TestDataFactory.create_saxo_position( + position_id="pos_log", + overrides={"PositionBase": {"ExecutionTimeOpen": "Invalid Date Format"}} + ) + # We need to patch open for this test so it doesn't write to the filesystem + with patch('builtins.open', mock_open()): + # Act + # The method should execute without raising an exception + performance_monitor._log_performance_detail("pos_log", api_pos, 5.5) + + @patch('os.path.exists', return_value=True) + @patch('builtins.open', new_callable=mock_open) + def test_log_performance_detail(self, mock_open, mock_path_exists, performance_monitor): + """Test that the performance logger writes a valid JSON line to a file.""" + # Arrange + api_pos = TestDataFactory.create_saxo_position( + position_id="pos_log", + overrides={"PositionBase": {"ExecutionTimeOpen": "2023-01-01T12:00:00Z"}} + ) + performance_percent = 5.5 + + # Act + performance_monitor._log_performance_detail("pos_log", api_pos, performance_percent) + + # Assert + mock_open.assert_called_once() + # The handle is the return value of the call to open() + handle = mock_open() + handle.write.assert_called_once() + # Check the content that was written + written_content = handle.write.call_args[0][0] + import json + log_data = json.loads(written_content) + assert log_data["position_id"] == "pos_log" + assert log_data["performance"] == 5.5 + assert "open_hour" in log_data diff --git a/tests/test_position_service.py b/tests/test_position_service.py new file mode 100644 index 0000000..128f309 --- /dev/null +++ b/tests/test_position_service.py @@ -0,0 +1,194 @@ +import pytest +from unittest.mock import patch, MagicMock + +from src.trade.api_actions import PositionService, OrderService +from src.trade.exceptions import PositionNotFoundException, SaxoApiError +from tests.test_data_factory import TestDataFactory + +@pytest.fixture +def order_service(mock_api_client): + """Fixture for a mocked OrderService.""" + return MagicMock(spec=OrderService) + +@pytest.fixture +def position_service(mock_api_client, order_service, mock_config_manager): + """Fixture for PositionService.""" + return PositionService(mock_api_client, order_service, mock_config_manager, "account_key", "client_key") + +class TestPositionService: + + def test_get_open_positions_success(self, position_service, mock_api_client): + # Arrange + position_data = TestDataFactory.create_saxo_position(position_id="pos1") + mock_api_client.request.return_value = {"Data": [position_data]} + + # Act + result = position_service.get_open_positions() + + # Assert + assert result["__count"] == 1 + assert result["Data"][0]["PositionId"] == "pos1" + + def test_get_open_positions_empty(self, position_service, mock_api_client): + # Arrange + mock_api_client.request.return_value = {"Data": []} + + # Act + result = position_service.get_open_positions() + + # Assert + assert result["__count"] == 0 + assert result["Data"] == [] + + def test_get_spending_power_success(self, position_service, mock_api_client): + # Arrange + mock_api_client.request.return_value = {"SpendingPower": 50000.0} + + # Act + result = position_service.get_spending_power() + + # Assert + assert result == 50000.0 + + def test_get_spending_power_missing_key(self, position_service, mock_api_client): + # Arrange + mock_api_client.request.return_value = {"SomeOtherKey": 123} + + # Act & Assert + with pytest.raises(SaxoApiError, match="Invalid balance response received, missing SpendingPower"): + position_service.get_spending_power() + + def test_get_spending_power_invalid_value(self, position_service, mock_api_client): + # Arrange + mock_api_client.request.return_value = {"SpendingPower": "not-a-number"} + + # Act & Assert + with pytest.raises(SaxoApiError, match="Invalid SpendingPower value received"): + position_service.get_spending_power() + + def test_get_spending_power_handles_string_value(self, position_service, mock_api_client): + """Test that spending power is correctly parsed even if returned as a string.""" + # Arrange + mock_api_client.request.return_value = {"SpendingPower": "50000.0"} + + # Act + result = position_service.get_spending_power() + + # Assert + assert result == 50000.0 + assert isinstance(result, float) + + @patch.object(PositionService, 'get_open_positions') + def test_find_position_by_order_id_with_retry_found_first_try(self, mock_get_open_positions, position_service): + # Arrange + position_data = TestDataFactory.create_saxo_position(order_id="order1", position_id="pos1") + mock_get_open_positions.return_value = {"Data": [position_data]} + + # Act + result = position_service.find_position_by_order_id_with_retry("order1") + + # Assert + assert result["PositionId"] == "pos1" + mock_get_open_positions.assert_called_once() + + @patch('time.sleep', return_value=None) + @patch.object(PositionService, 'get_open_positions') + def test_should_cleanup_orphaned_order_on_position_confirmation_timeout(self, mock_get_open_positions, mock_sleep, position_service, order_service): + """ + This test covers the critical scenario where a position is not found after retries, + and an order cancellation should be attempted. + """ + # Arrange + # Simulate get_open_positions always returning an empty list + mock_get_open_positions.return_value = {"Data": []} + # Simulate that the order cancellation is successful + order_service.cancel_order.return_value = True + # The retry decorator uses a module-level constant (5), so we can't override it here. + # The test must assert against the actual behavior. + + # Act & Assert + with pytest.raises(PositionNotFoundException) as excinfo: + position_service.find_position_by_order_id_with_retry("orphan_order1") + + # Assert on the exception details + assert "Position not found after 5 retries" in str(excinfo.value) + assert "Successfully cancelled potentially orphan order" in str(excinfo.value) + assert excinfo.value.cancellation_attempted is True + assert excinfo.value.cancellation_succeeded is True + + # Assert that the mocks were called as expected + assert mock_get_open_positions.call_count == 5 # DEFAULT_RETRY_ATTEMPTS is 5 + order_service.cancel_order.assert_called_once_with("orphan_order1") + + @patch('time.sleep', return_value=None) + @patch.object(PositionService, 'get_open_positions') + def test_find_position_by_order_id_with_retry_not_found_and_cancel_fail(self, mock_get_open_positions, mock_sleep, position_service, order_service): + # Arrange + mock_get_open_positions.return_value = {"Data": []} + order_service.cancel_order.return_value = False # Simulate cancellation failure + # The retry decorator uses a module-level constant (5), so we can't override it here. + + # Act & Assert + with pytest.raises(PositionNotFoundException) as excinfo: + position_service.find_position_by_order_id_with_retry("order1") + + assert "Failed to cancel potentially orphan order" in str(excinfo.value) + assert excinfo.value.cancellation_succeeded is False + assert mock_get_open_positions.call_count == 5 # DEFAULT_RETRY_ATTEMPTS is 5 + order_service.cancel_order.assert_called_once_with("order1") + + def test_get_closed_positions_success(self, position_service, mock_api_client): + """Test that getting closed positions works correctly.""" + # Arrange + closed_position = TestDataFactory.create_saxo_closed_position(opening_position_id="pos1") + mock_api_client.request.return_value = {"Data": [closed_position]} + + # Act + result = position_service.get_closed_positions() + + # Assert + assert result["Data"][0]["ClosedPosition"]["OpeningPositionId"] == "pos1" + mock_api_client.request.assert_called_once() + + def test_get_single_position_success(self, position_service, mock_api_client): + """Test that getting a single position works correctly.""" + # Arrange + position = TestDataFactory.create_saxo_position(position_id="pos1") + mock_api_client.request.return_value = position + + # Act + result = position_service.get_single_position("pos1") + + # Assert + assert result["PositionId"] == "pos1" + mock_api_client.request.assert_called_once() + request_object = mock_api_client.request.call_args[0][0] + # The PositionId is formatted into the endpoint URL + assert "pos1" in request_object._endpoint + + def test_get_open_positions_handles_none_response(self, position_service, mock_api_client): + """Test that get_open_positions handles a None response from the API.""" + # Arrange + mock_api_client.request.return_value = None + + # Act + result = position_service.get_open_positions() + + # Assert + # Should return an empty structure instead of crashing + assert result == {'__count': 0, 'Data': []} + + @patch.object(PositionService, '_find_position_attempt') + def test_find_position_by_order_id_handles_unexpected_error(self, mock_find_attempt, position_service, order_service): + """Test the generic exception handler in find_position_by_order_id_with_retry.""" + # Arrange + simulated_error = ValueError("Unexpected error during position find") + mock_find_attempt.side_effect = simulated_error + + # Act & Assert + with pytest.raises(ValueError, match="Unexpected error during position find"): + position_service.find_position_by_order_id_with_retry("order1") + + # Ensure that in case of an unexpected error, order cancellation is NOT attempted + # because the state of the order/position is unknown. + order_service.cancel_order.assert_not_called() diff --git a/tests/test_saxo_api_action.py b/tests/test_saxo_api_action.py deleted file mode 100644 index 679f9c5..0000000 --- a/tests/test_saxo_api_action.py +++ /dev/null @@ -1,580 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock, call -import src.trade.api_actions as api_actions -from src.trade.api_actions import ( - TradingOrchestrator, - InstrumentService, - OrderService, - PositionService, - SaxoApiClient, - parse_saxo_turbo_description, - SaxoApiError, - InsufficientFundsException, - OrderPlacementError, - TokenAuthenticationException, - NoTurbosAvailableException, - NoMarketAvailableException, - PositionNotFoundException, - ApiRequestException, - PerformanceMonitor -) -from src.configuration import ConfigurationManager -from src.database import DbOrderManager, DbPositionManager -from src.trade.rules import TradingRule -from src.saxo_authen import SaxoAuth -from src.saxo_openapi.exceptions import OpenAPIError as SaxoOpenApiLibError -import requests -import json - -# region Fixtures - -@pytest.fixture -def mock_config_manager(): - """A mock for ConfigurationManager with a flexible side_effect.""" - manager = MagicMock(spec=ConfigurationManager) - - def get_config_value(key, default=None): - configs = { - "saxo_auth.env": "simulation", - "trade.config.general.api_limits": {"top_instruments": 200, "top_positions": 200, "top_closed_positions": 500}, - "trade.config.turbo_preference.price_range": {"min": 4, "max": 15}, - "trade.config.general.retry_config": {"max_retries": 3, "retry_sleep_seconds": 1}, - "trade.config.general.websocket": {"refresh_rate_ms": 10000}, - "trade.config.buying_power": {"safety_margins": {"bid_calculation": 1}, "max_account_funds_to_use_percentage": 100}, - "trade.config.position_management": {"performance_thresholds": {"stoploss_percent": -20, "max_profit_percent": 60}}, - "trade.config.general": {"timezone": "Europe/Paris"}, - "logging.persistant": {"log_path": "/tmp/logs"} - } - return configs.get(key, default) - - manager.get_config_value.side_effect = get_config_value - manager.get_logging_config.return_value = {"persistant": {"log_path": "/tmp/logs"}} - return manager - -@pytest.fixture -def mock_saxo_auth(): - """A mock for SaxoAuth.""" - auth = MagicMock(spec=SaxoAuth) - auth.get_token.return_value = "test_token" - return auth - -@pytest.fixture -def mock_db_order_manager(): - """A mock for DbOrderManager.""" - return MagicMock(spec=DbOrderManager) - -@pytest.fixture -def mock_db_position_manager(): - """A mock for DbPositionManager.""" - return MagicMock(spec=DbPositionManager) - -@pytest.fixture -def mock_trading_rule(): - """A mock for TradingRule.""" - rule = MagicMock(spec=TradingRule) - rule.get_rule_config.return_value = {"percent_profit_wanted_per_days": 1.0} - return rule - -@pytest.fixture -def mock_api_client(): - """A mock for SaxoApiClient that bypasses its internal SaxoOpenApiLib.""" - client = MagicMock(spec=SaxoApiClient) - return client - -# endregion - -# region Test Utility Functions - -def test_parse_saxo_turbo_description_valid(): - description = "TURBO LONG DAX 12345.67 CITI" - expected = { - "name": "TURBO", "kind": "LONG", "buysell": "DAX", - "price": "12345.67", "from": "CITI" - } - assert parse_saxo_turbo_description(description) == expected - -def test_parse_saxo_turbo_description_invalid(): - description = "This is not a valid turbo description" - assert parse_saxo_turbo_description(description) is None - -# endregion - -# region Test SaxoApiClient - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_init_and_token_refresh(mock_saxo_lib, mock_config_manager): - """Test that the client initializes and refreshes the token correctly.""" - mock_auth = MagicMock(spec=SaxoAuth) - # This simulates the token changing on the third call to get_token - mock_auth.get_token.side_effect = ["token1", "token1", "token2"] - - # Initialization of the client calls _ensure_valid_token_and_api_instance once - client = SaxoApiClient(mock_config_manager, mock_auth) - mock_saxo_lib.assert_called_once_with(access_token="token1", environment="simulation", request_params={"timeout": 30}) - - # Calling it again with the same token should not trigger a refresh - client._ensure_valid_token_and_api_instance() - mock_saxo_lib.assert_called_once() - - # Calling it again after the token has "changed" should trigger a refresh - client._ensure_valid_token_and_api_instance() - mock_saxo_lib.assert_called_with(access_token="token2", environment="simulation", request_params={"timeout": 30}) - assert mock_saxo_lib.call_count == 2 - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_request_success(mock_saxo_lib, mock_config_manager, mock_saxo_auth): - mock_api_instance = mock_saxo_lib.return_value - mock_api_instance.request.return_value = {"status": "success"} - - client = SaxoApiClient(mock_config_manager, mock_saxo_auth) - - response = client.request("some_endpoint_request_obj") - - assert response == {"status": "success"} - mock_api_instance.request.assert_called_once_with("some_endpoint_request_obj") - -@patch('src.trade.api_actions.SaxoOpenApiLib') -@pytest.mark.parametrize("status_code, error_code, error_content_str, expected_exception, is_order_endpoint", [ - (400, "InsufficientFunds", '{"Message": "Not enough money"}', InsufficientFundsException, False), - (400, "SomeError", '{"Message": "Bad request"}', OrderPlacementError, True), - (401, "AuthError", '{"Message": "Unauthorized"}', TokenAuthenticationException, False), - (429, "RateLimit", '{"Message": "Too many requests"}', SaxoApiError, False), - (500, "ServerError", 'Internal Server Error', SaxoApiError, False), -]) -def test_saxo_api_client_request_saxo_error_mapping(mock_saxo_lib, mock_config_manager, mock_saxo_auth, status_code, error_code, error_content_str, expected_exception, is_order_endpoint): - """Test that SaxoOpenApiLibError is correctly mapped to custom exceptions.""" - try: - content_json = json.loads(error_content_str) - content_json['ErrorCode'] = error_code - final_content = json.dumps(content_json) - except json.JSONDecodeError: - final_content = error_content_str - - mock_api_instance = mock_saxo_lib.return_value - mock_api_instance.request.side_effect = SaxoOpenApiLibError(code=status_code, content=final_content, reason="Some Reason") - - client = SaxoApiClient(mock_config_manager, mock_saxo_auth) - - mock_endpoint = MagicMock() - mock_endpoint.path = "/trade/v2/orders" if is_order_endpoint else "/some/other/endpoint" - - with pytest.raises(expected_exception): - client.request(mock_endpoint) - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_request_connection_error(mock_saxo_lib, mock_config_manager, mock_saxo_auth): - mock_api_instance = mock_saxo_lib.return_value - mock_api_instance.request.side_effect = requests.RequestException("Connection failed") - client = SaxoApiClient(mock_config_manager, mock_saxo_auth) - with pytest.raises(ApiRequestException, match="Underlying request failed: Connection failed"): - client.request("some_endpoint") - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_request_non_string_error_content(mock_saxo_lib, mock_config_manager, mock_saxo_auth): - """Test error handling when error content is not a string.""" - mock_api_instance = mock_saxo_lib.return_value - # Simulate error content being a dictionary instead of a string - mock_api_instance.request.side_effect = SaxoOpenApiLibError(code=500, content={"error": "detail"}, reason="Server Error") - client = SaxoApiClient(mock_config_manager, mock_saxo_auth) - with pytest.raises(SaxoApiError) as excinfo: - client.request("some_endpoint") - assert str({"error": "detail"}) in str(excinfo.value) - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_request_invalid_json_error(mock_saxo_lib, mock_config_manager, mock_saxo_auth): - """Test error handling with invalid JSON content.""" - mock_api_instance = mock_saxo_lib.return_value - mock_api_instance.request.side_effect = SaxoOpenApiLibError(code=500, content="Not a valid JSON", reason="Server Error") - client = SaxoApiClient(mock_config_manager, mock_saxo_auth) - with pytest.raises(SaxoApiError, match="Not a valid JSON"): - client.request("some_endpoint") - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_request_token_auth_exception_reraised(mock_saxo_lib, mock_config_manager, mock_saxo_auth): - """Test that TokenAuthenticationException is re-raised.""" - mock_saxo_auth.get_token.side_effect = TokenAuthenticationException("Token machine broke") - with pytest.raises(TokenAuthenticationException): - SaxoApiClient(mock_config_manager, mock_saxo_auth) - -@patch('src.trade.api_actions.SaxoOpenApiLib') -def test_saxo_api_client_unexpected_exception(mock_saxo_lib, mock_config_manager, mock_saxo_auth): - """Test wrapping of unexpected exceptions.""" - mock_api_instance = mock_saxo_lib.return_value - mock_api_instance.request.side_effect = Exception("Something totally unexpected") - client = SaxoApiClient(mock_config_manager, mock_saxo_auth) - with pytest.raises(ApiRequestException, match="Unexpected wrapper error: Something totally unexpected"): - client.request("some_endpoint") - -# endregion - -# region Test InstrumentService - -class TestInstrumentService: - - @pytest.fixture - def instrument_service(self, mock_api_client, mock_config_manager): - return InstrumentService(mock_api_client, mock_config_manager, "account_key") - - @patch('src.trade.api_actions.tr.infoprices.InfoPrices') - def test_get_infoprices_for_asset_type_success(self, mock_infoprices_req, instrument_service, mock_api_client): - mock_api_client.request.return_value = {"Data": ["price_info"]} - result = instrument_service._get_infoprices_for_asset_type("123,456", "Exchange1", "AssetType1") - assert result == {"Data": ["price_info"]} - mock_api_client.request.assert_called_once() - - @patch('time.sleep', return_value=None) - @patch('src.trade.api_actions.rd.instruments.Instruments') - @patch('src.trade.api_actions.tr.infoprices.InfoPrices') - @patch('src.trade.api_actions.tr.prices.CreatePriceSubscription') - def test_find_turbos_happy_path(self, mock_price_sub_req, mock_infoprices_req, mock_instruments_req, mock_sleep, instrument_service, mock_api_client): - mock_api_client.request.side_effect = [ - {"Data": [{"Identifier": 1, "Description": "TURBO LONG DAX 15000 CITI", "AssetType": "WarrantKnockOut"}]}, - {"Data": [{"Uic": 101, "Identifier": 1, "AssetType": "WarrantKnockOut", "Quote": {"Bid": 10, "Ask": 10.1, "PriceTypeAsk": "Tradable", "PriceTypeBid": "Tradable", "MarketState": "Open"}}]}, - {"Snapshot": {"Uic": 101, "DisplayAndFormat": {"Description": "Final TURBO LONG DAX 15000 CITI"}, "Quote": {"Ask": 10.05, "Bid": 9.95}}} - ] - result = instrument_service.find_turbos("exchange1", "underlying1", "long") - assert result['selected_instrument']['uic'] == 101 - assert result['selected_instrument']['latest_ask'] == 10.05 - assert mock_api_client.request.call_count == 3 - - def test_find_turbos_no_initial_instruments(self, instrument_service, mock_api_client): - mock_api_client.request.return_value = {"Data": []} - with pytest.raises(NoTurbosAvailableException): - instrument_service.find_turbos("e1", "u1", "long") - - @patch('src.trade.api_actions.parse_saxo_turbo_description') - def test_find_turbos_sorting_error(self, mock_parse_description, instrument_service, mock_api_client): - # Simulate data that will cause a sorting error - mock_api_client.request.return_value = {"Data": [{"Identifier": 1, "Description": "A valid description"}]} - # Mock the parsing result to have a non-numeric price - mock_parse_description.return_value = {"price": "not_a_number"} - with pytest.raises(ValueError, match="Could not sort instruments by parsed price"): - instrument_service.find_turbos("e1", "u1", "long") - - @patch('time.sleep', return_value=None) - def test_find_turbos_price_subscription_fails(self, mock_sleep, instrument_service, mock_api_client): - # Happy path until the last step (price subscription) - mock_api_client.request.side_effect = [ - {"Data": [{"Identifier": 1, "Description": "TURBO LONG DAX 15000 CITI", "AssetType": "WarrantKnockOut"}]}, - {"Data": [{"Uic": 101, "Identifier": 1, "AssetType": "WarrantKnockOut", "Quote": {"Bid": 10, "Ask": 10.1, "PriceTypeAsk": "Tradable", "PriceTypeBid": "Tradable", "MarketState": "Open"}}]}, - # This time, the subscription fails - ApiRequestException("Subscription failed") - ] - - result = instrument_service.find_turbos("e1", "u1", "long") - # Should fall back to using the InfoPrice data - assert result is not None - assert result['selected_instrument']['uic'] == 101 - # latest_ask should be from the InfoPrice response, not the (failed) subscription - assert result['selected_instrument']['latest_ask'] == 10.1 - assert result['selected_instrument']['subscription_context_id'] is None # Should be None on failure - - @patch('time.sleep', return_value=None) - def test_find_turbos_no_infoprice_data(self, mock_sleep, instrument_service, mock_api_client): - # First call to get instruments succeeds - mock_api_client.request.side_effect = [ - {"Data": [{"Identifier": 1, "Description": "TURBO LONG DAX 15000 CITI", "AssetType": "WarrantKnockOut"}]}, - # Subsequent calls to get infoprices fail - None, None, None - ] - with pytest.raises(NoMarketAvailableException, match="Failed to obtain valid InfoPrice data"): - instrument_service.find_turbos("e1", "u1", "long") - - @patch('time.sleep', return_value=None) - def test_find_turbos_no_quote_in_infoprice_data(self, mock_sleep, instrument_service, mock_api_client): - # The bid check loop should exit gracefully if no items have a "Quote" field - mock_api_client.request.side_effect = [ - {"Data": [{"Identifier": 1, "Description": "TURBO LONG DAX 15000 CITI", "AssetType": "WarrantKnockOut"}]}, - # InfoPrice data is missing the "Quote" field - {"Data": [{"Uic": 101, "Identifier": 1, "AssetType": "WarrantKnockOut"}]}, - ] - with pytest.raises(NoMarketAvailableException, match="No instruments with Bid data available after retries and final filtering."): - instrument_service.find_turbos("e1", "u1", "long") - -# endregion - -# region Test OrderService - -class TestOrderService: - @pytest.fixture - def order_service(self, mock_api_client): - return OrderService(mock_api_client, "account_key", "client_key") - - @patch('src.trade.api_actions.tr.orders.Order') - def test_place_market_order_success(self, mock_order_req, order_service, mock_api_client): - mock_api_client.request.return_value = {"OrderId": "12345"} - result = order_service.place_market_order(uic=1, asset_type="FxSpot", amount=100, buy_sell="Buy") - assert result == {"OrderId": "12345"} - mock_api_client.request.assert_called_once() - - def test_place_market_order_api_error(self, order_service, mock_api_client): - mock_api_client.request.side_effect = OrderPlacementError("API rejected order") - with pytest.raises(OrderPlacementError): - order_service.place_market_order(uic=1, asset_type="FxSpot", amount=100, buy_sell="Buy") - - @patch('src.trade.api_actions.tr.orders.CancelOrders') - def test_cancel_order_unexpected_exception(self, mock_cancel_req, order_service, mock_api_client): - mock_api_client.request.side_effect = Exception("Unexpected error") - result = order_service.cancel_order("123") - assert result is False - -# endregion - -# region Test PositionService - -class TestPositionService: - @pytest.fixture - def order_service(self, mock_api_client): - return OrderService(mock_api_client, "account_key", "client_key") - - @pytest.fixture - def position_service(self, mock_api_client, order_service, mock_config_manager): - return PositionService(mock_api_client, order_service, mock_config_manager, "account_key", "client_key") - - @patch('src.trade.api_actions.pf.positions.PositionsMe') - def test_get_open_positions_success(self, mock_positions_req, position_service, mock_api_client): - mock_api_client.request.return_value = {"Data": [{"PositionId": "pos1"}]} - result = position_service.get_open_positions() - assert result["__count"] == 1 - assert result["Data"][0]["PositionId"] == "pos1" - - @patch('src.trade.api_actions.pf.closedpositions.ClosedPositionsMe') - def test_get_closed_positions_success(self, mock_closed_positions_req, position_service, mock_api_client): - mock_api_client.request.return_value = {"Data": [{"PositionId": "pos1"}]} - result = position_service.get_closed_positions() - assert result["Data"][0]["PositionId"] == "pos1" - - @patch('src.trade.api_actions.pf.positions.SinglePosition') - def test_get_single_position_success(self, mock_single_position_req, position_service, mock_api_client): - mock_api_client.request.return_value = {"PositionId": "pos1"} - result = position_service.get_single_position("pos1") - assert result["PositionId"] == "pos1" - - @patch.object(PositionService, 'get_open_positions') - def test_find_position_by_order_id_with_retry_found_first_try(self, mock_get_open_positions, position_service): - mock_get_open_positions.return_value = {"Data": [{"PositionBase": {"SourceOrderId": "order1"}, "PositionId": "pos1"}]} - result = position_service.find_position_by_order_id_with_retry("order1") - assert result["PositionId"] == "pos1" - mock_get_open_positions.assert_called_once() - - @patch('time.sleep', return_value=None) - @patch.object(PositionService, 'get_open_positions') - @patch.object(OrderService, 'cancel_order') - def test_find_position_by_order_id_with_retry_not_found_and_cancel_success(self, mock_cancel_order, mock_get_open_positions, mock_sleep, position_service): - mock_get_open_positions.return_value = {"Data": []} - mock_cancel_order.return_value = True - - with pytest.raises(PositionNotFoundException) as excinfo: - position_service.find_position_by_order_id_with_retry("order1") - - assert "Successfully cancelled" in str(excinfo.value) - assert excinfo.value.cancellation_succeeded is True - assert mock_get_open_positions.call_count == 5 - mock_cancel_order.assert_called_once_with("order1") - - @patch('time.sleep', return_value=None) - @patch.object(PositionService, 'get_open_positions') - @patch.object(OrderService, 'cancel_order') - def test_find_position_by_order_id_with_retry_not_found_and_cancel_fail(self, mock_cancel_order, mock_get_open_positions, mock_sleep, position_service): - mock_get_open_positions.return_value = {"Data": []} - mock_cancel_order.return_value = False - - with pytest.raises(PositionNotFoundException) as excinfo: - position_service.find_position_by_order_id_with_retry("order1") - - assert "Failed to cancel" in str(excinfo.value) - assert excinfo.value.cancellation_succeeded is False - - @patch('src.trade.api_actions.pf.balances.AccountBalances') - def test_get_spending_power_invalid_value(self, mock_balances_req, position_service, mock_api_client): - mock_api_client.request.return_value = {"SpendingPower": "not a number"} - with pytest.raises(SaxoApiError, match="Invalid SpendingPower value received"): - position_service.get_spending_power() - -# endregion - -# region Test TradingOrchestrator - -class TestTradingOrchestrator: - - @pytest.fixture - def trading_orchestrator(self, mock_config_manager, mock_db_order_manager, mock_db_position_manager): - instrument_service = MagicMock(spec=InstrumentService) - order_service = MagicMock(spec=OrderService) - position_service = MagicMock(spec=PositionService) - - return TradingOrchestrator( - instrument_service, - order_service, - position_service, - mock_config_manager, - mock_db_order_manager, - mock_db_position_manager - ) - - def test_calculate_bid_amount_success(self, trading_orchestrator): - turbo_info = {"selected_instrument": {"latest_ask": 10, "decimals": 2}} - amount = trading_orchestrator._calculate_bid_amount(turbo_info, 1000) - assert amount == 99 - - def test_execute_trade_signal_happy_path(self, trading_orchestrator, mock_db_order_manager, mock_db_position_manager): - trading_orchestrator.instrument_service.find_turbos.return_value = { - "selected_instrument": {"uic": 123, "asset_type": "TypeA", "latest_ask": 10, "decimals": 2, "description": "Desc", "symbol": "Sym", "currency": "EUR", "commissions": {}} - } - trading_orchestrator.position_service.get_spending_power.return_value = 1000 - trading_orchestrator.order_service.place_market_order.return_value = {"OrderId": "order1"} - trading_orchestrator.position_service.find_position_by_order_id_with_retry.return_value = { - "PositionId": "pos1", "PositionBase": {}, "DisplayAndFormat": {} - } - - result = trading_orchestrator.execute_trade_signal("e1", "u1", "long") - - assert result is not None - mock_db_order_manager.insert_turbo_order_data.assert_called_once() - mock_db_position_manager.insert_turbo_open_position_data.assert_called_once() - - def test_calculate_bid_amount_invalid_ask_price(self, trading_orchestrator): - turbo_info = {"selected_instrument": {"latest_ask": None, "decimals": 2}} - with pytest.raises(ValueError, match="Invalid ask price for bid calculation"): - trading_orchestrator._calculate_bid_amount(turbo_info, 1000) - - def test_execute_trade_signal_db_error(self, trading_orchestrator, mock_db_order_manager): - trading_orchestrator.instrument_service.find_turbos.return_value = { - "selected_instrument": {"uic": 123, "asset_type": "TypeA", "latest_ask": 10, "decimals": 2, "description": "Desc", "symbol": "Sym", "currency": "EUR", "commissions": {}} - } - trading_orchestrator.position_service.get_spending_power.return_value = 1000 - trading_orchestrator.order_service.place_market_order.return_value = {"OrderId": "order1"} - trading_orchestrator.position_service.find_position_by_order_id_with_retry.return_value = { - "PositionId": "pos1", "PositionBase": {}, "DisplayAndFormat": {} - } - mock_db_order_manager.insert_turbo_order_data.side_effect = Exception("DB Error") - - from src.trade.exceptions import DatabaseOperationException - with pytest.raises(DatabaseOperationException): - trading_orchestrator.execute_trade_signal("e1", "u1", "long") - -# endregion - -# region Test PerformanceMonitor - -class TestPerformanceMonitor: - - @pytest.fixture - def performance_monitor(self, mock_config_manager, mock_db_position_manager, mock_trading_rule): - position_service = MagicMock(spec=PositionService) - order_service = MagicMock(spec=OrderService) - rabbit_connection = MagicMock() - - return PerformanceMonitor( - position_service, - order_service, - mock_config_manager, - mock_db_position_manager, - mock_trading_rule, - rabbit_connection - ) - - @patch('time.sleep', return_value=None) - @patch('src.trade.api_actions.send_message_to_mq_for_telegram') - def test_fetch_and_update_closed_position_in_db_success(self, mock_send_message, mock_sleep, performance_monitor, mock_db_position_manager): - performance_monitor.position_service.get_closed_positions.return_value = { - "Data": [{"ClosedPosition": {"OpeningPositionId": "pos1", "ClosingPrice": 120, "OpenPrice": 100, "Amount": 10}, "DisplayAndFormat": {}}] - } - result = performance_monitor._fetch_and_update_closed_position_in_db("pos1", "Test Close") - assert result is True - mock_db_position_manager.update_turbo_position_data.assert_called_once() - mock_send_message.assert_called_once() - - @patch.object(PerformanceMonitor, '_log_performance_detail') - @patch.object(PerformanceMonitor, '_fetch_and_update_closed_position_in_db') - def test_check_all_positions_performance_triggers_stoploss(self, mock_update_db, mock_log_perf, performance_monitor): - performance_monitor.db_position_manager.get_open_positions_ids_actions.return_value = [{"position_id": "pos1"}] - performance_monitor.position_service.get_open_positions.return_value = { - "Data": [{"PositionId": "pos1", "PositionBase": {"OpenPrice": 100, "Amount": 10, "CanBeClosed": True, "Uic": 1, "AssetType": "T"}, "PositionView": {"Bid": 79}}] - } - performance_monitor.db_position_manager.get_max_position_percent.return_value = -10.0 - mock_update_db.return_value = True - result = performance_monitor.check_all_positions_performance() - performance_monitor.order_service.place_market_order.assert_called_once() - mock_update_db.assert_called_once() - - def test_check_all_positions_performance_no_positions(self, performance_monitor): - performance_monitor.db_position_manager.get_open_positions_ids_actions.return_value = [] - result = performance_monitor.check_all_positions_performance() - assert result == {"closed_positions_processed": [], "db_updates": [], "errors": 0} - - @patch('src.trade.api_actions.send_message_to_mq_for_telegram') - def test_close_managed_positions_by_criteria(self, mock_send_message, performance_monitor): - performance_monitor.db_position_manager.get_open_positions_ids_actions.return_value = [ - {"position_id": "pos1", "action": "long"}, - {"position_id": "pos2", "action": "short"}, - ] - performance_monitor.position_service.get_open_positions.return_value = { - "Data": [ - {"PositionId": "pos1", "PositionBase": {"Amount": 10, "CanBeClosed": True, "Uic": 1, "AssetType": "T"}}, - {"PositionId": "pos2", "PositionBase": {"Amount": -10, "CanBeClosed": True, "Uic": 2, "AssetType": "T"}}, - ] - } - performance_monitor.order_service.place_market_order.return_value = {"OrderId": "close_order"} - with patch.object(performance_monitor, '_fetch_and_update_closed_position_in_db', return_value=True): - result = performance_monitor.close_managed_positions_by_criteria(action_filter="long") - - assert result["closed_initiated_count"] == 1 - assert performance_monitor.order_service.place_market_order.call_count == 1 - - @patch('src.trade.api_actions.send_message_to_mq_for_telegram') - def test_sync_db_positions_with_api_success(self, mock_send_message, performance_monitor): - performance_monitor.db_position_manager.get_open_positions_ids.return_value = ["pos1_closed", "pos2_open"] - performance_monitor.position_service.get_open_positions.return_value = {"Data": [{"PositionId": "pos2_open"}]} - performance_monitor.position_service.get_closed_positions.return_value = { - "Data": [{"ClosedPosition": {"OpeningPositionId": "pos1_closed"}, "DisplayAndFormat": {}}] - } - result = performance_monitor.sync_db_positions_with_api() - assert len(result["updates_for_db"]) == 1 - assert result["updates_for_db"][0][0] == "pos1_closed" - - @patch('os.path.exists', return_value=True) - @patch('builtins.open', new_callable=MagicMock) - def test_log_performance_detail(self, mock_open, mock_path_exists, performance_monitor): - api_pos = { - "PositionBase": {"ExecutionTimeOpen": "2023-01-01T12:00:00Z"}, - "PositionView": {} - } - performance_monitor._log_performance_detail("pos1", api_pos, 1.23) - mock_open.assert_called_once() - handle = mock_open.return_value.__enter__() - handle.write.assert_called_once() - written_content = handle.write.call_args[0][0] - import json - log_data = json.loads(written_content) - assert log_data["position_id"] == "pos1" - assert log_data["performance"] == 1.23 - - @patch('time.sleep', return_value=None) - def test_fetch_and_update_closed_position_in_db_not_found(self, mock_sleep, performance_monitor): - performance_monitor.position_service.get_closed_positions.return_value = {"Data": []} - result = performance_monitor._fetch_and_update_closed_position_in_db("pos1", "Test Close") - assert result is False - - def test_check_all_positions_performance_api_fail(self, performance_monitor): - performance_monitor.db_position_manager.get_open_positions_ids_actions.return_value = [{"position_id": "pos1"}] - performance_monitor.position_service.get_open_positions.side_effect = ApiRequestException("API Error") - result = performance_monitor.check_all_positions_performance() - assert result["errors"] == 1 - - def test_close_managed_positions_no_filter(self, performance_monitor): - performance_monitor.db_position_manager.get_open_positions_ids_actions.return_value = [ - {"position_id": "pos1", "action": "long"}, - ] - performance_monitor.position_service.get_open_positions.return_value = { - "Data": [ - {"PositionId": "pos1", "PositionBase": {"Amount": 10, "CanBeClosed": True, "Uic": 1, "AssetType": "T"}}, - ] - } - performance_monitor.order_service.place_market_order.return_value = {"OrderId": "close_order"} - with patch.object(performance_monitor, '_fetch_and_update_closed_position_in_db', return_value=True): - result = performance_monitor.close_managed_positions_by_criteria() - - assert result["closed_initiated_count"] == 1 - -# endregion \ No newline at end of file diff --git a/tests/test_saxo_api_client.py b/tests/test_saxo_api_client.py new file mode 100644 index 0000000..ebbf8be --- /dev/null +++ b/tests/test_saxo_api_client.py @@ -0,0 +1,131 @@ +import pytest +from unittest.mock import patch, MagicMock +import json +import requests + +from src.trade.api_actions import SaxoApiClient +from src.trade.exceptions import ( + SaxoApiError, + InsufficientFundsException, + OrderPlacementError, + TokenAuthenticationException, + ApiRequestException, +) +from src.saxo_authen import SaxoAuth +from src.saxo_openapi.exceptions import OpenAPIError as SaxoOpenApiLibError + +# Fixtures will be in conftest.py, but for now, let's assume they are available. +# We will create conftest.py later. + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_init_and_token_refresh(mock_saxo_lib, mock_config_manager): + """Test that the client initializes and refreshes the token correctly.""" + mock_auth = MagicMock(spec=SaxoAuth) + mock_auth.get_token.side_effect = ["token1", "token1", "token2"] + + client = SaxoApiClient(mock_config_manager, mock_auth) + mock_saxo_lib.assert_called_once_with(access_token="token1", environment="simulation", request_params={"timeout": 30}) + + client._ensure_valid_token_and_api_instance() + mock_saxo_lib.assert_called_once() + + client._ensure_valid_token_and_api_instance() + mock_saxo_lib.assert_called_with(access_token="token2", environment="simulation", request_params={"timeout": 30}) + assert mock_saxo_lib.call_count == 2 + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_request_success(mock_saxo_lib, mock_config_manager, mock_saxo_auth): + mock_api_instance = mock_saxo_lib.return_value + mock_api_instance.request.return_value = {"status": "success"} + + client = SaxoApiClient(mock_config_manager, mock_saxo_auth) + response = client.request("some_endpoint_request_obj") + + assert response == {"status": "success"} + mock_api_instance.request.assert_called_once_with("some_endpoint_request_obj") + +@patch('src.trade.api_actions.SaxoOpenApiLib') +@pytest.mark.parametrize("status_code, error_code, error_content_str, expected_exception, is_order_endpoint", [ + (400, "InsufficientFunds", '{"Message": "Not enough money"}', InsufficientFundsException, False), + (400, "SomeError", '{"Message": "Bad request"}', OrderPlacementError, True), + (401, "AuthError", '{"Message": "Unauthorized"}', TokenAuthenticationException, False), + (429, "RateLimit", '{"Message": "Too many requests"}', SaxoApiError, False), + (500, "ServerError", 'Internal Server Error', SaxoApiError, False), +]) +def test_saxo_api_client_request_saxo_error_mapping(mock_saxo_lib, mock_config_manager, mock_saxo_auth, status_code, error_code, error_content_str, expected_exception, is_order_endpoint): + """Test that SaxoOpenApiLibError is correctly mapped to custom exceptions.""" + try: + content_json = json.loads(error_content_str) + if 'ErrorCode' not in content_json: + content_json['ErrorCode'] = error_code + final_content = json.dumps(content_json) + except json.JSONDecodeError: + final_content = error_content_str + + mock_api_instance = mock_saxo_lib.return_value + mock_api_instance.request.side_effect = SaxoOpenApiLibError(code=status_code, content=final_content, reason="Some Reason") + + client = SaxoApiClient(mock_config_manager, mock_saxo_auth) + + mock_endpoint = MagicMock() + mock_endpoint.path = "/trade/v2/orders" if is_order_endpoint else "/some/other/endpoint" + mock_endpoint.data = {"some": "data"} + mock_endpoint.params = {"some": "params"} + + + with pytest.raises(expected_exception) as excinfo: + client.request(mock_endpoint) + + # Improved validation + if hasattr(excinfo.value, 'status_code'): + assert excinfo.value.status_code == status_code + + if expected_exception in [InsufficientFundsException, OrderPlacementError, TokenAuthenticationException, SaxoApiError]: + if final_content.startswith('{'): + assert excinfo.value.saxo_error_details == json.loads(final_content) + else: + assert excinfo.value.saxo_error_details == final_content + + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_request_connection_error(mock_saxo_lib, mock_config_manager, mock_saxo_auth): + mock_api_instance = mock_saxo_lib.return_value + mock_api_instance.request.side_effect = requests.RequestException("Connection failed") + client = SaxoApiClient(mock_config_manager, mock_saxo_auth) + with pytest.raises(ApiRequestException, match="Underlying request failed: Connection failed"): + client.request("some_endpoint") + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_request_non_string_error_content(mock_saxo_lib, mock_config_manager, mock_saxo_auth): + """Test error handling when error content is not a string.""" + mock_api_instance = mock_saxo_lib.return_value + mock_api_instance.request.side_effect = SaxoOpenApiLibError(code=500, content={"error": "detail"}, reason="Server Error") + client = SaxoApiClient(mock_config_manager, mock_saxo_auth) + with pytest.raises(SaxoApiError) as excinfo: + client.request("some_endpoint") + assert str({"error": "detail"}) in str(excinfo.value) + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_request_invalid_json_error(mock_saxo_lib, mock_config_manager, mock_saxo_auth): + """Test error handling with invalid JSON content.""" + mock_api_instance = mock_saxo_lib.return_value + mock_api_instance.request.side_effect = SaxoOpenApiLibError(code=500, content="Not a valid JSON", reason="Server Error") + client = SaxoApiClient(mock_config_manager, mock_saxo_auth) + with pytest.raises(SaxoApiError, match="Not a valid JSON"): + client.request("some_endpoint") + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_request_token_auth_exception_reraised(mock_saxo_lib, mock_config_manager, mock_saxo_auth): + """Test that TokenAuthenticationException is re-raised.""" + mock_saxo_auth.get_token.side_effect = TokenAuthenticationException("Token machine broke") + with pytest.raises(TokenAuthenticationException): + SaxoApiClient(mock_config_manager, mock_saxo_auth) + +@patch('src.trade.api_actions.SaxoOpenApiLib') +def test_saxo_api_client_unexpected_exception(mock_saxo_lib, mock_config_manager, mock_saxo_auth): + """Test wrapping of unexpected exceptions.""" + mock_api_instance = mock_saxo_lib.return_value + mock_api_instance.request.side_effect = Exception("Something totally unexpected") + client = SaxoApiClient(mock_config_manager, mock_saxo_auth) + with pytest.raises(ApiRequestException, match="Unexpected wrapper error: Something totally unexpected"): + client.request("some_endpoint") diff --git a/tests/test_trading_orchestrator.py b/tests/test_trading_orchestrator.py new file mode 100644 index 0000000..888b017 --- /dev/null +++ b/tests/test_trading_orchestrator.py @@ -0,0 +1,229 @@ +import pytest +from unittest.mock import MagicMock, ANY + +from src.trade.api_actions import TradingOrchestrator, InstrumentService, OrderService, PositionService +from src.database import DbOrderManager, DbPositionManager +from src.trade.exceptions import InsufficientFundsException, DatabaseOperationException +from tests.test_data_factory import TestDataFactory + +@pytest.fixture +def mock_instrument_service(): + return MagicMock(spec=InstrumentService) + +@pytest.fixture +def mock_order_service(): + return MagicMock(spec=OrderService) + +@pytest.fixture +def mock_position_service(): + return MagicMock(spec=PositionService) + +@pytest.fixture +def trading_orchestrator( + mock_instrument_service, + mock_order_service, + mock_position_service, + mock_config_manager, + mock_db_order_manager, + mock_db_position_manager +): + """Fixture for TradingOrchestrator.""" + return TradingOrchestrator( + instrument_service=mock_instrument_service, + order_service=mock_order_service, + position_service=mock_position_service, + config_manager=mock_config_manager, + db_order_manager=mock_db_order_manager, + db_position_manager=mock_db_position_manager, + ) + +class TestTradingOrchestrator: + + def test_calculate_bid_amount_success(self, trading_orchestrator): + # Arrange + turbo_info = {"selected_instrument": {"latest_ask": 10, "decimals": 2}} + trading_orchestrator.safety_margins = {"bid_calculation": 1} + trading_orchestrator.buying_power_config = {"max_account_funds_to_use_percentage": 100} + + + # Act + amount = trading_orchestrator._calculate_bid_amount(turbo_info, 1000) + + # Assert + # (1000 / 10) = 100 units. 100 - 1 (safety) = 99. + assert amount == 99 + + def test_should_calculate_bid_amount_with_zero_spending_power(self, trading_orchestrator): + """Test bid calculation with zero or negative spending power.""" + # Arrange + turbo_info = {"selected_instrument": {"latest_ask": 10, "decimals": 2}} + + # Act & Assert + with pytest.raises(InsufficientFundsException): + trading_orchestrator._calculate_bid_amount(turbo_info, 0) + + with pytest.raises(InsufficientFundsException): + trading_orchestrator._calculate_bid_amount(turbo_info, -100) + + def test_calculate_bid_amount_invalid_ask_price(self, trading_orchestrator): + # Arrange + turbo_info = {"selected_instrument": {"latest_ask": None, "decimals": 2}} + + # Act & Assert + with pytest.raises(ValueError, match="Invalid ask price for bid calculation"): + trading_orchestrator._calculate_bid_amount(turbo_info, 1000) + + def test_execute_trade_signal_happy_path(self, trading_orchestrator, mock_instrument_service, mock_position_service, mock_order_service, mock_db_order_manager, mock_db_position_manager): + # Arrange + # The find_turbos function constructs a specific dictionary structure. + # The orchestrator test must mock this structure accurately. + turbo_info = { + "selected_instrument": { + "uic": 123, + "asset_type": "WarrantKnockOut", + "description": "Test Turbo", + "latest_ask": 10.0, # This is the key the orchestrator uses + "latest_bid": 9.9, + "decimals": 2, + "symbol": "TSTO", + "currency": "EUR", + "commissions": {} + } + } + order_response = TestDataFactory.create_order_response(order_id="order1") + position_data = TestDataFactory.create_saxo_position(position_id="pos1") + + mock_instrument_service.find_turbos.return_value = turbo_info + mock_position_service.get_spending_power.return_value = 1000 + mock_order_service.place_market_order.return_value = order_response + mock_position_service.find_position_by_order_id_with_retry.return_value = position_data + + # Act + result = trading_orchestrator.execute_trade_signal("e1", "u1", "long") + + # Assert + assert result is not None + assert result['message'] == "Successfully executed and recorded trade for long." + mock_instrument_service.find_turbos.assert_called_once_with("e1", "u1", "long") + mock_position_service.get_spending_power.assert_called_once() + mock_order_service.place_market_order.assert_called_once() + mock_position_service.find_position_by_order_id_with_retry.assert_called_once_with("order1") + mock_db_order_manager.insert_turbo_order_data.assert_called_once() + mock_db_position_manager.insert_turbo_open_position_data.assert_called_once() + + def test_should_handle_position_found_but_db_insert_fails(self, trading_orchestrator, mock_instrument_service, mock_position_service, mock_order_service, mock_db_order_manager): + """Test critical scenario: trade executes but DB persistence fails.""" + # Arrange + turbo_info = { + "selected_instrument": { + "uic": 123, + "asset_type": "WarrantKnockOut", + "description": "Test Turbo", + "latest_ask": 10.0, + "latest_bid": 9.9, + "decimals": 2, + "symbol": "TSTO", + "currency": "EUR", + "commissions": {} + } + } + order_response = TestDataFactory.create_order_response(order_id="order1") + position_data = TestDataFactory.create_saxo_position(position_id="pos1") + + mock_instrument_service.find_turbos.return_value = turbo_info + mock_position_service.get_spending_power.return_value = 1000 + mock_order_service.place_market_order.return_value = order_response + mock_position_service.find_position_by_order_id_with_retry.return_value = position_data + + # Simulate a database error on the first insert + mock_db_order_manager.insert_turbo_order_data.side_effect = Exception("DB Connection Error") + + # Act & Assert + with pytest.raises(DatabaseOperationException) as excinfo: + trading_orchestrator.execute_trade_signal("e1", "u1", "long") + + # Assert that the exception is correctly propagated + assert "CRITICAL: Failed to persist executed trade" in str(excinfo.value) + assert excinfo.value.operation == "insert_trade_data" + assert excinfo.value.entity_id == "order1" + + # Ensure the order was placed but the DB insert was the point of failure + mock_order_service.place_market_order.assert_called_once() + mock_db_order_manager.insert_turbo_order_data.assert_called_once() + # The position insert should not be called if the order insert fails + trading_orchestrator.db_position_manager.insert_turbo_open_position_data.assert_not_called() + + def test_should_handle_concurrent_trade_execution(self, trading_orchestrator, mock_instrument_service, mock_position_service, mock_order_service): + """ + Tests that the system correctly handles multiple trade signals in sequence, + updating the spending power between trades. + """ + # Arrange + # First trade + turbo_info_1 = { "selected_instrument": { "uic": 1, "asset_type": "TypeA", "latest_ask": 10.0, "decimals": 2, "description": "T1", "symbol": "T1", "currency": "EUR", "commissions": {} } } + order_response_1 = TestDataFactory.create_order_response(order_id="order1") + position_data_1 = TestDataFactory.create_saxo_position(position_id="pos1") + + # Second trade + turbo_info_2 = { "selected_instrument": { "uic": 2, "asset_type": "TypeB", "latest_ask": 8.0, "decimals": 2, "description": "T2", "symbol": "T2", "currency": "EUR", "commissions": {} } } + + + # Mock the chained calls + mock_instrument_service.find_turbos.side_effect = [turbo_info_1, turbo_info_2] + # First trade is based on 1000, second on the remainder + mock_position_service.get_spending_power.side_effect = [1000.0, 10.0] + mock_order_service.place_market_order.return_value = order_response_1 + mock_position_service.find_position_by_order_id_with_retry.return_value = position_data_1 + + # --- Act & Assert: First Trade --- + # With spending power 1000 and price 10, amount is 99, cost is 990. Remainder is 10. + result_1 = trading_orchestrator.execute_trade_signal("e1", "u1", "long") + assert result_1 is not None + mock_order_service.place_market_order.assert_called_once() # Ensure it was called for the first trade + + # --- Act & Assert: Second Trade --- + # With spending power 10 and price 8, it should fail as it can't even buy 1 unit with safety margin. + with pytest.raises(InsufficientFundsException): + trading_orchestrator.execute_trade_signal("e2", "u2", "short") + + # Final assertions + assert mock_position_service.get_spending_power.call_count == 2 + # The order placement should NOT have been called a second time + assert mock_order_service.place_market_order.call_count == 1 + + def test_execute_trade_signal_with_zero_fund_percentage(self, trading_orchestrator, mock_instrument_service, mock_position_service): + """Test that trade execution fails if max_account_funds_to_use_percentage is 0.""" + # Arrange + turbo_info = { "selected_instrument": { "uic": 1, "asset_type": "TypeA", "latest_ask": 10.0, "decimals": 2, "description": "T1", "symbol": "T1", "currency": "EUR", "commissions": {} } } + mock_instrument_service.find_turbos.return_value = turbo_info + mock_position_service.get_spending_power.return_value = 1000.0 + # Override the configuration for this test + trading_orchestrator.buying_power_config['max_account_funds_to_use_percentage'] = 0 + + # Act & Assert + with pytest.raises(InsufficientFundsException): + trading_orchestrator.execute_trade_signal("e1", "u1", "long") + + def test_execute_trade_signal_handles_unexpected_position_error(self, trading_orchestrator, mock_instrument_service, mock_position_service, mock_order_service): + """ + Test that if an unexpected error occurs during position finding, the order + is cancelled and the original error is re-raised. + """ + # Arrange + turbo_info = { "selected_instrument": { "uic": 1, "asset_type": "TypeA", "latest_ask": 10.0, "decimals": 2, "description": "T1", "symbol": "T1", "currency": "EUR", "commissions": {} } } + order_response = TestDataFactory.create_order_response(order_id="order1") + # This is the unexpected error we want to simulate + simulated_error = ValueError("A surprising error in the position service") + + mock_instrument_service.find_turbos.return_value = turbo_info + mock_position_service.get_spending_power.return_value = 1000.0 + mock_order_service.place_market_order.return_value = order_response + # The position finding step will raise an unexpected error + mock_position_service.find_position_by_order_id_with_retry.side_effect = simulated_error + + # Act & Assert + with pytest.raises(ValueError, match="A surprising error in the position service"): + trading_orchestrator.execute_trade_signal("e1", "u1", "long") + + # Crucially, assert that cleanup was attempted + mock_order_service.cancel_order.assert_called_once_with("order1") diff --git a/wata.egg-info/PKG-INFO b/wata.egg-info/PKG-INFO new file mode 100644 index 0000000..449a9c0 --- /dev/null +++ b/wata.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: wata +Version: 0.1 +License-File: LICENSE +Requires-Dist: requests +Requires-Dist: python-dotenv +Dynamic: license-file +Dynamic: requires-dist diff --git a/wata.egg-info/SOURCES.txt b/wata.egg-info/SOURCES.txt new file mode 100644 index 0000000..20f2311 --- /dev/null +++ b/wata.egg-info/SOURCES.txt @@ -0,0 +1,21 @@ +LICENSE +README.md +setup.py +tests/test_configuration_manager.py +tests/test_data_factory.py +tests/test_db_position_manager.py +tests/test_instrument_service.py +tests/test_order_service.py +tests/test_performance_monitor.py +tests/test_position_service.py +tests/test_saxo_api_client.py +tests/test_trade_rules.py +tests/test_trading_orchestrator.py +tests/test_web_server.py +tests/test_web_server_advanced.py +wata.egg-info/PKG-INFO +wata.egg-info/SOURCES.txt +wata.egg-info/dependency_links.txt +wata.egg-info/entry_points.txt +wata.egg-info/requires.txt +wata.egg-info/top_level.txt \ No newline at end of file diff --git a/wata.egg-info/dependency_links.txt b/wata.egg-info/dependency_links.txt new file mode 100644 index 0000000..e69de29 diff --git a/wata.egg-info/entry_points.txt b/wata.egg-info/entry_points.txt new file mode 100644 index 0000000..0b4ee61 --- /dev/null +++ b/wata.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +watasaxoauth = src.saxo_authen.cli:main +watawebtoken = src.web_server.cli:main diff --git a/wata.egg-info/requires.txt b/wata.egg-info/requires.txt new file mode 100644 index 0000000..df7458c --- /dev/null +++ b/wata.egg-info/requires.txt @@ -0,0 +1,2 @@ +requests +python-dotenv diff --git a/wata.egg-info/top_level.txt b/wata.egg-info/top_level.txt new file mode 100644 index 0000000..e69de29