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.
@@ -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/
@@ -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.
@@ -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,2 @@
1
+ __version__ = "0.1.0"
2
+ CONTRACT_VERSION = "1.0"
@@ -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] = []