pyactuator 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyactuator/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """PyActuator — thin broker-agnostic execution layer for trading."""
2
+
3
+ from pyactuator.client import ExecutionClient
4
+ from pyactuator.types import (
5
+ CancelResponse,
6
+ Fill,
7
+ OrderRequest,
8
+ OrderResponse,
9
+ OrderStatus,
10
+ OrderType,
11
+ Side,
12
+ TimeInForce,
13
+ )
14
+
15
+ __all__ = [
16
+ "CancelResponse",
17
+ "ExecutionClient",
18
+ "Fill",
19
+ "OrderRequest",
20
+ "OrderResponse",
21
+ "OrderStatus",
22
+ "OrderType",
23
+ "Side",
24
+ "TimeInForce",
25
+ ]
@@ -0,0 +1,9 @@
1
+ """Broker adapters implementing ExecutionClient."""
2
+
3
+ from pyactuator.adapters.base import BaseExecutionClient
4
+ from pyactuator.adapters.mock import MockExecutionClient
5
+
6
+ __all__ = [
7
+ "BaseExecutionClient",
8
+ "MockExecutionClient",
9
+ ]
@@ -0,0 +1,272 @@
1
+ """Alpaca broker adapter (requires pyactuator[alpaca])."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import Callable
8
+ from datetime import UTC, datetime
9
+ from decimal import Decimal
10
+ from typing import Any
11
+
12
+ from pyactuator.adapters.base import BaseExecutionClient
13
+ from pyactuator.types import (
14
+ CancelResponse,
15
+ Fill,
16
+ OrderRequest,
17
+ OrderResponse,
18
+ OrderStatus,
19
+ OrderType,
20
+ Side,
21
+ TimeInForce,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ try:
27
+ from alpaca.trading.client import TradingClient
28
+ from alpaca.trading.enums import (
29
+ OrderSide as AlpacaOrderSide,
30
+ )
31
+ from alpaca.trading.enums import (
32
+ TimeInForce as AlpacaTimeInForce,
33
+ )
34
+ from alpaca.trading.requests import (
35
+ LimitOrderRequest,
36
+ MarketOrderRequest,
37
+ StopLimitOrderRequest,
38
+ StopOrderRequest,
39
+ )
40
+ from alpaca.trading.stream import TradingStream
41
+
42
+ _ALPACA_AVAILABLE = True
43
+ except ImportError:
44
+ _ALPACA_AVAILABLE = False
45
+
46
+
47
+ def _alpaca_required() -> None:
48
+ if not _ALPACA_AVAILABLE:
49
+ raise ImportError(
50
+ "alpaca-py is required for AlpacaExecutionClient. "
51
+ "Install with: pip install pyactuator[alpaca]"
52
+ )
53
+
54
+
55
+ def _map_status(alpaca_status: str | None) -> str:
56
+ """Map Alpaca order status to normalized status."""
57
+ if not alpaca_status:
58
+ return "pending"
59
+ s = alpaca_status.lower()
60
+ if s in ("pending_new", "accepted", "new"):
61
+ return "open" if s == "accepted" or s == "new" else "pending_new"
62
+ if s in ("partially_filled", "filled", "canceled", "rejected", "expired"):
63
+ return s
64
+ return s
65
+
66
+
67
+ class AlpacaExecutionClient(BaseExecutionClient):
68
+ """Alpaca execution adapter using alpaca-py.
69
+
70
+ Sync SDK calls are run in asyncio.to_thread so the client is non-blocking.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ api_key: str,
76
+ api_secret: str,
77
+ *,
78
+ paper: bool = True,
79
+ base_url: str | None = None,
80
+ ) -> None:
81
+ _alpaca_required()
82
+ self._api_key = api_key
83
+ self._api_secret = api_secret
84
+ self._paper = paper
85
+ self._base_url = base_url
86
+ self._client = TradingClient(
87
+ api_key=api_key,
88
+ secret_key=api_secret,
89
+ paper=paper,
90
+ url_override=base_url,
91
+ )
92
+ self._stream: TradingStream | None = None
93
+ self._fill_callback: Callable[[Fill], None] | None = None
94
+ self._stream_task: asyncio.Task[None] | None = None
95
+
96
+ def _build_order_request(self, order: OrderRequest) -> Any:
97
+ common = {
98
+ "symbol": order.symbol,
99
+ "qty": float(order.quantity),
100
+ "side": AlpacaOrderSide.BUY if order.side == Side.BUY else AlpacaOrderSide.SELL,
101
+ "time_in_force": self._map_tif(order.time_in_force),
102
+ "client_order_id": order.client_order_id,
103
+ "extended_hours": order.extended_hours,
104
+ }
105
+ if order.order_type == OrderType.MARKET:
106
+ return MarketOrderRequest(**common)
107
+ if order.order_type == OrderType.LIMIT:
108
+ if order.limit_price is None:
109
+ raise ValueError("Limit price required for limit orders")
110
+ return LimitOrderRequest(**common, limit_price=float(order.limit_price))
111
+ if order.order_type == OrderType.STOP:
112
+ if order.stop_price is None:
113
+ raise ValueError("Stop price required for stop orders")
114
+ return StopOrderRequest(**common, stop_price=float(order.stop_price))
115
+ if order.order_type == OrderType.STOP_LIMIT:
116
+ if order.limit_price is None or order.stop_price is None:
117
+ raise ValueError("Stop and limit price required for stop_limit")
118
+ return StopLimitOrderRequest(
119
+ **common,
120
+ stop_price=float(order.stop_price),
121
+ limit_price=float(order.limit_price),
122
+ )
123
+ raise ValueError(f"Unsupported order type: {order.order_type}")
124
+
125
+ def _map_tif(self, tif: TimeInForce) -> Any:
126
+ m = {
127
+ TimeInForce.DAY: AlpacaTimeInForce.DAY,
128
+ TimeInForce.GTC: AlpacaTimeInForce.GTC,
129
+ TimeInForce.IOC: AlpacaTimeInForce.IOC,
130
+ TimeInForce.FOK: AlpacaTimeInForce.FOK,
131
+ TimeInForce.OPG: AlpacaTimeInForce.OPG,
132
+ TimeInForce.CLS: AlpacaTimeInForce.CLS,
133
+ }
134
+ return m.get(tif, AlpacaTimeInForce.DAY)
135
+
136
+ async def submit(self, order: OrderRequest) -> OrderResponse:
137
+ _alpaca_required()
138
+ try:
139
+ alpaca_req = self._build_order_request(order)
140
+ alpaca_order = await asyncio.to_thread(
141
+ self._client.submit_order,
142
+ alpaca_req,
143
+ )
144
+ return OrderResponse(
145
+ success=True,
146
+ external_order_id=str(alpaca_order.id),
147
+ client_order_id=alpaca_order.client_order_id,
148
+ status=_map_status(alpaca_order.status.value if alpaca_order.status else None),
149
+ raw={
150
+ "id": str(alpaca_order.id),
151
+ "status": getattr(alpaca_order.status, "value", None),
152
+ },
153
+ )
154
+ except Exception as e:
155
+ logger.exception("Alpaca submit failed")
156
+ return OrderResponse(
157
+ success=False,
158
+ client_order_id=order.client_order_id,
159
+ message=str(e),
160
+ )
161
+
162
+ async def get_status(self, external_order_id: str) -> OrderStatus:
163
+ _alpaca_required()
164
+ alpaca_order = await asyncio.to_thread(
165
+ self._client.get_order_by_id,
166
+ external_order_id,
167
+ )
168
+ side = Side.BUY if (alpaca_order.side and alpaca_order.side.value == "buy") else Side.SELL
169
+ return OrderStatus(
170
+ external_order_id=str(alpaca_order.id),
171
+ client_order_id=alpaca_order.client_order_id,
172
+ symbol=alpaca_order.symbol or "",
173
+ side=side,
174
+ status=_map_status(alpaca_order.status.value if alpaca_order.status else None),
175
+ quantity=Decimal(str(alpaca_order.qty)) if alpaca_order.qty else Decimal("0"),
176
+ filled_quantity=Decimal(str(alpaca_order.filled_qty))
177
+ if alpaca_order.filled_qty
178
+ else Decimal("0"),
179
+ filled_avg_price=Decimal(str(alpaca_order.filled_avg_price))
180
+ if alpaca_order.filled_avg_price
181
+ else None,
182
+ limit_price=Decimal(str(alpaca_order.limit_price))
183
+ if alpaca_order.limit_price
184
+ else None,
185
+ stop_price=Decimal(str(alpaca_order.stop_price)) if alpaca_order.stop_price else None,
186
+ created_at=alpaca_order.created_at,
187
+ updated_at=alpaca_order.updated_at,
188
+ )
189
+
190
+ async def cancel(self, external_order_id: str) -> CancelResponse:
191
+ _alpaca_required()
192
+ try:
193
+ await asyncio.to_thread(
194
+ self._client.cancel_order_by_id,
195
+ external_order_id,
196
+ )
197
+ return CancelResponse(success=True, external_order_id=external_order_id)
198
+ except Exception as e:
199
+ logger.exception("Alpaca cancel failed")
200
+ return CancelResponse(
201
+ success=False,
202
+ external_order_id=external_order_id,
203
+ message=str(e),
204
+ )
205
+
206
+ async def subscribe_fills(self, callback: Callable[[Fill], None]) -> None:
207
+ _alpaca_required()
208
+ self._fill_callback = callback
209
+ self._stream = TradingStream(
210
+ api_key=self._api_key,
211
+ secret_key=self._api_secret,
212
+ paper=self._paper,
213
+ )
214
+
215
+ @self._stream.subscribe_trade_updates # type: ignore[untyped-decorator]
216
+ async def on_trade_update(data: Any) -> None:
217
+ event = getattr(data, "event", None)
218
+ if event not in ("fill", "partial_fill"):
219
+ return
220
+ if not self._fill_callback:
221
+ return
222
+ order = getattr(data, "order", None)
223
+ if not order:
224
+ return
225
+ fill_id = f"{getattr(order, 'id', '')}_{getattr(data, 'timestamp', '')}"
226
+ qty = getattr(data, "qty", None) or getattr(order, "filled_qty", 0)
227
+ price = getattr(data, "price", None) or getattr(order, "filled_avg_price", 0)
228
+ ts = getattr(data, "timestamp", None) or datetime.now(UTC)
229
+ side = (
230
+ Side.BUY
231
+ if (getattr(order, "side", None) and getattr(order.side, "value", "") == "buy")
232
+ else Side.SELL
233
+ )
234
+ fill = Fill(
235
+ fill_id=fill_id,
236
+ external_order_id=str(getattr(order, "id", "")),
237
+ client_order_id=getattr(order, "client_order_id", None),
238
+ symbol=getattr(order, "symbol", ""),
239
+ side=side,
240
+ quantity=Decimal(str(qty)),
241
+ price=Decimal(str(price)),
242
+ commission=None,
243
+ executed_at=ts,
244
+ )
245
+ self._fill_callback(fill)
246
+
247
+ self._stream_task = asyncio.create_task(self._run_stream())
248
+ logger.info("Subscribed to Alpaca trade updates")
249
+
250
+ async def _run_stream(self) -> None:
251
+ if not self._stream:
252
+ return
253
+ try:
254
+ await self._stream._run_forever()
255
+ except asyncio.CancelledError:
256
+ pass
257
+ except Exception as e:
258
+ logger.error("Alpaca stream error: %s", e)
259
+
260
+ async def close(self) -> None:
261
+ if self._stream_task:
262
+ self._stream_task.cancel()
263
+ try:
264
+ await self._stream_task
265
+ except asyncio.CancelledError:
266
+ pass
267
+ self._stream_task = None
268
+ if self._stream:
269
+ await self._stream.close()
270
+ self._stream = None
271
+ self._fill_callback = None
272
+ logger.debug("AlpacaExecutionClient closed")
@@ -0,0 +1,39 @@
1
+ """Abstract base for execution adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Callable
7
+
8
+ from pyactuator.types import CancelResponse, Fill, OrderRequest, OrderResponse, OrderStatus
9
+
10
+
11
+ class BaseExecutionClient(ABC):
12
+ """Abstract base class for execution adapters.
13
+
14
+ Implements the ExecutionClient protocol; subclasses provide broker-specific
15
+ logic for submit, get_status, cancel, subscribe_fills, and close.
16
+ """
17
+
18
+ @abstractmethod
19
+ async def submit(self, order: OrderRequest) -> OrderResponse:
20
+ """Submit an order to the broker."""
21
+ ...
22
+
23
+ @abstractmethod
24
+ async def get_status(self, external_order_id: str) -> OrderStatus:
25
+ """Get current status of an order."""
26
+ ...
27
+
28
+ @abstractmethod
29
+ async def cancel(self, external_order_id: str) -> CancelResponse:
30
+ """Cancel an existing order."""
31
+ ...
32
+
33
+ async def subscribe_fills(self, callback: Callable[[Fill], None]) -> None: # noqa: B027
34
+ """Subscribe to fill updates. Default: no-op (adapter may override)."""
35
+ pass
36
+
37
+ async def close(self) -> None: # noqa: B027
38
+ """Release resources. Default: no-op (adapter may override)."""
39
+ pass
@@ -0,0 +1,170 @@
1
+ """Mock/paper execution adapter for tests and paper trading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from decimal import Decimal
10
+
11
+ from pyactuator.adapters.base import BaseExecutionClient
12
+ from pyactuator.types import (
13
+ CancelResponse,
14
+ Fill,
15
+ OrderRequest,
16
+ OrderResponse,
17
+ OrderStatus,
18
+ Side,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class _MockOrder:
24
+ """In-memory order record."""
25
+
26
+ external_id: str
27
+ client_order_id: str
28
+ symbol: str
29
+ side: Side
30
+ quantity: Decimal
31
+ filled_quantity: Decimal
32
+ filled_avg_price: Decimal | None
33
+ limit_price: Decimal | None
34
+ stop_price: Decimal | None
35
+ status: str # pending_new, open, filled, partially_filled, canceled, rejected
36
+ created_at: datetime
37
+ updated_at: datetime
38
+
39
+
40
+ class MockExecutionClient(BaseExecutionClient):
41
+ """In-memory execution client for tests and paper trading.
42
+
43
+ submit() returns success with a generated external_order_id. Optionally
44
+ configure scripted fills (e.g. immediate full fill at a price) via
45
+ fill_style or a custom fill callback.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ default_fill_price: Decimal | None = None,
52
+ immediate_fill: bool = True,
53
+ fill_callback: Callable[[OrderRequest, str], Fill | None] | None = None,
54
+ ) -> None:
55
+ """Initialize mock client.
56
+
57
+ Args:
58
+ default_fill_price: If set and immediate_fill is True, use this price for fills.
59
+ immediate_fill: If True, after submit we immediately "fill" the order (status=filled)
60
+ using default_fill_price or 0 if not set.
61
+ fill_callback: If set, called (order, external_id) after submit; if it returns a Fill,
62
+ that fill is recorded and (if subscribe_fills was used) the callback is invoked.
63
+ """
64
+ self._orders: dict[str, _MockOrder] = {}
65
+ self._fills: list[Fill] = []
66
+ self._fill_callback = fill_callback
67
+ self._default_fill_price = default_fill_price or Decimal("0")
68
+ self._immediate_fill = immediate_fill
69
+ self._subscribed_callback: Callable[[Fill], None] | None = None
70
+
71
+ async def submit(self, order: OrderRequest) -> OrderResponse:
72
+ external_id = str(uuid.uuid4())
73
+ now = datetime.now(UTC)
74
+ mock_order = _MockOrder(
75
+ external_id=external_id,
76
+ client_order_id=order.client_order_id,
77
+ symbol=order.symbol,
78
+ side=order.side,
79
+ quantity=order.quantity,
80
+ filled_quantity=Decimal("0"),
81
+ filled_avg_price=None,
82
+ limit_price=order.limit_price,
83
+ stop_price=order.stop_price,
84
+ status="open",
85
+ created_at=now,
86
+ updated_at=now,
87
+ )
88
+ self._orders[external_id] = mock_order
89
+
90
+ if self._immediate_fill:
91
+ fill = Fill(
92
+ fill_id=f"{external_id}-fill",
93
+ external_order_id=external_id,
94
+ client_order_id=order.client_order_id,
95
+ symbol=order.symbol,
96
+ side=order.side,
97
+ quantity=order.quantity,
98
+ price=self._default_fill_price,
99
+ commission=None,
100
+ executed_at=now,
101
+ )
102
+ mock_order.filled_quantity = order.quantity
103
+ mock_order.filled_avg_price = self._default_fill_price
104
+ mock_order.status = "filled"
105
+ mock_order.updated_at = now
106
+ self._fills.append(fill)
107
+ if self._subscribed_callback:
108
+ self._subscribed_callback(fill)
109
+ elif self._fill_callback:
110
+ custom_fill = self._fill_callback(order, external_id)
111
+ if custom_fill:
112
+ self._fills.append(custom_fill)
113
+ mock_order.filled_quantity = custom_fill.quantity
114
+ mock_order.filled_avg_price = custom_fill.price
115
+ mock_order.status = (
116
+ "filled" if custom_fill.quantity >= order.quantity else "partially_filled"
117
+ )
118
+ mock_order.updated_at = custom_fill.executed_at or now
119
+ if self._subscribed_callback:
120
+ self._subscribed_callback(custom_fill)
121
+
122
+ return OrderResponse(
123
+ success=True,
124
+ external_order_id=external_id,
125
+ client_order_id=order.client_order_id,
126
+ status=mock_order.status,
127
+ )
128
+
129
+ async def get_status(self, external_order_id: str) -> OrderStatus:
130
+ if external_order_id not in self._orders:
131
+ raise LookupError(f"Order not found: {external_order_id}")
132
+ o = self._orders[external_order_id]
133
+ return OrderStatus(
134
+ external_order_id=o.external_id,
135
+ client_order_id=o.client_order_id,
136
+ symbol=o.symbol,
137
+ side=o.side,
138
+ status=o.status,
139
+ quantity=o.quantity,
140
+ filled_quantity=o.filled_quantity,
141
+ filled_avg_price=o.filled_avg_price,
142
+ limit_price=o.limit_price,
143
+ stop_price=o.stop_price,
144
+ created_at=o.created_at,
145
+ updated_at=o.updated_at,
146
+ )
147
+
148
+ async def cancel(self, external_order_id: str) -> CancelResponse:
149
+ if external_order_id not in self._orders:
150
+ return CancelResponse(
151
+ success=False,
152
+ external_order_id=external_order_id,
153
+ message="Order not found",
154
+ )
155
+ o = self._orders[external_order_id]
156
+ if o.status in ("filled", "canceled", "rejected"):
157
+ return CancelResponse(
158
+ success=False,
159
+ external_order_id=external_order_id,
160
+ message=f"Order already {o.status}",
161
+ )
162
+ o.status = "canceled"
163
+ o.updated_at = datetime.now(UTC)
164
+ return CancelResponse(success=True, external_order_id=external_order_id)
165
+
166
+ async def subscribe_fills(self, callback: Callable[[Fill], None]) -> None:
167
+ self._subscribed_callback = callback
168
+
169
+ async def close(self) -> None:
170
+ self._subscribed_callback = None
pyactuator/client.py ADDED
@@ -0,0 +1,68 @@
1
+ """ExecutionClient protocol — interface implemented by all broker adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ from pyactuator.types import CancelResponse, Fill, OrderRequest, OrderResponse, OrderStatus
9
+
10
+
11
+ @runtime_checkable
12
+ class ExecutionClient(Protocol):
13
+ """Protocol for execution adapters.
14
+
15
+ All broker implementations (Alpaca, Mock, future IB/crypto) implement this
16
+ interface so the stack (pystator, OrderManager) stays broker-agnostic.
17
+ """
18
+
19
+ async def submit(self, order: OrderRequest) -> OrderResponse:
20
+ """Submit an order to the broker.
21
+
22
+ Args:
23
+ order: Order request with client_order_id, symbol, side, quantity, etc.
24
+
25
+ Returns:
26
+ OrderResponse with success, external_order_id, status, or rejection message.
27
+ """
28
+ ...
29
+
30
+ async def get_status(self, external_order_id: str) -> OrderStatus:
31
+ """Get current status of an order.
32
+
33
+ Args:
34
+ external_order_id: Broker's order ID.
35
+
36
+ Returns:
37
+ Normalized OrderStatus (symbol, side, status, filled_quantity, etc.).
38
+
39
+ Raises:
40
+ LookupError or broker-specific error if order not found.
41
+ """
42
+ ...
43
+
44
+ async def cancel(self, external_order_id: str) -> CancelResponse:
45
+ """Cancel an existing order.
46
+
47
+ Args:
48
+ external_order_id: Broker's order ID to cancel.
49
+
50
+ Returns:
51
+ CancelResponse with success and optional message.
52
+ """
53
+ ...
54
+
55
+ async def subscribe_fills(self, callback: Callable[[Fill], None]) -> None:
56
+ """Subscribe to execution (fill) updates.
57
+
58
+ Adapter-dependent: Alpaca uses TradingStream; mock may invoke callback
59
+ from scripted fills. Callback may be invoked from a background task.
60
+
61
+ Args:
62
+ callback: Function called when a fill is received (same event loop or thread).
63
+ """
64
+ ...
65
+
66
+ async def close(self) -> None:
67
+ """Release resources (streams, connections). Call when done with the client."""
68
+ ...
@@ -0,0 +1,13 @@
1
+ """Optional execution helpers: retry, idempotency."""
2
+
3
+ from pyactuator.helpers.idempotency import (
4
+ generate_client_order_id,
5
+ validate_client_order_id,
6
+ )
7
+ from pyactuator.helpers.retry import RetryExecutionClient
8
+
9
+ __all__ = [
10
+ "RetryExecutionClient",
11
+ "generate_client_order_id",
12
+ "validate_client_order_id",
13
+ ]
@@ -0,0 +1,67 @@
1
+ """Idempotency key handling for execution.
2
+
3
+ Alpaca (and many brokers) support client_order_id as the idempotency key:
4
+ submitting the same client_order_id again is deduplicated. This module
5
+ provides helpers to generate and validate idempotency keys so callers
6
+ (e.g. pystator, OrderManager) pass a consistent key.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import uuid
13
+
14
+ # Alpaca allows max 128 characters for client_order_id
15
+ MAX_CLIENT_ORDER_ID_LENGTH = 128
16
+
17
+
18
+ def generate_client_order_id(
19
+ prefix: str = "pa",
20
+ *,
21
+ order_id: str | None = None,
22
+ event_id: str | None = None,
23
+ max_length: int = MAX_CLIENT_ORDER_ID_LENGTH,
24
+ ) -> str:
25
+ """Generate a client order ID suitable for idempotency.
26
+
27
+ Uses prefix + short hash of (order_id or event_id or uuid) so the same
28
+ logical order always yields the same client_order_id.
29
+
30
+ Args:
31
+ prefix: Short prefix (e.g. "pa" for pyactuator, "tc" for trading controller).
32
+ order_id: Optional order ID (e.g. from pystator or DB).
33
+ event_id: Optional event ID if no order_id.
34
+ max_length: Cap length (Alpaca: 128).
35
+
36
+ Returns:
37
+ String safe for use as client_order_id.
38
+ """
39
+ raw = order_id or event_id or str(uuid.uuid4())
40
+ h = hashlib.sha256(raw.encode()).hexdigest()[:16]
41
+ candidate = f"{prefix}-{h}"
42
+ if len(candidate) <= max_length:
43
+ return candidate
44
+ return candidate[:max_length]
45
+
46
+
47
+ def validate_client_order_id(
48
+ client_order_id: str,
49
+ *,
50
+ max_length: int = MAX_CLIENT_ORDER_ID_LENGTH,
51
+ allowed_chars: str | None = None,
52
+ ) -> bool:
53
+ """Validate client_order_id length and optionally character set.
54
+
55
+ Args:
56
+ client_order_id: The ID to validate.
57
+ max_length: Max allowed length (default 128 for Alpaca).
58
+ allowed_chars: If set, only these characters are allowed (e.g. alphanumeric + hyphen).
59
+
60
+ Returns:
61
+ True if valid.
62
+ """
63
+ if len(client_order_id) > max_length or len(client_order_id) == 0:
64
+ return False
65
+ if allowed_chars is not None:
66
+ return all(c in allowed_chars for c in client_order_id)
67
+ return True
@@ -0,0 +1,97 @@
1
+ """Optional retry policy for submit/cancel.
2
+
3
+ Wrap an ExecutionClient to retry on transient failures (e.g. network errors).
4
+ Do not retry on validation or rejection (success=False with message).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import random
12
+ from collections.abc import Callable
13
+
14
+ from pyactuator.client import ExecutionClient
15
+ from pyactuator.types import CancelResponse, Fill, OrderRequest, OrderResponse, OrderStatus
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _is_retryable_submit(response: OrderResponse) -> bool:
21
+ """True if we should retry (transient failure). False for rejections."""
22
+ if response.success:
23
+ return False
24
+ msg = (response.message or "").lower()
25
+ if "insufficient" in msg or "invalid" in msg or "reject" in msg or "not allowed" in msg:
26
+ return False
27
+ return True
28
+
29
+
30
+ def _is_retryable_cancel(response: CancelResponse) -> bool:
31
+ if response.success:
32
+ return False
33
+ msg = (response.message or "").lower()
34
+ if "not found" in msg or "already" in msg:
35
+ return False
36
+ return True
37
+
38
+
39
+ class RetryExecutionClient:
40
+ """Wraps an ExecutionClient with retry for submit and cancel."""
41
+
42
+ def __init__(
43
+ self,
44
+ client: ExecutionClient,
45
+ *,
46
+ max_attempts: int = 3,
47
+ base_delay: float = 1.0,
48
+ max_delay: float = 30.0,
49
+ retry_submit: bool = True,
50
+ retry_cancel: bool = True,
51
+ ) -> None:
52
+ self._client = client
53
+ self._max_attempts = max_attempts
54
+ self._base_delay = base_delay
55
+ self._max_delay = max_delay
56
+ self._retry_submit = retry_submit
57
+ self._retry_cancel = retry_cancel
58
+
59
+ async def submit(self, order: OrderRequest) -> OrderResponse:
60
+ attempt = 0
61
+ while True:
62
+ response = await self._client.submit(order)
63
+ if (
64
+ not self._retry_submit
65
+ or not _is_retryable_submit(response)
66
+ or attempt >= self._max_attempts - 1
67
+ ):
68
+ return response
69
+ attempt += 1
70
+ delay = min(self._base_delay * (2**attempt), self._max_delay) * (0.5 + random.random())
71
+ logger.warning(
72
+ "Submit attempt %s returned retryable failure; retrying in %.2fs", attempt, delay
73
+ )
74
+ await asyncio.sleep(delay)
75
+
76
+ async def get_status(self, external_order_id: str) -> OrderStatus:
77
+ return await self._client.get_status(external_order_id)
78
+
79
+ async def cancel(self, external_order_id: str) -> CancelResponse:
80
+ attempt = 0
81
+ while True:
82
+ response = await self._client.cancel(external_order_id)
83
+ if (
84
+ not self._retry_cancel
85
+ or not _is_retryable_cancel(response)
86
+ or attempt >= self._max_attempts - 1
87
+ ):
88
+ return response
89
+ attempt += 1
90
+ delay = min(self._base_delay * (2**attempt), self._max_delay) * (0.5 + random.random())
91
+ await asyncio.sleep(delay)
92
+
93
+ async def subscribe_fills(self, callback: Callable[[Fill], None]) -> None:
94
+ await self._client.subscribe_fills(callback)
95
+
96
+ async def close(self) -> None:
97
+ await self._client.close()
pyactuator/py.typed ADDED
File without changes
pyactuator/types.py ADDED
@@ -0,0 +1,159 @@
1
+ """Core execution types: order request/response, status, fill, cancel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from decimal import Decimal
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+
12
+ class Side(StrEnum):
13
+ """Order side."""
14
+
15
+ BUY = "buy"
16
+ SELL = "sell"
17
+
18
+
19
+ class OrderType(StrEnum):
20
+ """Order type."""
21
+
22
+ MARKET = "market"
23
+ LIMIT = "limit"
24
+ STOP = "stop"
25
+ STOP_LIMIT = "stop_limit"
26
+
27
+
28
+ class TimeInForce(StrEnum):
29
+ """Order time in force."""
30
+
31
+ DAY = "day"
32
+ GTC = "gtc"
33
+ IOC = "ioc"
34
+ FOK = "fok"
35
+ OPG = "opg"
36
+ CLS = "cls"
37
+
38
+
39
+ @dataclass
40
+ class OrderRequest:
41
+ """Request to submit an order to the broker.
42
+
43
+ Attributes:
44
+ client_order_id: Our internal order ID (also used as idempotency key; max 128 chars for Alpaca).
45
+ symbol: Trading symbol (e.g. "AAPL").
46
+ side: Buy or sell.
47
+ quantity: Order quantity.
48
+ order_type: Market, limit, stop, stop_limit.
49
+ time_in_force: Day, GTC, IOC, FOK, OPG, CLS.
50
+ limit_price: Limit price (required for limit/stop_limit).
51
+ stop_price: Stop price (required for stop/stop_limit).
52
+ extended_hours: If true, order eligible for premarket/afterhours (Alpaca: limit + day only).
53
+ """
54
+
55
+ client_order_id: str
56
+ symbol: str
57
+ side: Side
58
+ quantity: Decimal
59
+ order_type: OrderType = OrderType.MARKET
60
+ time_in_force: TimeInForce = TimeInForce.DAY
61
+ limit_price: Decimal | None = None
62
+ stop_price: Decimal | None = None
63
+ extended_hours: bool = False
64
+
65
+
66
+ @dataclass
67
+ class OrderResponse:
68
+ """Response from order submission.
69
+
70
+ Attributes:
71
+ success: Whether the order was accepted by the broker.
72
+ external_order_id: Broker's order ID.
73
+ client_order_id: Our client order ID.
74
+ status: Initial order status from broker (e.g. pending, open, accepted).
75
+ message: Optional message (e.g. rejection reason).
76
+ raw: Raw broker response for debugging.
77
+ """
78
+
79
+ success: bool
80
+ external_order_id: str | None = None
81
+ client_order_id: str | None = None
82
+ status: str = "pending"
83
+ message: str | None = None
84
+ raw: dict[str, Any] | None = None
85
+
86
+
87
+ @dataclass
88
+ class OrderStatus:
89
+ """Normalized order status (from get_status).
90
+
91
+ Attributes:
92
+ external_order_id: Broker's order ID.
93
+ client_order_id: Our client order ID.
94
+ symbol: Trading symbol.
95
+ side: Buy or sell.
96
+ status: pending_new, open, filled, partially_filled, canceled, rejected, expired.
97
+ quantity: Order quantity.
98
+ filled_quantity: Filled quantity so far.
99
+ filled_avg_price: Average fill price.
100
+ limit_price: Limit price if applicable.
101
+ stop_price: Stop price if applicable.
102
+ created_at: Order creation time.
103
+ updated_at: Last update time.
104
+ """
105
+
106
+ external_order_id: str
107
+ client_order_id: str | None
108
+ symbol: str
109
+ side: Side
110
+ status: str
111
+ quantity: Decimal
112
+ filled_quantity: Decimal
113
+ filled_avg_price: Decimal | None
114
+ limit_price: Decimal | None
115
+ stop_price: Decimal | None
116
+ created_at: datetime | None
117
+ updated_at: datetime | None
118
+
119
+
120
+ @dataclass
121
+ class Fill:
122
+ """Execution (fill) report from broker.
123
+
124
+ Attributes:
125
+ fill_id: Broker's execution/fill ID.
126
+ external_order_id: Broker's order ID.
127
+ client_order_id: Our client order ID.
128
+ symbol: Trading symbol.
129
+ side: Buy or sell.
130
+ quantity: Fill quantity.
131
+ price: Fill price.
132
+ commission: Commission charged (optional).
133
+ executed_at: Execution timestamp.
134
+ """
135
+
136
+ fill_id: str
137
+ external_order_id: str
138
+ client_order_id: str | None
139
+ symbol: str
140
+ side: Side
141
+ quantity: Decimal
142
+ price: Decimal
143
+ commission: Decimal | None = None
144
+ executed_at: datetime | None = None
145
+
146
+
147
+ @dataclass
148
+ class CancelResponse:
149
+ """Response from order cancellation.
150
+
151
+ Attributes:
152
+ success: Whether the cancel was accepted.
153
+ external_order_id: Broker's order ID.
154
+ message: Optional message.
155
+ """
156
+
157
+ success: bool
158
+ external_order_id: str | None = None
159
+ message: str | None = None
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyactuator
3
+ Version: 0.0.1
4
+ Summary: Thin broker-agnostic execution layer for trading: order submission, status, and fills
5
+ Author-email: StatFYI <contact@statfyi.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/statfyi/pyactuator
8
+ Project-URL: Documentation, https://github.com/statfyi/pyactuator#readme
9
+ Project-URL: Repository, https://github.com/statfyi/pyactuator
10
+ Project-URL: Issues, https://github.com/statfyi/pyactuator/issues
11
+ Project-URL: Changelog, https://github.com/statfyi/pyactuator/blob/main/CHANGELOG.md
12
+ Keywords: trading,execution,broker,alpaca,order,finance
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Classifier: Topic :: Office/Business :: Financial
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ Provides-Extra: alpaca
25
+ Requires-Dist: alpaca-py>=0.14.0; extra == "alpaca"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.5; extra == "dev"
32
+ Requires-Dist: pre-commit>=3.0; extra == "dev"
33
+ Requires-Dist: build>=1.0; extra == "dev"
34
+ Requires-Dist: twine>=5.0; extra == "dev"
35
+
36
+ # pyactuator
37
+
38
+ Thin broker-agnostic execution layer for trading systems: submit orders, poll status, cancel, and (optionally) subscribe to fills. Designed to sit between your FSM/OMS (e.g. pystator) and broker APIs (Alpaca, future IB/crypto).
39
+
40
+ ## Features
41
+
42
+ - **Normalized types**: `OrderRequest`, `OrderResponse`, `OrderStatus`, `Fill` — your stack stays broker-agnostic.
43
+ - **ExecutionClient protocol**: One interface (`submit`, `get_status`, `cancel`, optional `subscribe_fills`) implemented per broker.
44
+ - **Adapters**: Alpaca (via alpaca-py), Mock (in-memory for tests and paper).
45
+ - **Optional helpers**: Retry policy, idempotency key handling, timeout wrapper.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ # Core only (types, protocol, mock adapter)
51
+ pip install pyactuator
52
+
53
+ # With Alpaca broker support
54
+ pip install pyactuator[alpaca]
55
+
56
+ # Development
57
+ pip install -e ".[dev]"
58
+ ```
59
+
60
+ ## Quick start
61
+
62
+ ```python
63
+ from decimal import Decimal
64
+ from pyactuator import ExecutionClient, OrderRequest, Side, OrderType, TimeInForce
65
+ from pyactuator.adapters.mock import MockExecutionClient
66
+
67
+ # Use mock for tests or paper
68
+ client: ExecutionClient = MockExecutionClient()
69
+
70
+ order = OrderRequest(
71
+ client_order_id="my-order-001",
72
+ symbol="AAPL",
73
+ side=Side.BUY,
74
+ quantity=Decimal("10"),
75
+ order_type=OrderType.MARKET,
76
+ time_in_force=TimeInForce.DAY,
77
+ )
78
+ response = await client.submit(order)
79
+ print(response.success, response.external_order_id)
80
+
81
+ status = await client.get_status(response.external_order_id)
82
+ await client.close()
83
+ ```
84
+
85
+ With Alpaca (requires `pip install pyactuator[alpaca]`):
86
+
87
+ ```python
88
+ from pyactuator.adapters.alpaca import AlpacaExecutionClient
89
+
90
+ client = AlpacaExecutionClient(
91
+ api_key="...",
92
+ api_secret="...",
93
+ paper=True,
94
+ )
95
+ # Same OrderRequest / submit / get_status / cancel
96
+ ```
97
+
98
+ Optional retry wrapper and idempotency helpers:
99
+
100
+ ```python
101
+ from pyactuator.helpers import RetryExecutionClient, generate_client_order_id
102
+ from pyactuator.adapters.alpaca import AlpacaExecutionClient
103
+
104
+ client = AlpacaExecutionClient(api_key="...", api_secret="...", paper=True)
105
+ client = RetryExecutionClient(client, max_attempts=3)
106
+ order_id = generate_client_order_id(prefix="pa", order_id="my-internal-id")
107
+ order = OrderRequest(client_order_id=order_id, symbol="AAPL", side=Side.BUY, quantity=Decimal("10"), ...)
108
+ ```
109
+
110
+ ## Integration with pystator
111
+
112
+ Your FSM or OrderManager receives an `ExecutionClient` (injected or constructed). When the FSM triggers "submit" (e.g. after risk approval via pyfortis), call `await client.submit(order_request)`. pystator stays broker-agnostic; execution is behind this single interface.
113
+
114
+ ## License
115
+
116
+ MIT.
@@ -0,0 +1,15 @@
1
+ pyactuator/__init__.py,sha256=zmoc51-mhcTxq5z_g9ZpclVfTpBa4YN4-AUY1o-t7bM,457
2
+ pyactuator/client.py,sha256=eFNT1XyLNXNHC-TK3IL6qhjuEHeygx-D9QWjddU2GdM,2121
3
+ pyactuator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pyactuator/types.py,sha256=OcvDyI9ujlt4nB1IAZHDnHbIndnndh1t0E3z6JUDqIE,4170
5
+ pyactuator/adapters/__init__.py,sha256=PmysjZH8TejJzDPgagV1Fi3tm4EF6DD80ylhvOXMOAY,236
6
+ pyactuator/adapters/alpaca.py,sha256=6gm9OTd3W1HbjnI0QYOGMtoEcrsrifWOItXzzmhtzug,9956
7
+ pyactuator/adapters/base.py,sha256=bvld5i6YS6AvkH5FhhABFzxQp-YOYAeK51Ya5a5H4y4,1250
8
+ pyactuator/adapters/mock.py,sha256=3dPh-S-Nl0CM8NVrfG4H5R5FqdAzGL3ygZcY8dQhvP0,6162
9
+ pyactuator/helpers/__init__.py,sha256=muY47fUNtFsycmtZD8lEO-iGdduv3ZihWND5OuvsCz8,327
10
+ pyactuator/helpers/idempotency.py,sha256=fQON1CgsRkPFKK5XN8ikloJswbsKr3aZ6DipPyrbw9M,2115
11
+ pyactuator/helpers/retry.py,sha256=NruWZG9K8ROf-iuZQ6fuMut3qBmP2VIAZBuU219fhvY,3242
12
+ pyactuator-0.0.1.dist-info/METADATA,sha256=XwtdJSK6903y4DOpv-Jj1oUoD7D191vnnjbJO7pqHJc,4176
13
+ pyactuator-0.0.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
14
+ pyactuator-0.0.1.dist-info/top_level.txt,sha256=op74AikGGPFvoHjaSIz8-1iXgH4g9vSOLIyjlMdiUV4,11
15
+ pyactuator-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pyactuator