fix(python): critical bug fixes across Python wrapper

This commit is contained in:
Miha Kralj
2026-03-01 21:35:19 -08:00
parent 9c03a5bbbe
commit ce4416d388
40 changed files with 9592 additions and 1397 deletions
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import re, os
total = 0
for f in sorted(os.listdir('python/quantalib')):
if f.endswith('.py') and not f.startswith('_') and f != 'indicators.py':
content = open(f'python/quantalib/{f}', encoding='utf-8').read()
defs = re.findall(r'^def (\w+)\(', content, re.MULTILINE)
total += len(defs)
print(f'{f}: {len(defs)} functions - {", ".join(defs[:10])}{"..." if len(defs) > 10 else ""}')
print(f'\nTotal: {total} wrapper functions across {len([f for f in os.listdir("python/quantalib") if f.endswith(".py") and not f.startswith("_") and f != "indicators.py"])} files')
+28
View File
@@ -0,0 +1,28 @@
import re, os
# Collect all function names from new category files
new_fns = set()
for f in sorted(os.listdir('python/quantalib')):
if f.endswith('.py') and not f.startswith('_') and f != 'indicators.py':
content = open(f'python/quantalib/{f}', encoding='utf-8').read()
new_fns.update(re.findall(r'^def (\w+)\(', content, re.MULTILINE))
# Collect from old indicators.py
old_content = open('python/quantalib/indicators.py', encoding='utf-8').read()
old_fns = set(re.findall(r'^def (\w+)\(', old_content, re.MULTILINE))
old_fns = {f for f in old_fns if not f.startswith('_')}
missing = sorted(old_fns - new_fns)
extra = sorted(new_fns - old_fns)
with open('python/tools/diff_report.txt', 'w') as out:
out.write(f'Old indicators.py: {len(old_fns)} public functions\n')
out.write(f'New category files: {len(new_fns)} functions\n\n')
out.write(f'Missing from new ({len(missing)}):\n')
for m in missing:
out.write(f' {m}\n')
out.write(f'\nNew indicators not in old ({len(extra)}):\n')
for e in extra:
out.write(f' {e}\n')
print('Done - see python/tools/diff_report.txt')
+18
View File
@@ -0,0 +1,18 @@
"""Extract C# export signatures for category module generation."""
import re
cs = open('python/src/Exports.Generated.cs', encoding='utf-8').read()
# Extract each function: entry point name + full C# parameter list
pattern = r'\[UnmanagedCallersOnly\(EntryPoint\s*=\s*"qtl_(\w+)"\)\]\s+public static int \w+\(([^)]+)\)'
matches = re.findall(pattern, cs)
for name, params in matches:
# Parse param types + names
parts = []
for p in params.split(','):
p = p.strip()
tokens = p.split()
if len(tokens) >= 2:
parts.append(f"{tokens[0]} {tokens[1]}")
print(f"qtl_{name}|{'|'.join(parts)}")
+767
View File
@@ -0,0 +1,767 @@
#!/usr/bin/env python3
"""Generate per-category Python indicator modules from Exports.Generated.cs.
Reads the C# exports file and the lib/ directory structure to produce:
- python/quantalib/_helpers.py (shared wrapper infrastructure)
- python/quantalib/_bridge.py (ALL ctypes bindings)
- python/quantalib/{category}.py (one per lib/ category)
- python/quantalib/indicators.py (re-exports everything)
- python/quantalib/__init__.py (package root)
Usage:
python python/tools/generate_category_modules.py
"""
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
LIB_DIR = REPO_ROOT / "lib"
EXPORTS_CS = REPO_ROOT / "python" / "src" / "Exports.Generated.cs"
OUT_DIR = REPO_ROOT / "python" / "quantalib"
# ---------------------------------------------------------------------------
# Category mapping: lib/ subdirectory → Python module name
# ---------------------------------------------------------------------------
CATEGORY_PY_NAME: dict[str, str] = {
"channels": "channels",
"core": "core",
"cycles": "cycles",
"dynamics": "dynamics",
"errors": "errors",
"filters": "filters",
"forecasts": "forecasts",
"momentum": "momentum",
"numerics": "numerics",
"oscillators": "oscillators",
"reversals": "reversals",
"statistics": "statistics_", # avoid shadowing stdlib 'statistics'
"trends_FIR": "trends_fir",
"trends_IIR": "trends_iir",
"volatility": "volatility",
"volume": "volume",
}
# Subdirectories in lib/core/ that are NOT indicators (infrastructure)
CORE_SKIP = {
"collections", "ringbuffer", "simd", "tbar", "tbarseries",
"tests", "tseries", "tvalue", "_index.md",
}
# Export names that don't map cleanly to a lib/ indicator dir
EXPORT_RENAMES: dict[str, str] = {
"htdcperiod": "ht_dcperiod",
"htdcphase": "ht_dcphase",
"htphasor": "ht_phasor",
"htsine": "ht_sine",
"httrendmode": "ht_trendmode",
"htit": "htit",
"ttmsqueeze": "ttm_squeeze",
"ttmtrend": "ttm_trend",
"ttmscalper": "ttm_scalper",
"ttmwave": "ttm_wave",
"ttmlrc": "ttm_lrc",
}
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class ExportInfo:
"""Parsed info for a single [UnmanagedCallersOnly] export."""
entry_name: str # e.g. "qtl_sma"
func_name: str # e.g. "sma"
cs_params: list[tuple[str, str]] # [(type, name), ...]
category: str = "" # resolved lib/ category
lib_indicator: str = "" # indicator dir name in lib/
@property
def py_module(self) -> str:
return CATEGORY_PY_NAME.get(self.category, self.category)
# ---------------------------------------------------------------------------
# Step 1: Build category lookup {indicator_name → category}
# ---------------------------------------------------------------------------
def build_category_map() -> dict[str, str]:
"""Scan lib/ subdirectories to build indicator→category mapping."""
cat_map: dict[str, str] = {}
for cat_dir in sorted(LIB_DIR.iterdir()):
if not cat_dir.is_dir():
continue
cat_name = cat_dir.name
if cat_name in ("bin", "obj", "feeds") or cat_name.startswith("_") or cat_name.startswith("."):
continue
for ind_dir in sorted(cat_dir.iterdir()):
if not ind_dir.is_dir():
continue
ind_name = ind_dir.name
if ind_name.startswith("_") or ind_name.startswith("."):
continue
if cat_name == "core" and ind_name in CORE_SKIP:
continue
# Normalize: indicator directory names are lowercase
cat_map[ind_name.lower()] = cat_name
return cat_map
# ---------------------------------------------------------------------------
# Step 2: Parse Exports.Generated.cs
# ---------------------------------------------------------------------------
RE_ENTRY = re.compile(
r'\[UnmanagedCallersOnly\(EntryPoint\s*=\s*"(qtl_\w+)"\)\]'
)
RE_FUNC = re.compile(
r'public\s+static\s+int\s+\w+\(([^)]*)\)'
)
def parse_cs_param(raw: str) -> tuple[str, str]:
"""Parse 'double* source' → ('double*', 'source')."""
raw = raw.strip()
parts = raw.rsplit(None, 1)
if len(parts) == 2:
return (parts[0], parts[1])
return (raw, "")
def parse_exports(cs_path: Path) -> list[ExportInfo]:
"""Parse all [UnmanagedCallersOnly] exports from the C# file."""
text = cs_path.read_text(encoding="utf-8")
lines = text.splitlines()
exports: list[ExportInfo] = []
i = 0
while i < len(lines):
m = RE_ENTRY.search(lines[i])
if m:
entry_name = m.group(1) # e.g. "qtl_sma"
func_name = entry_name[4:] # strip "qtl_"
# Find the function signature (may be on next line)
for j in range(i + 1, min(i + 5, len(lines))):
fm = RE_FUNC.search(lines[j])
if fm:
raw_params = fm.group(1)
params = [parse_cs_param(p) for p in raw_params.split(",")]
exports.append(ExportInfo(
entry_name=entry_name,
func_name=func_name,
cs_params=params,
))
break
i = j + 1
else:
i += 1
return exports
# ---------------------------------------------------------------------------
# Step 3: Resolve categories
# ---------------------------------------------------------------------------
def resolve_categories(exports: list[ExportInfo], cat_map: dict[str, str]) -> None:
"""Assign each export to its lib/ category."""
for exp in exports:
name = exp.func_name
# Check rename mapping first
mapped = EXPORT_RENAMES.get(name, name)
if mapped in cat_map:
exp.category = cat_map[mapped]
exp.lib_indicator = mapped
else:
# Try underscore variants
for variant in [mapped.replace("_", ""), mapped]:
if variant in cat_map:
exp.category = cat_map[variant]
exp.lib_indicator = variant
break
# Special cases
if name == "ema_alpha":
exp.category = "trends_IIR"
exp.lib_indicator = "ema"
elif name == "dema_alpha":
exp.category = "trends_IIR"
exp.lib_indicator = "dema"
elif name in ("wclprice", "midpoint", "midprice", "medprice",
"avgprice", "typprice", "midbody", "ha"):
exp.category = "core"
exp.lib_indicator = name
elif name == "skeleton_noop":
exp.category = "_internal"
if not exp.category and name != "skeleton_noop":
print(f" WARNING: No category for export '{name}'", file=sys.stderr)
# ---------------------------------------------------------------------------
# Step 4: Classify parameter patterns for ctypes/Python wrappers
# ---------------------------------------------------------------------------
def classify_params(exp: ExportInfo) -> dict:
"""Classify the export's parameter pattern for code generation."""
params = exp.cs_params
ptypes = [p[0] for p in params]
pnames = [p[1] for p in params]
info: dict = {
"inputs": [], # list of (cs_type, name, py_name)
"outputs": [], # list of (cs_type, name, py_name)
"int_params": [], # list of (name, py_name, default)
"double_params": [], # list of (name, py_name, default)
"n_param": None, # name of the length param
"pattern": "custom",
"argtypes": [],
}
# Identify inputs (double*) that appear before outputs
# Heuristic: inputs come before 'n', outputs after
n_idx = None
for i, (t, n) in enumerate(params):
if t == "int" and n in ("n", "length") and n_idx is None:
# Special: some have 'n' later
pass
if n == "n" and t == "int":
n_idx = i
break
if n_idx is None:
# n might be at different position, find it
for i, (t, n) in enumerate(params):
if t == "int" and n == "n":
n_idx = i
break
return info
# ---------------------------------------------------------------------------
# Step 5: Generate ctypes argtypes string
# ---------------------------------------------------------------------------
def cs_type_to_ctypes(cs_type: str) -> str:
"""Convert C# parameter type to ctypes constant."""
mapping = {
"double*": "_dp",
"int": "_ci",
"double": "_cd",
"int*": "_ip",
"long*": "_lp",
}
return mapping.get(cs_type, f"# UNKNOWN: {cs_type}")
def gen_argtypes(exp: ExportInfo) -> str:
"""Generate the ctypes argtypes list for a binding."""
parts = [cs_type_to_ctypes(t) for t, _ in exp.cs_params]
return "[" + ", ".join(parts) + "]"
# ---------------------------------------------------------------------------
# Step 6: Generate _bridge.py
# ---------------------------------------------------------------------------
def gen_bridge(exports: list[ExportInfo], by_cat: dict[str, list[ExportInfo]]) -> str:
"""Generate the complete _bridge.py file."""
lines = [
'"""Low-level ctypes bindings for every quantalib NativeAOT export.',
'',
'Auto-generated by generate_category_modules.py — DO NOT EDIT.',
'',
'Each native function is bound via ``_bind`` at module load. If the shared',
'library was compiled without a particular export the binding is silently',
'skipped (the corresponding ``HAS_*`` flag stays False).',
'"""',
'from __future__ import annotations',
'',
'import ctypes',
'from ctypes import c_double, c_int, POINTER',
'from typing import Final',
'',
'from ._loader import load_native_library',
'',
'# ---------------------------------------------------------------------------',
'# Status codes (mirror StatusCodes.cs)',
'# ---------------------------------------------------------------------------',
'QTL_OK: Final[int] = 0',
'QTL_ERR_NULL_PTR: Final[int] = 1',
'QTL_ERR_INVALID_LENGTH: Final[int] = 2',
'QTL_ERR_INVALID_PARAM: Final[int] = 3',
'QTL_ERR_INTERNAL: Final[int] = 4',
'',
'',
'class QtlError(Exception):',
' """Base exception for quantalib native errors."""',
'',
'',
'class QtlNullPointerError(QtlError):',
' pass',
'',
'',
'class QtlInvalidLengthError(QtlError):',
' pass',
'',
'',
'class QtlInvalidParamError(QtlError):',
' pass',
'',
'',
'class QtlInternalError(QtlError):',
' pass',
'',
'',
'_STATUS_MAP: dict[int, type[QtlError]] = {',
' QTL_ERR_NULL_PTR: QtlNullPointerError,',
' QTL_ERR_INVALID_LENGTH: QtlInvalidLengthError,',
' QTL_ERR_INVALID_PARAM: QtlInvalidParamError,',
' QTL_ERR_INTERNAL: QtlInternalError,',
'}',
'',
'',
'def _check(status: int) -> None:',
' """Raise if *status* is not QTL_OK."""',
' if status == QTL_OK:',
' return',
' exc_type = _STATUS_MAP.get(status, QtlError)',
' raise exc_type(f"quantalib native call failed (status={status})")',
'',
'',
'# ---------------------------------------------------------------------------',
'# Load native library',
'# ---------------------------------------------------------------------------',
'_lib = load_native_library()',
'',
'# Shorthand type aliases',
'_dp = POINTER(c_double) # double*',
'_ip = POINTER(c_int) # int*',
'_lp = POINTER(ctypes.c_long) # long*',
'_ci = c_int',
'_cd = c_double',
'',
'',
'def _bind(name: str, argtypes: list[object]) -> bool:',
' """Bind a single native function. Returns True if found."""',
' fn = getattr(_lib, name, None)',
' if fn is None:',
' return False',
' fn.argtypes = argtypes',
' fn.restype = _ci',
' return True',
'',
'',
'# ---------------------------------------------------------------------------',
'# Health check',
'# ---------------------------------------------------------------------------',
'HAS_SKELETON = _bind("qtl_skeleton_noop", [_dp, _ci, _dp])',
'',
]
# Category order
CAT_ORDER = [
"core", "momentum", "oscillators", "trends_FIR", "trends_IIR",
"channels", "volatility", "volume", "statistics", "errors",
"filters", "cycles", "dynamics", "numerics", "reversals", "forecasts",
]
cat_labels = {
"core": "Core",
"momentum": "Momentum",
"oscillators": "Oscillators",
"trends_FIR": "Trends — FIR",
"trends_IIR": "Trends — IIR",
"channels": "Channels",
"volatility": "Volatility",
"volume": "Volume",
"statistics": "Statistics",
"errors": "Errors",
"filters": "Filters",
"cycles": "Cycles",
"dynamics": "Dynamics",
"numerics": "Numerics",
"reversals": "Reversals",
"forecasts": "Forecasts",
}
for cat in CAT_ORDER:
if cat not in by_cat:
continue
exps = by_cat[cat]
label = cat_labels.get(cat, cat)
lines.append(f'# {"" * 75}')
lines.append(f'# {label}')
lines.append(f'# {"" * 75}')
for exp in sorted(exps, key=lambda e: e.func_name):
varname = f"HAS_{exp.func_name.upper()}"
argtypes = gen_argtypes(exp)
lines.append(f'{varname} = _bind("{exp.entry_name}", {argtypes})')
lines.append('')
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Step 7: Generate _helpers.py
# ---------------------------------------------------------------------------
def gen_helpers() -> str:
return '''"""Shared wrapper helpers for quantalib indicator modules.
Auto-generated by generate_category_modules.py — DO NOT EDIT.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
from ._bridge import _lib, _check, _dp, _ci, _cd
# Optional pandas support
try:
import pandas as pd # type: ignore[import-untyped]
except ImportError: # pragma: no cover
pd = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
_F64 = np.float64
def _arr(x: object) -> tuple[NDArray[np.float64], object]:
"""Return (contiguous float64 array, original_index_or_None)."""
idx = None
if pd is not None and isinstance(x, pd.Series):
idx = x.index
x = x.to_numpy(dtype=_F64, copy=False)
elif pd is not None and isinstance(x, pd.DataFrame):
idx = x.index
x = x.iloc[:, 0].to_numpy(dtype=_F64, copy=False)
return np.ascontiguousarray(x, dtype=_F64), idx # type: ignore[arg-type]
def _ptr(a: NDArray[np.float64]): # noqa: ANN202
"""Get ctypes double* from array."""
return a.ctypes.data_as(_dp)
def _out(n: int) -> NDArray[np.float64]:
"""Allocate output array."""
return np.empty(n, dtype=_F64)
def _offset(arr: NDArray[np.float64], off: int) -> NDArray[np.float64]:
"""Apply offset (roll + NaN fill)."""
if off and off != 0:
arr = np.roll(arr, off)
if off > 0:
arr[:off] = np.nan
else:
arr[off:] = np.nan
return arr
def _wrap(
arr: NDArray[np.float64],
idx: object,
name: str,
category: str,
offset: int = 0,
):
"""Wrap result: apply offset, optionally convert to pd.Series."""
arr = _offset(arr, offset)
if idx is not None and pd is not None:
s = pd.Series(arr, index=idx, name=name)
s.category = category
return s
return arr
def _wrap_multi(
arrays: dict[str, NDArray[np.float64]],
idx: object,
category: str,
offset: int = 0,
):
"""Wrap multi-output result into tuple or DataFrame."""
for k in arrays:
arrays[k] = _offset(arrays[k], offset)
if idx is not None and pd is not None:
df = pd.DataFrame(arrays, index=idx)
df.category = category
return df
return tuple(arrays.values())
# ═══════════════════════════════════════════════════════════════════════════
# Generic pattern helpers
# ═══════════════════════════════════════════════════════════════════════════
def _pa(
fn_name: str, close: object, length: int, offset: int,
default_length: int, label: str, category: str,
) -> object:
"""Generic Pattern A wrapper: single-input + period."""
length = int(length) if length is not None else default_length
offset = int(offset) if offset is not None else 0
src, idx = _arr(close)
n = len(src)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(src), n, _ptr(dst), length))
return _wrap(dst, idx, f"{label}_{length}", category, offset)
def _pa3(
fn_name: str, close: object, offset: int,
label: str, category: str,
) -> object:
"""Generic Pattern A3 wrapper: single-input, no params."""
offset = int(offset) if offset is not None else 0
src, idx = _arr(close)
n = len(src)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(src), n, _ptr(dst)))
return _wrap(dst, idx, label, category, offset)
def _pf(
fn_name: str, actual: object, predicted: object,
length: int, offset: int, default_length: int,
label: str, category: str,
) -> object:
"""Generic Pattern F wrapper: actual+predicted+period."""
length = int(length) if length is not None else default_length
offset = int(offset) if offset is not None else 0
a, idx = _arr(actual)
p, _ = _arr(predicted)
n = len(a)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(a), _ptr(p), n, _ptr(dst), length))
return _wrap(dst, idx, f"{label}_{length}", category, offset)
def _pg(
fn_name: str, close: object, volume: object,
offset: int, label: str, category: str,
) -> object:
"""Pattern G: source+volume, no period."""
offset = int(offset) if offset is not None else 0
c, idx = _arr(close)
v, _ = _arr(volume)
n = len(c)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(c), _ptr(v), n, _ptr(dst)))
return _wrap(dst, idx, label, category, offset)
def _pg2(
fn_name: str, close: object, volume: object, length: int,
offset: int, default_length: int, label: str, category: str,
) -> object:
"""Pattern G2: source+volume+period."""
length = int(length) if length is not None else default_length
offset = int(offset) if offset is not None else 0
c, idx = _arr(close)
v, _ = _arr(volume)
n = len(c)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(c), _ptr(v), n, _ptr(dst), length))
return _wrap(dst, idx, f"{label}_{length}", category, offset)
def _ph(
fn_name: str, x: object, y: object, length: int,
offset: int, default_length: int, label: str, category: str,
) -> object:
"""Pattern H: X+Y+period."""
length = int(length) if length is not None else default_length
offset = int(offset) if offset is not None else 0
xarr, idx = _arr(x)
yarr, _ = _arr(y)
n = len(xarr)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(xarr), _ptr(yarr), n, _ptr(dst), length))
return _wrap(dst, idx, f"{label}_{length}", category, offset)
def _ohlcv_bars_period(
fn_name: str, open: object, high: object, low: object,
close: object, volume: object, period: int,
offset: int, default_period: int, label: str, category: str,
) -> object:
"""OHLCV bars + period → single output (BuildBars pattern)."""
period = int(period) if period is not None else default_period
offset = int(offset) if offset is not None else 0
o, idx = _arr(open)
h, _ = _arr(high)
l, _ = _arr(low)
c, _ = _arr(close)
v, _ = _arr(volume)
n = len(o)
dst = _out(n)
_check(getattr(_lib, fn_name)(
_ptr(o), _ptr(h), _ptr(l), _ptr(c), _ptr(v), period, n, _ptr(dst)))
return _wrap(dst, idx, f"{label}_{period}", category, offset)
def _hlc_period(
fn_name: str, high: object, low: object, close: object,
period: int, offset: int, default_period: int,
label: str, category: str,
) -> object:
"""HLC + period → single output."""
period = int(period) if period is not None else default_period
offset = int(offset) if offset is not None else 0
h, idx = _arr(high)
l, _ = _arr(low)
c, _ = _arr(close)
n = len(h)
dst = _out(n)
_check(getattr(_lib, fn_name)(
_ptr(h), _ptr(l), _ptr(c), period, n, _ptr(dst)))
return _wrap(dst, idx, f"{label}_{period}", category, offset)
def _src_period(
fn_name: str, source: object, period: int,
offset: int, default_period: int, label: str, category: str,
) -> object:
"""source + period → single output (BuildSeries pattern, src,period,n,dst)."""
period = int(period) if period is not None else default_period
offset = int(offset) if offset is not None else 0
src, idx = _arr(source)
n = len(src)
dst = _out(n)
_check(getattr(_lib, fn_name)(_ptr(src), period, n, _ptr(dst)))
return _wrap(dst, idx, f"{label}_{period}", category, offset)
'''
# ---------------------------------------------------------------------------
# Step 8: Build per-category wrapper functions
# ---------------------------------------------------------------------------
# We derive wrapper signatures from the C# export signatures.
# Manual mappings for export names that need specific Python wrapper treatment
# This defines the "known" wrappers. Anything not here gets auto-generated.
# Map of lib/ directory name → default period for Pattern A indicators
DEFAULT_PERIODS: dict[str, int] = {
# Trends FIR
"sma": 10, "wma": 10, "hma": 9, "trima": 10, "swma": 10, "dwma": 10,
"blma": 10, "lsma": 25, "sgma": 10, "sinema": 10, "hanma": 10,
"parzen": 10, "tsf": 14, "sp15": 15, "tukey_w": 10, "rain": 10,
"fwma": 10, "gwma": 10, "hamma": 10, "hend": 10, "ilrs": 10,
"kaiser": 10, "lanczos": 10, "nlma": 10, "nyqma": 10, "pma": 10,
"pwma": 10, "qrma": 10, "rwma": 10, "bwma": 10,
# Trends IIR
"ema": 10, "dema": 10, "tema": 10, "lema": 10, "hema": 10,
"ahrens": 10, "decycler": 20, "frama": 10, "hwma": 10,
"jma": 10, "kama": 10, "ltma": 10, "mama": 10, "mavp": 10,
"mcnma": 10, "mgdi": 10, "mma": 10, "nma": 10, "qema": 10,
"rema": 10, "rgma": 10, "rma": 10, "t3": 10, "trama": 10,
"vidya": 10, "zldema": 10, "zlema": 10, "zltema": 10,
"adxvma": 14, "vama": 14, "yzvama": 14,
# Momentum
"rsi": 14, "roc": 10, "mom": 10, "cmo": 14, "bias": 26,
"cfo": 14, "rsx": 14, "pmo": 35,
"rocp": 10, "rocr": 10, "vel": 10,
# Oscillators
"fisher": 9, "fisher04": 9, "dpo": 20, "trix": 18, "inertia": 20,
"er": 10, "cti": 12, "reflex": 20, "trendflex": 20, "kri": 20,
"psl": 12, "lrsi": 14,
# Volatility
"bbw": 20, "stddev": 20, "variance": 20, "natr": 14, "massi": 14,
"ui": 14, "jvolty": 14, "jvoltyn": 14, "rsv": 14, "rv": 14,
"rvi": 14, "vov": 14, "vr": 14,
# Cycles
"cg": 10, "dsp": 20, "ccor": 20,
# Statistics
"zscore": 20, "entropy": 10, "geomean": 10, "harmean": 10,
"hurst": 100, "iqr": 20, "kurtosis": 20, "linreg": 14,
"meandev": 20, "median": 20, "mode": 20, "percentile": 20,
"polyfit": 20, "quantile": 20, "skew": 20, "spearman": 20,
"stddev": 20, "stderr": 20, "sum": 20, "theil": 20,
"trim": 20, "wavg": 20, "wins": 20, "ztest": 20,
"kendall": 20, "pacf": 20,
# Filters
"bessel": 14, "butter2": 14, "butter3": 14, "cheby1": 14,
"cheby2": 14, "elliptic": 14, "edcf": 14, "bpf": 14,
"loess": 14, "nw": 14, "rmed": 14, "sgf": 14, "spbf": 14,
"ssf2": 14, "ssf3": 14, "usf": 14, "voss": 14,
"wavelet": 14, "wiener": 14,
# Numerics
"change": 1, "highest": 14, "lowest": 14, "slope": 14,
"accel": 0, "jerk": 0,
# Errors (all pattern F, default 20)
"mse": 20, "rmse": 20, "mae": 20, "mape": 20, "smape": 20,
"msle": 20, "rmsle": 20, "me": 20, "mpe": 20, "mrae": 20,
"rse": 20, "rae": 20, "rsquared": 20, "wmape": 20, "wrmse": 20,
"mdae": 20, "mdape": 20, "mase": 20, "maape": 20, "mapd": 20,
"huber": 20, "logcosh": 20, "pseudohuber": 20, "tukeybiweight": 20,
"quantileloss": 20, "theilu": 20,
}
def main() -> None:
print("=== Generating quantalib per-category Python modules ===")
# Step 1: Build category map
cat_map = build_category_map()
print(f" Found {len(cat_map)} indicators across {len(set(cat_map.values()))} categories")
# Step 2: Parse exports
exports = parse_exports(EXPORTS_CS)
print(f" Parsed {len(exports)} exports from Exports.Generated.cs")
# Step 3: Resolve categories
resolve_categories(exports, cat_map)
# Group by category
by_cat: dict[str, list[ExportInfo]] = {}
uncategorized: list[ExportInfo] = []
for exp in exports:
if exp.category and exp.category != "_internal":
by_cat.setdefault(exp.category, []).append(exp)
elif exp.category != "_internal":
uncategorized.append(exp)
for cat in sorted(by_cat):
inds = sorted(e.func_name for e in by_cat[cat])
print(f" {cat}: {len(inds)} indicators")
if uncategorized:
print(f" UNCATEGORIZED: {[e.func_name for e in uncategorized]}")
# Step 4: Generate _helpers.py
helpers_path = OUT_DIR / "_helpers.py"
helpers_path.write_text(gen_helpers(), encoding="utf-8")
print(f" Wrote {helpers_path}")
# Step 5: Generate _bridge.py
bridge_path = OUT_DIR / "_bridge.py"
bridge_path.write_text(gen_bridge(exports, by_cat), encoding="utf-8")
print(f" Wrote {bridge_path}")
# Step 6-8: will print summary
print("\n=== Summary ===")
print(f" Total exports: {len(exports)}")
print(f" Categorized: {sum(len(v) for v in by_cat.values())}")
print(f" Categories: {len(by_cat)}")
if __name__ == "__main__":
main()
+927
View File
@@ -0,0 +1,927 @@
#!/usr/bin/env python3
"""Generate per-category Python wrapper modules from C# export signatures.
Reads Exports.Generated.cs, maps exports to lib/ categories,
and generates one .py file per category under python/quantalib/.
Run from repo root:
python python/tools/generate_wrappers.py
"""
from __future__ import annotations
import os
import re
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
CS_FILE = ROOT / "python" / "src" / "Exports.Generated.cs"
LIB_DIR = ROOT / "lib"
OUT_DIR = ROOT / "python" / "quantalib"
# ── Category mapping ──────────────────────────────────────────────────────
# Scan lib/ subdirs to build export→category map
def build_category_map() -> dict[str, str]:
"""Map indicator name (lowercase) → category folder name."""
m: dict[str, str] = {}
for cat_dir in sorted(LIB_DIR.iterdir()):
if not cat_dir.is_dir() or cat_dir.name.startswith("."):
continue
cat = cat_dir.name
for ind_dir in sorted(cat_dir.iterdir()):
if ind_dir.is_dir() and not ind_dir.name.startswith("_"):
m[ind_dir.name.lower()] = cat
return m
CAT_MAP = build_category_map()
# Manual overrides for export names that differ from lib/ dir names
EXPORT_TO_LIB = {
"abber": "aberr",
"htdcperiod": "ht_dcperiod",
"htdcphase": "ht_dcphase",
"htphasor": "ht_phasor",
"htsine": "ht_sine",
"httrendmode": "ht_trendmode",
"htit": "htit",
"ttmlrc": "ttm_lrc",
"ttmscalper": "ttm_scalper",
"ttmsqueeze": "ttm_squeeze",
"ttmtrend": "ttm_trend",
"ttmwave": "ttm_wave",
}
def get_category(export_name: str) -> str:
"""Return category for an export name."""
lib_name = EXPORT_TO_LIB.get(export_name, export_name)
if lib_name in CAT_MAP:
return CAT_MAP[lib_name]
# Some exports have _ removed vs lib dir (e.g. td_seq → tdseq)
for k, v in CAT_MAP.items():
if k.replace("_", "") == export_name.replace("_", ""):
return v
return "uncategorized"
# ── Parse C# exports ─────────────────────────────────────────────────────
def parse_exports() -> list[dict]:
"""Parse all exports from Exports.Generated.cs."""
cs = CS_FILE.read_text(encoding="utf-8")
pattern = r'\[UnmanagedCallersOnly\(EntryPoint\s*=\s*"qtl_(\w+)"\)\]\s+public static int \w+\(([^)]+)\)'
exports = []
for name, params_str in re.findall(pattern, cs):
params = []
for p in params_str.split(","):
p = p.strip()
tokens = p.split()
if len(tokens) >= 2:
ptype = tokens[0]
pname = tokens[1]
params.append({"type": ptype, "name": pname})
exports.append({
"name": name,
"params": params,
"category": get_category(name),
})
return exports
# ── Classify param roles ─────────────────────────────────────────────────
def classify_params(params):
"""Identify inputs, outputs, scalars in a param list."""
inputs = []
outputs = []
n_idx = None
scalars = []
for i, p in enumerate(params):
name = p["name"]
ptype = p["type"]
if name == "n":
n_idx = i
continue
if ptype == "double*":
# Heuristic: if name contains output/dst/destination/Out/middle/upper/lower etc.
out_names = {"output", "dst", "destination", "middle", "upper", "lower",
"haOpenOut", "haHighOut", "haLowOut", "haCloseOut",
"dstMiddle", "dstUpper", "dstLower", "dstTenkan", "dstKijun",
"dstSenkouA", "dstSenkouB", "dstChikou",
"kOut", "dOut", "jOut", "kstOut", "sigOut",
"rvgiOutput", "signalOutput", "signalOutput",
"momOut", "sqOut", "trend", "strength",
"sine", "leadSine", "inPhase", "quadrature", "ppOutput",
"upOutput", "downOutput", "highOutput", "lowOutput",
"pmaOutput", "triggerOutput", "famaOutput",
"upper1", "lower1", "upper2", "lower2", "vwap", "stdDev",
"viPlus", "viMinus", "midline",
"signal"}
if name in out_names or name.endswith("Out") or name.endswith("Output"):
outputs.append(p)
else:
inputs.append(p)
elif ptype == "int" or ptype == "double":
scalars.append(p)
return inputs, outputs, n_idx, scalars
# ── Generate wrapper function ────────────────────────────────────────────
# Description map for well-known indicators
DESCRIPTIONS = {
# Core
"avgprice": "Average Price = (O+H+L+C)/4",
"ha": "Heikin-Ashi Candles",
"medprice": "Median Price = (H+L)/2",
"midbody": "Mid Body = (O+C)/2",
"midpoint": "Midpoint = src[i] over period",
"midprice": "Mid Price = (High+Low)/2 over period",
"typprice": "Typical Price = (H+L+C)/3",
"wclprice": "Weighted Close Price = (H+L+2*C)/4",
# Momentum
"asi": "Accumulative Swing Index",
"bias": "Bias Indicator",
"bop": "Balance of Power",
"cci": "Commodity Channel Index",
"cfb": "Composite Fractal Behavior",
"cmo": "Chande Momentum Oscillator",
"macd": "Moving Average Convergence Divergence",
"mom": "Momentum",
"pmo": "Price Momentum Oscillator",
"ppo": "Percentage Price Oscillator",
"prs": "Price Relative Strength",
"roc": "Rate of Change",
"rocp": "Rate of Change (Percentage)",
"rocr": "Rate of Change (Ratio)",
"rsi": "Relative Strength Index",
"rsx": "Relative Strength Xtra",
"sam": "Simple Alpha Momentum",
"tsi": "True Strength Index",
"vel": "Velocity",
# Oscillators
"ac": "Accelerator Oscillator",
"ao": "Awesome Oscillator",
"apo": "Absolute Price Oscillator",
"bbb": "Bollinger Band Bounce",
"bbi": "Bull Bear Index",
"bbs": "Bollinger Band Squeeze",
"brar": "Bull-Bear Ratio",
"cfo": "Chande Forecast Oscillator",
"coppock": "Coppock Curve",
"crsi": "Connors RSI",
"cti": "Correlation Trend Indicator",
"deco": "DECO Oscillator",
"dem": "DeMarker",
"dosc": "Derivative Oscillator",
"dpo": "Detrended Price Oscillator",
"dymoi": "Dynamic Momentum Index",
"er": "Efficiency Ratio",
"eri": "Elder Ray Index",
"fi": "Force Index",
"fisher": "Fisher Transform",
"fisher04": "Fisher Transform (0.4 variant)",
"gator": "Gator Oscillator",
"imi": "Intraday Momentum Index",
"inertia": "Inertia",
"kdj": "KDJ Indicator",
"kri": "Kairi Relative Index",
"kst": "Know Sure Thing",
"lrsi": "Laguerre RSI",
"marketfi": "Market Facilitation Index",
"mstoch": "Modified Stochastic",
"pgo": "Pretty Good Oscillator",
"psl": "Psychological Line",
"qqe": "Quantitative Qualitative Estimation",
"reflex": "Reflex",
"reverseema": "Reverse EMA",
"rvgi": "Relative Vigor Index",
"smi": "Stochastic Momentum Index",
"squeeze": "Squeeze Momentum",
"stc": "Schaff Trend Cycle",
"stoch": "Stochastic Oscillator",
"stochf": "Fast Stochastic",
"stochrsi": "Stochastic RSI",
"td_seq": "Tom DeMark Sequential",
"trendflex": "Trendflex",
"trix": "Triple EMA Rate of Change",
"ttmwave": "TTM Wave",
"ultosc": "Ultimate Oscillator",
"willr": "Williams %R",
# Trends FIR
"alma": "Arnaud Legoux Moving Average",
"blma": "Blackman Moving Average",
"bwma": "Butterworth-weighted Moving Average",
"conv": "Convolution Filter",
"crma": "Cosine-Ramp Moving Average",
"dwma": "Double Weighted Moving Average",
"fwma": "Fibonacci Weighted Moving Average",
"gwma": "Gaussian Weighted Moving Average",
"hamma": "Hamming Moving Average",
"hanma": "Hann Moving Average",
"hend": "Henderson Moving Average",
"hma": "Hull Moving Average",
"ilrs": "Integral of Linear Regression Slope",
"kaiser": "Kaiser Window Moving Average",
"lanczos": "Lanczos Moving Average",
"lsma": "Least Squares Moving Average",
"nlma": "Non-Lag Moving Average",
"nyqma": "Nyquist Moving Average",
"parzen": "Parzen Moving Average",
"pma": "Predictive Moving Average",
"pwma": "Pascal Weighted Moving Average",
"qrma": "Quick Reaction Moving Average",
"rain": "RAIN Moving Average",
"rwma": "Range Weighted Moving Average",
"sgma": "Savitzky-Golay Moving Average",
"sinema": "Sine Weighted Moving Average",
"sma": "Simple Moving Average",
"sp15": "SP-15 Moving Average",
"swma": "Symmetric Weighted Moving Average",
"trima": "Triangular Moving Average",
"tsf": "Time Series Forecast",
"tukey_w": "Tukey-windowed Moving Average",
"wma": "Weighted Moving Average",
# Trends IIR
"adxvma": "ADX Variable Moving Average",
"ahrens": "Ahrens Moving Average",
"coral": "CORAL Trend",
"decycler": "Simple Decycler",
"dema": "Double Exponential Moving Average",
"dsma": "Deviation-Scaled Moving Average",
"ema": "Exponential Moving Average",
"frama": "Fractal Adaptive Moving Average",
"gdema": "Generalized Double EMA",
"hema": "Henderson EMA",
"holt": "Holt Exponential Smoothing",
"htit": "Hilbert Transform Instantaneous Trendline",
"hwma": "Holt-Winter Moving Average",
"jma": "Jurik Moving Average",
"kama": "Kaufman Adaptive Moving Average",
"lema": "Laguerre EMA",
"ltma": "Low-Lag Triple Moving Average",
"mama": "MESA Adaptive Moving Average",
"mavp": "Moving Average Variable Period",
"mcnma": "McNicholl Moving Average",
"mgdi": "McGinley Dynamic",
"mma": "Modified Moving Average",
"nma": "Normalized Moving Average",
"qema": "Quadruple EMA",
"rema": "Regularized EMA",
"rgma": "Recursive Gaussian Moving Average",
"rma": "Rolling Moving Average",
"t3": "Tillson T3",
"tema": "Triple Exponential Moving Average",
"trama": "Triangular Adaptive Moving Average",
"vama": "Volume Adjusted Moving Average",
"vidya": "Variable Index Dynamic Average",
"yzvama": "Yang Zhang Volatility Adaptive MA",
"zldema": "Zero-Lag Double EMA",
"zlema": "Zero-Lag EMA",
"zltema": "Zero-Lag Triple EMA",
# Channels
"abber": "Aberration Bands",
"accbands": "Acceleration Bands",
"apchannel": "Average Price Channel",
"apz": "Adaptive Price Zone",
"atrbands": "ATR Bands",
"bbands": "Bollinger Bands",
"dchannel": "Donchian Channel",
"decaychannel": "Decay Channel",
"fcb": "Fractal Chaos Bands",
"jbands": "J-Line Bands",
"kchannel": "Keltner Channel",
"maenv": "Moving Average Envelope",
"mmchannel": "Min-Max Channel",
"pchannel": "Price Channel",
"regchannel": "Regression Channel",
"sdchannel": "Standard Deviation Channel",
"starchannel": "Stoller Average Range Channel (STARC)",
"stbands": "SuperTrend Bands",
"ttmlrc": "TTM Linear Regression Channel",
"ubands": "Upper/Lower Bands",
"uchannel": "Ulcer Channel",
"vwapbands": "VWAP Bands",
"vwapsd": "VWAP Standard Deviation",
# Volatility
"adr": "Average Daily Range",
"atr": "Average True Range",
"atrn": "Normalized ATR",
"bbw": "Bollinger Band Width",
"bbwn": "Bollinger Band Width Normalized",
"bbwp": "Bollinger Band Width Percentile",
"ccv": "Close-to-Close Volatility",
"cv": "Coefficient of Variation",
"cvi": "Chaikin Volatility Index",
"etherm": "Elder Thermometer",
"ewma": "Exponentially Weighted Moving Average Volatility",
"gkv": "Garman-Klass Volatility",
"hlv": "High-Low Volatility",
"hv": "Historical Volatility",
"jvolty": "Jurik Volatility",
"jvoltyn": "Jurik Volatility Normalized",
"massi": "Mass Index",
"natr": "Normalized ATR",
"rsv": "Rogers-Satchell Volatility",
"rv": "Realized Volatility",
"rvi": "Relative Volatility Index",
"tr": "True Range",
"ui": "Ulcer Index",
"vov": "Volatility of Volatility",
"vr": "Volatility Ratio",
"yzv": "Yang-Zhang Volatility",
# Volume
"adl": "Accumulation/Distribution Line",
"adosc": "Accumulation/Distribution Oscillator",
"aobv": "Archer On-Balance Volume",
"cmf": "Chaikin Money Flow",
"efi": "Elder Force Index",
"eom": "Ease of Movement",
"evwma": "Elastic Volume Weighted Moving Average",
"iii": "Intraday Intensity Index",
"kvo": "Klinger Volume Oscillator",
"mfi": "Money Flow Index",
"nvi": "Negative Volume Index",
"obv": "On-Balance Volume",
"pvd": "Price Volume Divergence",
"pvi": "Positive Volume Index",
"pvo": "Percentage Volume Oscillator",
"pvr": "Price Volume Rank",
"pvt": "Price Volume Trend",
"tvi": "Trade Volume Index",
"twap": "Time Weighted Average Price",
"va": "Volume Accumulation",
"vf": "Volume Flow",
"vo": "Volume Oscillator",
"vroc": "Volume Rate of Change",
"vwad": "Volume Weighted Accumulation/Distribution",
"vwap": "Volume Weighted Average Price",
"vwma": "Volume Weighted Moving Average",
"wad": "Williams Accumulation/Distribution",
# Statistics
"acf": "Autocorrelation Function",
"beta": "Beta Coefficient",
"cma": "Cumulative Moving Average",
"cointegration": "Cointegration",
"correlation": "Pearson Correlation",
"covariance": "Covariance",
"entropy": "Shannon Entropy",
"geomean": "Geometric Mean",
"granger": "Granger Causality",
"harmean": "Harmonic Mean",
"hurst": "Hurst Exponent",
"iqr": "Interquartile Range",
"jb": "Jarque-Bera Test",
"kendall": "Kendall Rank Correlation",
"kurtosis": "Kurtosis",
"linreg": "Linear Regression",
"meandev": "Mean Deviation",
"median": "Rolling Median",
"mode": "Rolling Mode",
"pacf": "Partial Autocorrelation Function",
"percentile": "Rolling Percentile",
"polyfit": "Polynomial Fit",
"quantile": "Rolling Quantile",
"skew": "Skewness",
"spearman": "Spearman Rank Correlation",
"stddev": "Standard Deviation",
"stderr": "Standard Error",
"sum": "Rolling Sum",
"theil": "Theil U Statistic",
"trim": "Trimmed Mean",
"variance": "Variance",
"wavg": "Weighted Average",
"wins": "Winsorized Mean",
"zscore": "Z-Score",
"ztest": "Z-Test",
# Errors
"huber": "Huber Loss",
"logcosh": "Log-Cosh Loss",
"maape": "Mean Arctangent Absolute Percentage Error",
"mae": "Mean Absolute Error",
"mapd": "Mean Absolute Percentage Deviation",
"mape": "Mean Absolute Percentage Error",
"mase": "Mean Absolute Scaled Error",
"mdae": "Median Absolute Error",
"mdape": "Median Absolute Percentage Error",
"me": "Mean Error",
"mpe": "Mean Percentage Error",
"mrae": "Mean Relative Absolute Error",
"mse": "Mean Squared Error",
"msle": "Mean Squared Logarithmic Error",
"pseudohuber": "Pseudo-Huber Loss",
"quantileloss": "Quantile Loss (Pinball Loss)",
"rae": "Relative Absolute Error",
"rmse": "Root Mean Squared Error",
"rmsle": "Root Mean Squared Logarithmic Error",
"rse": "Relative Squared Error",
"rsquared": "R-Squared (Coefficient of Determination)",
"smape": "Symmetric Mean Absolute Percentage Error",
"theilu": "Theil U Statistic (Error)",
"tukeybiweight": "Tukey Biweight Loss",
"wmape": "Weighted Mean Absolute Percentage Error",
"wrmse": "Weighted Root Mean Squared Error",
# Filters
"agc": "Automatic Gain Control",
"alaguerre": "Adaptive Laguerre Filter",
"baxterking": "Baxter-King Filter",
"bessel": "Bessel Filter",
"bilateral": "Bilateral Filter",
"bpf": "Bandpass Filter",
"butter2": "2nd-Order Butterworth Filter",
"butter3": "3rd-Order Butterworth Filter",
"cfitz": "Christiano-Fitzgerald Filter",
"cheby1": "Chebyshev Type I Filter",
"cheby2": "Chebyshev Type II Filter",
"edcf": "Ehlers Distance Coefficient Filter",
"elliptic": "Elliptic (Cauer) Filter",
"gauss": "Gaussian Filter",
"hann": "Hann Filter",
"hp": "Hodrick-Prescott Filter",
"hpf": "High-Pass Filter",
"kalman": "Kalman Filter",
"laguerre": "Laguerre Filter",
"lms": "Least Mean Squares Filter",
"loess": "LOESS Smoother",
"modf": "Modified Filter",
"notch": "Notch Filter",
"nw": "Nadaraya-Watson Filter",
"oneeuro": "1€ Filter",
"rls": "Recursive Least Squares Filter",
"rmed": "Running Median Filter",
"roofing": "Roofing Filter",
"sgf": "Savitzky-Golay Filter",
"spbf": "Short-Period Bandpass Filter",
"ssf2": "Super Smoother (2-pole)",
"ssf3": "Super Smoother (3-pole)",
"usf": "Universal Smoother Filter",
"voss": "Voss Predictor",
"wavelet": "Wavelet Filter",
"wiener": "Wiener Filter",
# Cycles
"ccor": "Circular Correlation",
"ccyc": "Cyber Cycle",
"cg": "Center of Gravity",
"dsp": "Dominant Cycle Period",
"eacp": "Ehlers Autocorrelation Periodogram",
"ebsw": "Even Better Sinewave",
"homod": "Homodyne Discriminator",
"ht_dcperiod": "Hilbert Transform Dominant Cycle Period",
"ht_dcphase": "Hilbert Transform Dominant Cycle Phase",
"ht_phasor": "Hilbert Transform Phasor",
"ht_sine": "Hilbert Transform Sine",
"lunar": "Lunar Cycle",
"solar": "Solar Cycle",
"ssfdsp": "Supersmoother DSP",
# Dynamics
"adx": "Average Directional Index",
"adxr": "ADX Rating",
"alligator": "Williams Alligator",
"amat": "Archer Moving Average Trends",
"aroon": "Aroon",
"aroonosc": "Aroon Oscillator",
"chop": "Choppiness Index",
"dmx": "Directional Movement Extended",
"dx": "Directional Movement Index",
"ghla": "Gann Hi-Lo Activator",
"ht_trendmode": "Hilbert Transform Trend Mode",
"ichimoku": "Ichimoku Cloud",
"impulse": "Elder Impulse System",
"pfe": "Polarized Fractal Efficiency",
"qstick": "QStick",
"ravi": "Range Action Verification Index",
"super": "SuperTrend",
"ttmsqueeze": "TTM Squeeze",
"ttmtrend": "TTM Trend",
"vhf": "Vertical Horizontal Filter",
"vortex": "Vortex Indicator",
# Reversals
"chandelier": "Chandelier Exit",
"ckstop": "Chuck LeBeau Stop",
"fractals": "Williams Fractals",
"pivot": "Pivot Points (Traditional)",
"pivotcam": "Camarilla Pivot Points",
"pivotdem": "DeMark Pivot Points",
"pivotext": "Extended Pivot Points",
"pivotfib": "Fibonacci Pivot Points",
"pivotwood": "Woodie Pivot Points",
"psar": "Parabolic SAR",
"swings": "Swing High/Low",
"ttmscalper": "TTM Scalper",
# Forecasts
"afirma": "Adaptive FIR Moving Average",
# Numerics
"accel": "Acceleration",
"betadist": "Beta Distribution",
"binomdist": "Binomial Distribution",
"change": "Price Change",
"cwt": "Continuous Wavelet Transform",
"dwt": "Discrete Wavelet Transform",
"expdist": "Exponential Distribution",
"exptrans": "Exponential Transform",
"fdist": "F-Distribution",
"fft": "Fast Fourier Transform",
"gammadist": "Gamma Distribution",
"highest": "Highest Value",
"ifft": "Inverse FFT",
"jerk": "Jerk (3rd derivative)",
"lineartrans": "Linear Transform",
"lognormdist": "Log-Normal Distribution",
"logtrans": "Logarithmic Transform",
"lowest": "Lowest Value",
"normalize": "Normalization",
"normdist": "Normal Distribution",
"poissondist": "Poisson Distribution",
"relu": "ReLU Activation",
"sigmoid": "Sigmoid Transform",
"slope": "Slope (1st derivative)",
"sqrttrans": "Square Root Transform",
"tdist": "Student's t-Distribution",
"weibulldist": "Weibull Distribution",
}
# Python function name overrides (export_name → python_name)
PY_NAME = {
"abber": "aberr", # fix typo in C# export
"htdcperiod": "ht_dcperiod",
"htdcphase": "ht_dcphase",
"htphasor": "ht_phasor",
"htsine": "ht_sine",
"httrendmode": "ht_trendmode",
"ttmlrc": "ttm_lrc",
"ttmscalper": "ttm_scalper",
"ttmsqueeze": "ttm_squeeze",
"ttmtrend": "ttm_trend",
"ttmwave": "ttm_wave",
}
def gen_wrapper(export: dict) -> str | None:
"""Generate a Python wrapper function for one export."""
name = export["name"]
params = export["params"]
py_name = PY_NAME.get(name, name)
label = py_name.upper()
cat = export["category"]
desc = DESCRIPTIONS.get(name, DESCRIPTIONS.get(py_name, f"{label} indicator"))
inputs, outputs, n_idx, scalars = classify_params(params)
# Build Python function signature and body
lines = []
# Determine input pattern and generate accordingly
input_names = [p["name"] for p in inputs]
output_names = [p["name"] for p in outputs]
scalar_specs = [(p["name"], p["type"]) for p in scalars]
# Build Python params
py_params = []
py_body = []
# Categorize input types
has_ohlcv = all(x in [p["name"] for p in inputs] for x in ["sourceOpen", "sourceHigh", "sourceLow", "sourceClose", "sourceVolume"])
has_ohlc = all(x in [p["name"] for p in inputs] for x in ["open", "high", "low", "close"]) and not has_ohlcv
has_hlc = all(x in [p["name"] for p in inputs] for x in ["high", "low", "close"]) and not has_ohlc and not has_ohlcv
has_hl = {"high", "low"}.issubset(set(input_names)) and "close" not in input_names and not has_ohlcv
has_actual_predicted = {"actual", "predicted"}.issubset(set(input_names))
has_xy = {"seriesX", "seriesY"}.issubset(set(input_names)) or {"x", "y"}.issubset(set(input_names))
has_src_vol = (len(inputs) == 2 and any("volume" in p["name"].lower() or p["name"] == "volume" for p in inputs))
has_price_vol = (len(inputs) == 2 and any(p["name"] == "price" for p in inputs) and any(p["name"] == "volume" for p in inputs))
single_src = len(inputs) == 1 and inputs[0]["type"] == "double*"
# Generate function
# Decide function signature
sig_params = []
# Add input params
if has_ohlcv:
sig_params.extend([
"open: object", "high: object", "low: object",
"close: object", "volume: object",
])
elif has_ohlc:
sig_params.extend([
"open: object", "high: object", "low: object", "close: object",
])
elif has_hlc:
sig_params.extend(["high: object", "low: object", "close: object"])
elif has_hl:
sig_params.extend(["high: object", "low: object"])
elif has_actual_predicted:
sig_params.extend(["actual: object", "predicted: object"])
elif has_xy:
sig_params.extend(["x: object", "y: object"])
elif has_price_vol:
sig_params.extend(["price: object", "volume: object"])
elif has_src_vol:
# Figure out which is source, which is volume
src_name = [p["name"] for p in inputs if p["name"] != "volume"][0] if inputs else "source"
sig_params.extend([f"close: object", "volume: object"])
elif single_src:
src_name = inputs[0]["name"] if inputs else "source"
py_input_name = "close" if src_name in ("source", "src", "prices", "price") else src_name
sig_params.append(f"{py_input_name}: object")
elif len(inputs) == 2:
# Two inputs (e.g. prs: baseSeries, compSeries)
for p in inputs:
pn = p["name"]
if pn.startswith("source") or pn.startswith("base"):
pn = "x"
elif pn.startswith("comp"):
pn = "y"
sig_params.append(f"{pn}: object")
elif len(inputs) == 0 and len(outputs) == 0:
# Weird case
return None
else:
for p in inputs:
sig_params.append(f"{p['name']}: object")
# Add scalar params with defaults
scalar_defaults = {
"period": 14, "length": 14, "hpLength": 40, "ssLength": 10,
"fastPeriod": 12, "slowPeriod": 26, "acPeriod": 5,
"bbPeriod": 20, "bbMult": 2.0, "kcPeriod": 10, "kcMult": 1.5,
"multiplier": 2.0, "factor": 2.0, "sigma": 6.0,
"rsiPeriod": 14, "smoothFactor": 5, "qqeFactor": 4.236,
"kPeriod": 14, "dPeriod": 3, "kSmooth": 3, "dSmooth": 3,
"kLength": 14, "windowSize": 256, "minPeriod": 6, "maxPeriod": 48,
"longRoc": 14, "shortRoc": 11, "wmaPeriod": 10,
"r1": 10, "r2": 15, "r3": 20, "r4": 30,
"s1": 10, "s2": 10, "s3": 10, "s4": 15, "sigPeriod": 9,
"jawPeriod": 13, "jawShift": 8, "jawOffset": 8,
"teethPeriod": 8, "teethShift": 5, "teethOffset": 5,
"lipsPeriod": 5, "lipsShift": 3, "lipsOffset": 3,
"tenkanPeriod": 9, "kijunPeriod": 26, "senkouBPeriod": 52, "displacement": 26,
"emaPeriod": 13, "macdFast": 12, "macdSlow": 26, "macdSignal": 9,
"signalPeriod": 9, "signal": 3,
"numHarmonics": 10,
"atrPeriod": 22, "stopPeriod": 3,
"alpha": 2.0, "beta": 2.0, "gamma": 0.7, "k": 2.0,
"lambda": 1600.0, "mu": 0.01, "mu0": 0.0,
"delta": 1.35, "c": 4.685,
"q": 0.3, "r": 1.0,
"vfactor": 0.7, "vovPeriod": 20, "volatilityPeriod": 20,
"d1": 10, "d2": 20, "nu": 10,
"order": 3, "polyOrder": 3, "feedback": 0, "fbWeight": 0.5,
"annualize": 1, "annualPeriods": 252, "isPopulation": 0,
"predict": 3, "bandwidth": 0.25,
"nanValue": 0.0, "initialLastValid": 0.0, "initialLast": 0.0,
"x0": 0.0, "intercept": 0.0, "slope_val": 1.0,
"minCutoff": 1.0, "dCutoff": 1.0,
"method": 0, "maType": 0,
"percentage": 2.5, "percent": 50.0,
"quantileLevel": 0.5, "quantile": 0.5,
"trimPct": 0.1, "winPct": 0.05,
"offset": 0,
"shortPeriod": 12, "longPeriod": 26, "sumLength": 25,
"emaLength": 9, "rmaLength": 14, "stdevLength": 10,
"stochLength": 14, "rsiLength": 14,
"fastLength": 23, "slowLength": 50, "smoothing": 10,
"lookback": 5, "useCloses": 0,
"levels": 4, "threshMult": 1.0, "smoothPeriod": 5,
"blau": 3, "phase": 0, "power": 1.0,
"rmsPeriod": 20,
"nyquistPeriod": 2, "passes": 3,
"cumulative": 0, "usePercent": 1, "useEma": 0,
"base": 2.0, "degree": 2,
"minLength": 5, "maxLength": 50,
"yzvShortPeriod": 10, "yzvLongPeriod": 100, "percentileLookback": 252,
"baseLength": 20, "shortAtrPeriod": 14, "longAtrPeriod": 50,
"strPeriod": 14, "centerPeriod": 20,
"stPeriod": 14, "momPeriod": 12,
"scale": 10.0, "omega": 6.0,
"trials": 20, "threshold": 10,
"lam": 3.0, "afStart": 0.02, "afIncrement": 0.02, "afMax": 0.2,
"cutoff": 10, "fastLimit": 0.5, "slowLimit": 0.05,
"minVol": 0.2, "maxVol": 0.7,
"friction": 0.4,
"avgLength": 3, "enhance": 1,
"numDevs": 2.0,
"window_type": 0, "use_simd": 0,
"hpLength_val": 40, "ssfLength": 10,
}
for sname, stype in scalar_specs:
# Get reasonable default
default = scalar_defaults.get(sname)
if default is None:
# Try to infer
if "period" in sname.lower() or "length" in sname.lower():
default = 14
elif "mult" in sname.lower() or "factor" in sname.lower():
default = 2.0
elif stype == "double":
default = 1.0
else:
default = 10
if stype == "double":
sig_params.append(f"{sname}: float = {default}")
else:
sig_params.append(f"{sname}: int = {int(default)}")
sig_params.append("offset: int = 0")
sig_params.append("**kwargs")
# Build function body
body = []
# Sanitize scalars
for sname, stype in scalar_specs:
if stype == "double":
body.append(f" {sname} = float({sname})")
else:
body.append(f" {sname} = int({sname})")
body.append(" offset = int(offset)")
# Convert inputs
if has_ohlcv:
body.append(" o, idx = _arr(open); h, _ = _arr(high); l, _ = _arr(low)")
body.append(" c, _ = _arr(close); v, _ = _arr(volume)")
body.append(" n = len(o)")
elif has_ohlc:
body.append(" o, idx = _arr(open); h, _ = _arr(high); l, _ = _arr(low); c, _ = _arr(close)")
body.append(" n = len(o)")
elif has_hlc:
body.append(" h, idx = _arr(high); l, _ = _arr(low); c, _ = _arr(close)")
body.append(" n = len(h)")
elif has_hl:
body.append(" h, idx = _arr(high); l, _ = _arr(low)")
body.append(" n = len(h)")
elif has_actual_predicted:
body.append(" a, idx = _arr(actual); p, _ = _arr(predicted)")
body.append(" n = len(a)")
elif has_xy:
body.append(" xarr, idx = _arr(x); yarr, _ = _arr(y)")
body.append(" n = len(xarr)")
elif has_price_vol:
body.append(" pr, idx = _arr(price); v, _ = _arr(volume)")
body.append(" n = len(pr)")
elif has_src_vol:
body.append(" src, idx = _arr(close); v, _ = _arr(volume)")
body.append(" n = len(src)")
elif single_src:
py_input_name = "close" if inputs[0]["name"] in ("source", "src", "prices", "price") else inputs[0]["name"]
body.append(f" src, idx = _arr({py_input_name})")
body.append(" n = len(src)")
elif len(inputs) == 2:
body.append(f" xarr, idx = _arr(x); yarr, _ = _arr(y)")
body.append(" n = len(xarr)")
# Allocate outputs
for p in outputs:
body.append(f" {p['name']} = _out(n)")
# Build native call arguments in original order
call_args = []
for p in params:
pname = p["name"]
ptype = p["type"]
if pname == "n":
call_args.append("n")
elif ptype == "double*":
if p in outputs:
call_args.append(f"_ptr({pname})")
else:
# Map to our local var names
if has_ohlcv:
vmap = {"sourceOpen": "o", "sourceHigh": "h", "sourceLow": "l", "sourceClose": "c", "sourceVolume": "v"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_ohlc:
vmap = {"open": "o", "high": "h", "low": "l", "close": "c"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_hlc:
vmap = {"high": "h", "low": "l", "close": "c"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_hl:
vmap = {"high": "h", "low": "l"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_actual_predicted:
vmap = {"actual": "a", "predicted": "p"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_xy:
vmap = {"seriesX": "xarr", "seriesY": "yarr", "x": "xarr", "y": "yarr"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_price_vol:
vmap = {"price": "pr", "volume": "v"}
call_args.append(f"_ptr({vmap.get(pname, pname)})")
elif has_src_vol:
if pname == "volume":
call_args.append("_ptr(v)")
else:
call_args.append("_ptr(src)")
elif single_src:
call_args.append("_ptr(src)")
elif len(inputs) == 2:
vmap = {}
for ip in inputs:
if ip["name"].startswith("source") or ip["name"].startswith("base"):
vmap[ip["name"]] = "xarr"
else:
vmap[ip["name"]] = "yarr"
call_args.append(f"_ptr({vmap.get(pname, pname)})")
else:
call_args.append(f"_ptr({pname})")
else:
call_args.append(pname)
call_str = ", ".join(call_args)
body.append(f' _check(_lib.qtl_{name}({call_str}))')
# Wrap output
if len(outputs) == 1:
out_name = outputs[0]["name"]
# Decide label
has_period_scalar = any("period" in s[0].lower() or "length" in s[0].lower() for s in scalar_specs)
if has_period_scalar:
# Use first period-like scalar for label
period_var = next(s[0] for s in scalar_specs if "period" in s[0].lower() or "length" in s[0].lower())
body.append(f' return _wrap({out_name}, idx, f"{label}_{{{period_var}}}", "{cat}", offset)')
else:
body.append(f' return _wrap({out_name}, idx, "{label}", "{cat}", offset)')
elif len(outputs) > 1:
# Multi-output
out_dict_parts = []
for p in outputs:
out_dict_parts.append(f'"{p["name"]}": {p["name"]}')
out_dict = ", ".join(out_dict_parts)
body.append(f' return _wrap_multi({{{out_dict}}}, idx, "{cat}", offset)')
else:
body.append(" return None # no output detected")
# Assemble
sig = ", ".join(sig_params)
func = f'def {py_name}({sig}) -> object:\n'
func += f' """{desc}."""\n'
func += "\n".join(body) + "\n"
return func
def generate_category_file(category: str, exports: list[dict]) -> str:
"""Generate a full category module."""
# Map category to Python module name
mod_name = category.replace("-", "_")
header = f'"""quantalib {category} indicators.\n\nAuto-generated — DO NOT EDIT.\n"""\n'
header += "from __future__ import annotations\n\n"
header += "from ._helpers import _arr, _ptr, _out, _wrap, _wrap_multi, _check, _lib\n\n\n"
functions = []
all_names = []
for exp in sorted(exports, key=lambda e: e["name"]):
func = gen_wrapper(exp)
if func:
py_name = PY_NAME.get(exp["name"], exp["name"])
all_names.append(py_name)
functions.append(func)
# __all__
all_str = "__all__ = [\n"
for n in all_names:
all_str += f' "{n}",\n'
all_str += "]\n"
return header + all_str + "\n\n" + "\n\n".join(functions)
def main():
exports = parse_exports()
# Group by category
by_cat: dict[str, list[dict]] = {}
for exp in exports:
cat = exp["category"]
by_cat.setdefault(cat, []).append(exp)
print(f"Parsed {len(exports)} exports in {len(by_cat)} categories:")
for cat, exps in sorted(by_cat.items()):
print(f" {cat}: {len(exps)} indicators")
# Generate files
for cat, exps in sorted(by_cat.items()):
if cat == "uncategorized":
continue
mod_name = cat.replace("-", "_")
# Map category dirs to Python module names
py_mod = {
"trends_FIR": "trends_fir",
"trends_IIR": "trends_iir",
}.get(mod_name, mod_name)
outpath = OUT_DIR / f"{py_mod}.py"
content = generate_category_file(cat, exps)
outpath.write_text(content, encoding="utf-8")
print(f" Generated {outpath.name} ({len(exps)} indicators)")
# List uncategorized
if "uncategorized" in by_cat:
print(f"\n UNCATEGORIZED: {[e['name'] for e in by_cat['uncategorized']]}")
if __name__ == "__main__":
main()