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 +25 -0
- pyactuator/adapters/__init__.py +9 -0
- pyactuator/adapters/alpaca.py +272 -0
- pyactuator/adapters/base.py +39 -0
- pyactuator/adapters/mock.py +170 -0
- pyactuator/client.py +68 -0
- pyactuator/helpers/__init__.py +13 -0
- pyactuator/helpers/idempotency.py +67 -0
- pyactuator/helpers/retry.py +97 -0
- pyactuator/py.typed +0 -0
- pyactuator/types.py +159 -0
- pyactuator-0.0.1.dist-info/METADATA +116 -0
- pyactuator-0.0.1.dist-info/RECORD +15 -0
- pyactuator-0.0.1.dist-info/WHEEL +5 -0
- pyactuator-0.0.1.dist-info/top_level.txt +1 -0
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,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 @@
|
|
|
1
|
+
pyactuator
|