tradingkit-py 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.
Files changed (77) hide show
  1. tradingkit_py-0.1.0/LICENSE +21 -0
  2. tradingkit_py-0.1.0/PKG-INFO +303 -0
  3. tradingkit_py-0.1.0/README.md +270 -0
  4. tradingkit_py-0.1.0/pyproject.toml +74 -0
  5. tradingkit_py-0.1.0/setup.cfg +4 -0
  6. tradingkit_py-0.1.0/tests/test_aggregation.py +93 -0
  7. tradingkit_py-0.1.0/tests/test_backtest.py +115 -0
  8. tradingkit_py-0.1.0/tests/test_clickhouse_identifiers.py +76 -0
  9. tradingkit_py-0.1.0/tests/test_clickhouse_integration.py +130 -0
  10. tradingkit_py-0.1.0/tests/test_clickhouse_manager.py +188 -0
  11. tradingkit_py-0.1.0/tests/test_collector.py +236 -0
  12. tradingkit_py-0.1.0/tests/test_cpp_pool.py +220 -0
  13. tradingkit_py-0.1.0/tests/test_executors.py +109 -0
  14. tradingkit_py-0.1.0/tests/test_indicator.py +129 -0
  15. tradingkit_py-0.1.0/tests/test_params.py +85 -0
  16. tradingkit_py-0.1.0/tests/test_pipeline.py +93 -0
  17. tradingkit_py-0.1.0/tests/test_plugin_registry.py +26 -0
  18. tradingkit_py-0.1.0/tests/test_remote_executor.py +59 -0
  19. tradingkit_py-0.1.0/tests/test_resolver.py +191 -0
  20. tradingkit_py-0.1.0/tests/test_runner_server.py +154 -0
  21. tradingkit_py-0.1.0/tests/test_safe_pickle.py +91 -0
  22. tradingkit_py-0.1.0/tests/test_schema.py +129 -0
  23. tradingkit_py-0.1.0/tests/test_source.py +116 -0
  24. tradingkit_py-0.1.0/tests/test_strategy.py +136 -0
  25. tradingkit_py-0.1.0/tests/test_timeframe.py +58 -0
  26. tradingkit_py-0.1.0/tradingkit/__init__.py +147 -0
  27. tradingkit_py-0.1.0/tradingkit/_docker/compiler/Dockerfile +6 -0
  28. tradingkit_py-0.1.0/tradingkit/_docker/compiler/compile.sh +19 -0
  29. tradingkit_py-0.1.0/tradingkit/_docker/runner/Dockerfile +7 -0
  30. tradingkit_py-0.1.0/tradingkit/_docker/runner/runner_worker.py +143 -0
  31. tradingkit_py-0.1.0/tradingkit/_docker/runner/seccomp.json +44 -0
  32. tradingkit_py-0.1.0/tradingkit/aggregation.py +329 -0
  33. tradingkit_py-0.1.0/tradingkit/backtest/__init__.py +4 -0
  34. tradingkit_py-0.1.0/tradingkit/backtest/result.py +100 -0
  35. tradingkit_py-0.1.0/tradingkit/backtest/runner.py +133 -0
  36. tradingkit_py-0.1.0/tradingkit/collector.py +572 -0
  37. tradingkit_py-0.1.0/tradingkit/context.py +38 -0
  38. tradingkit_py-0.1.0/tradingkit/core/__init__.py +0 -0
  39. tradingkit_py-0.1.0/tradingkit/core/clickhouse/__init__.py +136 -0
  40. tradingkit_py-0.1.0/tradingkit/core/clickhouse/_candles.py +234 -0
  41. tradingkit_py-0.1.0/tradingkit/core/clickhouse/_connections.py +90 -0
  42. tradingkit_py-0.1.0/tradingkit/core/clickhouse/_indicators_signals.py +161 -0
  43. tradingkit_py-0.1.0/tradingkit/core/clickhouse/_plugin_library.py +43 -0
  44. tradingkit_py-0.1.0/tradingkit/core/clickhouse/_sql.py +138 -0
  45. tradingkit_py-0.1.0/tradingkit/core/clickhouse/_unit_tables.py +254 -0
  46. tradingkit_py-0.1.0/tradingkit/core/live_feed.py +78 -0
  47. tradingkit_py-0.1.0/tradingkit/core/params.py +148 -0
  48. tradingkit_py-0.1.0/tradingkit/core/plugin_registry.py +36 -0
  49. tradingkit_py-0.1.0/tradingkit/core/resolver.py +361 -0
  50. tradingkit_py-0.1.0/tradingkit/core/script_ast.py +56 -0
  51. tradingkit_py-0.1.0/tradingkit/core/timeframe.py +74 -0
  52. tradingkit_py-0.1.0/tradingkit/executor/__init__.py +23 -0
  53. tradingkit_py-0.1.0/tradingkit/executor/_worker.py +103 -0
  54. tradingkit_py-0.1.0/tradingkit/executor/base.py +82 -0
  55. tradingkit_py-0.1.0/tradingkit/executor/cpp_pool.py +161 -0
  56. tradingkit_py-0.1.0/tradingkit/executor/launchers/__init__.py +25 -0
  57. tradingkit_py-0.1.0/tradingkit/executor/launchers/base.py +44 -0
  58. tradingkit_py-0.1.0/tradingkit/executor/launchers/docker_.py +212 -0
  59. tradingkit_py-0.1.0/tradingkit/executor/launchers/subprocess_.py +89 -0
  60. tradingkit_py-0.1.0/tradingkit/executor/local.py +63 -0
  61. tradingkit_py-0.1.0/tradingkit/executor/remote.py +166 -0
  62. tradingkit_py-0.1.0/tradingkit/executor/subprocess_.py +189 -0
  63. tradingkit_py-0.1.0/tradingkit/indicator.py +520 -0
  64. tradingkit_py-0.1.0/tradingkit/pipeline.py +252 -0
  65. tradingkit_py-0.1.0/tradingkit/py.typed +0 -0
  66. tradingkit_py-0.1.0/tradingkit/runner/__init__.py +0 -0
  67. tradingkit_py-0.1.0/tradingkit/runner/_safe_pickle.py +94 -0
  68. tradingkit_py-0.1.0/tradingkit/runner/server.py +286 -0
  69. tradingkit_py-0.1.0/tradingkit/schema.py +292 -0
  70. tradingkit_py-0.1.0/tradingkit/source.py +259 -0
  71. tradingkit_py-0.1.0/tradingkit/strategy.py +380 -0
  72. tradingkit_py-0.1.0/tradingkit_py.egg-info/PKG-INFO +303 -0
  73. tradingkit_py-0.1.0/tradingkit_py.egg-info/SOURCES.txt +75 -0
  74. tradingkit_py-0.1.0/tradingkit_py.egg-info/dependency_links.txt +1 -0
  75. tradingkit_py-0.1.0/tradingkit_py.egg-info/entry_points.txt +2 -0
  76. tradingkit_py-0.1.0/tradingkit_py.egg-info/requires.txt +24 -0
  77. tradingkit_py-0.1.0/tradingkit_py.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maksym Holovin
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,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: tradingkit-py
3
+ Version: 0.1.0
4
+ Summary: Async trading framework with Polars, ClickHouse, and pluggable executors
5
+ Author-email: Maksym Holovin <holovin.maksym@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: aiohttp<4.0.0,>=3.9.0
11
+ Requires-Dist: aiohttp-jinja2==1.5.1
12
+ Requires-Dist: jinja2==3.1.2
13
+ Requires-Dist: polars>=0.20
14
+ Requires-Dist: pyarrow>=14.0.0
15
+ Requires-Dist: numpy>=2.2.6
16
+ Requires-Dist: TA-Lib>=0.4.0
17
+ Requires-Dist: numba>=0.59.0
18
+ Requires-Dist: asynch>=0.2.0
19
+ Requires-Dist: pydantic<3.0.0,>=2.6.0
20
+ Requires-Dist: pyyaml<7.0.0,>=6.0.1
21
+ Requires-Dist: python-dateutil<3.0.0,>=2.8.2
22
+ Requires-Dist: pytz>=2023.3
23
+ Requires-Dist: rich<14.0.0,>=13.7.0
24
+ Requires-Dist: websockets<13.0,>=12.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
28
+ Requires-Dist: mypy>=1.9; extra == "dev"
29
+ Requires-Dist: ruff>=0.4; extra == "dev"
30
+ Provides-Extra: cpp
31
+ Requires-Dist: docker>=7.0.0; extra == "cpp"
32
+ Dynamic: license-file
33
+
34
+ # tradingkit
35
+
36
+ [![CI](https://github.com/mholovion/trading-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/mholovion/trading-framework/actions/workflows/ci.yml)
37
+
38
+ Async trading strategy framework built on [Polars](https://pola.rs), [ClickHouse](https://clickhouse.com),
39
+ and pluggable executors. Write an `Indicator`/`Strategy`/`DataSource` once, then run it
40
+ in-process, in a sandboxed subprocess, on a remote worker, or against historical data in
41
+ a backtest — without changing the plugin code.
42
+
43
+ ---
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install tradingkit-py
49
+ ```
50
+
51
+ (PyPI distribution name is `tradingkit-py` — an unrelated project already holds the bare
52
+ `tradingkit` name — but the actual import is unaffected: `import tradingkit`.)
53
+
54
+ Requires Python ≥3.11. `TA-Lib` needs the native library installed first (see
55
+ [ta-lib.org](https://ta-lib.org)); everything else is a normal wheel dependency.
56
+
57
+ For development:
58
+
59
+ ```bash
60
+ git clone https://github.com/mholovion/trading-framework.git
61
+ cd trading-framework
62
+ pip install -e ".[dev]"
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Quick start
68
+
69
+ ```python
70
+ import asyncio
71
+ import numpy as np
72
+ import polars as pl
73
+ from tradingkit import Indicator, IndicatorContext, Strategy, Signal, BarContext
74
+ from tradingkit.backtest import BacktestRunner
75
+
76
+
77
+ class RSI(Indicator):
78
+ def compute(self, ctx: IndicatorContext) -> np.ndarray:
79
+ return ctx.ta.rsi(ctx.np.close, self.period)
80
+
81
+ def required_periods(self) -> int:
82
+ return self.period + 1
83
+
84
+
85
+ class MeanReversion(Strategy):
86
+ async def on_bar(self, bar: BarContext) -> Signal | None:
87
+ # Signal.type is a free-form string in general, but BacktestRunner's
88
+ # FIFO trade pairing specifically recognizes "buy"/"sell" — use those
89
+ # two if you want trades in BacktestResult.trades.
90
+ if bar.rsi < 30:
91
+ return Signal("buy", confidence=0.8, metadata={"direction": "long"})
92
+ if bar.rsi > 70:
93
+ return Signal("sell", confidence=0.7)
94
+ return None
95
+
96
+
97
+ async def main() -> None:
98
+ data = pl.read_csv("candles.csv") # timestamp, open, high, low, close, volume
99
+ runner = BacktestRunner() # defaults to LocalExecutor (in-process, no isolation)
100
+ result = await runner.run(
101
+ data=data,
102
+ indicators={"rsi": RSI(period=14)},
103
+ strategy=MeanReversion(),
104
+ )
105
+ print(result.summary())
106
+
107
+
108
+ asyncio.run(main())
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Architecture
114
+
115
+ ```
116
+ DataSource ──► Pipeline ──► Indicator(s) ──► Strategy ──► Signal
117
+ │ │ │
118
+ └────────────── all three run through a PluginExecutor ──────────┘
119
+ (Local / Subprocess / Remote / CppRunnerPool)
120
+
121
+ ClickHouseManager + DependencyResolver (optional, application layer)
122
+ caches indicator/strategy output in ClickHouse and resolves
123
+ dependencies on demand instead of recomputing from scratch.
124
+ ```
125
+
126
+ - **`DataSource`** ([tradingkit/source.py](tradingkit/source.py)) — fetches OHLCV data (exchange, CSV, custom API).
127
+ - **`Indicator`** ([tradingkit/indicator.py](tradingkit/indicator.py)) — `compute(ctx) -> np.ndarray`, given a `IndicatorContext` wrapping a `pl.DataFrame`. `ctx.ta.*` exposes 200+ TA-Lib functions plus numba-compiled primitives.
128
+ - **`Strategy`** ([tradingkit/strategy.py](tradingkit/strategy.py)) — `on_bar(bar) -> Signal | None`, given a `BarContext` with row + indicator values as dynamic attributes (`bar.close`, `bar.rsi`, ...).
129
+ - **`Pipeline`** ([tradingkit/pipeline.py](tradingkit/pipeline.py)) — wires a source + indicators + strategy into a single re-runnable, serializable unit.
130
+ - **`PluginExecutor`** ([tradingkit/executor/](tradingkit/executor/)) — where the above actually run:
131
+
132
+ | Executor | Isolation | Use case |
133
+ |---|---|---|
134
+ | `LocalExecutor` | none | development, trusted plugins |
135
+ | `SubprocessExecutor` | separate process, Arrow IPC, memory/timeout limits | untrusted-ish plugins, single machine |
136
+ | `RemoteExecutor` | separate host over HTTP | horizontal scaling, dedicated compute nodes — see [Security model](#security-model) before exposing it beyond localhost |
137
+ | `CppRunnerPool` (attach to any of the above) | Docker, `--network=none`, seccomp, read-only rootfs | compiled `CppIndicator`/`CppStrategyPlugin` payloads |
138
+
139
+ - **`BacktestRunner`** ([tradingkit/backtest/](tradingkit/backtest/)) — runs a strategy bar-by-bar over a pre-loaded `pl.DataFrame` and returns a `BacktestResult` (trades, win rate, drawdown, ...).
140
+ - **`ClickHouseManager` + `DependencyResolver`** ([tradingkit/core/](tradingkit/core/)) — optional application-layer caching: resolves an indicator/strategy request by walking its dependency chain and only recomputing what's missing from ClickHouse.
141
+ - **`AggregationContext` / `AggregationWorker`** ([tradingkit/aggregation.py](tradingkit/aggregation.py)) — periodic scripts that read arbitrary ClickHouse tables and write derived series (cross-symbol spreads, higher-timeframe values projected onto a lower timeframe, etc.).
142
+
143
+ ---
144
+
145
+ ## Dynamic (string-based) plugins
146
+
147
+ `Indicator`/`Strategy`/`DataSource` subclasses are regular Python classes — defined in
148
+ your codebase, imported at process start. Sometimes the plugin code itself isn't known
149
+ until runtime instead (loaded from a database row, a config file, a user-facing editor):
150
+ `ScriptIndicator` / `ScriptStrategy` / `ScriptSource` / `AggregationScript` cover that case
151
+ by taking the plugin body as a plain string and `exec()`-ing it on demand, instead of
152
+ requiring a class defined ahead of time:
153
+
154
+ ```python
155
+ from tradingkit import ScriptIndicator
156
+
157
+ indicator = ScriptIndicator(code="""
158
+ result = ta.rsi(close, 14)
159
+ """, period=14)
160
+ ```
161
+
162
+ Because this runs via `exec()` with no sandboxing, treat the `code` string with the same
163
+ trust as any other code you run — see [Security model](#security-model).
164
+
165
+ ---
166
+
167
+ ## Security model
168
+
169
+ tradingkit executes plugin code by design — that's the product. Two things are worth
170
+ being explicit about before you deploy it:
171
+
172
+ **1. In-process script execution has no sandbox.** `ScriptIndicator`, `ScriptStrategy`,
173
+ `ScriptSource`, and `AggregationScript` run their `code` string via `exec()` with full
174
+ interpreter privileges — no import restrictions, no resource limits. Only run code you
175
+ personally wrote or reviewed this way. If you need to run less-trusted plugin code, route
176
+ it through `SubprocessExecutor` (process boundary + memory/timeout limits) or
177
+ `CppRunnerPool` (Docker, `--network=none`, seccomp, read-only rootfs) instead of
178
+ `LocalExecutor`.
179
+
180
+ **2. `tradingkit-runner` requires a token and rejects non-loopback binds without one.**
181
+ `RemoteExecutor`/`tradingkit-runner` ship live plugin objects over the wire as pickle, so
182
+ the runner authenticates every `/compute/*` request (`Authorization: Bearer <token>`,
183
+ constant-time comparison, checked before the body is read) and decodes payloads with an
184
+ allowlisting unpickler (`tradingkit.runner._safe_pickle`) that blocks the standard
185
+ `os`/`subprocess`/`eval` pickle RCE gadgets even from an authenticated caller. Binding to
186
+ anything other than `127.0.0.1` requires both `--allow-remote` and a token — the process
187
+ refuses to start otherwise.
188
+
189
+ That said: **the token is a shared secret between trusted peers, not a full authz or
190
+ encryption layer.** Plain HTTP sends it in cleartext, and the restricted unpickler is a
191
+ targeted defense against known gadget classes, not a general-purpose sandbox. Don't put
192
+ `tradingkit-runner` on the public internet — run it behind a TLS-terminating reverse
193
+ proxy or keep it on a private network (VPN/VPC):
194
+
195
+ ```bash
196
+ tradingkit-runner --token "$(openssl rand -hex 32)" # loopback only, default
197
+ tradingkit-runner --token "$TOKEN" --host 0.0.0.0 --allow-remote # only behind TLS/VPN
198
+ ```
199
+
200
+ ```python
201
+ executor = RemoteExecutor("https://tradingkit-runner.internal:8082", token=TOKEN)
202
+ ```
203
+
204
+ **3. `DockerRunnerLauncher` mounts `docker.sock`.** Whatever process can reach that socket
205
+ has effective root on the host — it's what lets the launcher build and start the sandboxed
206
+ C++ runner containers on first use. Treat access to the host running `tradingkit-runner`/
207
+ `CppRunnerPool` with that in mind; don't give untrusted users shell access to it.
208
+
209
+ ---
210
+
211
+ ## Aggregation
212
+
213
+ `AggregationContext.query(table, symbol=..., start_ts=..., end_ts=...)` reads *any*
214
+ ClickHouse table by name — tradingkit ships no built-in aggregation scripts
215
+ (`BUILTIN_AGGREGATIONS` is an empty registry by default), but the mechanism covers a
216
+ few patterns directly:
217
+
218
+ - **Cross-symbol** — query two symbols, combine them (spread, ratio, custom index).
219
+ - **Cross-timeframe** — query a pre-aggregated table for a different bucket size (e.g.
220
+ `candles_3600s` for 1h buckets) and project it onto a lower-timeframe series; this is
221
+ the exact case `query()`'s own docstring calls out.
222
+ - **Cross-source** — the same `query()` call works on any table regardless of what wrote
223
+ it, so combining two independently-ingested tables (e.g. spot price + a funding-rate
224
+ feed) uses the same call shape. There's no bundled example of this one yet — you write
225
+ the join.
226
+
227
+ ```python
228
+ OUTPUT_TABLE = "spread_btc_eth"
229
+ OUTPUT_SCHEMA = {"timestamp": "Int64", "spread": "Float64", "ratio": "Float64"}
230
+ INTERVAL_S = 60
231
+
232
+ async def aggregate(ctx, start_ts: int, end_ts: int) -> list[dict]:
233
+ btc = await ctx.query("candles", symbol="BTC_USDT", start_ts=start_ts, end_ts=end_ts)
234
+ eth = await ctx.query("candles", symbol="ETH_USDT", start_ts=start_ts, end_ts=end_ts)
235
+ joined = btc.join(eth, on="timestamp", suffix="_eth")
236
+ return [
237
+ {"timestamp": int(r["timestamp"]), "spread": r["close"] - r["close_eth"],
238
+ "ratio": r["close"] / r["close_eth"] if r["close_eth"] else 0.0}
239
+ for r in joined.iter_rows(named=True)
240
+ ]
241
+ ```
242
+
243
+ `AggregationWorker` polls `plugin_library` for scripts like this one (`type="aggregation"`,
244
+ defines `aggregate()`) and runs each on `INTERVAL_S`, writing results to `OUTPUT_TABLE`.
245
+
246
+ **Known limitation — parametric ClickHouse aggregates.** Separately from the Python-script
247
+ path above, `tradingkit.core.clickhouse` also has a lower-level, ClickHouse-native
248
+ aggregation path (`Fold` in `tradingkit/schema.py`, plus `ensure_agg_table`/`ensure_mv`/
249
+ `backfill_agg`/`query_agg`), backed by `AggregatingMergeTree` tables and materialized
250
+ views using ClickHouse's `-State`/`-Merge` combinators. Argument-less functions (`sum`,
251
+ `count`, `avg`, `min`, `max`, ...) generate correct SQL and are covered by integration
252
+ tests against a real server. Parametric functions (`quantile`, `sumIf`, ...) don't yet —
253
+ the combinator suffix gets placed incorrectly relative to the function's own arguments,
254
+ producing SQL ClickHouse won't accept as written. This needs an actual syntax-generation
255
+ fix, not a quick patch, and isn't done yet — don't rely on `Fold` with parametric
256
+ functions until this is resolved.
257
+
258
+ **Registering named builtins.** `plugin_library` (ClickHouse-stored, per-project) is the
259
+ primary way to add aggregation/strategy scripts, but a host app can additionally register
260
+ its own named builtins — e.g. a curated set it always wants available regardless of
261
+ project — by pointing an environment variable at a module:
262
+
263
+ ```bash
264
+ export TRADINGKIT_AGGREGATIONS_MODULE=myapp.aggregations # exposes BUILTIN_AGGREGATIONS: dict[str, str]
265
+ export TRADINGKIT_STRATEGIES_MODULE=myapp.strategies # exposes BUILTIN_STRATEGIES: dict[str, str]
266
+ ```
267
+
268
+ Unset (the default), both registries are empty — see `tradingkit.core.plugin_registry`.
269
+
270
+ ---
271
+
272
+ ## Development
273
+
274
+ ```bash
275
+ pip install -e ".[dev]"
276
+ ruff check .
277
+ pytest
278
+ mypy tradingkit # non-blocking in CI — existing type-coverage gaps, not enforced yet
279
+ ```
280
+
281
+ CI (`.github/workflows/ci.yml`) runs `ruff` and `pytest` on Python 3.11 and 3.12 for every
282
+ push/PR, including a real ClickHouse service container and (on Docker-capable runners) a
283
+ real `CppRunnerPool` container.
284
+
285
+ Most of the suite needs nothing beyond `pip install -e ".[dev]"`. Two groups of tests
286
+ auto-skip unless their dependency is actually reachable, and light up locally too if you
287
+ have it running:
288
+
289
+ ```bash
290
+ # tests/test_clickhouse_integration.py — real ClickHouse instead of a mocked transport
291
+ docker run --rm -p 8123:8123 -p 9000:9000 clickhouse/clickhouse-server
292
+ CLICKHOUSE_HOST=localhost pytest tests/test_clickhouse_integration.py
293
+
294
+ # tests/test_cpp_pool.py — SubprocessRunnerLauncher tier needs only g++ (runs by default);
295
+ # the DockerRunnerLauncher tier additionally needs `pip install -e ".[dev,cpp]"` and Docker
296
+ pytest tests/test_cpp_pool.py
297
+ ```
298
+
299
+ ---
300
+
301
+ ## License
302
+
303
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,270 @@
1
+ # tradingkit
2
+
3
+ [![CI](https://github.com/mholovion/trading-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/mholovion/trading-framework/actions/workflows/ci.yml)
4
+
5
+ Async trading strategy framework built on [Polars](https://pola.rs), [ClickHouse](https://clickhouse.com),
6
+ and pluggable executors. Write an `Indicator`/`Strategy`/`DataSource` once, then run it
7
+ in-process, in a sandboxed subprocess, on a remote worker, or against historical data in
8
+ a backtest — without changing the plugin code.
9
+
10
+ ---
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install tradingkit-py
16
+ ```
17
+
18
+ (PyPI distribution name is `tradingkit-py` — an unrelated project already holds the bare
19
+ `tradingkit` name — but the actual import is unaffected: `import tradingkit`.)
20
+
21
+ Requires Python ≥3.11. `TA-Lib` needs the native library installed first (see
22
+ [ta-lib.org](https://ta-lib.org)); everything else is a normal wheel dependency.
23
+
24
+ For development:
25
+
26
+ ```bash
27
+ git clone https://github.com/mholovion/trading-framework.git
28
+ cd trading-framework
29
+ pip install -e ".[dev]"
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ import asyncio
38
+ import numpy as np
39
+ import polars as pl
40
+ from tradingkit import Indicator, IndicatorContext, Strategy, Signal, BarContext
41
+ from tradingkit.backtest import BacktestRunner
42
+
43
+
44
+ class RSI(Indicator):
45
+ def compute(self, ctx: IndicatorContext) -> np.ndarray:
46
+ return ctx.ta.rsi(ctx.np.close, self.period)
47
+
48
+ def required_periods(self) -> int:
49
+ return self.period + 1
50
+
51
+
52
+ class MeanReversion(Strategy):
53
+ async def on_bar(self, bar: BarContext) -> Signal | None:
54
+ # Signal.type is a free-form string in general, but BacktestRunner's
55
+ # FIFO trade pairing specifically recognizes "buy"/"sell" — use those
56
+ # two if you want trades in BacktestResult.trades.
57
+ if bar.rsi < 30:
58
+ return Signal("buy", confidence=0.8, metadata={"direction": "long"})
59
+ if bar.rsi > 70:
60
+ return Signal("sell", confidence=0.7)
61
+ return None
62
+
63
+
64
+ async def main() -> None:
65
+ data = pl.read_csv("candles.csv") # timestamp, open, high, low, close, volume
66
+ runner = BacktestRunner() # defaults to LocalExecutor (in-process, no isolation)
67
+ result = await runner.run(
68
+ data=data,
69
+ indicators={"rsi": RSI(period=14)},
70
+ strategy=MeanReversion(),
71
+ )
72
+ print(result.summary())
73
+
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Architecture
81
+
82
+ ```
83
+ DataSource ──► Pipeline ──► Indicator(s) ──► Strategy ──► Signal
84
+ │ │ │
85
+ └────────────── all three run through a PluginExecutor ──────────┘
86
+ (Local / Subprocess / Remote / CppRunnerPool)
87
+
88
+ ClickHouseManager + DependencyResolver (optional, application layer)
89
+ caches indicator/strategy output in ClickHouse and resolves
90
+ dependencies on demand instead of recomputing from scratch.
91
+ ```
92
+
93
+ - **`DataSource`** ([tradingkit/source.py](tradingkit/source.py)) — fetches OHLCV data (exchange, CSV, custom API).
94
+ - **`Indicator`** ([tradingkit/indicator.py](tradingkit/indicator.py)) — `compute(ctx) -> np.ndarray`, given a `IndicatorContext` wrapping a `pl.DataFrame`. `ctx.ta.*` exposes 200+ TA-Lib functions plus numba-compiled primitives.
95
+ - **`Strategy`** ([tradingkit/strategy.py](tradingkit/strategy.py)) — `on_bar(bar) -> Signal | None`, given a `BarContext` with row + indicator values as dynamic attributes (`bar.close`, `bar.rsi`, ...).
96
+ - **`Pipeline`** ([tradingkit/pipeline.py](tradingkit/pipeline.py)) — wires a source + indicators + strategy into a single re-runnable, serializable unit.
97
+ - **`PluginExecutor`** ([tradingkit/executor/](tradingkit/executor/)) — where the above actually run:
98
+
99
+ | Executor | Isolation | Use case |
100
+ |---|---|---|
101
+ | `LocalExecutor` | none | development, trusted plugins |
102
+ | `SubprocessExecutor` | separate process, Arrow IPC, memory/timeout limits | untrusted-ish plugins, single machine |
103
+ | `RemoteExecutor` | separate host over HTTP | horizontal scaling, dedicated compute nodes — see [Security model](#security-model) before exposing it beyond localhost |
104
+ | `CppRunnerPool` (attach to any of the above) | Docker, `--network=none`, seccomp, read-only rootfs | compiled `CppIndicator`/`CppStrategyPlugin` payloads |
105
+
106
+ - **`BacktestRunner`** ([tradingkit/backtest/](tradingkit/backtest/)) — runs a strategy bar-by-bar over a pre-loaded `pl.DataFrame` and returns a `BacktestResult` (trades, win rate, drawdown, ...).
107
+ - **`ClickHouseManager` + `DependencyResolver`** ([tradingkit/core/](tradingkit/core/)) — optional application-layer caching: resolves an indicator/strategy request by walking its dependency chain and only recomputing what's missing from ClickHouse.
108
+ - **`AggregationContext` / `AggregationWorker`** ([tradingkit/aggregation.py](tradingkit/aggregation.py)) — periodic scripts that read arbitrary ClickHouse tables and write derived series (cross-symbol spreads, higher-timeframe values projected onto a lower timeframe, etc.).
109
+
110
+ ---
111
+
112
+ ## Dynamic (string-based) plugins
113
+
114
+ `Indicator`/`Strategy`/`DataSource` subclasses are regular Python classes — defined in
115
+ your codebase, imported at process start. Sometimes the plugin code itself isn't known
116
+ until runtime instead (loaded from a database row, a config file, a user-facing editor):
117
+ `ScriptIndicator` / `ScriptStrategy` / `ScriptSource` / `AggregationScript` cover that case
118
+ by taking the plugin body as a plain string and `exec()`-ing it on demand, instead of
119
+ requiring a class defined ahead of time:
120
+
121
+ ```python
122
+ from tradingkit import ScriptIndicator
123
+
124
+ indicator = ScriptIndicator(code="""
125
+ result = ta.rsi(close, 14)
126
+ """, period=14)
127
+ ```
128
+
129
+ Because this runs via `exec()` with no sandboxing, treat the `code` string with the same
130
+ trust as any other code you run — see [Security model](#security-model).
131
+
132
+ ---
133
+
134
+ ## Security model
135
+
136
+ tradingkit executes plugin code by design — that's the product. Two things are worth
137
+ being explicit about before you deploy it:
138
+
139
+ **1. In-process script execution has no sandbox.** `ScriptIndicator`, `ScriptStrategy`,
140
+ `ScriptSource`, and `AggregationScript` run their `code` string via `exec()` with full
141
+ interpreter privileges — no import restrictions, no resource limits. Only run code you
142
+ personally wrote or reviewed this way. If you need to run less-trusted plugin code, route
143
+ it through `SubprocessExecutor` (process boundary + memory/timeout limits) or
144
+ `CppRunnerPool` (Docker, `--network=none`, seccomp, read-only rootfs) instead of
145
+ `LocalExecutor`.
146
+
147
+ **2. `tradingkit-runner` requires a token and rejects non-loopback binds without one.**
148
+ `RemoteExecutor`/`tradingkit-runner` ship live plugin objects over the wire as pickle, so
149
+ the runner authenticates every `/compute/*` request (`Authorization: Bearer <token>`,
150
+ constant-time comparison, checked before the body is read) and decodes payloads with an
151
+ allowlisting unpickler (`tradingkit.runner._safe_pickle`) that blocks the standard
152
+ `os`/`subprocess`/`eval` pickle RCE gadgets even from an authenticated caller. Binding to
153
+ anything other than `127.0.0.1` requires both `--allow-remote` and a token — the process
154
+ refuses to start otherwise.
155
+
156
+ That said: **the token is a shared secret between trusted peers, not a full authz or
157
+ encryption layer.** Plain HTTP sends it in cleartext, and the restricted unpickler is a
158
+ targeted defense against known gadget classes, not a general-purpose sandbox. Don't put
159
+ `tradingkit-runner` on the public internet — run it behind a TLS-terminating reverse
160
+ proxy or keep it on a private network (VPN/VPC):
161
+
162
+ ```bash
163
+ tradingkit-runner --token "$(openssl rand -hex 32)" # loopback only, default
164
+ tradingkit-runner --token "$TOKEN" --host 0.0.0.0 --allow-remote # only behind TLS/VPN
165
+ ```
166
+
167
+ ```python
168
+ executor = RemoteExecutor("https://tradingkit-runner.internal:8082", token=TOKEN)
169
+ ```
170
+
171
+ **3. `DockerRunnerLauncher` mounts `docker.sock`.** Whatever process can reach that socket
172
+ has effective root on the host — it's what lets the launcher build and start the sandboxed
173
+ C++ runner containers on first use. Treat access to the host running `tradingkit-runner`/
174
+ `CppRunnerPool` with that in mind; don't give untrusted users shell access to it.
175
+
176
+ ---
177
+
178
+ ## Aggregation
179
+
180
+ `AggregationContext.query(table, symbol=..., start_ts=..., end_ts=...)` reads *any*
181
+ ClickHouse table by name — tradingkit ships no built-in aggregation scripts
182
+ (`BUILTIN_AGGREGATIONS` is an empty registry by default), but the mechanism covers a
183
+ few patterns directly:
184
+
185
+ - **Cross-symbol** — query two symbols, combine them (spread, ratio, custom index).
186
+ - **Cross-timeframe** — query a pre-aggregated table for a different bucket size (e.g.
187
+ `candles_3600s` for 1h buckets) and project it onto a lower-timeframe series; this is
188
+ the exact case `query()`'s own docstring calls out.
189
+ - **Cross-source** — the same `query()` call works on any table regardless of what wrote
190
+ it, so combining two independently-ingested tables (e.g. spot price + a funding-rate
191
+ feed) uses the same call shape. There's no bundled example of this one yet — you write
192
+ the join.
193
+
194
+ ```python
195
+ OUTPUT_TABLE = "spread_btc_eth"
196
+ OUTPUT_SCHEMA = {"timestamp": "Int64", "spread": "Float64", "ratio": "Float64"}
197
+ INTERVAL_S = 60
198
+
199
+ async def aggregate(ctx, start_ts: int, end_ts: int) -> list[dict]:
200
+ btc = await ctx.query("candles", symbol="BTC_USDT", start_ts=start_ts, end_ts=end_ts)
201
+ eth = await ctx.query("candles", symbol="ETH_USDT", start_ts=start_ts, end_ts=end_ts)
202
+ joined = btc.join(eth, on="timestamp", suffix="_eth")
203
+ return [
204
+ {"timestamp": int(r["timestamp"]), "spread": r["close"] - r["close_eth"],
205
+ "ratio": r["close"] / r["close_eth"] if r["close_eth"] else 0.0}
206
+ for r in joined.iter_rows(named=True)
207
+ ]
208
+ ```
209
+
210
+ `AggregationWorker` polls `plugin_library` for scripts like this one (`type="aggregation"`,
211
+ defines `aggregate()`) and runs each on `INTERVAL_S`, writing results to `OUTPUT_TABLE`.
212
+
213
+ **Known limitation — parametric ClickHouse aggregates.** Separately from the Python-script
214
+ path above, `tradingkit.core.clickhouse` also has a lower-level, ClickHouse-native
215
+ aggregation path (`Fold` in `tradingkit/schema.py`, plus `ensure_agg_table`/`ensure_mv`/
216
+ `backfill_agg`/`query_agg`), backed by `AggregatingMergeTree` tables and materialized
217
+ views using ClickHouse's `-State`/`-Merge` combinators. Argument-less functions (`sum`,
218
+ `count`, `avg`, `min`, `max`, ...) generate correct SQL and are covered by integration
219
+ tests against a real server. Parametric functions (`quantile`, `sumIf`, ...) don't yet —
220
+ the combinator suffix gets placed incorrectly relative to the function's own arguments,
221
+ producing SQL ClickHouse won't accept as written. This needs an actual syntax-generation
222
+ fix, not a quick patch, and isn't done yet — don't rely on `Fold` with parametric
223
+ functions until this is resolved.
224
+
225
+ **Registering named builtins.** `plugin_library` (ClickHouse-stored, per-project) is the
226
+ primary way to add aggregation/strategy scripts, but a host app can additionally register
227
+ its own named builtins — e.g. a curated set it always wants available regardless of
228
+ project — by pointing an environment variable at a module:
229
+
230
+ ```bash
231
+ export TRADINGKIT_AGGREGATIONS_MODULE=myapp.aggregations # exposes BUILTIN_AGGREGATIONS: dict[str, str]
232
+ export TRADINGKIT_STRATEGIES_MODULE=myapp.strategies # exposes BUILTIN_STRATEGIES: dict[str, str]
233
+ ```
234
+
235
+ Unset (the default), both registries are empty — see `tradingkit.core.plugin_registry`.
236
+
237
+ ---
238
+
239
+ ## Development
240
+
241
+ ```bash
242
+ pip install -e ".[dev]"
243
+ ruff check .
244
+ pytest
245
+ mypy tradingkit # non-blocking in CI — existing type-coverage gaps, not enforced yet
246
+ ```
247
+
248
+ CI (`.github/workflows/ci.yml`) runs `ruff` and `pytest` on Python 3.11 and 3.12 for every
249
+ push/PR, including a real ClickHouse service container and (on Docker-capable runners) a
250
+ real `CppRunnerPool` container.
251
+
252
+ Most of the suite needs nothing beyond `pip install -e ".[dev]"`. Two groups of tests
253
+ auto-skip unless their dependency is actually reachable, and light up locally too if you
254
+ have it running:
255
+
256
+ ```bash
257
+ # tests/test_clickhouse_integration.py — real ClickHouse instead of a mocked transport
258
+ docker run --rm -p 8123:8123 -p 9000:9000 clickhouse/clickhouse-server
259
+ CLICKHOUSE_HOST=localhost pytest tests/test_clickhouse_integration.py
260
+
261
+ # tests/test_cpp_pool.py — SubprocessRunnerLauncher tier needs only g++ (runs by default);
262
+ # the DockerRunnerLauncher tier additionally needs `pip install -e ".[dev,cpp]"` and Docker
263
+ pytest tests/test_cpp_pool.py
264
+ ```
265
+
266
+ ---
267
+
268
+ ## License
269
+
270
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Distribution name for PyPI ("tradingkit" is already taken by an unrelated package) --
7
+ # the importable package is still `tradingkit` (see [tool.setuptools.packages.find]
8
+ # below), so `import tradingkit` in every example stays unchanged.
9
+ name = "tradingkit-py"
10
+ version = "0.1.0"
11
+ description = "Async trading framework with Polars, ClickHouse, and pluggable executors"
12
+ readme = "README.md"
13
+ requires-python = ">=3.11"
14
+ license = { text = "MIT" }
15
+ authors = [
16
+ { name = "Maksym Holovin", email = "holovin.maksym@gmail.com" },
17
+ ]
18
+ dependencies = [
19
+ "aiohttp>=3.9.0,<4.0.0",
20
+ "aiohttp-jinja2==1.5.1",
21
+ "jinja2==3.1.2",
22
+ "polars>=0.20",
23
+ "pyarrow>=14.0.0",
24
+ "numpy>=2.2.6",
25
+ "TA-Lib>=0.4.0",
26
+ "numba>=0.59.0",
27
+ "asynch>=0.2.0",
28
+ "pydantic>=2.6.0,<3.0.0",
29
+ "pyyaml>=6.0.1,<7.0.0",
30
+ "python-dateutil>=2.8.2,<3.0.0",
31
+ "pytz>=2023.3",
32
+ "rich>=13.7.0,<14.0.0",
33
+ "websockets>=12.0,<13.0",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.9", "ruff>=0.4"]
38
+ cpp = ["docker>=7.0.0"]
39
+
40
+ [project.scripts]
41
+ tradingkit-runner = "tradingkit.runner.server:main"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["."]
45
+ include = ["tradingkit*"]
46
+
47
+ [tool.setuptools.package-data]
48
+ "tradingkit" = [
49
+ "py.typed",
50
+ "_docker/runner/Dockerfile",
51
+ "_docker/runner/runner_worker.py",
52
+ "_docker/runner/seccomp.json",
53
+ "_docker/compiler/Dockerfile",
54
+ "_docker/compiler/compile.sh",
55
+ ]
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+ target-version = "py311"
60
+
61
+ [tool.ruff.lint]
62
+ # E702 (semicolon-joined statements) is a deliberate, consistent style throughout the
63
+ # ClickHouse query-building methods: `if x is not None: where += "..."; params["k"] = x`
64
+ # keeps each optional filter to one line. Not a formatting slip — leave it alone.
65
+ ignore = ["E702"]
66
+
67
+ [tool.mypy]
68
+ python_version = "3.11"
69
+ strict = false
70
+ ignore_missing_imports = true
71
+
72
+ [tool.pytest.ini_options]
73
+ testpaths = ["tests"]
74
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+