Skip to content

Commit 1399c77

Browse files
authored
feat: Add Account Configuration API support (#70)
- Implemented get_configuration() and update_configuration() methods in Account class - Created AccountConfigModel dataclass with all configuration fields - Added support for all account settings: dtbp_check, fractional_trading, max_margin_multiplier, no_shorting, pdt_check, ptp_no_exception_entry, suspend_trade, trade_confirm_email - Comprehensive validation for configuration parameters - Added 14 unit tests and 8 integration tests - Full type safety with mypy strict mode - Updated DEVELOPMENT_PLAN.md This completes the first feature of Phase 2 (Important Enhancements)
1 parent 1802445 commit 1399c77

File tree

5 files changed

+605
-10
lines changed

5 files changed

+605
-10
lines changed

DEVELOPMENT_PLAN.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,21 @@ main
118118

119119
### Phase 2: Important Enhancements (Weeks 4-5)
120120

121-
#### 2.1 Account Configuration
121+
#### 2.1 Account Configuration
122122
**Branch**: `feature/account-config`
123123
**Priority**: 🟡 High
124124
**Estimated Time**: 1 day
125+
**Actual Time**: < 1 day
126+
**Completed**: 2025-01-15
125127

126128
**Tasks**:
127-
- [ ] Update `trading/account.py` module
128-
- [ ] Implement `get_configuration()` method
129-
- [ ] Implement `update_configuration()` method
130-
- [ ] Create `AccountConfigModel` dataclass
131-
- [ ] Add PDT, trade confirmation settings
132-
- [ ] Add comprehensive tests (5+ test cases)
133-
- [ ] Update documentation
129+
- [x] Update `trading/account.py` module
130+
- [x] Implement `get_configuration()` method
131+
- [x] Implement `update_configuration()` method
132+
- [x] Create `AccountConfigModel` dataclass
133+
- [x] Add PDT, trade confirmation, margin, and all configuration settings
134+
- [x] Add comprehensive tests (14 unit tests, 8 integration tests)
135+
- [x] Update documentation
134136

135137
**Acceptance Criteria**:
136138
- Can read and update account configurations
@@ -288,12 +290,12 @@ main
288290

289291
## 📈 Progress Tracking
290292

291-
### Overall Progress: 🟦 30% Complete
293+
### Overall Progress: 🟦 35% Complete
292294

293295
| Phase | Status | Progress | Estimated Completion |
294296
|-------|--------|----------|---------------------|
295297
| Phase 1: Critical Features | ✅ Complete | 100% | Week 1 |
296-
| Phase 2: Important Enhancements | ⬜ Not Started | 0% | Week 5 |
298+
| Phase 2: Important Enhancements | 🟦 In Progress | 33% | Week 2 |
297299
| Phase 3: Performance & Quality | ⬜ Not Started | 0% | Week 7 |
298300
| Phase 4: Advanced Features | ⬜ Not Started | 0% | Week 10 |
299301

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class AccountConfigModel:
6+
"""Model for account configuration settings.
7+
8+
Attributes:
9+
dtbp_check: Day trade buying power check setting ("entry", "exit", "both")
10+
fractional_trading: Whether fractional trading is enabled
11+
max_margin_multiplier: Maximum margin multiplier allowed ("1", "2", "4")
12+
no_shorting: Whether short selling is disabled
13+
pdt_check: Pattern day trader check setting ("entry", "exit", "both")
14+
ptp_no_exception_entry: Whether PTP no exception entry is enabled
15+
suspend_trade: Whether trading is suspended
16+
trade_confirm_email: Trade confirmation email setting ("all", "none")
17+
"""
18+
19+
dtbp_check: str
20+
fractional_trading: bool
21+
max_margin_multiplier: str
22+
no_shorting: bool
23+
pdt_check: str
24+
ptp_no_exception_entry: bool
25+
suspend_trade: bool
26+
trade_confirm_email: str
27+
28+
29+
def account_config_class_from_dict(data: dict) -> AccountConfigModel:
30+
"""Create AccountConfigModel from API response dictionary.
31+
32+
Args:
33+
data: Dictionary containing account configuration data from API
34+
35+
Returns:
36+
AccountConfigModel instance
37+
"""
38+
return AccountConfigModel(
39+
dtbp_check=data.get("dtbp_check", "entry"),
40+
fractional_trading=data.get("fractional_trading", False),
41+
max_margin_multiplier=data.get("max_margin_multiplier", "1"),
42+
no_shorting=data.get("no_shorting", False),
43+
pdt_check=data.get("pdt_check", "entry"),
44+
ptp_no_exception_entry=data.get("ptp_no_exception_entry", False),
45+
suspend_trade=data.get("suspend_trade", False),
46+
trade_confirm_email=data.get("trade_confirm_email", "all"),
47+
)

src/py_alpaca_api/trading/account.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
AccountActivityModel,
99
account_activity_class_from_dict,
1010
)
11+
from py_alpaca_api.models.account_config_model import (
12+
AccountConfigModel,
13+
account_config_class_from_dict,
14+
)
1115
from py_alpaca_api.models.account_model import AccountModel, account_class_from_dict
1216

1317

@@ -183,3 +187,110 @@ def portfolio_history(
183187
# Ensure we always return a DataFrame
184188
assert isinstance(portfolio_df, pd.DataFrame)
185189
return portfolio_df
190+
191+
############################################
192+
# Get Account Configuration
193+
############################################
194+
def get_configuration(self) -> AccountConfigModel:
195+
"""Retrieves the current account configuration settings.
196+
197+
Returns:
198+
AccountConfigModel: The current account configuration.
199+
200+
Raises:
201+
APIRequestError: If the request to retrieve configuration fails.
202+
"""
203+
url = f"{self.base_url}/account/configurations"
204+
http_response = Requests().request("GET", url, headers=self.headers)
205+
206+
if http_response.status_code != 200:
207+
raise APIRequestError(
208+
http_response.status_code,
209+
f"Failed to retrieve account configuration: {http_response.status_code}",
210+
)
211+
212+
response = json.loads(http_response.text)
213+
return account_config_class_from_dict(response)
214+
215+
############################################
216+
# Update Account Configuration
217+
############################################
218+
def update_configuration(
219+
self,
220+
dtbp_check: str | None = None,
221+
fractional_trading: bool | None = None,
222+
max_margin_multiplier: str | None = None,
223+
no_shorting: bool | None = None,
224+
pdt_check: str | None = None,
225+
ptp_no_exception_entry: bool | None = None,
226+
suspend_trade: bool | None = None,
227+
trade_confirm_email: str | None = None,
228+
) -> AccountConfigModel:
229+
"""Updates the account configuration settings.
230+
231+
Args:
232+
dtbp_check: Day trade buying power check ("entry", "exit", "both")
233+
fractional_trading: Whether to enable fractional trading
234+
max_margin_multiplier: Maximum margin multiplier ("1", "2", "4")
235+
no_shorting: Whether to disable short selling
236+
pdt_check: Pattern day trader check ("entry", "exit", "both")
237+
ptp_no_exception_entry: Whether to enable PTP no exception entry
238+
suspend_trade: Whether to suspend trading
239+
trade_confirm_email: Trade confirmation emails ("all", "none")
240+
241+
Returns:
242+
AccountConfigModel: The updated account configuration.
243+
244+
Raises:
245+
APIRequestError: If the request to update configuration fails.
246+
ValueError: If invalid parameter values are provided.
247+
"""
248+
# Validate parameters using a validation map
249+
validations = {
250+
"dtbp_check": (dtbp_check, ["entry", "exit", "both"]),
251+
"pdt_check": (pdt_check, ["entry", "exit", "both"]),
252+
"max_margin_multiplier": (max_margin_multiplier, ["1", "2", "4"]),
253+
"trade_confirm_email": (trade_confirm_email, ["all", "none"]),
254+
}
255+
256+
for param_name, (value, valid_values) in validations.items():
257+
if value and value not in valid_values:
258+
raise ValueError(
259+
f"{param_name} must be one of: {', '.join(valid_values)}"
260+
)
261+
262+
# Build request body with only provided parameters
263+
body: dict[str, str | bool] = {}
264+
if dtbp_check is not None:
265+
body["dtbp_check"] = dtbp_check
266+
if fractional_trading is not None:
267+
body["fractional_trading"] = fractional_trading
268+
if max_margin_multiplier is not None:
269+
body["max_margin_multiplier"] = max_margin_multiplier
270+
if no_shorting is not None:
271+
body["no_shorting"] = no_shorting
272+
if pdt_check is not None:
273+
body["pdt_check"] = pdt_check
274+
if ptp_no_exception_entry is not None:
275+
body["ptp_no_exception_entry"] = ptp_no_exception_entry
276+
if suspend_trade is not None:
277+
body["suspend_trade"] = suspend_trade
278+
if trade_confirm_email is not None:
279+
body["trade_confirm_email"] = trade_confirm_email
280+
281+
if not body:
282+
raise ValueError("At least one configuration parameter must be provided")
283+
284+
url = f"{self.base_url}/account/configurations"
285+
http_response = Requests().request(
286+
"PATCH", url, headers=self.headers, json=body
287+
)
288+
289+
if http_response.status_code != 200:
290+
raise APIRequestError(
291+
http_response.status_code,
292+
f"Failed to update account configuration: {http_response.status_code}",
293+
)
294+
295+
response = json.loads(http_response.text)
296+
return account_config_class_from_dict(response)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import os
2+
3+
import pytest
4+
5+
from py_alpaca_api import PyAlpacaAPI
6+
from py_alpaca_api.models.account_config_model import AccountConfigModel
7+
8+
9+
@pytest.mark.skipif(
10+
not os.environ.get("ALPACA_API_KEY") or not os.environ.get("ALPACA_SECRET_KEY"),
11+
reason="API credentials not set",
12+
)
13+
class TestAccountConfigIntegration:
14+
@pytest.fixture
15+
def alpaca(self):
16+
return PyAlpacaAPI(
17+
api_key=os.environ.get("ALPACA_API_KEY"),
18+
api_secret=os.environ.get("ALPACA_SECRET_KEY"),
19+
api_paper=True,
20+
)
21+
22+
@pytest.fixture
23+
def original_config(self, alpaca):
24+
"""Get the original configuration to restore after tests."""
25+
return alpaca.trading.account.get_configuration()
26+
27+
def test_get_configuration(self, alpaca):
28+
config = alpaca.trading.account.get_configuration()
29+
30+
assert isinstance(config, AccountConfigModel)
31+
# These fields should always be present
32+
assert config.dtbp_check in ["entry", "exit", "both"]
33+
assert isinstance(config.fractional_trading, bool)
34+
assert config.max_margin_multiplier in ["1", "2", "4"]
35+
assert isinstance(config.no_shorting, bool)
36+
assert config.pdt_check in ["entry", "exit", "both"]
37+
assert isinstance(config.ptp_no_exception_entry, bool)
38+
assert isinstance(config.suspend_trade, bool)
39+
assert config.trade_confirm_email in ["all", "none"]
40+
41+
def test_update_single_configuration_param(self, alpaca, original_config):
42+
# Toggle trade confirmation email
43+
new_setting = "none" if original_config.trade_confirm_email == "all" else "all"
44+
45+
updated_config = alpaca.trading.account.update_configuration(
46+
trade_confirm_email=new_setting
47+
)
48+
49+
assert isinstance(updated_config, AccountConfigModel)
50+
assert updated_config.trade_confirm_email == new_setting
51+
52+
# Verify other settings remain unchanged
53+
assert updated_config.dtbp_check == original_config.dtbp_check
54+
assert updated_config.fractional_trading == original_config.fractional_trading
55+
assert updated_config.no_shorting == original_config.no_shorting
56+
assert updated_config.pdt_check == original_config.pdt_check
57+
assert updated_config.suspend_trade == original_config.suspend_trade
58+
59+
# Restore original setting
60+
alpaca.trading.account.update_configuration(
61+
trade_confirm_email=original_config.trade_confirm_email
62+
)
63+
64+
def test_update_multiple_configuration_params(self, alpaca, original_config):
65+
# Toggle no_shorting and change pdt_check
66+
new_no_shorting = not original_config.no_shorting
67+
new_pdt_check = "exit" if original_config.pdt_check == "entry" else "entry"
68+
69+
updated_config = alpaca.trading.account.update_configuration(
70+
no_shorting=new_no_shorting, pdt_check=new_pdt_check
71+
)
72+
73+
assert isinstance(updated_config, AccountConfigModel)
74+
assert updated_config.no_shorting == new_no_shorting
75+
assert updated_config.pdt_check == new_pdt_check
76+
77+
# Verify other settings remain unchanged
78+
assert updated_config.dtbp_check == original_config.dtbp_check
79+
assert updated_config.fractional_trading == original_config.fractional_trading
80+
assert (
81+
updated_config.max_margin_multiplier
82+
== original_config.max_margin_multiplier
83+
)
84+
assert updated_config.suspend_trade == original_config.suspend_trade
85+
assert updated_config.trade_confirm_email == original_config.trade_confirm_email
86+
87+
# Restore original settings
88+
alpaca.trading.account.update_configuration(
89+
no_shorting=original_config.no_shorting,
90+
pdt_check=original_config.pdt_check,
91+
)
92+
93+
def test_update_margin_multiplier(self, alpaca, original_config):
94+
# Test changing margin multiplier
95+
current_multiplier = original_config.max_margin_multiplier
96+
new_multiplier = "2" if current_multiplier != "2" else "4"
97+
98+
updated_config = alpaca.trading.account.update_configuration(
99+
max_margin_multiplier=new_multiplier
100+
)
101+
102+
assert updated_config.max_margin_multiplier == new_multiplier
103+
104+
# Restore original
105+
alpaca.trading.account.update_configuration(
106+
max_margin_multiplier=original_config.max_margin_multiplier
107+
)
108+
109+
def test_update_dtbp_check(self, alpaca, original_config):
110+
# Cycle through dtbp_check options
111+
options = ["entry", "exit", "both"]
112+
current = original_config.dtbp_check
113+
new_value = options[(options.index(current) + 1) % 3]
114+
115+
updated_config = alpaca.trading.account.update_configuration(
116+
dtbp_check=new_value
117+
)
118+
119+
assert updated_config.dtbp_check == new_value
120+
121+
# Restore original
122+
alpaca.trading.account.update_configuration(
123+
dtbp_check=original_config.dtbp_check
124+
)
125+
126+
def test_toggle_fractional_trading(self, alpaca, original_config):
127+
# Toggle fractional trading
128+
new_value = not original_config.fractional_trading
129+
130+
updated_config = alpaca.trading.account.update_configuration(
131+
fractional_trading=new_value
132+
)
133+
134+
assert updated_config.fractional_trading == new_value
135+
136+
# Restore original
137+
alpaca.trading.account.update_configuration(
138+
fractional_trading=original_config.fractional_trading
139+
)
140+
141+
def test_configuration_persistence(self, alpaca, original_config):
142+
# Update a configuration
143+
new_email_setting = (
144+
"none" if original_config.trade_confirm_email == "all" else "all"
145+
)
146+
alpaca.trading.account.update_configuration(
147+
trade_confirm_email=new_email_setting
148+
)
149+
150+
# Get configuration again to verify persistence
151+
config = alpaca.trading.account.get_configuration()
152+
assert config.trade_confirm_email == new_email_setting
153+
154+
# Restore original
155+
alpaca.trading.account.update_configuration(
156+
trade_confirm_email=original_config.trade_confirm_email
157+
)
158+
159+
def test_invalid_parameter_handling(self, alpaca):
160+
# Test that invalid parameters raise appropriate errors
161+
with pytest.raises(ValueError):
162+
alpaca.trading.account.update_configuration(dtbp_check="invalid")
163+
164+
with pytest.raises(ValueError):
165+
alpaca.trading.account.update_configuration(pdt_check="invalid")
166+
167+
with pytest.raises(ValueError):
168+
alpaca.trading.account.update_configuration(max_margin_multiplier="5")
169+
170+
with pytest.raises(ValueError):
171+
alpaca.trading.account.update_configuration(trade_confirm_email="sometimes")
172+
173+
@pytest.mark.skip(reason="Suspend trade affects account functionality")
174+
def test_suspend_trade_toggle(self, alpaca, original_config):
175+
# This test is skipped as it would actually suspend trading
176+
# Only run manually when testing this specific feature
177+
updated_config = alpaca.trading.account.update_configuration(suspend_trade=True)
178+
assert updated_config.suspend_trade is True
179+
180+
# Immediately restore
181+
alpaca.trading.account.update_configuration(
182+
suspend_trade=original_config.suspend_trade
183+
)

0 commit comments

Comments
 (0)