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.
- pyactuator-0.0.5/PKG-INFO +116 -0
- pyactuator-0.0.5/README.md +81 -0
- pyactuator-0.0.5/pyproject.toml +121 -0
- pyactuator-0.0.5/setup.cfg +4 -0
- pyactuator-0.0.5/src/pyactuator/__init__.py +25 -0
- pyactuator-0.0.5/src/pyactuator/adapters/__init__.py +9 -0
- pyactuator-0.0.5/src/pyactuator/adapters/alpaca.py +272 -0
- pyactuator-0.0.5/src/pyactuator/adapters/base.py +39 -0
- pyactuator-0.0.5/src/pyactuator/adapters/mock.py +170 -0
- pyactuator-0.0.5/src/pyactuator/client.py +68 -0
- pyactuator-0.0.5/src/pyactuator/helpers/__init__.py +13 -0
- pyactuator-0.0.5/src/pyactuator/helpers/idempotency.py +67 -0
- pyactuator-0.0.5/src/pyactuator/helpers/retry.py +97 -0
- pyactuator-0.0.5/src/pyactuator/py.typed +0 -0
- pyactuator-0.0.5/src/pyactuator/types.py +159 -0
- pyactuator-0.0.5/src/pyactuator.egg-info/PKG-INFO +116 -0
- pyactuator-0.0.5/src/pyactuator.egg-info/SOURCES.txt +18 -0
- pyactuator-0.0.5/src/pyactuator.egg-info/dependency_links.txt +1 -0
- pyactuator-0.0.5/src/pyactuator.egg-info/requires.txt +13 -0
- pyactuator-0.0.5/src/pyactuator.egg-info/top_level.txt +1 -0
|
@@ -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,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
|