stochastic-sh 1.0.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.
Files changed (34) hide show
  1. stochastic_sh-1.0.0/PKG-INFO +8 -0
  2. stochastic_sh-1.0.0/README.md +50 -0
  3. stochastic_sh-1.0.0/pyproject.toml +32 -0
  4. stochastic_sh-1.0.0/setup.cfg +4 -0
  5. stochastic_sh-1.0.0/stochastic/__init__.py +41 -0
  6. stochastic_sh-1.0.0/stochastic/fingerprint.py +194 -0
  7. stochastic_sh-1.0.0/stochastic/indicators.py +157 -0
  8. stochastic_sh-1.0.0/stochastic/metrics.py +731 -0
  9. stochastic_sh-1.0.0/stochastic/metrics_polars.py +519 -0
  10. stochastic_sh-1.0.0/stochastic/reason.py +26 -0
  11. stochastic_sh-1.0.0/stochastic/runtime.py +73 -0
  12. stochastic_sh-1.0.0/stochastic/strategy.py +1171 -0
  13. stochastic_sh-1.0.0/stochastic/v1/__init__.py +0 -0
  14. stochastic_sh-1.0.0/stochastic/v1/broker_pb2.py +62 -0
  15. stochastic_sh-1.0.0/stochastic/v1/broker_pb2_grpc.py +145 -0
  16. stochastic_sh-1.0.0/stochastic/v1/gateway_pb2.py +104 -0
  17. stochastic_sh-1.0.0/stochastic/v1/gateway_pb2_grpc.py +174 -0
  18. stochastic_sh-1.0.0/stochastic/v1/metrics_pb2.py +56 -0
  19. stochastic_sh-1.0.0/stochastic/v1/metrics_pb2_grpc.py +130 -0
  20. stochastic_sh-1.0.0/stochastic/windowing.py +333 -0
  21. stochastic_sh-1.0.0/stochastic_sh.egg-info/PKG-INFO +8 -0
  22. stochastic_sh-1.0.0/stochastic_sh.egg-info/SOURCES.txt +32 -0
  23. stochastic_sh-1.0.0/stochastic_sh.egg-info/dependency_links.txt +1 -0
  24. stochastic_sh-1.0.0/stochastic_sh.egg-info/requires.txt +3 -0
  25. stochastic_sh-1.0.0/stochastic_sh.egg-info/top_level.txt +1 -0
  26. stochastic_sh-1.0.0/tests/test_fingerprint.py +216 -0
  27. stochastic_sh-1.0.0/tests/test_indicators.py +144 -0
  28. stochastic_sh-1.0.0/tests/test_interval_trader_params.py +163 -0
  29. stochastic_sh-1.0.0/tests/test_metric_labels.py +274 -0
  30. stochastic_sh-1.0.0/tests/test_metrics.py +419 -0
  31. stochastic_sh-1.0.0/tests/test_metrics_polars.py +539 -0
  32. stochastic_sh-1.0.0/tests/test_ou_sscore.py +254 -0
  33. stochastic_sh-1.0.0/tests/test_strategy.py +935 -0
  34. stochastic_sh-1.0.0/tests/test_windowing.py +373 -0
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: stochastic-sh
3
+ Version: 1.0.0
4
+ Summary: Stochastic strategy SDK — write and run trading strategies against the engine
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: grpcio>=1.80
7
+ Requires-Dist: protobuf>=6.31
8
+ Requires-Dist: polars>=1.40
@@ -0,0 +1,50 @@
1
+ # Stochastic SDK
2
+
3
+ The Python SDK for writing and running trading strategies against a local
4
+ Stochastic engine. `import stochastic` to build a strategy; the `examples/`
5
+ directory has runnable strategies to start from.
6
+
7
+ ## Prerequisites
8
+
9
+ - Python 3.12+ on your PATH
10
+ - A running Stochastic engine — install the CLI, then bring up the stack:
11
+
12
+ ```
13
+ brew install stochastic-sh/tap/stochastic-ctl
14
+
15
+ stochastic-ctl infra up
16
+ ```
17
+
18
+ ## Install
19
+
20
+ Create a virtual environment and install the SDK into it:
21
+
22
+ ```
23
+ python -m venv .venv
24
+
25
+ . .venv/bin/activate
26
+
27
+ pip install -e .
28
+ ```
29
+
30
+ `pip install -e .` resolves the runtime dependencies (grpcio, protobuf, polars)
31
+ from `pyproject.toml`. If you would rather not install, add this directory to
32
+ `PYTHONPATH` instead — but the editable install is the supported path.
33
+
34
+ ## Run an example
35
+
36
+ ```
37
+ python examples/interval_trader.py
38
+ ```
39
+
40
+ The example strategies connect to the engine's gateway on localhost, so bring
41
+ the engine up first (`stochastic-ctl infra up`, then start a run). See
42
+ <https://docs.stochastic.sh> for the full backtest walkthrough.
43
+
44
+ ## What's in here
45
+
46
+ - `stochastic/` — the SDK package: strategy base classes, runtime, metrics,
47
+ windowing, plus the generated gRPC stubs under `stochastic/v1/`
48
+ - `examples/` — runnable example strategies + `examples/requirements.txt`
49
+ - `pyproject.toml` — packaging metadata + runtime dependencies
50
+ - `requirements.txt` — the runtime dependencies (mirror of `pyproject.toml`)
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stochastic-sh"
7
+ version = "1.0.0"
8
+ description = "Stochastic strategy SDK — write and run trading strategies against the engine"
9
+ requires-python = ">=3.12"
10
+ # Runtime deps the package actually imports. Pins are dictated by the
11
+ # generated gRPC/protobuf stubs under stochastic/v1: the *_pb2_grpc.py carry a
12
+ # hard GRPC_GENERATED_VERSION = 1.80.0 guard, and the *_pb2.py validate against
13
+ # protobuf gencode 6.31.1 — older runtimes are rejected at import time. polars
14
+ # powers the frame-stored metrics runtime (metrics_polars).
15
+ dependencies = [
16
+ "grpcio>=1.80",
17
+ "protobuf>=6.31",
18
+ "polars>=1.40",
19
+ ]
20
+
21
+ # Distribution name is `stochastic-sh` — matching the GitHub org, because the
22
+ # bare `stochastic` is taken on PyPI. The IMPORT name stays `stochastic`: all
23
+ # example code and the ibkr-wrapper already `import stochastic` /
24
+ # `from stochastic.v1 import ...`, and the two names are independent. Same
25
+ # shape as `pip install scikit-learn` / `import sklearn` — you type the
26
+ # distribution once and the import every day.
27
+ #
28
+ # VERSION TRACKS THE PRODUCT, not the SDK's own history. It jumped 0.14.0 ->
29
+ # 1.0.0 with the v1 release so `pip show stochastic-sh` and the app's About
30
+ # box agree. A PyPI version can never be reused, so this is a one-way door.
31
+ [tool.setuptools.packages.find]
32
+ include = ["stochastic*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """
2
+ Stochastic Python SDK
3
+
4
+ Public surface:
5
+ Bar, unpack_tick consume the Tick wire format
6
+ Reason recommended OrderRequest.reason constants
7
+ RollingWindow rolling-window primitive with built-in reductions
8
+ WilderRSI recursive RSI — state a rolling window cannot express
9
+ WilderRSIPanel one WilderRSI per (instrument, period)
10
+ Strategy, Context strategy framework (decorator + class shape)
11
+ strategy decorator alternative to subclassing Strategy
12
+ run top-level entry point — runs a strategy against the gateway
13
+ MetricsJob metrics-job framework (pre-computed metric-sets)
14
+ MetricsContext per-stream context passed to metrics-job hooks
15
+ run_metrics top-level entry point — runs a metrics job against the engine
16
+ """
17
+
18
+ from stochastic.indicators import WilderRSI, WilderRSIPanel
19
+ from stochastic.metrics import MetricsContext, MetricsJob, run_metrics
20
+ from stochastic.reason import Reason
21
+ from stochastic.runtime import Bar, unpack_tick
22
+ from stochastic.strategy import Context, Strategy, run, strategy
23
+ from stochastic.windowing import RollingWindow
24
+
25
+ __version__ = "0.6.0"
26
+
27
+ __all__ = [
28
+ "Bar",
29
+ "Context",
30
+ "MetricsContext",
31
+ "MetricsJob",
32
+ "Reason",
33
+ "RollingWindow",
34
+ "Strategy",
35
+ "WilderRSI",
36
+ "WilderRSIPanel",
37
+ "run",
38
+ "run_metrics",
39
+ "strategy",
40
+ "unpack_tick",
41
+ ]
@@ -0,0 +1,194 @@
1
+ """
2
+ Content hashing for strategy identity
3
+
4
+ Answers "did anything change since the backtest?" without requiring git
5
+
6
+ A vcs_ref answers that question only for people who use git, and only when the
7
+ tree is clean — a dirty tree means the ref describes code that is not what ran.
8
+ A content hash answers it always, which is why it is the fingerprint's code
9
+ component and the ref is only corroborating detail
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import sys
15
+ import sysconfig
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+ from typing import Any, Iterable
19
+
20
+ __all__ = ["code_sha256", "params_sha256", "canonical_json", "first_party_modules"]
21
+
22
+ # region Scope
23
+
24
+ def _stdlib_and_site_roots() -> set[Path]:
25
+ """
26
+ Directories whose contents are NOT the researcher's code
27
+
28
+ Everything under these is a dependency: the standard library, site-packages,
29
+ and the SDK's own installation. Hashing them would make the fingerprint
30
+ change on an unrelated `pip install`, and would take seconds rather than
31
+ milliseconds
32
+
33
+ :return: set of resolved directory paths
34
+ """
35
+ roots: set[Path] = set()
36
+ for key in ("stdlib", "platstdlib", "purelib", "platlib"):
37
+ path = sysconfig.get_paths().get(key)
38
+ if path:
39
+ roots.add(Path(path).resolve())
40
+ # A strategy importing the SDK must not hash the SDK — the engine already
41
+ # knows its own version, and an SDK upgrade is not a strategy change
42
+ roots.add(Path(__file__).resolve().parent)
43
+ return roots
44
+
45
+
46
+ def _is_first_party(path: Path, roots: set[Path]) -> bool:
47
+ """
48
+ Whether a module file belongs to the researcher rather than to a dependency
49
+
50
+ :param path: resolved module file path
51
+ :param roots: dependency directories from _stdlib_and_site_roots
52
+ :return: True when the file is the researcher's own code
53
+ """
54
+ for root in roots:
55
+ try:
56
+ path.relative_to(root)
57
+ except ValueError:
58
+ continue
59
+ return False
60
+ return True
61
+
62
+
63
+ def first_party_modules(entry: ModuleType | None = None) -> list[Path]:
64
+ """
65
+ Every first-party source file currently loaded, sorted by path
66
+
67
+ Reads sys.modules rather than walking imports statically, because a
68
+ strategy that imports conditionally or inside a function would defeat a
69
+ static walk — and what ran is what we want to describe, not what the source
70
+ appears to import
71
+
72
+ The consequence to know about: a module imported LATER than the hello is not
73
+ in the hash. Strategies import at module scope, so this is rare, and the
74
+ alternative (hashing at first bar) would mean the identity arrives after the
75
+ engine has already accepted the connection
76
+
77
+ :param entry: unused today; accepted so callers can pin an entry point later
78
+ :return: sorted list of resolved paths
79
+ """
80
+ roots = _stdlib_and_site_roots()
81
+ found: set[Path] = set()
82
+ for module in list(sys.modules.values()):
83
+ source = getattr(module, "__file__", None)
84
+ if not source or not source.endswith(".py"):
85
+ continue
86
+ try:
87
+ resolved = Path(source).resolve()
88
+ except OSError:
89
+ continue
90
+ if not resolved.is_file():
91
+ continue
92
+ if _is_first_party(resolved, roots):
93
+ found.add(resolved)
94
+ return sorted(found)
95
+
96
+ # endregion
97
+
98
+ # region Hashing
99
+
100
+ def code_sha256(entry: ModuleType | None = None) -> str:
101
+ """
102
+ Hash of the strategy's own source — every first-party module, sorted by path
103
+
104
+ Each file contributes its path RELATIVE to the common root plus its bytes.
105
+ The relative path is deliberate: hashing absolute paths would make the same
106
+ code hash differently on two machines, which defeats the whole purpose when
107
+ a backtest runs on a laptop and the live session runs on a server
108
+
109
+ File bytes are hashed raw rather than normalised. Reformatting IS a change
110
+ to the code; pretending otherwise means a fingerprint that says "unchanged"
111
+ about a file someone edited
112
+
113
+ :param entry: optional entry module, forwarded to first_party_modules
114
+ :return: hex sha256, or "" when no first-party source could be identified
115
+ """
116
+ paths = first_party_modules(entry)
117
+ if not paths:
118
+ return ""
119
+
120
+ # Common root so the hash is machine-independent. With a single file the
121
+ # root is its directory, so the relative name is just the filename
122
+ try:
123
+ root = _common_root(paths)
124
+ except ValueError:
125
+ root = paths[0].parent
126
+
127
+ digest = hashlib.sha256()
128
+ for path in paths:
129
+ try:
130
+ rel = path.relative_to(root)
131
+ except ValueError:
132
+ rel = Path(path.name)
133
+ digest.update(str(rel).encode("utf-8"))
134
+ digest.update(b"\0")
135
+ try:
136
+ digest.update(path.read_bytes())
137
+ except OSError:
138
+ # A file that vanished between listing and reading contributes its
139
+ # name only. Better a hash that still distinguishes runs than an
140
+ # exception that stops a strategy connecting
141
+ digest.update(b"<unreadable>")
142
+ digest.update(b"\0")
143
+ return digest.hexdigest()
144
+
145
+
146
+ def _common_root(paths: Iterable[Path]) -> Path:
147
+ """
148
+ Deepest directory containing every path
149
+
150
+ :param paths: resolved file paths
151
+ :return: the common parent directory
152
+ """
153
+ parts = [p.parent.parts for p in paths]
154
+ common: list[str] = []
155
+ for chunk in zip(*parts):
156
+ if len(set(chunk)) != 1:
157
+ break
158
+ common.append(chunk[0])
159
+ if not common:
160
+ raise ValueError("no common root")
161
+ return Path(*common)
162
+
163
+
164
+ def canonical_json(value: Any) -> str:
165
+ """
166
+ Deterministic JSON — sorted keys, no insignificant whitespace
167
+
168
+ Canonicalisation happens HERE rather than in the engine so the SDK's form is
169
+ authoritative. Two sides independently deciding how to order keys is two
170
+ chances to disagree, and the disagreement would surface as a fingerprint
171
+ mismatch nobody could explain
172
+
173
+ :param value: any JSON-serialisable value
174
+ :return: canonical JSON text
175
+ """
176
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
177
+
178
+
179
+ def params_sha256(params: Any) -> str:
180
+ """
181
+ Hash of the canonical form of a strategy's declared parameters
182
+
183
+ :param params: the params mapping, or None
184
+ :return: hex sha256, or "" when there are no params
185
+ """
186
+ if params is None:
187
+ return ""
188
+ try:
189
+ canonical = canonical_json(params)
190
+ except (TypeError, ValueError):
191
+ return ""
192
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
193
+
194
+ # endregion
@@ -0,0 +1,157 @@
1
+ """
2
+ Stateful indicators that a rolling window cannot express
3
+
4
+ `ctx.window(...)` covers everything derivable from the last N bars — means,
5
+ z-scores, EMAs. What it cannot cover is an indicator defined RECURSIVELY, where
6
+ today's value depends on yesterday's value rather than on a window of prices.
7
+ Wilder's RSI is the canonical one, and it is why this module exists
8
+
9
+ # Why these carry their own state, and what that costs
10
+
11
+ A recursive indicator holds price-denominated numbers between ticks, and the
12
+ SDK cannot see inside them. Rolling windows re-base themselves across corporate
13
+ actions; these cannot, so every one of them exposes `rebase(price_factor)` and
14
+ the job MUST call it from `on_action`
15
+
16
+ Skip that call and a 4:1 split reads as a 75% single-bar loss, which produces a
17
+ plausible-looking RSI of ~0 that no assertion catches
18
+
19
+ # Why this is in the SDK rather than copied into each job
20
+
21
+ It was copied into each job — three times, with the same seeding logic and the
22
+ same re-basing requirement, and a bug in one would have been a bug in all. The
23
+ warm-up is the subtle part: Wilder seeds the averages with a SIMPLE mean of the
24
+ first `period` moves and only then switches to recursive smoothing, and an
25
+ implementation that starts smoothing immediately produces numbers that look
26
+ right and drift
27
+
28
+ `sdk/python/examples/metrics/core_indicators.py` still hand-rolls it on purpose,
29
+ as the worked example of writing a stateful metric. Everything else imports it
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ from dataclasses import dataclass, field
36
+
37
+
38
+ @dataclass
39
+ class WilderRSI:
40
+ """
41
+ Wilder's Relative Strength Index, one instrument, one period
42
+
43
+ Recursive smoothing, so it carries state between ticks rather than
44
+ re-deriving from a window
45
+ """
46
+
47
+ period: int = 14
48
+
49
+ _prev: float | None = None
50
+ _gain: float = 0.0
51
+ _loss: float = 0.0
52
+ _n: int = 0
53
+
54
+ def update(self, close: float) -> float:
55
+ """
56
+ Feed one close and get the current RSI
57
+
58
+ :param close: the bar's close price
59
+ :return: RSI in [0, 100], or NaN while warming up
60
+ """
61
+ if self._prev is None:
62
+ self._prev = close
63
+ return math.nan
64
+
65
+ change = close - self._prev
66
+ gain, loss = max(change, 0.0), max(-change, 0.0)
67
+ self._prev = close
68
+ self._n += 1
69
+
70
+ if self._n < self.period:
71
+ # Accumulating the seed. NaN means "there is no value", which is
72
+ # the same thing a gap means — one meaning, deliberately
73
+ self._gain += gain
74
+ self._loss += loss
75
+ return math.nan
76
+
77
+ if self._n == self.period:
78
+ # Wilder seeds with a SIMPLE mean of the first `period` moves and
79
+ # only then smooths. Starting the smoothing one bar early gives
80
+ # numbers that look plausible and drift from every reference
81
+ # implementation
82
+ self._gain = (self._gain + gain) / self.period
83
+ self._loss = (self._loss + loss) / self.period
84
+ else:
85
+ self._gain = (self._gain * (self.period - 1) + gain) / self.period
86
+ self._loss = (self._loss * (self.period - 1) + loss) / self.period
87
+
88
+ if self._loss == 0:
89
+ # No down moves in the window. RSI is 100 by definition — this is
90
+ # not a division-by-zero guard, it is the definition
91
+ return 100.0
92
+ return 100.0 - 100.0 / (1.0 + self._gain / self._loss)
93
+
94
+ def rebase(self, price_factor: float) -> None:
95
+ """
96
+ Re-base the price-denominated state across a corporate action
97
+
98
+ MUST be called from the job's `on_action`. The state holds price
99
+ DIFFERENCES, so they scale by the same factor the prices do
100
+
101
+ :param price_factor: the action's price factor (0.25 for a 4:1 split)
102
+ """
103
+ if self._prev is not None:
104
+ self._prev *= price_factor
105
+ self._gain *= price_factor
106
+ self._loss *= price_factor
107
+
108
+
109
+ @dataclass
110
+ class WilderRSIPanel:
111
+ """
112
+ One WilderRSI per (instrument, period), created on first sight
113
+
114
+ The bookkeeping every multi-instrument job would otherwise write itself.
115
+ Keyed by instrument_id rather than symbol so a ticker rename cannot split
116
+ one series in two
117
+ """
118
+
119
+ periods: tuple[int, ...] = (14,)
120
+ _state: dict[tuple[int, int], WilderRSI] = field(default_factory=dict)
121
+
122
+ def update(self, instrument_id: int, close: float, period: int) -> float:
123
+ """
124
+ Feed one close for one instrument at one period
125
+
126
+ :param instrument_id: durable instrument key
127
+ :param close: the bar's close price
128
+ :param period: RSI period; need not be in `periods`
129
+ :return: RSI in [0, 100], or NaN while warming up
130
+ """
131
+ key = (instrument_id, period)
132
+ rsi = self._state.get(key)
133
+ if rsi is None:
134
+ rsi = WilderRSI(period=period)
135
+ self._state[key] = rsi
136
+ return rsi.update(close)
137
+
138
+ def update_all(self, instrument_id: int, close: float) -> dict[int, float]:
139
+ """
140
+ Feed one close and get every configured period at once
141
+
142
+ :param instrument_id: durable instrument key
143
+ :param close: the bar's close price
144
+ :return: period -> RSI
145
+ """
146
+ return {p: self.update(instrument_id, close, p) for p in self.periods}
147
+
148
+ def rebase(self, instrument_id: int, price_factor: float) -> None:
149
+ """
150
+ Re-base every period held for one instrument
151
+
152
+ :param instrument_id: the instrument the action touched
153
+ :param price_factor: the action's price factor
154
+ """
155
+ for (iid, _period), rsi in self._state.items():
156
+ if iid == instrument_id:
157
+ rsi.rebase(price_factor)