pyactuator 0.0.5__tar.gz

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.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyactuator
3
+ Version: 0.0.5
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,81 @@
1
+ # pyactuator
2
+
3
+ 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).
4
+
5
+ ## Features
6
+
7
+ - **Normalized types**: `OrderRequest`, `OrderResponse`, `OrderStatus`, `Fill` — your stack stays broker-agnostic.
8
+ - **ExecutionClient protocol**: One interface (`submit`, `get_status`, `cancel`, optional `subscribe_fills`) implemented per broker.
9
+ - **Adapters**: Alpaca (via alpaca-py), Mock (in-memory for tests and paper).
10
+ - **Optional helpers**: Retry policy, idempotency key handling, timeout wrapper.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ # Core only (types, protocol, mock adapter)
16
+ pip install pyactuator
17
+
18
+ # With Alpaca broker support
19
+ pip install pyactuator[alpaca]
20
+
21
+ # Development
22
+ pip install -e ".[dev]"
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from decimal import Decimal
29
+ from pyactuator import ExecutionClient, OrderRequest, Side, OrderType, TimeInForce
30
+ from pyactuator.adapters.mock import MockExecutionClient
31
+
32
+ # Use mock for tests or paper
33
+ client: ExecutionClient = MockExecutionClient()
34
+
35
+ order = OrderRequest(
36
+ client_order_id="my-order-001",
37
+ symbol="AAPL",
38
+ side=Side.BUY,
39
+ quantity=Decimal("10"),
40
+ order_type=OrderType.MARKET,
41
+ time_in_force=TimeInForce.DAY,
42
+ )
43
+ response = await client.submit(order)
44
+ print(response.success, response.external_order_id)
45
+
46
+ status = await client.get_status(response.external_order_id)
47
+ await client.close()
48
+ ```
49
+
50
+ With Alpaca (requires `pip install pyactuator[alpaca]`):
51
+
52
+ ```python
53
+ from pyactuator.adapters.alpaca import AlpacaExecutionClient
54
+
55
+ client = AlpacaExecutionClient(
56
+ api_key="...",
57
+ api_secret="...",
58
+ paper=True,
59
+ )
60
+ # Same OrderRequest / submit / get_status / cancel
61
+ ```
62
+
63
+ Optional retry wrapper and idempotency helpers:
64
+
65
+ ```python
66
+ from pyactuator.helpers import RetryExecutionClient, generate_client_order_id
67
+ from pyactuator.adapters.alpaca import AlpacaExecutionClient
68
+
69
+ client = AlpacaExecutionClient(api_key="...", api_secret="...", paper=True)
70
+ client = RetryExecutionClient(client, max_attempts=3)
71
+ order_id = generate_client_order_id(prefix="pa", order_id="my-internal-id")
72
+ order = OrderRequest(client_order_id=order_id, symbol="AAPL", side=Side.BUY, quantity=Decimal("10"), ...)
73
+ ```
74
+
75
+ ## Integration with pystator
76
+
77
+ 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.
78
+
79
+ ## License
80
+
81
+ MIT.
@@ -0,0 +1,121 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyactuator"
7
+ version = "0.0.5"
8
+ description = "Thin broker-agnostic execution layer for trading: order submission, status, and fills"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "StatFYI", email = "contact@statfyi.com" },
14
+ ]
15
+ keywords = [
16
+ "trading",
17
+ "execution",
18
+ "broker",
19
+ "alpaca",
20
+ "order",
21
+ "finance",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Developers",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Typing :: Typed",
32
+ "Topic :: Office/Business :: Financial",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/statfyi/pyactuator"
37
+ Documentation = "https://github.com/statfyi/pyactuator#readme"
38
+ Repository = "https://github.com/statfyi/pyactuator"
39
+ Issues = "https://github.com/statfyi/pyactuator/issues"
40
+ Changelog = "https://github.com/statfyi/pyactuator/blob/main/CHANGELOG.md"
41
+
42
+ [project.optional-dependencies]
43
+ # pip install pyactuator → core only (types, protocol, mock adapter)
44
+ # pip install pyactuator[alpaca] → + Alpaca broker adapter
45
+ # pip install pyactuator[dev] → testing & release
46
+ alpaca = ["alpaca-py>=0.14.0"]
47
+ dev = [
48
+ "pytest>=7.0",
49
+ "pytest-asyncio>=0.23.0",
50
+ "pytest-cov>=4.0",
51
+ "ruff>=0.4.0",
52
+ "mypy>=1.5",
53
+ "pre-commit>=3.0",
54
+ "build>=1.0",
55
+ "twine>=5.0",
56
+ ]
57
+
58
+ [tool.setuptools.packages.find]
59
+ where = ["src"]
60
+ include = ["pyactuator*"]
61
+
62
+ [tool.setuptools.package-data]
63
+ pyactuator = ["py.typed"]
64
+
65
+ # --- Lint & format (ruff) ---
66
+ [tool.ruff]
67
+ target-version = "py311"
68
+ line-length = 100
69
+ src = ["src", "tests"]
70
+
71
+ [tool.ruff.lint]
72
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
73
+ ignore = ["E501"]
74
+
75
+ [tool.ruff.lint.per-file-ignores]
76
+ "__init__.py" = ["F401"]
77
+ "tests/*" = ["B008"]
78
+
79
+ [tool.ruff.format]
80
+ quote-style = "double"
81
+
82
+ # --- Type checking ---
83
+ [tool.mypy]
84
+ python_version = "3.11"
85
+ strict = true
86
+ warn_return_any = true
87
+ warn_unused_configs = true
88
+ exclude = ["tests/"]
89
+
90
+ [[tool.mypy.overrides]]
91
+ module = ["alpaca.*"]
92
+ ignore_missing_imports = true
93
+
94
+ # --- Tests ---
95
+ [tool.pytest.ini_options]
96
+ testpaths = ["tests"]
97
+ python_files = ["test_*.py"]
98
+ python_classes = ["Test*"]
99
+ python_functions = ["test_*"]
100
+ asyncio_mode = "auto"
101
+ asyncio_default_fixture_loop_scope = "function"
102
+ addopts = ["-v", "--strict-markers", "--tb=short"]
103
+ markers = [
104
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
105
+ "integration: integration tests",
106
+ "unit: unit tests",
107
+ ]
108
+ filterwarnings = ["error", "ignore::UserWarning"]
109
+
110
+ # --- Coverage ---
111
+ [tool.coverage.run]
112
+ source = ["src/pyactuator"]
113
+ omit = ["*/tests/*", "*/__pycache__/*"]
114
+
115
+ [tool.coverage.report]
116
+ exclude_lines = [
117
+ "pragma: no cover",
118
+ "def __repr__",
119
+ "raise NotImplementedError",
120
+ "if TYPE_CHECKING:",
121
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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