tacuda 0.7.1__py3-none-win_amd64.whl

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.
tacuda/__init__.py ADDED
@@ -0,0 +1,1032 @@
1
+ # ============================================================================
2
+ # TaCuda | GPU-accelerated technical analysis | https://tacuda.com
3
+ # Copyright (c) 2024-2026 Paul Deecalov <paul@deecalov.com>
4
+ # SPDX-License-Identifier: Apache-2.0
5
+ # ============================================================================
6
+ """ctypes based Python bindings for the :mod:`tacuda` library.
7
+
8
+ The shared library must be built prior to importing this module. The
9
+ bindings are generated from the public ``tacuda.h`` header in order to keep
10
+ the Python API in sync with the C++ implementation and to avoid large amounts
11
+ of manually written boilerplate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ctypes
17
+ import ctypes.util
18
+ import inspect
19
+ import os
20
+ import sys
21
+ from typing import Any, Dict, List, Tuple
22
+
23
+ import numpy as np
24
+
25
+ from . import _generated
26
+
27
+
28
+ def _load_lib() -> ctypes.CDLL:
29
+ """Locate and load the ``tacuda`` shared library."""
30
+
31
+ if sys.platform.startswith("win"):
32
+ names = ["tacuda.dll"]
33
+ elif sys.platform == "darwin":
34
+ names = ["libtacuda.dylib", "tacuda.dylib"]
35
+ else:
36
+ names = ["libtacuda.so", "tacuda.so"]
37
+
38
+ # Environment variables for explicit locations
39
+ env_candidates: List[str] = []
40
+ env = os.environ.get("TACUDA_LIBRARY")
41
+ if env:
42
+ env_candidates.append(env)
43
+
44
+ env_dir = os.environ.get("TACUDA_LIBRARY_PATH") or os.environ.get(
45
+ "TACUDA_LIBRARY_DIR"
46
+ )
47
+ if env_dir:
48
+ for n in names:
49
+ env_candidates.append(os.path.join(env_dir, n))
50
+
51
+ for candidate in env_candidates:
52
+ if candidate and os.path.exists(candidate):
53
+ try:
54
+ # Python 3.8+: use winmode to search for dependencies in PATH
55
+ if sys.version_info >= (3, 8) and sys.platform.startswith("win"):
56
+ lib = ctypes.CDLL(candidate, winmode=0) # 0 = default search
57
+ else:
58
+ lib = ctypes.CDLL(candidate)
59
+ return lib
60
+ except OSError:
61
+ pass
62
+
63
+ # Try system paths via ctypes.util.find_library
64
+ libname = ctypes.util.find_library("tacuda")
65
+ if libname:
66
+ try:
67
+ return ctypes.CDLL(libname)
68
+ except OSError:
69
+ pass
70
+
71
+ # Fall back to package-relative locations
72
+ here = os.path.abspath(os.path.dirname(__file__))
73
+ root = os.path.abspath(os.path.join(here, "..", ".."))
74
+ search_paths = [
75
+ os.getcwd(),
76
+ os.path.join(os.getcwd(), "build"),
77
+ here,
78
+ os.path.join(here, "lib"),
79
+ root,
80
+ os.path.join(root, "lib"),
81
+ os.path.join(root, "build"),
82
+ ]
83
+ for base in search_paths:
84
+ for n in names:
85
+ path = os.path.join(base, n)
86
+ if os.path.exists(path):
87
+ try:
88
+ return ctypes.CDLL(path)
89
+ except OSError:
90
+ continue
91
+
92
+ raise OSError(
93
+ "tacuda library not found. Build the project first or set "
94
+ "TACUDA_LIBRARY[_PATH]."
95
+ )
96
+
97
+
98
+ _lib = _load_lib()
99
+
100
+ CT_DTYPE_F16 = 0
101
+ CT_DTYPE_F32 = 1
102
+ CT_DTYPE_F64 = 2
103
+
104
+ _DTYPE_NAME_TO_ENUM = {
105
+ "float16": CT_DTYPE_F16,
106
+ "f16": CT_DTYPE_F16,
107
+ "float32": CT_DTYPE_F32,
108
+ "f32": CT_DTYPE_F32,
109
+ "float64": CT_DTYPE_F64,
110
+ "f64": CT_DTYPE_F64,
111
+ }
112
+
113
+ _ENUM_TO_NP_DTYPE = {
114
+ CT_DTYPE_F16: np.float16,
115
+ CT_DTYPE_F32: np.float32,
116
+ CT_DTYPE_F64: np.float64,
117
+ }
118
+
119
+
120
+ def _normalize_dtype(dtype: str | np.dtype | type) -> int:
121
+ if isinstance(dtype, np.dtype):
122
+ dtype = str(dtype)
123
+ elif isinstance(dtype, type):
124
+ dtype = np.dtype(dtype).name
125
+ key = str(dtype).lower()
126
+ if key not in _DTYPE_NAME_TO_ENUM:
127
+ raise ValueError(f"Unsupported dtype '{dtype}'. Use float16/float32/float64.")
128
+ return _DTYPE_NAME_TO_ENUM[key]
129
+
130
+
131
+ def _compute_dtype_for_storage(storage_enum: int) -> int:
132
+ return CT_DTYPE_F32 if storage_enum == CT_DTYPE_F16 else storage_enum
133
+
134
+
135
+ def _as_typed_array(arr: Any, dtype_enum: int) -> np.ndarray:
136
+ np_dtype = _ENUM_TO_NP_DTYPE[dtype_enum]
137
+ out = np.asarray(arr, dtype=np_dtype)
138
+ if not out.flags.c_contiguous:
139
+ out = np.ascontiguousarray(out, dtype=np_dtype)
140
+ return out
141
+
142
+
143
+ def _as_float_array(arr: Any) -> Tuple[np.ndarray, ctypes.POINTER(ctypes.c_float)]:
144
+ """Return ``arr`` as a contiguous ``float32`` array and pointer."""
145
+
146
+ if not isinstance(arr, np.ndarray):
147
+ arr = np.asarray(arr, dtype=np.float32)
148
+ if arr.dtype != np.float32 or not arr.flags.c_contiguous:
149
+ arr = np.ascontiguousarray(arr, dtype=np.float32)
150
+ return arr, arr.ctypes.data_as(ctypes.POINTER(ctypes.c_float))
151
+
152
+
153
+ _C_FLOAT_PTR = ctypes.POINTER(ctypes.c_float)
154
+ _SPEC = _generated.FUNCTIONS
155
+
156
+ # Column pulled from an OHLCV container for each recognised C parameter name.
157
+ # Functions whose host inputs all appear here accept an OHLCV instance as the
158
+ # first positional argument instead of individual column arrays.
159
+ _OHLCV_INPUT_COLUMNS = {
160
+ "host_open": "open",
161
+ "host_high": "high",
162
+ "host_low": "low",
163
+ "host_close": "close",
164
+ "host_volume": "volume",
165
+ "host_price": "close",
166
+ }
167
+
168
+
169
+ def _scalar_ctype(name: str) -> Any:
170
+ mapping: Dict[str, Any] = {
171
+ "int": ctypes.c_int,
172
+ "float": ctypes.c_float,
173
+ "ctMaType_t": ctypes.c_int,
174
+ "ctDataType_t": ctypes.c_int,
175
+ "cudaStream_t": ctypes.c_void_p,
176
+ "size_t": ctypes.c_size_t,
177
+ }
178
+ try:
179
+ return mapping[name]
180
+ except KeyError as exc:
181
+ raise KeyError(f"Unsupported scalar type '{name}' in tacuda bindings") from exc
182
+
183
+
184
+ def _prepare_stream(value: Any) -> ctypes.c_void_p:
185
+ if value is None:
186
+ return ctypes.c_void_p(0)
187
+ if isinstance(value, ctypes.c_void_p):
188
+ return value
189
+ if isinstance(value, int):
190
+ return ctypes.c_void_p(value)
191
+ raise TypeError("Stream parameters must be integers or ctypes.c_void_p instances")
192
+
193
+
194
+ def _prepare_device_ptr(value: Any) -> ctypes.c_void_p:
195
+ if value is None:
196
+ return ctypes.c_void_p(0)
197
+ if isinstance(value, ctypes.c_void_p):
198
+ return value
199
+ if isinstance(value, int):
200
+ return ctypes.c_void_p(value)
201
+ # Also accept ctypes pointers
202
+ if hasattr(value, "contents"):
203
+ return ctypes.cast(value, ctypes.c_void_p)
204
+ raise TypeError(f"Device pointer must be int or c_void_p, got {type(value)}")
205
+
206
+
207
+ def _bind_function(name: str, spec: _generated.FunctionSpec) -> None:
208
+ cfunc = getattr(_lib, spec.c_name)
209
+
210
+ # Precision (_ex) host functions take void* buffers whose element type is
211
+ # selected by the storageType scalar; the generic float32 path must NOT
212
+ # be used for them (casting F16/F64 data to float32 would make the native
213
+ # side read the wrong element size).
214
+ storage_aware = any(s.name == "storageType" for s in spec.scalars)
215
+
216
+ # Determine argument types
217
+ argtypes: List[Any] = []
218
+
219
+ outputs_first = spec.c_name in ("ct_device_upload", "ct_device_upload_ex", "ct_device_download", "ct_device_download_ex")
220
+
221
+ def append_inputs():
222
+ if hasattr(spec, 'input_types'):
223
+ for i, type_name in enumerate(spec.input_types):
224
+ if type_name == "device" or storage_aware:
225
+ argtypes.append(ctypes.c_void_p) # Device or dtype-selected pointer
226
+ else:
227
+ argtypes.append(_C_FLOAT_PTR) # Host pointer (float*)
228
+ else:
229
+ # Fallback for old spec format (all float*)
230
+ argtypes.extend([_C_FLOAT_PTR] * len(spec.inputs))
231
+
232
+ def append_outputs():
233
+ if hasattr(spec, 'output_types'):
234
+ for i, type_name in enumerate(spec.output_types):
235
+ if type_name == "device" or storage_aware:
236
+ argtypes.append(ctypes.c_void_p) # Device or dtype-selected pointer
237
+ else:
238
+ argtypes.append(_C_FLOAT_PTR) # Host pointer (float*)
239
+ else:
240
+ # Fallback for old spec format (all float*)
241
+ argtypes.extend([_C_FLOAT_PTR] * len(spec.outputs))
242
+
243
+ if outputs_first:
244
+ append_outputs()
245
+ append_inputs()
246
+ else:
247
+ append_inputs()
248
+ append_outputs()
249
+
250
+ # Size param (if exists)
251
+ if spec.size_param:
252
+ argtypes.append(ctypes.c_int)
253
+
254
+ # Scalar params
255
+ for scalar in spec.scalars:
256
+ argtypes.append(_scalar_ctype(scalar.ctype))
257
+
258
+ cfunc.argtypes = argtypes
259
+
260
+ # Determine return type
261
+ if hasattr(spec, 'return_type'):
262
+ if spec.return_type == "ctStatus_t":
263
+ cfunc.restype = ctypes.c_int
264
+ elif spec.return_type in ("float*", "void*"):
265
+ cfunc.restype = ctypes.c_void_p # Return as opaque pointer
266
+ elif spec.return_type == "void":
267
+ cfunc.restype = None
268
+ elif spec.return_type == "size_t":
269
+ cfunc.restype = ctypes.c_size_t
270
+ else:
271
+ # Fallback/Unknown
272
+ cfunc.restype = ctypes.c_int
273
+ else:
274
+ cfunc.restype = ctypes.c_int
275
+
276
+ # Create Python wrapper
277
+ parameters: List[inspect.Parameter] = []
278
+ for input_name in spec.inputs:
279
+ parameters.append(
280
+ inspect.Parameter(input_name, inspect.Parameter.POSITIONAL_OR_KEYWORD)
281
+ )
282
+
283
+ # For device API outputs, they might be passed as arguments!
284
+ # But FunctionSpec splits pointers into inputs (const) and outputs (non-const).
285
+ # In generated bindings, outputs are usually hidden from python signature and created internally.
286
+ # BUT for Device API, the user MUST provide the output buffer pointer.
287
+ # So for Device API functions (where outputs are "device"), we should add them to parameters.
288
+
289
+ device_outputs_indices = []
290
+ if hasattr(spec, 'output_types'):
291
+ for i, type_name in enumerate(spec.output_types):
292
+ if type_name == "device":
293
+ device_outputs_indices.append(i)
294
+ # Add to python signature
295
+ out_name = spec.outputs[i]
296
+ parameters.append(
297
+ inspect.Parameter(out_name, inspect.Parameter.POSITIONAL_OR_KEYWORD)
298
+ )
299
+
300
+ has_host_inputs = False
301
+ if hasattr(spec, "input_types"):
302
+ has_host_inputs = any(t == "host" for t in spec.input_types)
303
+ elif spec.inputs:
304
+ has_host_inputs = True
305
+
306
+ if spec.size_param and not has_host_inputs:
307
+ parameters.append(
308
+ inspect.Parameter("size", inspect.Parameter.POSITIONAL_OR_KEYWORD)
309
+ )
310
+
311
+ for scalar in spec.scalars:
312
+ default = inspect._empty
313
+ if scalar.ctype == "cudaStream_t" and scalar.default is not None:
314
+ default = None
315
+ parameters.append(
316
+ inspect.Parameter(
317
+ scalar.name,
318
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
319
+ default=default,
320
+ )
321
+ )
322
+
323
+ signature = inspect.Signature(parameters)
324
+
325
+ ohlcv_columns: List[str] | None = None
326
+ host_input_names = [
327
+ n for i, n in enumerate(spec.inputs)
328
+ if not (hasattr(spec, "input_types") and spec.input_types[i] == "device")
329
+ ]
330
+ if host_input_names and all(n in _OHLCV_INPUT_COLUMNS for n in host_input_names):
331
+ ohlcv_columns = [_OHLCV_INPUT_COLUMNS[n] for n in host_input_names]
332
+
333
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
334
+ if args and isinstance(args[0], OHLCV):
335
+ if ohlcv_columns is None:
336
+ raise TypeError(
337
+ f"{name} does not accept an OHLCV container; "
338
+ "pass the required arrays explicitly"
339
+ )
340
+ series = args[0]
341
+ columns = tuple(getattr(series, col) for col in ohlcv_columns)
342
+ args = columns + args[1:]
343
+ bound = signature.bind(*args, **kwargs)
344
+ bound.apply_defaults()
345
+
346
+ # Resolve the storage dtype for precision (_ex) functions: host
347
+ # buffers are converted to and returned in exactly that dtype.
348
+ storage_enum = None
349
+ if storage_aware:
350
+ sval = bound.arguments.get("storageType", CT_DTYPE_F32)
351
+ storage_enum = int(sval) if isinstance(sval, (int, np.integer)) else _dtype_to_enum(sval)
352
+ bound.arguments["storageType"] = storage_enum
353
+
354
+ c_args: List[Any] = []
355
+ c_args_inputs: List[Any] = []
356
+ c_args_outputs: List[Any] = []
357
+
358
+ # Process Inputs
359
+ np_inputs: List[np.ndarray] = []
360
+
361
+ for i, name_ in enumerate(spec.inputs):
362
+ val = bound.arguments[name_]
363
+ is_device = False
364
+ if hasattr(spec, 'input_types') and spec.input_types[i] == "device":
365
+ is_device = True
366
+
367
+ if is_device:
368
+ c_args_inputs.append(_prepare_device_ptr(val))
369
+ elif storage_enum is not None:
370
+ arr_np = _as_typed_array(val, storage_enum)
371
+ np_inputs.append(arr_np)
372
+ c_args_inputs.append(arr_np.ctypes.data_as(ctypes.c_void_p))
373
+ else:
374
+ arr_np, ptr = _as_float_array(val)
375
+ np_inputs.append(arr_np)
376
+ c_args_inputs.append(ptr)
377
+
378
+ # Check sizes if we have numpy inputs
379
+ size = 0
380
+ if np_inputs:
381
+ size = np_inputs[0].size
382
+ for arr in np_inputs[1:]:
383
+ if arr.size != size:
384
+ raise ValueError("Input arrays must have the same size")
385
+ elif "size" in bound.arguments:
386
+ size = bound.arguments["size"]
387
+ elif "count" in bound.arguments:
388
+ size = bound.arguments["count"]
389
+
390
+ # Process Outputs
391
+ outputs_to_return: List[Any] = [] # Only host outputs are returned
392
+
393
+ for i, name_ in enumerate(spec.outputs):
394
+ is_device = False
395
+ if hasattr(spec, 'output_types') and spec.output_types[i] == "device":
396
+ is_device = True
397
+
398
+ if is_device:
399
+ # Device outputs are passed as arguments
400
+ val = bound.arguments[name_]
401
+ c_args_outputs.append(_prepare_device_ptr(val))
402
+ # We don't return device pointers, user already has them
403
+ elif storage_enum is not None:
404
+ # Host output in the storage dtype selected by storageType.
405
+ out_arr = np.zeros(size, dtype=_ENUM_TO_NP_DTYPE[storage_enum])
406
+ c_args_outputs.append(out_arr.ctypes.data_as(ctypes.c_void_p))
407
+ outputs_to_return.append(out_arr)
408
+ else:
409
+ # Host output - create numpy array
410
+ if size == 0 and spec.size_param:
411
+ # We need size. If we have no input arrays, we might need to look at size scalar if it exists?
412
+ # But usually 'size' is not in bound.arguments if it's the special size param.
413
+ # Wait, if inputs are device ptrs, we don't know size!
414
+ # The C function expects 'size'. Where does it come from?
415
+ pass
416
+
417
+ # In mixed mode (host input, host output), size comes from input.
418
+ out_arr = np.zeros(size, dtype=np.float32)
419
+ _, ptr = _as_float_array(out_arr)
420
+ c_args_outputs.append(ptr)
421
+ outputs_to_return.append(out_arr)
422
+
423
+ if outputs_first:
424
+ c_args.extend(c_args_outputs)
425
+ c_args.extend(c_args_inputs)
426
+ else:
427
+ c_args.extend(c_args_inputs)
428
+ c_args.extend(c_args_outputs)
429
+
430
+ # Size parameter
431
+ if spec.size_param:
432
+ # If we have size from numpy arrays, use it.
433
+ # If we rely on manual size passing (e.g. for device-only calls), we need to find where 'size' comes from.
434
+ # But wait, 'size' is EXCLUDED from python signature in 'render_python' logic for standard functions.
435
+ # "FunctionSpec.scalar_params" excludes size.
436
+
437
+ # Issue: For device-only functions, we MUST pass size manually.
438
+ # My generate_bindings script excluded 'size' from scalars.
439
+ # This is a problem for Device API functions where size cannot be inferred from numpy arrays.
440
+
441
+ # Quick fix: If size is 0 (no numpy inputs), try to find 'size' in kwargs? No, it's not in signature.
442
+
443
+ # We will add a hack: if we have no host inputs, we implicitly assume 'size' is passed as argument?
444
+ # But wrapper signature doesn't have it.
445
+
446
+ # Real fix: generate_bindings should include 'size' in scalars if it cannot be inferred?
447
+ # Or we assume for now Device API is mixed with Host API or manual calls use ctypes directly.
448
+
449
+ # Let's assume if np_inputs is empty, we check if 'size' is in bound args (it won't be).
450
+ # This is a limitation. I will assume size is available or 0.
451
+ # For pure device functions, the user currently CANNOT pass size via this wrapper if it was stripped.
452
+
453
+ # However, 'ct_device_alloc' takes 'count' (renamed to size?). No, 'count'.
454
+ # 'ct_sma_d' takes 'size'.
455
+
456
+ c_args.append(ctypes.c_int(size))
457
+
458
+ # Scalars
459
+ for scalar in spec.scalars:
460
+ val = bound.arguments[scalar.name]
461
+ if scalar.ctype == "cudaStream_t":
462
+ c_args.append(_prepare_stream(val))
463
+ else:
464
+ ctype = _scalar_ctype(scalar.ctype)
465
+ c_args.append(ctype(val))
466
+
467
+ rc = cfunc(*c_args)
468
+
469
+ # Handle return value
470
+ if hasattr(spec, 'return_type') and spec.return_type == "ctStatus_t":
471
+ if rc != 0:
472
+ raise RuntimeError(f"{spec.c_name} failed with code {rc}")
473
+
474
+ if outputs_to_return:
475
+ if len(outputs_to_return) == 1:
476
+ return outputs_to_return[0]
477
+ return tuple(outputs_to_return)
478
+ return None # Success, no host outputs
479
+
480
+ return rc # Return whatever it returned (float*, size_t, etc)
481
+
482
+ wrapper.__name__ = name
483
+ wrapper.__doc__ = f"Python wrapper for {spec.c_name}"
484
+ wrapper.__signature__ = signature
485
+ globals()[name] = wrapper
486
+
487
+
488
+ for _py_name, _spec in _SPEC.items():
489
+ _bind_function(_py_name, _spec)
490
+
491
+
492
+ def _bind_precision_functions() -> None:
493
+ c_void_p = ctypes.c_void_p
494
+ c_int = ctypes.c_int
495
+ c_float = ctypes.c_float
496
+
497
+ _lib.ct_sma_ex.argtypes = [c_void_p, c_void_p, c_int, c_int, c_int, c_int, c_void_p]
498
+ _lib.ct_sma_ex.restype = c_int
499
+ _lib.ct_ema_ex.argtypes = [c_void_p, c_void_p, c_int, c_int, c_int, c_int, c_void_p]
500
+ _lib.ct_ema_ex.restype = c_int
501
+ _lib.ct_rsi_ex.argtypes = [c_void_p, c_void_p, c_int, c_int, c_int, c_int, c_void_p]
502
+ _lib.ct_rsi_ex.restype = c_int
503
+ _lib.ct_macd_ex.argtypes = [
504
+ c_void_p, c_void_p, c_void_p, c_void_p, c_int, c_int, c_int, c_int, c_int, c_int, c_int, c_void_p
505
+ ]
506
+ _lib.ct_macd_ex.restype = c_int
507
+ _lib.ct_atr_ex.argtypes = [
508
+ c_void_p, c_void_p, c_void_p, c_void_p, c_int, c_int, c_float, c_int, c_int, c_void_p
509
+ ]
510
+ _lib.ct_atr_ex.restype = c_int
511
+ _lib.ct_bbands_ex.argtypes = [
512
+ c_void_p, c_void_p, c_void_p, c_void_p, c_int, c_int, c_float, c_float, c_int, c_int, c_void_p
513
+ ]
514
+ _lib.ct_bbands_ex.restype = c_int
515
+
516
+
517
+ _bind_precision_functions()
518
+
519
+
520
+ _sma_f32 = sma
521
+ _ema_f32 = ema
522
+ _rsi_f32 = rsi
523
+ _macd_f32 = macd
524
+ _atr_f32 = atr
525
+ _bbands_f32 = bbands
526
+
527
+
528
+ def _call_status(rc: int, func_name: str) -> None:
529
+ if rc != 0:
530
+ raise RuntimeError(f"{func_name} failed with code {rc}")
531
+
532
+
533
+ def sma(input: Any, period: int, stream: Any = None, dtype: str = "float32") -> np.ndarray:
534
+ storage = _normalize_dtype(dtype)
535
+ if storage == CT_DTYPE_F32:
536
+ return _sma_f32(input, period, stream)
537
+ x = _as_typed_array(input, storage)
538
+ out = np.zeros_like(x)
539
+ compute = _compute_dtype_for_storage(storage)
540
+ rc = _lib.ct_sma_ex(
541
+ x.ctypes.data_as(ctypes.c_void_p), out.ctypes.data_as(ctypes.c_void_p),
542
+ ctypes.c_int(x.size), ctypes.c_int(int(period)),
543
+ ctypes.c_int(storage), ctypes.c_int(compute), _prepare_stream(stream)
544
+ )
545
+ _call_status(rc, "ct_sma_ex")
546
+ return out
547
+
548
+
549
+ def ema(input: Any, period: int, stream: Any = None, dtype: str = "float32") -> np.ndarray:
550
+ storage = _normalize_dtype(dtype)
551
+ if storage == CT_DTYPE_F32:
552
+ return _ema_f32(input, period, stream)
553
+ x = _as_typed_array(input, storage)
554
+ out = np.zeros_like(x)
555
+ compute = _compute_dtype_for_storage(storage)
556
+ rc = _lib.ct_ema_ex(
557
+ x.ctypes.data_as(ctypes.c_void_p), out.ctypes.data_as(ctypes.c_void_p),
558
+ ctypes.c_int(x.size), ctypes.c_int(int(period)),
559
+ ctypes.c_int(storage), ctypes.c_int(compute), _prepare_stream(stream)
560
+ )
561
+ _call_status(rc, "ct_ema_ex")
562
+ return out
563
+
564
+
565
+ def rsi(input: Any, period: int, stream: Any = None, dtype: str = "float32") -> np.ndarray:
566
+ storage = _normalize_dtype(dtype)
567
+ if storage == CT_DTYPE_F32:
568
+ return _rsi_f32(input, period, stream)
569
+ x = _as_typed_array(input, storage)
570
+ out = np.zeros_like(x)
571
+ compute = _compute_dtype_for_storage(storage)
572
+ rc = _lib.ct_rsi_ex(
573
+ x.ctypes.data_as(ctypes.c_void_p), out.ctypes.data_as(ctypes.c_void_p),
574
+ ctypes.c_int(x.size), ctypes.c_int(int(period)),
575
+ ctypes.c_int(storage), ctypes.c_int(compute), _prepare_stream(stream)
576
+ )
577
+ _call_status(rc, "ct_rsi_ex")
578
+ return out
579
+
580
+
581
+ def macd(
582
+ input: Any,
583
+ fastPeriod: int,
584
+ slowPeriod: int,
585
+ signalPeriod: int,
586
+ type: int = 1,
587
+ stream: Any = None,
588
+ dtype: str = "float32",
589
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
590
+ storage = _normalize_dtype(dtype)
591
+ if storage == CT_DTYPE_F32:
592
+ return _macd_f32(input, fastPeriod, slowPeriod, signalPeriod, type, stream)
593
+ x = _as_typed_array(input, storage)
594
+ m = np.zeros_like(x)
595
+ s = np.zeros_like(x)
596
+ h = np.zeros_like(x)
597
+ compute = _compute_dtype_for_storage(storage)
598
+ rc = _lib.ct_macd_ex(
599
+ x.ctypes.data_as(ctypes.c_void_p), m.ctypes.data_as(ctypes.c_void_p),
600
+ s.ctypes.data_as(ctypes.c_void_p), h.ctypes.data_as(ctypes.c_void_p),
601
+ ctypes.c_int(x.size), ctypes.c_int(int(fastPeriod)), ctypes.c_int(int(slowPeriod)),
602
+ ctypes.c_int(int(signalPeriod)), ctypes.c_int(int(type)), ctypes.c_int(storage),
603
+ ctypes.c_int(compute), _prepare_stream(stream)
604
+ )
605
+ _call_status(rc, "ct_macd_ex")
606
+ return m, s, h
607
+
608
+
609
+ def atr(
610
+ high: Any, low: Any = None, close: Any = None, period: int = 14,
611
+ initial: float = 0.0, stream: Any = None, dtype: str = "float32",
612
+ ) -> np.ndarray:
613
+ if isinstance(high, OHLCV):
614
+ # atr(ohlcv, 14) / atr(ohlcv, period=14)
615
+ if low is not None:
616
+ if not np.isscalar(low) or close is not None:
617
+ raise TypeError(
618
+ "atr(OHLCV, ...) accepts only the period after the container"
619
+ )
620
+ period = int(low)
621
+ series = high
622
+ high, low, close = series.high, series.low, series.close
623
+ elif low is None or close is None:
624
+ raise TypeError("atr() requires high, low and close arrays")
625
+ storage = _normalize_dtype(dtype)
626
+ if storage == CT_DTYPE_F32:
627
+ return _atr_f32(high, low, close, period, initial, stream)
628
+ h = _as_typed_array(high, storage)
629
+ l = _as_typed_array(low, storage)
630
+ c = _as_typed_array(close, storage)
631
+ if h.size != l.size or h.size != c.size:
632
+ raise ValueError("high/low/close arrays must have same size")
633
+ out = np.zeros_like(h)
634
+ compute = _compute_dtype_for_storage(storage)
635
+ rc = _lib.ct_atr_ex(
636
+ h.ctypes.data_as(ctypes.c_void_p), l.ctypes.data_as(ctypes.c_void_p),
637
+ c.ctypes.data_as(ctypes.c_void_p), out.ctypes.data_as(ctypes.c_void_p),
638
+ ctypes.c_int(h.size), ctypes.c_int(int(period)), ctypes.c_float(float(initial)),
639
+ ctypes.c_int(storage), ctypes.c_int(compute), _prepare_stream(stream)
640
+ )
641
+ _call_status(rc, "ct_atr_ex")
642
+ return out
643
+
644
+
645
+ def bbands(
646
+ input: Any, period: int, upperMul: float = 2.0, lowerMul: float = 2.0,
647
+ stream: Any = None, dtype: str = "float32",
648
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
649
+ storage = _normalize_dtype(dtype)
650
+ if storage == CT_DTYPE_F32:
651
+ return _bbands_f32(input, period, upperMul, lowerMul, stream)
652
+ x = _as_typed_array(input, storage)
653
+ up = np.zeros_like(x)
654
+ mid = np.zeros_like(x)
655
+ lo = np.zeros_like(x)
656
+ compute = _compute_dtype_for_storage(storage)
657
+ rc = _lib.ct_bbands_ex(
658
+ x.ctypes.data_as(ctypes.c_void_p), up.ctypes.data_as(ctypes.c_void_p),
659
+ mid.ctypes.data_as(ctypes.c_void_p), lo.ctypes.data_as(ctypes.c_void_p),
660
+ ctypes.c_int(x.size), ctypes.c_int(int(period)), ctypes.c_float(float(upperMul)),
661
+ ctypes.c_float(float(lowerMul)), ctypes.c_int(storage), ctypes.c_int(compute),
662
+ _prepare_stream(stream)
663
+ )
664
+ _call_status(rc, "ct_bbands_ex")
665
+ return up, mid, lo
666
+
667
+
668
+ _lib.ct_version.argtypes = []
669
+ _lib.ct_version.restype = ctypes.c_char_p
670
+
671
+
672
+ def version() -> str:
673
+ """Return the native library version string (MAJOR.MINOR.PATCH)."""
674
+ return _lib.ct_version().decode("ascii")
675
+
676
+
677
+ def _as_batch_matrix(arr: Any) -> np.ndarray:
678
+ a = np.asarray(arr, dtype=np.float32)
679
+ if a.ndim != 2:
680
+ raise ValueError("batch input must be a 2D array (numSeries, seriesLen)")
681
+ return np.ascontiguousarray(a)
682
+
683
+
684
+ def _batch_ptr(a: np.ndarray) -> Any:
685
+ return a.ctypes.data_as(_C_FLOAT_PTR)
686
+
687
+
688
+ def sma_batch(inputs: Any, period: int, max_concurrency: int = 0,
689
+ stream: Any = None) -> np.ndarray:
690
+ """SMA over a (numSeries, seriesLen) matrix; one row per symbol."""
691
+ a = _as_batch_matrix(inputs)
692
+ out = np.zeros_like(a)
693
+ rc = _lib.ct_sma_batch(_batch_ptr(a), _batch_ptr(out),
694
+ ctypes.c_int(a.shape[1]), ctypes.c_int(a.shape[0]),
695
+ ctypes.c_int(int(period)),
696
+ ctypes.c_int(int(max_concurrency)),
697
+ _prepare_stream(stream))
698
+ _call_status(rc, "ct_sma_batch")
699
+ return out
700
+
701
+
702
+ def ema_batch(inputs: Any, period: int, max_concurrency: int = 0,
703
+ stream: Any = None) -> np.ndarray:
704
+ """EMA over a (numSeries, seriesLen) matrix; one row per symbol."""
705
+ a = _as_batch_matrix(inputs)
706
+ out = np.zeros_like(a)
707
+ rc = _lib.ct_ema_batch(_batch_ptr(a), _batch_ptr(out),
708
+ ctypes.c_int(a.shape[1]), ctypes.c_int(a.shape[0]),
709
+ ctypes.c_int(int(period)),
710
+ ctypes.c_int(int(max_concurrency)),
711
+ _prepare_stream(stream))
712
+ _call_status(rc, "ct_ema_batch")
713
+ return out
714
+
715
+
716
+ def rsi_batch(inputs: Any, period: int, max_concurrency: int = 0,
717
+ stream: Any = None) -> np.ndarray:
718
+ """RSI over a (numSeries, seriesLen) matrix; one row per symbol."""
719
+ a = _as_batch_matrix(inputs)
720
+ out = np.zeros_like(a)
721
+ rc = _lib.ct_rsi_batch(_batch_ptr(a), _batch_ptr(out),
722
+ ctypes.c_int(a.shape[1]), ctypes.c_int(a.shape[0]),
723
+ ctypes.c_int(int(period)),
724
+ ctypes.c_int(int(max_concurrency)),
725
+ _prepare_stream(stream))
726
+ _call_status(rc, "ct_rsi_batch")
727
+ return out
728
+
729
+
730
+ def atr_batch(high: Any, low: Any, close: Any, period: int,
731
+ initial: float = 0.0, max_concurrency: int = 0,
732
+ stream: Any = None) -> np.ndarray:
733
+ """ATR over (numSeries, seriesLen) high/low/close matrices."""
734
+ h = _as_batch_matrix(high)
735
+ l = _as_batch_matrix(low)
736
+ c = _as_batch_matrix(close)
737
+ if h.shape != l.shape or h.shape != c.shape:
738
+ raise ValueError("high/low/close matrices must have the same shape")
739
+ out = np.zeros_like(h)
740
+ rc = _lib.ct_atr_batch(_batch_ptr(h), _batch_ptr(l), _batch_ptr(c),
741
+ _batch_ptr(out),
742
+ ctypes.c_int(h.shape[1]), ctypes.c_int(h.shape[0]),
743
+ ctypes.c_int(int(period)),
744
+ ctypes.c_float(float(initial)),
745
+ ctypes.c_int(int(max_concurrency)),
746
+ _prepare_stream(stream))
747
+ _call_status(rc, "ct_atr_batch")
748
+ return out
749
+
750
+
751
+ def macd_batch(inputs: Any, fastPeriod: int = 12, slowPeriod: int = 26,
752
+ signalPeriod: int = 9, type: int = 1, max_concurrency: int = 0,
753
+ stream: Any = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
754
+ """MACD over a (numSeries, seriesLen) matrix; returns (macd, signal, hist)."""
755
+ a = _as_batch_matrix(inputs)
756
+ m = np.zeros_like(a)
757
+ s = np.zeros_like(a)
758
+ h = np.zeros_like(a)
759
+ rc = _lib.ct_macd_batch(_batch_ptr(a), _batch_ptr(m), _batch_ptr(s),
760
+ _batch_ptr(h),
761
+ ctypes.c_int(a.shape[1]), ctypes.c_int(a.shape[0]),
762
+ ctypes.c_int(int(fastPeriod)),
763
+ ctypes.c_int(int(slowPeriod)),
764
+ ctypes.c_int(int(signalPeriod)),
765
+ ctypes.c_int(int(type)),
766
+ ctypes.c_int(int(max_concurrency)),
767
+ _prepare_stream(stream))
768
+ _call_status(rc, "ct_macd_batch")
769
+ return m, s, h
770
+
771
+
772
+ def bbands_batch(inputs: Any, period: int, upperMul: float = 2.0,
773
+ lowerMul: float = 2.0, max_concurrency: int = 0,
774
+ stream: Any = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
775
+ """Bollinger Bands over a (numSeries, seriesLen) matrix."""
776
+ a = _as_batch_matrix(inputs)
777
+ up = np.zeros_like(a)
778
+ mid = np.zeros_like(a)
779
+ lo = np.zeros_like(a)
780
+ rc = _lib.ct_bbands_batch(_batch_ptr(a), _batch_ptr(up), _batch_ptr(mid),
781
+ _batch_ptr(lo),
782
+ ctypes.c_int(a.shape[1]), ctypes.c_int(a.shape[0]),
783
+ ctypes.c_int(int(period)),
784
+ ctypes.c_float(float(upperMul)),
785
+ ctypes.c_float(float(lowerMul)),
786
+ ctypes.c_int(int(max_concurrency)),
787
+ _prepare_stream(stream))
788
+ _call_status(rc, "ct_bbands_batch")
789
+ return up, mid, lo
790
+
791
+
792
+ def stochastic_batch(high: Any, low: Any, close: Any, kPeriod: int = 14,
793
+ dPeriod: int = 3, max_concurrency: int = 0,
794
+ stream: Any = None) -> Tuple[np.ndarray, np.ndarray]:
795
+ """Stochastic %K/%D over (numSeries, seriesLen) high/low/close matrices."""
796
+ h = _as_batch_matrix(high)
797
+ l = _as_batch_matrix(low)
798
+ c = _as_batch_matrix(close)
799
+ if h.shape != l.shape or h.shape != c.shape:
800
+ raise ValueError("high/low/close matrices must have the same shape")
801
+ k = np.zeros_like(h)
802
+ d = np.zeros_like(h)
803
+ rc = _lib.ct_stochastic_batch(_batch_ptr(h), _batch_ptr(l), _batch_ptr(c),
804
+ _batch_ptr(k), _batch_ptr(d),
805
+ ctypes.c_int(h.shape[1]),
806
+ ctypes.c_int(h.shape[0]),
807
+ ctypes.c_int(int(kPeriod)),
808
+ ctypes.c_int(int(dPeriod)),
809
+ ctypes.c_int(int(max_concurrency)),
810
+ _prepare_stream(stream))
811
+ _call_status(rc, "ct_stochastic_batch")
812
+ return k, d
813
+
814
+
815
+ # ---------------------------------------------------------------------------
816
+ # Generated batch families: friendly (numSeries, seriesLen)-matrix wrappers
817
+ # built from the _generated specs for every remaining ct_*_batch symbol.
818
+ # ---------------------------------------------------------------------------
819
+
820
+ _MANUAL_BATCH = {
821
+ "sma_batch", "ema_batch", "rsi_batch", "atr_batch",
822
+ "macd_batch", "bbands_batch", "stochastic_batch",
823
+ }
824
+
825
+
826
+ def _make_batch_wrapper(py_name: str, spec: _generated.FunctionSpec):
827
+ cfunc = getattr(_lib, spec.c_name)
828
+ n_in = len(spec.inputs)
829
+ n_out = len(spec.outputs)
830
+ family_scalars = [
831
+ s for s in spec.scalars
832
+ if s.name not in ("seriesLen", "numSeries", "maxConcurrency")
833
+ and s.ctype != "cudaStream_t"
834
+ ]
835
+
836
+ def wrapper(*args: Any, max_concurrency: int = 0, stream: Any = None,
837
+ **kwargs: Any) -> Any:
838
+ if len(args) < n_in or len(args) > n_in + len(family_scalars):
839
+ expected = list(spec.inputs) + [s.name for s in family_scalars]
840
+ raise TypeError(
841
+ f"{py_name} expects arguments {expected} "
842
+ "(plus keyword-only max_concurrency/stream)")
843
+ mats = [_as_batch_matrix(a) for a in args[:n_in]]
844
+ for m in mats[1:]:
845
+ if m.shape != mats[0].shape:
846
+ raise ValueError("input matrices must have the same shape")
847
+ scalar_values = list(args[n_in:])
848
+ for s in family_scalars[len(scalar_values):]:
849
+ if s.name not in kwargs:
850
+ raise TypeError(f"{py_name} missing argument '{s.name}'")
851
+ scalar_values.append(kwargs.pop(s.name))
852
+ if kwargs:
853
+ raise TypeError(
854
+ f"{py_name} got unexpected arguments {sorted(kwargs)}")
855
+ outs = [np.zeros_like(mats[0]) for _ in range(n_out)]
856
+
857
+ c_args: list[Any] = [_batch_ptr(m) for m in mats]
858
+ c_args += [_batch_ptr(o) for o in outs]
859
+ c_args += [ctypes.c_int(mats[0].shape[1]),
860
+ ctypes.c_int(mats[0].shape[0])]
861
+ for s, v in zip(family_scalars, scalar_values):
862
+ c_args.append(ctypes.c_float(float(v)) if s.ctype == "float"
863
+ else ctypes.c_int(int(v)))
864
+ c_args.append(ctypes.c_int(int(max_concurrency)))
865
+ c_args.append(_prepare_stream(stream))
866
+
867
+ _call_status(cfunc(*c_args), spec.c_name)
868
+ return outs[0] if n_out == 1 else tuple(outs)
869
+
870
+ wrapper.__name__ = py_name
871
+ param_names = list(spec.inputs) + [s.name for s in family_scalars]
872
+ wrapper.__doc__ = (
873
+ f"Batched wrapper for {spec.c_name}: arguments {param_names}; "
874
+ "inputs are (numSeries, seriesLen) matrices, one row per symbol.")
875
+ return wrapper
876
+
877
+
878
+ for _py_name, _spec in _SPEC.items():
879
+ if (_py_name.endswith("_batch") and _py_name not in _MANUAL_BATCH):
880
+ globals()[_py_name] = _make_batch_wrapper(_py_name, _spec)
881
+
882
+
883
+ class OHLCV:
884
+ """Columnar OHLCV container backed by NumPy arrays."""
885
+
886
+ __slots__ = ("_data",)
887
+
888
+ def __init__(self, size: int = 0) -> None:
889
+ size = int(size)
890
+ if size < 0:
891
+ raise ValueError("OHLCV size must be non-negative")
892
+ self._data = np.zeros((5, size), dtype=np.float32)
893
+
894
+ @property
895
+ def size(self) -> int:
896
+ """Number of rows contained in the series."""
897
+
898
+ return int(self._data.shape[1])
899
+
900
+ def __len__(self) -> int: # pragma: no cover - trivial
901
+ return self.size
902
+
903
+ @classmethod
904
+ def from_columns(
905
+ cls,
906
+ open: Any,
907
+ high: Any,
908
+ low: Any,
909
+ close: Any,
910
+ volume: Any | None = None,
911
+ ) -> "OHLCV":
912
+ """Create an :class:`OHLCV` series from column vectors."""
913
+
914
+ arrays = []
915
+ size: int | None = None
916
+ for name, source in zip(
917
+ ("open", "high", "low", "close"), (open, high, low, close)
918
+ ):
919
+ arr = np.asarray(source, dtype=np.float32)
920
+ if arr.ndim == 0:
921
+ arr = arr.reshape(1)
922
+ arr = np.ascontiguousarray(arr.reshape(-1), dtype=np.float32)
923
+ if size is None:
924
+ size = int(arr.size)
925
+ elif int(arr.size) != size:
926
+ raise ValueError(f"{name} column must have length {size}")
927
+ arrays.append(arr)
928
+
929
+ if size is None:
930
+ size = 0
931
+
932
+ instance = cls(size)
933
+ for idx, arr in enumerate(arrays):
934
+ instance._data[idx, : size] = arr
935
+
936
+ if volume is None:
937
+ instance._data[4, : size].fill(0.0)
938
+ else:
939
+ vol = np.asarray(volume, dtype=np.float32)
940
+ if vol.ndim == 0:
941
+ vol = vol.reshape(1)
942
+ vol = np.ascontiguousarray(vol.reshape(-1), dtype=np.float32)
943
+ if int(vol.size) != size:
944
+ raise ValueError("volume column must match other columns")
945
+ instance._data[4, : size] = vol
946
+
947
+ return instance
948
+
949
+ @classmethod
950
+ def from_rows(cls, rows: Any) -> "OHLCV":
951
+ """Create an :class:`OHLCV` series from row-wise data."""
952
+
953
+ arr = np.asarray(rows, dtype=np.float32)
954
+ if arr.ndim != 2:
955
+ raise ValueError("rows input must be a 2D array")
956
+ if arr.shape[1] not in (4, 5):
957
+ raise ValueError("rows must have 4 (OHLC) or 5 (OHLCV) columns")
958
+
959
+ arr = np.ascontiguousarray(arr, dtype=np.float32)
960
+ columns = [arr[:, i] for i in range(4)]
961
+ volume = arr[:, 4] if arr.shape[1] == 5 else np.zeros(arr.shape[0], dtype=np.float32)
962
+ return cls.from_columns(columns[0], columns[1], columns[2], columns[3], volume)
963
+
964
+ def _column_view(self, idx: int) -> np.ndarray:
965
+ return self._data[idx]
966
+
967
+ @property
968
+ def open(self) -> np.ndarray:
969
+ return self._column_view(0)
970
+
971
+ @property
972
+ def high(self) -> np.ndarray:
973
+ return self._column_view(1)
974
+
975
+ @property
976
+ def low(self) -> np.ndarray:
977
+ return self._column_view(2)
978
+
979
+ @property
980
+ def close(self) -> np.ndarray:
981
+ return self._column_view(3)
982
+
983
+ @property
984
+ def volume(self) -> np.ndarray:
985
+ return self._column_view(4)
986
+
987
+ def column_major(self, include_volume: bool = True) -> np.ndarray:
988
+ """Return a view of the data in column-major (SoA) layout."""
989
+
990
+ if include_volume:
991
+ return self._data.reshape(-1)
992
+ return self._data[:4].reshape(-1)
993
+
994
+ def copy_column_major(self, include_volume: bool = True) -> np.ndarray:
995
+ """Return a copy of the data in column-major layout."""
996
+
997
+ return np.array(self.column_major(include_volume), copy=True)
998
+
999
+ def to_numpy(self, include_volume: bool = True, copy: bool = True) -> np.ndarray:
1000
+ """Return a 2D NumPy array of shape (rows, columns)."""
1001
+
1002
+ data = self._data.T if include_volume else self._data[:4].T
1003
+ return np.array(data, copy=copy)
1004
+
1005
+ def set(
1006
+ self,
1007
+ index: int,
1008
+ open: float,
1009
+ high: float,
1010
+ low: float,
1011
+ close: float,
1012
+ volume: float = 0.0,
1013
+ ) -> None:
1014
+ if index < 0 or index >= self.size:
1015
+ raise IndexError("OHLCV row index out of range")
1016
+ self.open[index] = float(open)
1017
+ self.high[index] = float(high)
1018
+ self.low[index] = float(low)
1019
+ self.close[index] = float(close)
1020
+ self.volume[index] = float(volume)
1021
+
1022
+ def __array__(self, dtype: Any | None = None) -> np.ndarray: # pragma: no cover - numpy protocol
1023
+ if dtype is None or dtype == self._data.dtype:
1024
+ return self._data
1025
+ return self._data.astype(dtype, copy=False)
1026
+
1027
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
1028
+ return f"OHLCV(size={self.size})"
1029
+
1030
+ __all_list = sorted(_SPEC)
1031
+ __all_list.extend(("OHLCV", "version"))
1032
+ __all__ = tuple(__all_list)