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.
- stochastic_sh-1.0.0/PKG-INFO +8 -0
- stochastic_sh-1.0.0/README.md +50 -0
- stochastic_sh-1.0.0/pyproject.toml +32 -0
- stochastic_sh-1.0.0/setup.cfg +4 -0
- stochastic_sh-1.0.0/stochastic/__init__.py +41 -0
- stochastic_sh-1.0.0/stochastic/fingerprint.py +194 -0
- stochastic_sh-1.0.0/stochastic/indicators.py +157 -0
- stochastic_sh-1.0.0/stochastic/metrics.py +731 -0
- stochastic_sh-1.0.0/stochastic/metrics_polars.py +519 -0
- stochastic_sh-1.0.0/stochastic/reason.py +26 -0
- stochastic_sh-1.0.0/stochastic/runtime.py +73 -0
- stochastic_sh-1.0.0/stochastic/strategy.py +1171 -0
- stochastic_sh-1.0.0/stochastic/v1/__init__.py +0 -0
- stochastic_sh-1.0.0/stochastic/v1/broker_pb2.py +62 -0
- stochastic_sh-1.0.0/stochastic/v1/broker_pb2_grpc.py +145 -0
- stochastic_sh-1.0.0/stochastic/v1/gateway_pb2.py +104 -0
- stochastic_sh-1.0.0/stochastic/v1/gateway_pb2_grpc.py +174 -0
- stochastic_sh-1.0.0/stochastic/v1/metrics_pb2.py +56 -0
- stochastic_sh-1.0.0/stochastic/v1/metrics_pb2_grpc.py +130 -0
- stochastic_sh-1.0.0/stochastic/windowing.py +333 -0
- stochastic_sh-1.0.0/stochastic_sh.egg-info/PKG-INFO +8 -0
- stochastic_sh-1.0.0/stochastic_sh.egg-info/SOURCES.txt +32 -0
- stochastic_sh-1.0.0/stochastic_sh.egg-info/dependency_links.txt +1 -0
- stochastic_sh-1.0.0/stochastic_sh.egg-info/requires.txt +3 -0
- stochastic_sh-1.0.0/stochastic_sh.egg-info/top_level.txt +1 -0
- stochastic_sh-1.0.0/tests/test_fingerprint.py +216 -0
- stochastic_sh-1.0.0/tests/test_indicators.py +144 -0
- stochastic_sh-1.0.0/tests/test_interval_trader_params.py +163 -0
- stochastic_sh-1.0.0/tests/test_metric_labels.py +274 -0
- stochastic_sh-1.0.0/tests/test_metrics.py +419 -0
- stochastic_sh-1.0.0/tests/test_metrics_polars.py +539 -0
- stochastic_sh-1.0.0/tests/test_ou_sscore.py +254 -0
- stochastic_sh-1.0.0/tests/test_strategy.py +935 -0
- 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,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)
|