diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 6c0f011..eab65c1 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -118,19 +118,21 @@ main ### Phase 2: Important Enhancements (Weeks 4-5) -#### 2.1 Account Configuration ⬜ +#### 2.1 Account Configuration ✅ **Branch**: `feature/account-config` **Priority**: 🟡 High **Estimated Time**: 1 day +**Actual Time**: < 1 day +**Completed**: 2025-01-15 **Tasks**: -- [ ] Update `trading/account.py` module -- [ ] Implement `get_configuration()` method -- [ ] Implement `update_configuration()` method -- [ ] Create `AccountConfigModel` dataclass -- [ ] Add PDT, trade confirmation settings -- [ ] Add comprehensive tests (5+ test cases) -- [ ] Update documentation +- [x] Update `trading/account.py` module +- [x] Implement `get_configuration()` method +- [x] Implement `update_configuration()` method +- [x] Create `AccountConfigModel` dataclass +- [x] Add PDT, trade confirmation, margin, and all configuration settings +- [x] Add comprehensive tests (14 unit tests, 8 integration tests) +- [x] Update documentation **Acceptance Criteria**: - Can read and update account configurations @@ -288,12 +290,12 @@ main ## 📈 Progress Tracking -### Overall Progress: 🟦 30% Complete +### Overall Progress: 🟦 35% Complete | Phase | Status | Progress | Estimated Completion | |-------|--------|----------|---------------------| | Phase 1: Critical Features | ✅ Complete | 100% | Week 1 | -| Phase 2: Important Enhancements | ⬜ Not Started | 0% | Week 5 | +| Phase 2: Important Enhancements | 🟦 In Progress | 33% | Week 2 | | Phase 3: Performance & Quality | ⬜ Not Started | 0% | Week 7 | | Phase 4: Advanced Features | ⬜ Not Started | 0% | Week 10 | diff --git a/src/py_alpaca_api/models/account_config_model.py b/src/py_alpaca_api/models/account_config_model.py new file mode 100644 index 0000000..c6a8c0d --- /dev/null +++ b/src/py_alpaca_api/models/account_config_model.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + + +@dataclass +class AccountConfigModel: + """Model for account configuration settings. + + Attributes: + dtbp_check: Day trade buying power check setting ("entry", "exit", "both") + fractional_trading: Whether fractional trading is enabled + max_margin_multiplier: Maximum margin multiplier allowed ("1", "2", "4") + no_shorting: Whether short selling is disabled + pdt_check: Pattern day trader check setting ("entry", "exit", "both") + ptp_no_exception_entry: Whether PTP no exception entry is enabled + suspend_trade: Whether trading is suspended + trade_confirm_email: Trade confirmation email setting ("all", "none") + """ + + dtbp_check: str + fractional_trading: bool + max_margin_multiplier: str + no_shorting: bool + pdt_check: str + ptp_no_exception_entry: bool + suspend_trade: bool + trade_confirm_email: str + + +def account_config_class_from_dict(data: dict) -> AccountConfigModel: + """Create AccountConfigModel from API response dictionary. + + Args: + data: Dictionary containing account configuration data from API + + Returns: + AccountConfigModel instance + """ + return AccountConfigModel( + dtbp_check=data.get("dtbp_check", "entry"), + fractional_trading=data.get("fractional_trading", False), + max_margin_multiplier=data.get("max_margin_multiplier", "1"), + no_shorting=data.get("no_shorting", False), + pdt_check=data.get("pdt_check", "entry"), + ptp_no_exception_entry=data.get("ptp_no_exception_entry", False), + suspend_trade=data.get("suspend_trade", False), + trade_confirm_email=data.get("trade_confirm_email", "all"), + ) diff --git a/src/py_alpaca_api/trading/account.py b/src/py_alpaca_api/trading/account.py index b03033f..248a88c 100644 --- a/src/py_alpaca_api/trading/account.py +++ b/src/py_alpaca_api/trading/account.py @@ -8,6 +8,10 @@ AccountActivityModel, account_activity_class_from_dict, ) +from py_alpaca_api.models.account_config_model import ( + AccountConfigModel, + account_config_class_from_dict, +) from py_alpaca_api.models.account_model import AccountModel, account_class_from_dict @@ -183,3 +187,110 @@ def portfolio_history( # Ensure we always return a DataFrame assert isinstance(portfolio_df, pd.DataFrame) return portfolio_df + + ############################################ + # Get Account Configuration + ############################################ + def get_configuration(self) -> AccountConfigModel: + """Retrieves the current account configuration settings. + + Returns: + AccountConfigModel: The current account configuration. + + Raises: + APIRequestError: If the request to retrieve configuration fails. + """ + url = f"{self.base_url}/account/configurations" + http_response = Requests().request("GET", url, headers=self.headers) + + if http_response.status_code != 200: + raise APIRequestError( + http_response.status_code, + f"Failed to retrieve account configuration: {http_response.status_code}", + ) + + response = json.loads(http_response.text) + return account_config_class_from_dict(response) + + ############################################ + # Update Account Configuration + ############################################ + def update_configuration( + self, + dtbp_check: str | None = None, + fractional_trading: bool | None = None, + max_margin_multiplier: str | None = None, + no_shorting: bool | None = None, + pdt_check: str | None = None, + ptp_no_exception_entry: bool | None = None, + suspend_trade: bool | None = None, + trade_confirm_email: str | None = None, + ) -> AccountConfigModel: + """Updates the account configuration settings. + + Args: + dtbp_check: Day trade buying power check ("entry", "exit", "both") + fractional_trading: Whether to enable fractional trading + max_margin_multiplier: Maximum margin multiplier ("1", "2", "4") + no_shorting: Whether to disable short selling + pdt_check: Pattern day trader check ("entry", "exit", "both") + ptp_no_exception_entry: Whether to enable PTP no exception entry + suspend_trade: Whether to suspend trading + trade_confirm_email: Trade confirmation emails ("all", "none") + + Returns: + AccountConfigModel: The updated account configuration. + + Raises: + APIRequestError: If the request to update configuration fails. + ValueError: If invalid parameter values are provided. + """ + # Validate parameters using a validation map + validations = { + "dtbp_check": (dtbp_check, ["entry", "exit", "both"]), + "pdt_check": (pdt_check, ["entry", "exit", "both"]), + "max_margin_multiplier": (max_margin_multiplier, ["1", "2", "4"]), + "trade_confirm_email": (trade_confirm_email, ["all", "none"]), + } + + for param_name, (value, valid_values) in validations.items(): + if value and value not in valid_values: + raise ValueError( + f"{param_name} must be one of: {', '.join(valid_values)}" + ) + + # Build request body with only provided parameters + body: dict[str, str | bool] = {} + if dtbp_check is not None: + body["dtbp_check"] = dtbp_check + if fractional_trading is not None: + body["fractional_trading"] = fractional_trading + if max_margin_multiplier is not None: + body["max_margin_multiplier"] = max_margin_multiplier + if no_shorting is not None: + body["no_shorting"] = no_shorting + if pdt_check is not None: + body["pdt_check"] = pdt_check + if ptp_no_exception_entry is not None: + body["ptp_no_exception_entry"] = ptp_no_exception_entry + if suspend_trade is not None: + body["suspend_trade"] = suspend_trade + if trade_confirm_email is not None: + body["trade_confirm_email"] = trade_confirm_email + + if not body: + raise ValueError("At least one configuration parameter must be provided") + + url = f"{self.base_url}/account/configurations" + http_response = Requests().request( + "PATCH", url, headers=self.headers, json=body + ) + + if http_response.status_code != 200: + raise APIRequestError( + http_response.status_code, + f"Failed to update account configuration: {http_response.status_code}", + ) + + response = json.loads(http_response.text) + return account_config_class_from_dict(response) diff --git a/tests/test_integration/test_account_config_integration.py b/tests/test_integration/test_account_config_integration.py new file mode 100644 index 0000000..423e7a9 --- /dev/null +++ b/tests/test_integration/test_account_config_integration.py @@ -0,0 +1,183 @@ +import os + +import pytest + +from py_alpaca_api import PyAlpacaAPI +from py_alpaca_api.models.account_config_model import AccountConfigModel + + +@pytest.mark.skipif( + not os.environ.get("ALPACA_API_KEY") or not os.environ.get("ALPACA_SECRET_KEY"), + reason="API credentials not set", +) +class TestAccountConfigIntegration: + @pytest.fixture + def alpaca(self): + return PyAlpacaAPI( + api_key=os.environ.get("ALPACA_API_KEY"), + api_secret=os.environ.get("ALPACA_SECRET_KEY"), + api_paper=True, + ) + + @pytest.fixture + def original_config(self, alpaca): + """Get the original configuration to restore after tests.""" + return alpaca.trading.account.get_configuration() + + def test_get_configuration(self, alpaca): + config = alpaca.trading.account.get_configuration() + + assert isinstance(config, AccountConfigModel) + # These fields should always be present + assert config.dtbp_check in ["entry", "exit", "both"] + assert isinstance(config.fractional_trading, bool) + assert config.max_margin_multiplier in ["1", "2", "4"] + assert isinstance(config.no_shorting, bool) + assert config.pdt_check in ["entry", "exit", "both"] + assert isinstance(config.ptp_no_exception_entry, bool) + assert isinstance(config.suspend_trade, bool) + assert config.trade_confirm_email in ["all", "none"] + + def test_update_single_configuration_param(self, alpaca, original_config): + # Toggle trade confirmation email + new_setting = "none" if original_config.trade_confirm_email == "all" else "all" + + updated_config = alpaca.trading.account.update_configuration( + trade_confirm_email=new_setting + ) + + assert isinstance(updated_config, AccountConfigModel) + assert updated_config.trade_confirm_email == new_setting + + # Verify other settings remain unchanged + assert updated_config.dtbp_check == original_config.dtbp_check + assert updated_config.fractional_trading == original_config.fractional_trading + assert updated_config.no_shorting == original_config.no_shorting + assert updated_config.pdt_check == original_config.pdt_check + assert updated_config.suspend_trade == original_config.suspend_trade + + # Restore original setting + alpaca.trading.account.update_configuration( + trade_confirm_email=original_config.trade_confirm_email + ) + + def test_update_multiple_configuration_params(self, alpaca, original_config): + # Toggle no_shorting and change pdt_check + new_no_shorting = not original_config.no_shorting + new_pdt_check = "exit" if original_config.pdt_check == "entry" else "entry" + + updated_config = alpaca.trading.account.update_configuration( + no_shorting=new_no_shorting, pdt_check=new_pdt_check + ) + + assert isinstance(updated_config, AccountConfigModel) + assert updated_config.no_shorting == new_no_shorting + assert updated_config.pdt_check == new_pdt_check + + # Verify other settings remain unchanged + assert updated_config.dtbp_check == original_config.dtbp_check + assert updated_config.fractional_trading == original_config.fractional_trading + assert ( + updated_config.max_margin_multiplier + == original_config.max_margin_multiplier + ) + assert updated_config.suspend_trade == original_config.suspend_trade + assert updated_config.trade_confirm_email == original_config.trade_confirm_email + + # Restore original settings + alpaca.trading.account.update_configuration( + no_shorting=original_config.no_shorting, + pdt_check=original_config.pdt_check, + ) + + def test_update_margin_multiplier(self, alpaca, original_config): + # Test changing margin multiplier + current_multiplier = original_config.max_margin_multiplier + new_multiplier = "2" if current_multiplier != "2" else "4" + + updated_config = alpaca.trading.account.update_configuration( + max_margin_multiplier=new_multiplier + ) + + assert updated_config.max_margin_multiplier == new_multiplier + + # Restore original + alpaca.trading.account.update_configuration( + max_margin_multiplier=original_config.max_margin_multiplier + ) + + def test_update_dtbp_check(self, alpaca, original_config): + # Cycle through dtbp_check options + options = ["entry", "exit", "both"] + current = original_config.dtbp_check + new_value = options[(options.index(current) + 1) % 3] + + updated_config = alpaca.trading.account.update_configuration( + dtbp_check=new_value + ) + + assert updated_config.dtbp_check == new_value + + # Restore original + alpaca.trading.account.update_configuration( + dtbp_check=original_config.dtbp_check + ) + + def test_toggle_fractional_trading(self, alpaca, original_config): + # Toggle fractional trading + new_value = not original_config.fractional_trading + + updated_config = alpaca.trading.account.update_configuration( + fractional_trading=new_value + ) + + assert updated_config.fractional_trading == new_value + + # Restore original + alpaca.trading.account.update_configuration( + fractional_trading=original_config.fractional_trading + ) + + def test_configuration_persistence(self, alpaca, original_config): + # Update a configuration + new_email_setting = ( + "none" if original_config.trade_confirm_email == "all" else "all" + ) + alpaca.trading.account.update_configuration( + trade_confirm_email=new_email_setting + ) + + # Get configuration again to verify persistence + config = alpaca.trading.account.get_configuration() + assert config.trade_confirm_email == new_email_setting + + # Restore original + alpaca.trading.account.update_configuration( + trade_confirm_email=original_config.trade_confirm_email + ) + + def test_invalid_parameter_handling(self, alpaca): + # Test that invalid parameters raise appropriate errors + with pytest.raises(ValueError): + alpaca.trading.account.update_configuration(dtbp_check="invalid") + + with pytest.raises(ValueError): + alpaca.trading.account.update_configuration(pdt_check="invalid") + + with pytest.raises(ValueError): + alpaca.trading.account.update_configuration(max_margin_multiplier="5") + + with pytest.raises(ValueError): + alpaca.trading.account.update_configuration(trade_confirm_email="sometimes") + + @pytest.mark.skip(reason="Suspend trade affects account functionality") + def test_suspend_trade_toggle(self, alpaca, original_config): + # This test is skipped as it would actually suspend trading + # Only run manually when testing this specific feature + updated_config = alpaca.trading.account.update_configuration(suspend_trade=True) + assert updated_config.suspend_trade is True + + # Immediately restore + alpaca.trading.account.update_configuration( + suspend_trade=original_config.suspend_trade + ) diff --git a/tests/test_trading/test_account_config.py b/tests/test_trading/test_account_config.py new file mode 100644 index 0000000..ef0456c --- /dev/null +++ b/tests/test_trading/test_account_config.py @@ -0,0 +1,252 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from py_alpaca_api.exceptions import APIRequestError +from py_alpaca_api.models.account_config_model import ( + AccountConfigModel, + account_config_class_from_dict, +) +from py_alpaca_api.trading.account import Account + + +class TestAccountConfig: + @pytest.fixture + def account(self): + headers = { + "APCA-API-KEY-ID": "test_key", + "APCA-API-SECRET-KEY": "test_secret", + } + base_url = "https://paper-api.alpaca.markets/v2" + return Account(headers=headers, base_url=base_url) + + @pytest.fixture + def mock_config_response(self): + return { + "dtbp_check": "entry", + "fractional_trading": True, + "max_margin_multiplier": "4", + "no_shorting": False, + "pdt_check": "entry", + "ptp_no_exception_entry": False, + "suspend_trade": False, + "trade_confirm_email": "all", + } + + def test_get_configuration_success(self, account, mock_config_response): + with patch("py_alpaca_api.trading.account.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = json.dumps(mock_config_response) + mock_requests.return_value.request.return_value = mock_response + + result = account.get_configuration() + + assert isinstance(result, AccountConfigModel) + assert result.dtbp_check == "entry" + assert result.fractional_trading is True + assert result.max_margin_multiplier == "4" + assert result.no_shorting is False + assert result.pdt_check == "entry" + assert result.ptp_no_exception_entry is False + assert result.suspend_trade is False + assert result.trade_confirm_email == "all" + + mock_requests.return_value.request.assert_called_once_with( + "GET", + f"{account.base_url}/account/configurations", + headers=account.headers, + ) + + def test_get_configuration_failure(self, account): + with patch("py_alpaca_api.trading.account.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + mock_requests.return_value.request.return_value = mock_response + + with pytest.raises(APIRequestError) as exc_info: + account.get_configuration() + + assert exc_info.value.status_code == 401 + assert "Failed to retrieve account configuration" in str(exc_info.value) + + def test_update_configuration_single_param(self, account, mock_config_response): + mock_config_response["suspend_trade"] = True + + with patch("py_alpaca_api.trading.account.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = json.dumps(mock_config_response) + mock_requests.return_value.request.return_value = mock_response + + result = account.update_configuration(suspend_trade=True) + + assert isinstance(result, AccountConfigModel) + assert result.suspend_trade is True + + mock_requests.return_value.request.assert_called_once_with( + "PATCH", + f"{account.base_url}/account/configurations", + headers=account.headers, + json={"suspend_trade": True}, + ) + + def test_update_configuration_multiple_params(self, account, mock_config_response): + mock_config_response["no_shorting"] = True + mock_config_response["trade_confirm_email"] = "none" + + with patch("py_alpaca_api.trading.account.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = json.dumps(mock_config_response) + mock_requests.return_value.request.return_value = mock_response + + result = account.update_configuration( + no_shorting=True, trade_confirm_email="none" + ) + + assert isinstance(result, AccountConfigModel) + assert result.no_shorting is True + assert result.trade_confirm_email == "none" + + mock_requests.return_value.request.assert_called_once_with( + "PATCH", + f"{account.base_url}/account/configurations", + headers=account.headers, + json={"no_shorting": True, "trade_confirm_email": "none"}, + ) + + def test_update_configuration_all_params(self, account): + updated_config = { + "dtbp_check": "both", + "fractional_trading": False, + "max_margin_multiplier": "2", + "no_shorting": True, + "pdt_check": "exit", + "ptp_no_exception_entry": True, + "suspend_trade": True, + "trade_confirm_email": "none", + } + + with patch("py_alpaca_api.trading.account.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = json.dumps(updated_config) + mock_requests.return_value.request.return_value = mock_response + + result = account.update_configuration( + dtbp_check="both", + fractional_trading=False, + max_margin_multiplier="2", + no_shorting=True, + pdt_check="exit", + ptp_no_exception_entry=True, + suspend_trade=True, + trade_confirm_email="none", + ) + + assert isinstance(result, AccountConfigModel) + assert result.dtbp_check == "both" + assert result.fractional_trading is False + assert result.max_margin_multiplier == "2" + assert result.no_shorting is True + assert result.pdt_check == "exit" + assert result.ptp_no_exception_entry is True + assert result.suspend_trade is True + assert result.trade_confirm_email == "none" + + def test_update_configuration_invalid_dtbp_check(self, account): + with pytest.raises(ValueError, match="dtbp_check must be one of"): + account.update_configuration(dtbp_check="invalid") + + def test_update_configuration_invalid_pdt_check(self, account): + with pytest.raises(ValueError, match="pdt_check must be one of"): + account.update_configuration(pdt_check="invalid") + + def test_update_configuration_invalid_margin_multiplier(self, account): + with pytest.raises(ValueError, match="max_margin_multiplier must be one of"): + account.update_configuration(max_margin_multiplier="3") + + def test_update_configuration_invalid_trade_confirm_email(self, account): + with pytest.raises(ValueError, match="trade_confirm_email must be one of"): + account.update_configuration(trade_confirm_email="some") + + def test_update_configuration_no_params(self, account): + with pytest.raises( + ValueError, match="At least one configuration parameter must be provided" + ): + account.update_configuration() + + def test_update_configuration_failure(self, account): + with patch("py_alpaca_api.trading.account.Requests") as mock_requests: + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + mock_requests.return_value.request.return_value = mock_response + + with pytest.raises(APIRequestError) as exc_info: + account.update_configuration(suspend_trade=True) + + assert exc_info.value.status_code == 400 + assert "Failed to update account configuration" in str(exc_info.value) + + +class TestAccountConfigModel: + def test_account_config_from_dict(self): + data = { + "dtbp_check": "both", + "fractional_trading": True, + "max_margin_multiplier": "2", + "no_shorting": True, + "pdt_check": "exit", + "ptp_no_exception_entry": True, + "suspend_trade": False, + "trade_confirm_email": "none", + } + + config = account_config_class_from_dict(data) + + assert isinstance(config, AccountConfigModel) + assert config.dtbp_check == "both" + assert config.fractional_trading is True + assert config.max_margin_multiplier == "2" + assert config.no_shorting is True + assert config.pdt_check == "exit" + assert config.ptp_no_exception_entry is True + assert config.suspend_trade is False + assert config.trade_confirm_email == "none" + + def test_account_config_from_dict_with_defaults(self): + # Test with empty dict to verify defaults + data = {} + + config = account_config_class_from_dict(data) + + assert isinstance(config, AccountConfigModel) + assert config.dtbp_check == "entry" + assert config.fractional_trading is False + assert config.max_margin_multiplier == "1" + assert config.no_shorting is False + assert config.pdt_check == "entry" + assert config.ptp_no_exception_entry is False + assert config.suspend_trade is False + assert config.trade_confirm_email == "all" + + def test_account_config_from_dict_partial(self): + data = { + "dtbp_check": "exit", + "fractional_trading": True, + "trade_confirm_email": "none", + } + + config = account_config_class_from_dict(data) + + assert config.dtbp_check == "exit" + assert config.fractional_trading is True + assert config.trade_confirm_email == "none" + # Check defaults for missing fields + assert config.max_margin_multiplier == "1" + assert config.no_shorting is False + assert config.pdt_check == "entry"