Skip to content

Commit b7aab1f

Browse files
TexasCodingclaude
andauthored
feat: Add Enhanced Order Management features (#72)
- Implement replace_order() method for updating existing orders - Add client_order_id support to all order methods - Add order_class parameter for OTO/OCO/bracket orders - Enhance order validation logic - Add comprehensive tests (13 unit, 10 integration) - Update DEVELOPMENT_PLAN.md Note: Client order ID operations use order list filtering as Alpaca API removed direct :by_client_order_id endpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <[email protected]>
1 parent 181e3d2 commit b7aab1f

File tree

4 files changed

+803
-15
lines changed

4 files changed

+803
-15
lines changed

DEVELOPMENT_PLAN.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,24 +161,26 @@ main
161161
- Implements caching with 24-hour TTL
162162
- Proper documentation of code meanings
163163

164-
#### 2.3 Enhanced Order Management
164+
#### 2.3 Enhanced Order Management
165165
**Branch**: `feature/order-enhancements`
166166
**Priority**: 🟡 High
167167
**Estimated Time**: 2 days
168+
**Actual Time**: < 1 day
169+
**Completed**: 2025-01-15
168170

169171
**Tasks**:
170-
- [ ] Update `trading/orders.py` module
171-
- [ ] Implement `replace_order()` method
172-
- [ ] Add `client_order_id` support to all order methods
173-
- [ ] Add `extended_hours` parameter
174-
- [ ] Add `order_class` for OTO/OCO orders
175-
- [ ] Improve order validation
176-
- [ ] Add comprehensive tests (10+ test cases)
177-
- [ ] Update documentation
172+
- [x] Update `trading/orders.py` module
173+
- [x] Implement `replace_order()` method
174+
- [x] Add `client_order_id` support to all order methods
175+
- [x] Add `extended_hours` parameter (already existed)
176+
- [x] Add `order_class` for OTO/OCO orders
177+
- [x] Improve order validation
178+
- [x] Add comprehensive tests (13 unit tests, 10 integration tests)
179+
- [x] Update documentation
178180

179181
**Acceptance Criteria**:
180182
- Can replace existing orders
181-
- Client order ID tracking works
183+
- Client order ID tracking works (using order list filtering)
182184
- Extended hours orders properly flagged
183185
- OTO/OCO order classes supported
184186

@@ -292,12 +294,12 @@ main
292294

293295
## 📈 Progress Tracking
294296

295-
### Overall Progress: 🟦 40% Complete
297+
### Overall Progress: 🟦 50% Complete
296298

297299
| Phase | Status | Progress | Estimated Completion |
298300
|-------|--------|----------|---------------------|
299301
| Phase 1: Critical Features | ✅ Complete | 100% | Week 1 |
300-
| Phase 2: Important Enhancements | 🟦 In Progress | 67% | Week 2 |
302+
| Phase 2: Important Enhancements | ✅ Complete | 100% | Week 2 |
301303
| Phase 3: Performance & Quality | ⬜ Not Started | 0% | Week 7 |
302304
| Phase 4: Advanced Features | ⬜ Not Started | 0% | Week 10 |
303305

src/py_alpaca_api/trading/orders.py

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,126 @@ def cancel_all(self) -> str:
8888
)
8989
return f"{len(response)} orders have been cancelled"
9090

91+
########################################################
92+
# \\\\\\\\\ Replace Order /////////////////////#
93+
########################################################
94+
def replace_order(
95+
self,
96+
order_id: str,
97+
qty: float | None = None,
98+
limit_price: float | None = None,
99+
stop_price: float | None = None,
100+
trail: float | None = None,
101+
time_in_force: str | None = None,
102+
client_order_id: str | None = None,
103+
) -> OrderModel:
104+
"""Replace an existing order with updated parameters.
105+
106+
Args:
107+
order_id: The ID of the order to replace.
108+
qty: The new quantity for the order.
109+
limit_price: The new limit price for limit orders.
110+
stop_price: The new stop price for stop orders.
111+
trail: The new trail amount for trailing stop orders (percent or price).
112+
time_in_force: The new time in force for the order.
113+
client_order_id: Optional client-assigned ID for the replacement order.
114+
115+
Returns:
116+
OrderModel: The replaced order.
117+
118+
Raises:
119+
ValidationError: If no parameters are provided to update.
120+
APIRequestError: If the API request fails.
121+
"""
122+
# At least one parameter must be provided
123+
if not any([qty, limit_price, stop_price, trail, time_in_force]):
124+
raise ValidationError(
125+
"At least one parameter must be provided to replace the order"
126+
)
127+
128+
body: dict[str, str | float | None] = {}
129+
if qty is not None:
130+
body["qty"] = qty
131+
if limit_price is not None:
132+
body["limit_price"] = limit_price
133+
if stop_price is not None:
134+
body["stop_price"] = stop_price
135+
if trail is not None:
136+
body["trail"] = trail
137+
if time_in_force is not None:
138+
body["time_in_force"] = time_in_force
139+
if client_order_id is not None:
140+
body["client_order_id"] = client_order_id
141+
142+
url = f"{self.base_url}/orders/{order_id}"
143+
144+
response = json.loads(
145+
Requests()
146+
.request(method="PATCH", url=url, headers=self.headers, json=body)
147+
.text
148+
)
149+
return order_class_from_dict(response)
150+
151+
########################################################
152+
# \\\\\\\ Get Order By Client ID ////////////////#
153+
########################################################
154+
def get_by_client_order_id(self, client_order_id: str) -> OrderModel:
155+
"""Retrieves order information by client order ID.
156+
157+
Note: This queries all orders and filters by client_order_id.
158+
The Alpaca API doesn't have a direct endpoint for this.
159+
160+
Args:
161+
client_order_id: The client-assigned ID of the order to retrieve.
162+
163+
Returns:
164+
OrderModel: An object representing the order information.
165+
166+
Raises:
167+
APIRequestError: If the request fails or order not found.
168+
ValidationError: If no order with given client_order_id is found.
169+
"""
170+
# Get all orders and filter by client_order_id
171+
params: dict[str, str | bool | float | int] = {"status": "all", "limit": 500}
172+
url = f"{self.base_url}/orders"
173+
174+
response = json.loads(
175+
Requests()
176+
.request(method="GET", url=url, headers=self.headers, params=params)
177+
.text
178+
)
179+
180+
# Find the order with matching client_order_id
181+
for order_data in response:
182+
if order_data.get("client_order_id") == client_order_id:
183+
return order_class_from_dict(order_data)
184+
185+
raise ValidationError(f"No order found with client_order_id: {client_order_id}")
186+
187+
########################################################
188+
# \\\\\\ Cancel Order By Client ID ///////////////#
189+
########################################################
190+
def cancel_by_client_order_id(self, client_order_id: str) -> str:
191+
"""Cancel an order by its client order ID.
192+
193+
Note: This first retrieves the order by client_order_id, then cancels by ID.
194+
195+
Args:
196+
client_order_id: The client-assigned ID of the order to be cancelled.
197+
198+
Returns:
199+
str: A message indicating the status of the cancellation.
200+
201+
Raises:
202+
APIRequestError: If the cancellation request fails.
203+
ValidationError: If no order with given client_order_id is found.
204+
"""
205+
# First get the order by client_order_id to get its ID
206+
order = self.get_by_client_order_id(client_order_id)
207+
208+
# Then cancel by the actual order ID
209+
return self.cancel_by_id(order.id)
210+
91211
@staticmethod
92212
def check_for_order_errors(
93213
symbol: str,
@@ -148,6 +268,8 @@ def market(
148268
side: str = "buy",
149269
time_in_force: str = "day",
150270
extended_hours: bool = False,
271+
client_order_id: str | None = None,
272+
order_class: str | None = None,
151273
) -> OrderModel:
152274
"""Submits a market order for a specified symbol.
153275
@@ -164,6 +286,8 @@ def market(
164286
(day/gtc/opg/ioc/fok). Defaults to "day".
165287
extended_hours (bool, optional): Whether to trade during extended hours.
166288
Defaults to False.
289+
client_order_id (str, optional): Client-assigned ID for the order. Defaults to None.
290+
order_class (str, optional): Order class (simple/bracket/oco/oto). Defaults to None.
167291
168292
Returns:
169293
OrderModel: An instance of the OrderModel representing the submitted order.
@@ -190,6 +314,8 @@ def market(
190314
entry_type="market",
191315
time_in_force=time_in_force,
192316
extended_hours=extended_hours,
317+
client_order_id=client_order_id,
318+
order_class=order_class,
193319
)
194320

195321
########################################################
@@ -206,6 +332,8 @@ def limit(
206332
side: str = "buy",
207333
time_in_force: str = "day",
208334
extended_hours: bool = False,
335+
client_order_id: str | None = None,
336+
order_class: str | None = None,
209337
) -> OrderModel:
210338
"""Limit order function that submits an order to buy or sell a specified symbol
211339
at a specified limit price.
@@ -226,6 +354,8 @@ def limit(
226354
or "gtc" (good till canceled). Default is "day".
227355
extended_hours (bool, optional): Whether to allow trading during extended
228356
hours. Default is False.
357+
client_order_id (str, optional): Client-assigned ID for the order. Defaults to None.
358+
order_class (str, optional): Order class (simple/bracket/oco/oto). Defaults to None.
229359
230360
Returns:
231361
OrderModel: The submitted order.
@@ -253,6 +383,8 @@ def limit(
253383
entry_type="limit",
254384
time_in_force=time_in_force,
255385
extended_hours=extended_hours,
386+
client_order_id=client_order_id,
387+
order_class=order_class,
256388
)
257389

258390
########################################################
@@ -268,6 +400,8 @@ def stop(
268400
stop_loss: float | None = None,
269401
time_in_force: str = "day",
270402
extended_hours: bool = False,
403+
client_order_id: str | None = None,
404+
order_class: str | None = None,
271405
) -> OrderModel:
272406
"""Args:
273407
@@ -283,6 +417,8 @@ def stop(
283417
Defaults to 'day'.
284418
extended_hours: A boolean value indicating whether to place the order during
285419
extended hours. Defaults to False.
420+
client_order_id: Client-assigned ID for the order. Defaults to None.
421+
order_class: Order class (simple/bracket/oco/oto). Defaults to None.
286422
287423
Returns:
288424
An instance of the OrderModel representing the submitted order.
@@ -311,6 +447,8 @@ def stop(
311447
entry_type="stop",
312448
time_in_force=time_in_force,
313449
extended_hours=extended_hours,
450+
client_order_id=client_order_id,
451+
order_class=order_class,
314452
)
315453

316454
########################################################
@@ -325,6 +463,8 @@ def stop_limit(
325463
side: str = "buy",
326464
time_in_force: str = "day",
327465
extended_hours: bool = False,
466+
client_order_id: str | None = None,
467+
order_class: str | None = None,
328468
) -> OrderModel:
329469
"""Submits a stop-limit order for trading.
330470
@@ -339,6 +479,8 @@ def stop_limit(
339479
Defaults to 'day'.
340480
extended_hours (bool, optional): Whether to allow trading during extended hours.
341481
Defaults to False.
482+
client_order_id (str, optional): Client-assigned ID for the order. Defaults to None.
483+
order_class (str, optional): Order class (simple/bracket/oco/oto). Defaults to None.
342484
343485
Returns:
344486
OrderModel: The submitted stop-limit order.
@@ -366,6 +508,8 @@ def stop_limit(
366508
entry_type="stop_limit",
367509
time_in_force=time_in_force,
368510
extended_hours=extended_hours,
511+
client_order_id=client_order_id,
512+
order_class=order_class,
369513
)
370514

371515
########################################################
@@ -380,6 +524,8 @@ def trailing_stop(
380524
side: str = "buy",
381525
time_in_force: str = "day",
382526
extended_hours: bool = False,
527+
client_order_id: str | None = None,
528+
order_class: str | None = None,
383529
) -> OrderModel:
384530
"""Submits a trailing stop order for the specified symbol.
385531
@@ -392,7 +538,10 @@ def trailing_stop(
392538
`trail_percent` or `trail_price` must be provided, not both. Defaults to None.
393539
side (str, optional): The side of the order, either 'buy' or 'sell'. Defaults to 'buy'.
394540
time_in_force (str, optional): The time in force for the order. Defaults to 'day'.
395-
extended_hours (bool, optional): Whether to allow trading during extended hours.\n Defaults to False.
541+
extended_hours (bool, optional): Whether to allow trading during extended hours.
542+
Defaults to False.
543+
client_order_id (str, optional): Client-assigned ID for the order. Defaults to None.
544+
order_class (str, optional): Order class (simple/bracket/oco/oto). Defaults to None.
396545
397546
Returns:
398547
OrderModel: The submitted trailing stop order.
@@ -426,6 +575,8 @@ def trailing_stop(
426575
entry_type="trailing_stop",
427576
time_in_force=time_in_force,
428577
extended_hours=extended_hours,
578+
client_order_id=client_order_id,
579+
order_class=order_class,
429580
)
430581

431582
########################################################
@@ -446,6 +597,8 @@ def _submit_order(
446597
side: str = "buy",
447598
time_in_force: str = "day",
448599
extended_hours: bool = False,
600+
client_order_id: str | None = None,
601+
order_class: str | None = None,
449602
) -> OrderModel:
450603
"""Submits an order to the Alpaca API.
451604
@@ -470,14 +623,28 @@ def _submit_order(
470623
side (str, optional): The side of the trade (buy or sell). Defaults to "buy".
471624
time_in_force (str, optional): The time in force for the order.
472625
Defaults to "day".
473-
extended_hours (bool, optional): Whether to allow trading during extended hours.\n Defaults to False.
626+
extended_hours (bool, optional): Whether to allow trading during extended hours.
627+
Defaults to False.
628+
client_order_id (str, optional): Client-assigned ID for the order. Defaults to None.
629+
order_class (str, optional): Order class (simple/bracket/oco/oto). Defaults to None.
474630
475631
Returns:
476632
OrderModel: The submitted order.
477633
478634
Raises:
479635
Exception: If the order submission fails.
480636
"""
637+
# Determine order class
638+
if order_class:
639+
# Use explicitly provided order class
640+
final_order_class = order_class
641+
elif take_profit or stop_loss:
642+
# Bracket order if take profit or stop loss is specified
643+
final_order_class = "bracket"
644+
else:
645+
# Default to simple
646+
final_order_class = "simple"
647+
481648
payload = {
482649
"symbol": symbol,
483650
"qty": qty if qty else None,
@@ -486,13 +653,14 @@ def _submit_order(
486653
"limit_price": limit_price if limit_price else None,
487654
"trail_percent": trail_percent if trail_percent else None,
488655
"trail_price": trail_price if trail_price else None,
489-
"order_class": "bracket" if take_profit or stop_loss else "simple",
656+
"order_class": final_order_class,
490657
"take_profit": take_profit,
491658
"stop_loss": stop_loss,
492659
"side": side if side == "buy" else "sell",
493660
"type": entry_type,
494661
"time_in_force": time_in_force,
495662
"extended_hours": extended_hours,
663+
"client_order_id": client_order_id if client_order_id else None,
496664
}
497665

498666
url = f"{self.base_url}/orders"

0 commit comments

Comments
 (0)