edenalpha 0.1.0__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.
- edenalpha-0.1.0/.gitignore +20 -0
- edenalpha-0.1.0/LICENSE +21 -0
- edenalpha-0.1.0/PKG-INFO +127 -0
- edenalpha-0.1.0/README.md +103 -0
- edenalpha-0.1.0/pyproject.toml +42 -0
- edenalpha-0.1.0/src/edenalpha/__init__.py +69 -0
- edenalpha-0.1.0/src/edenalpha/_version.py +2 -0
- edenalpha-0.1.0/src/edenalpha/cli.py +125 -0
- edenalpha-0.1.0/src/edenalpha/client.py +195 -0
- edenalpha-0.1.0/src/edenalpha/errors.py +46 -0
- edenalpha-0.1.0/src/edenalpha/host.py +128 -0
- edenalpha-0.1.0/src/edenalpha/py.typed +0 -0
- edenalpha-0.1.0/src/edenalpha/strategy.py +132 -0
- edenalpha-0.1.0/src/edenalpha/types.py +148 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.pyc
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
.env.prod
|
|
9
|
+
*.log
|
|
10
|
+
fyersDataSocket.log
|
|
11
|
+
# Local candle parquet cache (egress saver)
|
|
12
|
+
.candle_cache/
|
|
13
|
+
backend/.candle_cache/
|
|
14
|
+
backend/db_migration_dumps/
|
|
15
|
+
backend/scripts/migrate_supabase_db.ps1
|
|
16
|
+
backend/.env.prod.backup.22.06.2026
|
|
17
|
+
backend/ip.env
|
|
18
|
+
# Scratch dir some local test/shell runs create — never part of the repo
|
|
19
|
+
backend/.test-tmp/
|
|
20
|
+
.test-tmp/
|
edenalpha-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jerry John Thomas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
edenalpha-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edenalpha
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: EdenAlpha SDK — write trading strategies in Python and run them against the EdenAlpha engine (hosted backtesting; one contract shared with paper/live deployments).
|
|
5
|
+
Project-URL: Homepage, https://edenalpha.in
|
|
6
|
+
Author: EdenAlpha
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: backtesting,nse,quant,strategies,trading
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic>=2.5
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# edenalpha
|
|
26
|
+
|
|
27
|
+
Write trading strategies in Python. Run them against the EdenAlpha engine —
|
|
28
|
+
the same engine, fills, charges, and risk controls behind every EdenAlpha
|
|
29
|
+
backtest, paper deployment, and live deployment. This SDK's client and CLI
|
|
30
|
+
currently expose hosted backtesting; deployment happens in the web app.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# my_strategy.py
|
|
34
|
+
from edenalpha import strategy, Feature
|
|
35
|
+
|
|
36
|
+
@strategy(features=[Feature(name="rsi", period=14)])
|
|
37
|
+
def decide(ctx):
|
|
38
|
+
if ctx.position.is_open and ctx.features["rsi_14"] > 55:
|
|
39
|
+
return "EXIT"
|
|
40
|
+
if not ctx.position.is_open and ctx.features["rsi_14"] < 30:
|
|
41
|
+
return "BUY"
|
|
42
|
+
return "HOLD"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install edenalpha
|
|
47
|
+
edenalpha login # paste an API key from Settings -> API keys
|
|
48
|
+
edenalpha backtest my_strategy.py \
|
|
49
|
+
--symbol RELIANCE --timeframe 15m \
|
|
50
|
+
--start 2026-06-01 --end 2026-07-01
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
Your function is the **agent**; EdenAlpha is the **world**. On every bar
|
|
56
|
+
close the engine hands you a `Ctx` — the bar, your declared indicators
|
|
57
|
+
(computed server-side, identical to the web rule builder), your position,
|
|
58
|
+
your cash, and the available bar history — and you answer `BUY`, `SELL`
|
|
59
|
+
(opens a short), `EXIT`, or `HOLD`. Hosted backtests expose the full run
|
|
60
|
+
through the current bar; paper deployments expose a rolling window from
|
|
61
|
+
deployment time. Fills, charges, sizing, stop-losses, and
|
|
62
|
+
square-off stay in the engine, so a backtest here is directly comparable to
|
|
63
|
+
every other EdenAlpha run. The same strategy contract powers paper
|
|
64
|
+
deployments in the EdenAlpha web app (currently operator-gated); this
|
|
65
|
+
package's client and CLI expose **hosted backtesting**.
|
|
66
|
+
|
|
67
|
+
- **Sizing is server-owned.** You return direction; quantity comes from the
|
|
68
|
+
run's sizing configuration. A strategy that can't over-size in a backtest
|
|
69
|
+
can't over-size live.
|
|
70
|
+
- **Broker-agnostic.** Nothing in this contract names a broker; execution
|
|
71
|
+
routing happens server-side behind your deployment settings.
|
|
72
|
+
- **Typed everywhere.** `Ctx`, `Bar`, `Position`, `Decision` are Pydantic
|
|
73
|
+
models with full annotations (`py.typed` shipped) — your IDE's
|
|
74
|
+
autocomplete is the API reference.
|
|
75
|
+
- **Unit-testable.** `@strategy` returns a callable: build a fake `Ctx` and
|
|
76
|
+
assert on the returned `Decision` in plain pytest, no network involved.
|
|
77
|
+
|
|
78
|
+
## Python API
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import edenalpha
|
|
82
|
+
|
|
83
|
+
client = edenalpha.Client() # auth: EDENALPHA_API_KEY or `edenalpha login`
|
|
84
|
+
outcome = client.backtest(
|
|
85
|
+
"my_strategy.py",
|
|
86
|
+
symbol="RELIANCE", timeframe="15m",
|
|
87
|
+
start="2026-06-01", end="2026-07-01",
|
|
88
|
+
)
|
|
89
|
+
print(outcome.summary.net_return_pct)
|
|
90
|
+
for trade in outcome.trades:
|
|
91
|
+
print(trade["entry_time"], trade["net_pnl"])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Errors are typed (`AuthenticationError`, `ScopeError`,
|
|
95
|
+
`InsufficientCreditsError`, `RateLimitError`, `StrategyError`) and
|
|
96
|
+
retriable statuses (429/5xx) are retried with backoff automatically.
|
|
97
|
+
|
|
98
|
+
## Hosted execution
|
|
99
|
+
|
|
100
|
+
`client.backtest(...)` runs your file on EdenAlpha compute next to the data
|
|
101
|
+
(requires the `backtest:hosted` scope). Hosted strategies are single
|
|
102
|
+
self-contained files with an import allowlist (`numpy`, `pandas`, and the
|
|
103
|
+
computation-flavored stdlib). Inside hosted compute, auth is ambient — the
|
|
104
|
+
runner injects the session; your code never handles keys.
|
|
105
|
+
|
|
106
|
+
## Declared features
|
|
107
|
+
|
|
108
|
+
Any indicator from the EdenAlpha catalog (the same one behind the web rule
|
|
109
|
+
builder — Strategies → Reference lists all ~57):
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
Feature(name="rsi", period=14) # ctx.features["rsi_14"]
|
|
113
|
+
Feature(name="vwap") # ctx.features["vwap"]
|
|
114
|
+
Feature(name="sma", period=50, alias="slow_ma") # ctx.features["slow_ma"]
|
|
115
|
+
Feature(name="macd", params={"fast": 12, "slow": 26, "signal": 9},
|
|
116
|
+
outputs={"line": "macd", "signal": "macd_sig", "histogram": "macd_hist"})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Raw OHLCV (`open`, `high`, `low`, `close`, `volume`) is always present in
|
|
120
|
+
`ctx.features`. Prefer declared features over hand-rolled ones — they're
|
|
121
|
+
computed by the exact code that will feed your strategy in paper/live, so
|
|
122
|
+
train/serve skew can't happen.
|
|
123
|
+
|
|
124
|
+
## Requirements & license
|
|
125
|
+
|
|
126
|
+
Python 3.10+. MIT licensed — the SDK is open; the EdenAlpha engine and
|
|
127
|
+
platform are a separate, server-side service.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# edenalpha
|
|
2
|
+
|
|
3
|
+
Write trading strategies in Python. Run them against the EdenAlpha engine —
|
|
4
|
+
the same engine, fills, charges, and risk controls behind every EdenAlpha
|
|
5
|
+
backtest, paper deployment, and live deployment. This SDK's client and CLI
|
|
6
|
+
currently expose hosted backtesting; deployment happens in the web app.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
# my_strategy.py
|
|
10
|
+
from edenalpha import strategy, Feature
|
|
11
|
+
|
|
12
|
+
@strategy(features=[Feature(name="rsi", period=14)])
|
|
13
|
+
def decide(ctx):
|
|
14
|
+
if ctx.position.is_open and ctx.features["rsi_14"] > 55:
|
|
15
|
+
return "EXIT"
|
|
16
|
+
if not ctx.position.is_open and ctx.features["rsi_14"] < 30:
|
|
17
|
+
return "BUY"
|
|
18
|
+
return "HOLD"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install edenalpha
|
|
23
|
+
edenalpha login # paste an API key from Settings -> API keys
|
|
24
|
+
edenalpha backtest my_strategy.py \
|
|
25
|
+
--symbol RELIANCE --timeframe 15m \
|
|
26
|
+
--start 2026-06-01 --end 2026-07-01
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
Your function is the **agent**; EdenAlpha is the **world**. On every bar
|
|
32
|
+
close the engine hands you a `Ctx` — the bar, your declared indicators
|
|
33
|
+
(computed server-side, identical to the web rule builder), your position,
|
|
34
|
+
your cash, and the available bar history — and you answer `BUY`, `SELL`
|
|
35
|
+
(opens a short), `EXIT`, or `HOLD`. Hosted backtests expose the full run
|
|
36
|
+
through the current bar; paper deployments expose a rolling window from
|
|
37
|
+
deployment time. Fills, charges, sizing, stop-losses, and
|
|
38
|
+
square-off stay in the engine, so a backtest here is directly comparable to
|
|
39
|
+
every other EdenAlpha run. The same strategy contract powers paper
|
|
40
|
+
deployments in the EdenAlpha web app (currently operator-gated); this
|
|
41
|
+
package's client and CLI expose **hosted backtesting**.
|
|
42
|
+
|
|
43
|
+
- **Sizing is server-owned.** You return direction; quantity comes from the
|
|
44
|
+
run's sizing configuration. A strategy that can't over-size in a backtest
|
|
45
|
+
can't over-size live.
|
|
46
|
+
- **Broker-agnostic.** Nothing in this contract names a broker; execution
|
|
47
|
+
routing happens server-side behind your deployment settings.
|
|
48
|
+
- **Typed everywhere.** `Ctx`, `Bar`, `Position`, `Decision` are Pydantic
|
|
49
|
+
models with full annotations (`py.typed` shipped) — your IDE's
|
|
50
|
+
autocomplete is the API reference.
|
|
51
|
+
- **Unit-testable.** `@strategy` returns a callable: build a fake `Ctx` and
|
|
52
|
+
assert on the returned `Decision` in plain pytest, no network involved.
|
|
53
|
+
|
|
54
|
+
## Python API
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import edenalpha
|
|
58
|
+
|
|
59
|
+
client = edenalpha.Client() # auth: EDENALPHA_API_KEY or `edenalpha login`
|
|
60
|
+
outcome = client.backtest(
|
|
61
|
+
"my_strategy.py",
|
|
62
|
+
symbol="RELIANCE", timeframe="15m",
|
|
63
|
+
start="2026-06-01", end="2026-07-01",
|
|
64
|
+
)
|
|
65
|
+
print(outcome.summary.net_return_pct)
|
|
66
|
+
for trade in outcome.trades:
|
|
67
|
+
print(trade["entry_time"], trade["net_pnl"])
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Errors are typed (`AuthenticationError`, `ScopeError`,
|
|
71
|
+
`InsufficientCreditsError`, `RateLimitError`, `StrategyError`) and
|
|
72
|
+
retriable statuses (429/5xx) are retried with backoff automatically.
|
|
73
|
+
|
|
74
|
+
## Hosted execution
|
|
75
|
+
|
|
76
|
+
`client.backtest(...)` runs your file on EdenAlpha compute next to the data
|
|
77
|
+
(requires the `backtest:hosted` scope). Hosted strategies are single
|
|
78
|
+
self-contained files with an import allowlist (`numpy`, `pandas`, and the
|
|
79
|
+
computation-flavored stdlib). Inside hosted compute, auth is ambient — the
|
|
80
|
+
runner injects the session; your code never handles keys.
|
|
81
|
+
|
|
82
|
+
## Declared features
|
|
83
|
+
|
|
84
|
+
Any indicator from the EdenAlpha catalog (the same one behind the web rule
|
|
85
|
+
builder — Strategies → Reference lists all ~57):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
Feature(name="rsi", period=14) # ctx.features["rsi_14"]
|
|
89
|
+
Feature(name="vwap") # ctx.features["vwap"]
|
|
90
|
+
Feature(name="sma", period=50, alias="slow_ma") # ctx.features["slow_ma"]
|
|
91
|
+
Feature(name="macd", params={"fast": 12, "slow": 26, "signal": 9},
|
|
92
|
+
outputs={"line": "macd", "signal": "macd_sig", "histogram": "macd_hist"})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Raw OHLCV (`open`, `high`, `low`, `close`, `volume`) is always present in
|
|
96
|
+
`ctx.features`. Prefer declared features over hand-rolled ones — they're
|
|
97
|
+
computed by the exact code that will feed your strategy in paper/live, so
|
|
98
|
+
train/serve skew can't happen.
|
|
99
|
+
|
|
100
|
+
## Requirements & license
|
|
101
|
+
|
|
102
|
+
Python 3.10+. MIT licensed — the SDK is open; the EdenAlpha engine and
|
|
103
|
+
platform are a separate, server-side service.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "edenalpha"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "EdenAlpha SDK — write trading strategies in Python and run them against the EdenAlpha engine (hosted backtesting; one contract shared with paper/live deployments)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "EdenAlpha" }]
|
|
13
|
+
keywords = ["trading", "backtesting", "nse", "strategies", "quant"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"pydantic>=2.5",
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"typer>=0.12",
|
|
28
|
+
"rich>=13.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
edenalpha = "edenalpha.cli:app"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://edenalpha.in"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/edenalpha"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
# Explicit allowlist — provably nothing beyond the SDK can leak into the sdist.
|
|
42
|
+
only-include = ["src/edenalpha", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""EdenAlpha SDK — write trading strategies in Python, run them against the
|
|
2
|
+
EdenAlpha engine.
|
|
3
|
+
|
|
4
|
+
Quickstart::
|
|
5
|
+
|
|
6
|
+
from edenalpha import strategy, Feature
|
|
7
|
+
|
|
8
|
+
@strategy(features=[Feature(name="rsi", period=14)])
|
|
9
|
+
def decide(ctx):
|
|
10
|
+
if ctx.position.is_open and ctx.features["rsi_14"] > 55:
|
|
11
|
+
return "EXIT"
|
|
12
|
+
if not ctx.position.is_open and ctx.features["rsi_14"] < 30:
|
|
13
|
+
return "BUY"
|
|
14
|
+
return "HOLD"
|
|
15
|
+
|
|
16
|
+
Then::
|
|
17
|
+
|
|
18
|
+
$ edenalpha login
|
|
19
|
+
$ edenalpha backtest my_strategy.py --symbol RELIANCE --timeframe 15m \\
|
|
20
|
+
--start 2026-06-01 --end 2026-07-01
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from edenalpha._version import __version__
|
|
24
|
+
from edenalpha.client import Client
|
|
25
|
+
from edenalpha.errors import (
|
|
26
|
+
APIError,
|
|
27
|
+
AuthenticationError,
|
|
28
|
+
EdenAlphaError,
|
|
29
|
+
InsufficientCreditsError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ScopeError,
|
|
32
|
+
StrategyError,
|
|
33
|
+
TransportError,
|
|
34
|
+
)
|
|
35
|
+
from edenalpha.strategy import StrategyDef, load_strategy_file, strategy
|
|
36
|
+
from edenalpha.types import (
|
|
37
|
+
Action,
|
|
38
|
+
BacktestOutcome,
|
|
39
|
+
BacktestSummary,
|
|
40
|
+
Bar,
|
|
41
|
+
Ctx,
|
|
42
|
+
Decision,
|
|
43
|
+
Feature,
|
|
44
|
+
Position,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
"Client",
|
|
50
|
+
"strategy",
|
|
51
|
+
"StrategyDef",
|
|
52
|
+
"load_strategy_file",
|
|
53
|
+
"Action",
|
|
54
|
+
"Bar",
|
|
55
|
+
"Ctx",
|
|
56
|
+
"Decision",
|
|
57
|
+
"Feature",
|
|
58
|
+
"Position",
|
|
59
|
+
"BacktestOutcome",
|
|
60
|
+
"BacktestSummary",
|
|
61
|
+
"EdenAlphaError",
|
|
62
|
+
"StrategyError",
|
|
63
|
+
"TransportError",
|
|
64
|
+
"APIError",
|
|
65
|
+
"AuthenticationError",
|
|
66
|
+
"ScopeError",
|
|
67
|
+
"InsufficientCreditsError",
|
|
68
|
+
"RateLimitError",
|
|
69
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""``edenalpha`` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from edenalpha._version import __version__
|
|
12
|
+
from edenalpha.client import Client, save_api_key
|
|
13
|
+
from edenalpha.errors import EdenAlphaError
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="edenalpha",
|
|
17
|
+
help="EdenAlpha SDK — Python strategies against the EdenAlpha engine.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
console = Console()
|
|
22
|
+
err_console = Console(stderr=True, style="bold red")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fail(e: Exception) -> None:
|
|
26
|
+
err_console.print(f"{type(e).__name__}: {e}")
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def version() -> None:
|
|
32
|
+
"""Print the SDK version."""
|
|
33
|
+
console.print(f"edenalpha {__version__}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def login(
|
|
38
|
+
api_key: str = typer.Option(
|
|
39
|
+
None, "--key", help="API key (eak_...). Prompted for if omitted.",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Store your API key (~/.edenalpha/credentials.json) and verify it."""
|
|
43
|
+
if not api_key:
|
|
44
|
+
api_key = typer.prompt("API key (from Settings -> API keys)", hide_input=True)
|
|
45
|
+
try:
|
|
46
|
+
me = Client(api_key=api_key).whoami()
|
|
47
|
+
except EdenAlphaError as e:
|
|
48
|
+
_fail(e)
|
|
49
|
+
return
|
|
50
|
+
path = save_api_key(api_key)
|
|
51
|
+
console.print(f"[green]Logged in as[/green] {me['email']} (scopes: {', '.join(me['scopes'])})")
|
|
52
|
+
console.print(f"Key saved to {path}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def whoami() -> None:
|
|
57
|
+
"""Show the account and scopes behind the configured API key."""
|
|
58
|
+
try:
|
|
59
|
+
me = Client().whoami()
|
|
60
|
+
except EdenAlphaError as e:
|
|
61
|
+
_fail(e)
|
|
62
|
+
return
|
|
63
|
+
console.print(f"{me['email']} (scopes: {', '.join(me['scopes'])})")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def backtest(
|
|
68
|
+
strategy_file: Path = typer.Argument(..., help="Path to your @strategy Python file."),
|
|
69
|
+
symbol: str = typer.Option(..., "--symbol", "-s", help="NSE symbol, e.g. RELIANCE."),
|
|
70
|
+
timeframe: str = typer.Option("15m", "--timeframe", "-t", help="5m | 15m | 1h | 4h | 1d"),
|
|
71
|
+
start: str = typer.Option(..., "--start", help="Window start, YYYY-MM-DD."),
|
|
72
|
+
end: str = typer.Option(..., "--end", help="Window end, YYYY-MM-DD."),
|
|
73
|
+
product: str = typer.Option("intraday", "--product", help="intraday | delivery"),
|
|
74
|
+
cash: float = typer.Option(100_000, "--cash", help="Initial capital."),
|
|
75
|
+
trades: bool = typer.Option(False, "--trades", help="Also print the trade list."),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Run a hosted backtest on EdenAlpha compute and print the results."""
|
|
78
|
+
try:
|
|
79
|
+
with console.status("Running on EdenAlpha compute against real historical bars..."):
|
|
80
|
+
outcome = Client().backtest(
|
|
81
|
+
strategy_file,
|
|
82
|
+
symbol=symbol,
|
|
83
|
+
timeframe=timeframe,
|
|
84
|
+
start=start,
|
|
85
|
+
end=end,
|
|
86
|
+
product=product,
|
|
87
|
+
initial_cash=cash,
|
|
88
|
+
)
|
|
89
|
+
except EdenAlphaError as e:
|
|
90
|
+
_fail(e)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
s = outcome.summary
|
|
94
|
+
pnl_style = "green" if s.net_pnl >= 0 else "red"
|
|
95
|
+
table = Table(title=f"{s.symbol} {s.timeframe} {s.start_date} -> {s.end_date}")
|
|
96
|
+
table.add_column("Metric")
|
|
97
|
+
table.add_column("Value", justify="right")
|
|
98
|
+
table.add_row("Net P&L", f"[{pnl_style}]{s.net_pnl:,.2f}[/{pnl_style}]")
|
|
99
|
+
table.add_row("Return", f"[{pnl_style}]{s.net_return_pct:.2f}%[/{pnl_style}]")
|
|
100
|
+
table.add_row("Final equity", f"{s.final_equity:,.2f}")
|
|
101
|
+
table.add_row("Trades", str(s.total_trades))
|
|
102
|
+
if s.total_trades:
|
|
103
|
+
table.add_row("Win rate", f"{s.winning_trades / s.total_trades * 100:.0f}%")
|
|
104
|
+
console.print(table)
|
|
105
|
+
|
|
106
|
+
if trades and outcome.trades:
|
|
107
|
+
t = Table(title="Trades")
|
|
108
|
+
for col in ("entry_time", "exit_time", "side", "qty", "entry", "exit", "net P&L", "exit type"):
|
|
109
|
+
t.add_column(col)
|
|
110
|
+
for tr in outcome.trades:
|
|
111
|
+
style = "green" if tr.get("net_pnl", 0) >= 0 else "red"
|
|
112
|
+
t.add_row(
|
|
113
|
+
str(tr.get("entry_time", ""))[:16], str(tr.get("exit_time", ""))[:16],
|
|
114
|
+
tr.get("side", ""), str(tr.get("quantity", "")),
|
|
115
|
+
f"{tr.get('entry_price', 0):,.2f}", f"{tr.get('exit_price', 0):,.2f}",
|
|
116
|
+
f"[{style}]{tr.get('net_pnl', 0):,.2f}[/{style}]", tr.get("exit_type", ""),
|
|
117
|
+
)
|
|
118
|
+
console.print(t)
|
|
119
|
+
|
|
120
|
+
for w in outcome.warnings:
|
|
121
|
+
console.print(f"[yellow]warning:[/yellow] {w}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
app()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""HTTP client for the EdenAlpha API.
|
|
2
|
+
|
|
3
|
+
Auth resolution order (first hit wins):
|
|
4
|
+
|
|
5
|
+
1. ``api_key=`` passed explicitly
|
|
6
|
+
2. ``EDENALPHA_API_KEY`` environment variable
|
|
7
|
+
3. ``~/.edenalpha/credentials.json`` (written by ``edenalpha login``)
|
|
8
|
+
|
|
9
|
+
Inside EdenAlpha hosted compute none of this matters — the runner injects an
|
|
10
|
+
ambient session and your code never touches auth.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from edenalpha._version import __version__
|
|
23
|
+
from edenalpha.errors import (
|
|
24
|
+
APIError,
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
InsufficientCreditsError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
ScopeError,
|
|
29
|
+
TransportError,
|
|
30
|
+
)
|
|
31
|
+
from edenalpha.strategy import load_strategy_file
|
|
32
|
+
from edenalpha.types import BacktestOutcome
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE_URL = "https://api.edenalpha.in/api"
|
|
35
|
+
CREDENTIALS_PATH = Path.home() / ".edenalpha" / "credentials.json"
|
|
36
|
+
|
|
37
|
+
_RETRY_STATUSES = {429, 502, 503, 504}
|
|
38
|
+
_MAX_RETRIES = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_api_key(explicit: str | None = None) -> str | None:
|
|
42
|
+
if explicit:
|
|
43
|
+
return explicit
|
|
44
|
+
env = os.environ.get("EDENALPHA_API_KEY")
|
|
45
|
+
if env:
|
|
46
|
+
return env
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(CREDENTIALS_PATH.read_text())["api_key"]
|
|
49
|
+
except (OSError, KeyError, ValueError):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_api_key(api_key: str) -> Path:
|
|
54
|
+
CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
CREDENTIALS_PATH.write_text(json.dumps({"api_key": api_key}, indent=2))
|
|
56
|
+
try: # best-effort chmod; a no-op on Windows
|
|
57
|
+
CREDENTIALS_PATH.chmod(0o600)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
return CREDENTIALS_PATH
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _raise_for(response: httpx.Response) -> None:
|
|
64
|
+
if response.is_success:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
body = response.json()
|
|
68
|
+
detail = body.get("detail", body)
|
|
69
|
+
except ValueError:
|
|
70
|
+
body, detail = response.text, response.text
|
|
71
|
+
message = detail.get("message") if isinstance(detail, dict) else str(detail)
|
|
72
|
+
kwargs = {"status_code": response.status_code, "body": body}
|
|
73
|
+
if response.status_code == 401:
|
|
74
|
+
raise AuthenticationError(
|
|
75
|
+
message or "Invalid API key. Run `edenalpha login` or set EDENALPHA_API_KEY.",
|
|
76
|
+
**kwargs,
|
|
77
|
+
)
|
|
78
|
+
if response.status_code == 403:
|
|
79
|
+
raise ScopeError(message or "API key lacks the required scope.", **kwargs)
|
|
80
|
+
if response.status_code == 402:
|
|
81
|
+
raise InsufficientCreditsError(message or "Not enough credits.", **kwargs)
|
|
82
|
+
if response.status_code == 429:
|
|
83
|
+
raise RateLimitError(message or "Rate limited.", **kwargs)
|
|
84
|
+
raise APIError(message or f"API error {response.status_code}", **kwargs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Client:
|
|
88
|
+
"""Synchronous EdenAlpha client.
|
|
89
|
+
|
|
90
|
+
>>> import edenalpha
|
|
91
|
+
>>> client = edenalpha.Client() # auth from env / credentials file
|
|
92
|
+
>>> outcome = client.backtest("my_strategy.py", symbol="RELIANCE",
|
|
93
|
+
... timeframe="15m",
|
|
94
|
+
... start="2026-06-01", end="2026-07-01")
|
|
95
|
+
>>> outcome.summary.net_return_pct
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: str | None = None,
|
|
101
|
+
base_url: str | None = None,
|
|
102
|
+
timeout: float = 600.0,
|
|
103
|
+
):
|
|
104
|
+
self.api_key = resolve_api_key(api_key)
|
|
105
|
+
self.base_url = (base_url or os.environ.get("EDENALPHA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
106
|
+
self._http = httpx.Client(
|
|
107
|
+
base_url=self.base_url,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
headers={
|
|
110
|
+
"User-Agent": f"edenalpha-python/{__version__}",
|
|
111
|
+
**({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# -- plumbing ---------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
118
|
+
if not self.api_key:
|
|
119
|
+
raise AuthenticationError(
|
|
120
|
+
"No API key. Create one at Settings -> API keys, then run "
|
|
121
|
+
"`edenalpha login` or set EDENALPHA_API_KEY."
|
|
122
|
+
)
|
|
123
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
124
|
+
try:
|
|
125
|
+
response = self._http.request(method, path, **kwargs)
|
|
126
|
+
except httpx.RequestError as e:
|
|
127
|
+
# Network-level failure (DNS, refused, timeout) — retriable,
|
|
128
|
+
# surfaced as a typed EdenAlpha error, never a raw httpx one.
|
|
129
|
+
if attempt < _MAX_RETRIES:
|
|
130
|
+
time.sleep(min(2 ** attempt, 8))
|
|
131
|
+
continue
|
|
132
|
+
raise TransportError(
|
|
133
|
+
f"Could not reach {self.base_url}: {type(e).__name__}: {e}"
|
|
134
|
+
) from e
|
|
135
|
+
if response.status_code in _RETRY_STATUSES and attempt < _MAX_RETRIES:
|
|
136
|
+
retry_after = response.headers.get("Retry-After")
|
|
137
|
+
delay = float(retry_after) if retry_after else min(2 ** attempt, 8)
|
|
138
|
+
time.sleep(delay)
|
|
139
|
+
continue
|
|
140
|
+
_raise_for(response)
|
|
141
|
+
return response
|
|
142
|
+
raise APIError("Retries exhausted") # pragma: no cover — loop always returns/raises
|
|
143
|
+
|
|
144
|
+
# -- API surface ------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def whoami(self) -> dict:
|
|
147
|
+
"""Who this API key belongs to, and its scopes."""
|
|
148
|
+
return self._request("GET", "/code/whoami").json()
|
|
149
|
+
|
|
150
|
+
def backtest(
|
|
151
|
+
self,
|
|
152
|
+
strategy_file: str | Path,
|
|
153
|
+
*,
|
|
154
|
+
symbol: str,
|
|
155
|
+
timeframe: str,
|
|
156
|
+
start: str,
|
|
157
|
+
end: str,
|
|
158
|
+
product: str = "intraday",
|
|
159
|
+
initial_cash: float = 100_000,
|
|
160
|
+
sizing: dict | None = None,
|
|
161
|
+
risk: dict | None = None,
|
|
162
|
+
fill_policy: str = "next_open",
|
|
163
|
+
) -> BacktestOutcome:
|
|
164
|
+
"""Run ``strategy_file`` on EdenAlpha hosted compute against real
|
|
165
|
+
historical bars, through the same engine (fills, charges, risk) that
|
|
166
|
+
powers every other backtest. Requires the ``backtest:hosted`` scope.
|
|
167
|
+
"""
|
|
168
|
+
code = Path(strategy_file).read_text(encoding="utf-8")
|
|
169
|
+
load_strategy_file(strategy_file) # fail fast locally with a clear error
|
|
170
|
+
payload = {
|
|
171
|
+
"code": code,
|
|
172
|
+
"filename": Path(strategy_file).name,
|
|
173
|
+
"symbol": symbol,
|
|
174
|
+
"timeframe": timeframe,
|
|
175
|
+
"start_date": start,
|
|
176
|
+
"end_date": end,
|
|
177
|
+
"product": product,
|
|
178
|
+
"initial_cash": initial_cash,
|
|
179
|
+
"fill_policy": fill_policy,
|
|
180
|
+
}
|
|
181
|
+
if sizing:
|
|
182
|
+
payload["sizing"] = sizing
|
|
183
|
+
if risk:
|
|
184
|
+
payload["risk"] = risk
|
|
185
|
+
data = self._request("POST", "/code/backtests", json=payload).json()
|
|
186
|
+
return BacktestOutcome(**data)
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self._http.close()
|
|
190
|
+
|
|
191
|
+
def __enter__(self) -> "Client":
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(self, *exc) -> None:
|
|
195
|
+
self.close()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Exception taxonomy. Catch :class:`EdenAlphaError` for everything, or the
|
|
2
|
+
specific subclass you can actually handle — the OpenAI/Anthropic convention."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EdenAlphaError(Exception):
|
|
8
|
+
"""Base class for every error this SDK raises."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StrategyError(EdenAlphaError):
|
|
12
|
+
"""Your strategy file/function is invalid (bad return value, no
|
|
13
|
+
@strategy found, duplicate definitions...). The message says exactly
|
|
14
|
+
what to fix."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TransportError(EdenAlphaError):
|
|
18
|
+
"""The agent<->engine session broke (protocol violation, closed pipe)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIError(EdenAlphaError):
|
|
22
|
+
"""An API call failed. ``status_code`` and the server's message are
|
|
23
|
+
attached."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, status_code: int | None = None, body: object = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.body = body
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(APIError):
|
|
32
|
+
"""No/invalid API key. Create one under Settings -> API keys at
|
|
33
|
+
https://edenalpha.in, then run ``edenalpha login`` or set
|
|
34
|
+
``EDENALPHA_API_KEY``."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScopeError(APIError):
|
|
38
|
+
"""Your API key is valid but lacks the scope this call needs."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InsufficientCreditsError(APIError):
|
|
42
|
+
"""Your account doesn't have enough credits for this operation."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimitError(APIError):
|
|
46
|
+
"""Too many requests — the SDK already retried with backoff and gave up."""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Agent host — the child end of the agent<->engine session.
|
|
2
|
+
|
|
3
|
+
Run as ``python -m edenalpha.host <strategy_file>`` by the EdenAlpha hosted
|
|
4
|
+
runner (or any parent implementing the same protocol). Speaks
|
|
5
|
+
newline-delimited JSON: protocol IN on stdin, protocol OUT on the *original*
|
|
6
|
+
stdout fd. The user's own ``print()`` output is redirected to stderr so it
|
|
7
|
+
can never corrupt the protocol stream — their logs still reach the run log.
|
|
8
|
+
|
|
9
|
+
Frames (contract v1, see repo code.md):
|
|
10
|
+
|
|
11
|
+
parent -> agent:
|
|
12
|
+
{"type": "init", "symbol", "timeframe", "contract_version"}
|
|
13
|
+
{"type": "bar", "bars": [Bar, ...]} # incremental history
|
|
14
|
+
{"type": "decide", "seq": n, "ctx": {...}} # ctx WITHOUT history
|
|
15
|
+
{"type": "end"}
|
|
16
|
+
agent -> parent:
|
|
17
|
+
{"type": "ready", "contract_version", "sdk", "strategy", "features": [...]}
|
|
18
|
+
{"type": "action", "seq": n, "action": "BUY|SELL|EXIT|HOLD", "reason": ""}
|
|
19
|
+
{"type": "fatal", "error": "..."} # then exit 1
|
|
20
|
+
|
|
21
|
+
Replay-safe: duplicate ``seq`` frames are answered from a small action cache
|
|
22
|
+
without re-invoking user code, so stateful strategies survive parent retries.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from collections import OrderedDict
|
|
31
|
+
from typing import IO
|
|
32
|
+
|
|
33
|
+
from edenalpha._version import CONTRACT_VERSION, __version__
|
|
34
|
+
from edenalpha.errors import StrategyError, TransportError
|
|
35
|
+
from edenalpha.strategy import StrategyDef, load_strategy_file
|
|
36
|
+
from edenalpha.types import Bar, Ctx, Position
|
|
37
|
+
|
|
38
|
+
_ACTION_CACHE_SIZE = 64
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _write(out: IO[str], frame: dict) -> None:
|
|
42
|
+
out.write(json.dumps(frame, separators=(",", ":")) + "\n")
|
|
43
|
+
out.flush()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def serve(strategy_def: StrategyDef, stdin: IO[str], proto_out: IO[str]) -> None:
|
|
47
|
+
history: list[Bar] = []
|
|
48
|
+
actions: OrderedDict[int, dict] = OrderedDict()
|
|
49
|
+
|
|
50
|
+
_write(proto_out, {
|
|
51
|
+
"type": "ready",
|
|
52
|
+
"contract_version": CONTRACT_VERSION,
|
|
53
|
+
"sdk": f"edenalpha/{__version__}",
|
|
54
|
+
"strategy": {"name": strategy_def.name},
|
|
55
|
+
"features": strategy_def.feature_specs(),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
for line in stdin:
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if not line:
|
|
61
|
+
continue
|
|
62
|
+
frame = json.loads(line)
|
|
63
|
+
ftype = frame.get("type")
|
|
64
|
+
|
|
65
|
+
if ftype == "end":
|
|
66
|
+
return
|
|
67
|
+
if ftype == "init":
|
|
68
|
+
continue # informational in v1
|
|
69
|
+
if ftype == "bar":
|
|
70
|
+
history.extend(Bar(**b) for b in frame["bars"])
|
|
71
|
+
continue
|
|
72
|
+
if ftype == "decide":
|
|
73
|
+
seq = int(frame["seq"])
|
|
74
|
+
if seq in actions: # replay: answer from cache, never re-run user code
|
|
75
|
+
_write(proto_out, actions[seq])
|
|
76
|
+
continue
|
|
77
|
+
raw = frame["ctx"]
|
|
78
|
+
ctx = Ctx(
|
|
79
|
+
timestamp=raw["timestamp"],
|
|
80
|
+
symbol=raw["symbol"],
|
|
81
|
+
timeframe=raw["timeframe"],
|
|
82
|
+
bar=Bar(**raw["bar"]),
|
|
83
|
+
features=raw.get("features") or {},
|
|
84
|
+
prev_features=raw.get("prev_features") or {},
|
|
85
|
+
position=Position(**(raw.get("position") or {})),
|
|
86
|
+
cash=float(raw.get("cash", 0.0)),
|
|
87
|
+
history=tuple(history),
|
|
88
|
+
)
|
|
89
|
+
decision = strategy_def(ctx)
|
|
90
|
+
reply = {
|
|
91
|
+
"type": "action",
|
|
92
|
+
"seq": seq,
|
|
93
|
+
"action": decision.action,
|
|
94
|
+
"reason": decision.reason,
|
|
95
|
+
}
|
|
96
|
+
actions[seq] = reply
|
|
97
|
+
while len(actions) > _ACTION_CACHE_SIZE:
|
|
98
|
+
actions.popitem(last=False)
|
|
99
|
+
_write(proto_out, reply)
|
|
100
|
+
continue
|
|
101
|
+
raise TransportError(f"Unknown frame type from parent: {ftype!r}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
if len(sys.argv) != 2:
|
|
106
|
+
print("usage: python -m edenalpha.host <strategy_file>", file=sys.stderr)
|
|
107
|
+
return 2
|
|
108
|
+
|
|
109
|
+
# Reserve the real stdout for the protocol; user print() goes to stderr.
|
|
110
|
+
proto_out = os.fdopen(os.dup(sys.stdout.fileno()), "w", encoding="utf-8")
|
|
111
|
+
sys.stdout = sys.stderr
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
strategy_def = load_strategy_file(sys.argv[1])
|
|
115
|
+
except StrategyError as e:
|
|
116
|
+
_write(proto_out, {"type": "fatal", "error": str(e)})
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
serve(strategy_def, sys.stdin, proto_out)
|
|
121
|
+
except Exception as e: # noqa: BLE001 — report to parent, then fail
|
|
122
|
+
_write(proto_out, {"type": "fatal", "error": f"{type(e).__name__}: {e}"})
|
|
123
|
+
return 1
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The ``@strategy`` decorator — the whole authoring API.
|
|
2
|
+
|
|
3
|
+
A strategy file is a normal Python module with one decorated function::
|
|
4
|
+
|
|
5
|
+
from edenalpha import strategy, Feature
|
|
6
|
+
|
|
7
|
+
@strategy(features=[Feature(name="rsi", period=14)])
|
|
8
|
+
def decide(ctx):
|
|
9
|
+
if ctx.position.is_open and ctx.features["rsi_14"] > 55:
|
|
10
|
+
return "EXIT"
|
|
11
|
+
if not ctx.position.is_open and ctx.features["rsi_14"] < 30:
|
|
12
|
+
return "BUY"
|
|
13
|
+
return "HOLD"
|
|
14
|
+
|
|
15
|
+
Return an :data:`~edenalpha.types.Action` string, a
|
|
16
|
+
:class:`~edenalpha.types.Decision` (to attach a reason), or ``None`` (=HOLD).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib.util
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable, Iterable
|
|
26
|
+
|
|
27
|
+
from edenalpha.errors import StrategyError
|
|
28
|
+
from edenalpha.types import Action, Ctx, Decision, DecisionLike, Feature
|
|
29
|
+
|
|
30
|
+
_VALID_ACTIONS: set[str] = {"BUY", "SELL", "EXIT", "HOLD"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class StrategyDef:
|
|
35
|
+
"""What ``@strategy`` produces: your function plus its declarations.
|
|
36
|
+
Calling it still calls your function, so it stays unit-testable::
|
|
37
|
+
|
|
38
|
+
assert my_strategy(fake_ctx).action == "BUY"
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
fn: Callable[[Ctx], DecisionLike]
|
|
42
|
+
features: list[Feature] = field(default_factory=list)
|
|
43
|
+
name: str = ""
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if not self.name:
|
|
47
|
+
self.name = getattr(self.fn, "__name__", "strategy")
|
|
48
|
+
|
|
49
|
+
def __call__(self, ctx: Ctx) -> Decision:
|
|
50
|
+
return normalize_decision(self.fn(ctx))
|
|
51
|
+
|
|
52
|
+
def feature_specs(self) -> list[dict]:
|
|
53
|
+
return [f.spec() for f in self.features]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_decision(raw: DecisionLike) -> Decision:
|
|
57
|
+
"""Accept the friendly return forms; reject everything else loudly."""
|
|
58
|
+
if raw is None:
|
|
59
|
+
return Decision(action="HOLD")
|
|
60
|
+
if isinstance(raw, Decision):
|
|
61
|
+
return raw
|
|
62
|
+
if isinstance(raw, str):
|
|
63
|
+
action = raw.strip().upper()
|
|
64
|
+
if action not in _VALID_ACTIONS:
|
|
65
|
+
raise StrategyError(
|
|
66
|
+
f"decide() returned {raw!r} — must be one of "
|
|
67
|
+
f"{sorted(_VALID_ACTIONS)}, a Decision, or None (=HOLD)."
|
|
68
|
+
)
|
|
69
|
+
return Decision(action=action) # type: ignore[arg-type]
|
|
70
|
+
raise StrategyError(
|
|
71
|
+
f"decide() returned {type(raw).__name__} — must be an action string "
|
|
72
|
+
f"({sorted(_VALID_ACTIONS)}), a Decision, or None (=HOLD)."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def strategy(
|
|
77
|
+
features: Iterable[Feature | dict] | None = None,
|
|
78
|
+
*,
|
|
79
|
+
name: str = "",
|
|
80
|
+
) -> Callable[[Callable[[Ctx], DecisionLike]], StrategyDef]:
|
|
81
|
+
"""Declare a strategy.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
features: indicators to compute server-side (same catalog as the web
|
|
85
|
+
rule builder — see Strategies -> Reference). Dicts are accepted
|
|
86
|
+
for copy-paste parity with the web JSON: ``{"name": "rsi",
|
|
87
|
+
"period": 14}``.
|
|
88
|
+
name: display name; defaults to the function name.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
parsed: list[Feature] = []
|
|
92
|
+
for f in features or []:
|
|
93
|
+
parsed.append(f if isinstance(f, Feature) else Feature(**f))
|
|
94
|
+
|
|
95
|
+
def wrap(fn: Callable[[Ctx], DecisionLike]) -> StrategyDef:
|
|
96
|
+
return StrategyDef(fn=fn, features=parsed, name=name)
|
|
97
|
+
|
|
98
|
+
return wrap
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_strategy_file(path: str | Path) -> StrategyDef:
|
|
102
|
+
"""Import a strategy file and return its single ``@strategy`` definition.
|
|
103
|
+
|
|
104
|
+
Raises StrategyError when the file has zero or multiple definitions —
|
|
105
|
+
ambiguity is never guessed away.
|
|
106
|
+
"""
|
|
107
|
+
p = Path(path).resolve()
|
|
108
|
+
if not p.exists():
|
|
109
|
+
raise StrategyError(f"Strategy file not found: {p}")
|
|
110
|
+
spec = importlib.util.spec_from_file_location(f"_edenalpha_user_{p.stem}", p)
|
|
111
|
+
if spec is None or spec.loader is None:
|
|
112
|
+
raise StrategyError(f"Cannot import {p} as a Python module.")
|
|
113
|
+
module = importlib.util.module_from_spec(spec)
|
|
114
|
+
sys.modules[spec.name] = module
|
|
115
|
+
try:
|
|
116
|
+
spec.loader.exec_module(module)
|
|
117
|
+
except Exception as e: # surface the author's own error, clearly attributed
|
|
118
|
+
raise StrategyError(f"Error importing {p.name}: {type(e).__name__}: {e}") from e
|
|
119
|
+
|
|
120
|
+
defs = [v for v in vars(module).values() if isinstance(v, StrategyDef)]
|
|
121
|
+
if not defs:
|
|
122
|
+
raise StrategyError(
|
|
123
|
+
f"No @strategy function found in {p.name}. Decorate your decide "
|
|
124
|
+
f"function with @strategy(...) from the edenalpha package."
|
|
125
|
+
)
|
|
126
|
+
if len(defs) > 1:
|
|
127
|
+
names = ", ".join(d.name for d in defs)
|
|
128
|
+
raise StrategyError(
|
|
129
|
+
f"{p.name} defines {len(defs)} strategies ({names}) — keep exactly "
|
|
130
|
+
f"one per file."
|
|
131
|
+
)
|
|
132
|
+
return defs[0]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Typed models the strategy author sees.
|
|
2
|
+
|
|
3
|
+
Everything a `decide(ctx)` function touches is defined here — IDE
|
|
4
|
+
autocomplete over these types IS the API reference. Broker-agnostic by
|
|
5
|
+
design: nothing in the contract names a broker; orders, fills, and charges
|
|
6
|
+
happen server-side behind the deployment's configuration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal, Union
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
#: What a strategy may return from ``decide(ctx)``.
|
|
16
|
+
#:
|
|
17
|
+
#: - ``"BUY"`` — open a long position (ignored if already in a position)
|
|
18
|
+
#: - ``"SELL"`` — open a SHORT position (intraday only; ignored if in a position)
|
|
19
|
+
#: - ``"EXIT"`` — close the current position, long or short
|
|
20
|
+
#: - ``"HOLD"`` — do nothing (returning ``None`` means the same)
|
|
21
|
+
Action = Literal["BUY", "SELL", "EXIT", "HOLD"]
|
|
22
|
+
|
|
23
|
+
#: Anything ``decide`` may return: an Action string, a Decision, or None (=HOLD).
|
|
24
|
+
DecisionLike = Union[Action, "Decision", None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Bar(BaseModel):
|
|
28
|
+
"""One OHLCV candle."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(frozen=True)
|
|
31
|
+
|
|
32
|
+
timestamp: str
|
|
33
|
+
open: float
|
|
34
|
+
high: float
|
|
35
|
+
low: float
|
|
36
|
+
close: float
|
|
37
|
+
volume: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Position(BaseModel):
|
|
41
|
+
"""Your current position in the symbol (server-tracked)."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True)
|
|
44
|
+
|
|
45
|
+
side: Literal["long", "short", "flat"] = "flat"
|
|
46
|
+
quantity: int = 0
|
|
47
|
+
entry_price: float | None = None
|
|
48
|
+
unrealized_pnl: float = 0.0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_open(self) -> bool:
|
|
52
|
+
return self.side != "flat"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Decision(BaseModel):
|
|
56
|
+
"""An action with an optional human-readable reason (shows up in the
|
|
57
|
+
trade log). ``quantity`` is intentionally absent — sizing is owned by the
|
|
58
|
+
run/deployment configuration, not the strategy."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(frozen=True)
|
|
61
|
+
|
|
62
|
+
action: Action
|
|
63
|
+
reason: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Ctx(BaseModel):
|
|
67
|
+
"""Everything known at this bar close. Passed to your ``decide`` function.
|
|
68
|
+
|
|
69
|
+
``features`` holds your declared indicators by alias (e.g. ``rsi_14``)
|
|
70
|
+
plus the raw OHLCV fields; ``prev_features`` is the previous bar's values
|
|
71
|
+
(for cross-style logic). ``history`` is oldest-first through the current
|
|
72
|
+
bar. Hosted backtests retain the full run; paper deployments retain a
|
|
73
|
+
rolling window from deployment time. Reading either is local and free.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(frozen=True)
|
|
77
|
+
|
|
78
|
+
timestamp: str
|
|
79
|
+
symbol: str
|
|
80
|
+
timeframe: str
|
|
81
|
+
bar: Bar
|
|
82
|
+
features: dict[str, float | None]
|
|
83
|
+
prev_features: dict[str, float | None]
|
|
84
|
+
position: Position
|
|
85
|
+
cash: float
|
|
86
|
+
history: tuple[Bar, ...] = ()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Feature(BaseModel):
|
|
90
|
+
"""A declared indicator, computed server-side by the same engine that
|
|
91
|
+
powers rules strategies — identical values in backtest, paper, and live.
|
|
92
|
+
|
|
93
|
+
Examples::
|
|
94
|
+
|
|
95
|
+
Feature(name="rsi", period=14) # -> ctx.features["rsi_14"]
|
|
96
|
+
Feature(name="vwap") # -> ctx.features["vwap"]
|
|
97
|
+
Feature(name="sma", period=50, alias="slow_ma") # -> ctx.features["slow_ma"]
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(frozen=True)
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
period: int | None = None
|
|
104
|
+
source: str = "close"
|
|
105
|
+
alias: str | None = None
|
|
106
|
+
params: dict[str, float] = {}
|
|
107
|
+
outputs: dict[str, str] = {}
|
|
108
|
+
|
|
109
|
+
def spec(self) -> dict:
|
|
110
|
+
out: dict = {"name": self.name, "source": self.source}
|
|
111
|
+
if self.period is not None:
|
|
112
|
+
out["period"] = self.period
|
|
113
|
+
if self.alias:
|
|
114
|
+
out["alias"] = self.alias
|
|
115
|
+
if self.params:
|
|
116
|
+
out["params"] = dict(self.params)
|
|
117
|
+
if self.outputs:
|
|
118
|
+
out["outputs"] = dict(self.outputs)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class BacktestSummary(BaseModel):
|
|
123
|
+
"""Headline numbers of a completed backtest run."""
|
|
124
|
+
|
|
125
|
+
model_config = ConfigDict(extra="allow")
|
|
126
|
+
|
|
127
|
+
symbol: str
|
|
128
|
+
timeframe: str
|
|
129
|
+
start_date: str
|
|
130
|
+
end_date: str
|
|
131
|
+
initial_cash: float
|
|
132
|
+
final_equity: float
|
|
133
|
+
net_pnl: float = 0.0
|
|
134
|
+
net_return_pct: float = 0.0
|
|
135
|
+
total_trades: int = 0
|
|
136
|
+
winning_trades: int = 0
|
|
137
|
+
losing_trades: int = 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class BacktestOutcome(BaseModel):
|
|
141
|
+
"""Full result payload returned by :meth:`edenalpha.Client.backtest`."""
|
|
142
|
+
|
|
143
|
+
model_config = ConfigDict(extra="allow")
|
|
144
|
+
|
|
145
|
+
summary: BacktestSummary
|
|
146
|
+
trades: list[dict] = []
|
|
147
|
+
equity_curve: list[dict] = []
|
|
148
|
+
warnings: list[str] = []
|