mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-02 19:37:43 +00:00
feat: add new indicators (Decay, Edecay, MinusDi, MinusDm, PlusDi, PlusDm, Maxindex, Minindex, Sarext) and update pine scripts, core libs, validation tests, and python bindings
This commit is contained in:
+1
-1
@@ -101,4 +101,4 @@ The `ctypes` call adds 5-15 μs overhead. For arrays above a few hundred bars, N
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/mihakralj/quantalib/blob/main/LICENSE)
|
||||
[Apache License 2.0](https://github.com/mihakralj/quantalib/blob/main/LICENSE)
|
||||
|
||||
@@ -7,7 +7,7 @@ name = "quantalib"
|
||||
dynamic = ["version"]
|
||||
description = "High-performance technical analysis wrappers over QuanTAlib NativeAOT"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["numpy>=1.24"]
|
||||
authors = [
|
||||
@@ -18,7 +18,7 @@ classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Financial and Insurance Industry",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
|
||||
@@ -32,7 +32,4 @@
|
||||
<TrimmerRootAssembly Include="QuanTAlib" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="ValidateExportsCoverage" BeforeTargets="Build;Publish">
|
||||
<Exec Command="python tools/validate_exports.py --repo-root $(MSBuildThisFileDirectory).." WorkingDirectory="$(MSBuildThisFileDirectory)" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -17,11 +17,15 @@ __all__ = [
|
||||
"chop",
|
||||
"dmx",
|
||||
"dx",
|
||||
"minus_di",
|
||||
"minus_dm",
|
||||
"ghla",
|
||||
"ht_trendmode",
|
||||
"ichimoku",
|
||||
"impulse",
|
||||
"pfe",
|
||||
"plus_di",
|
||||
"plus_dm",
|
||||
"qstick",
|
||||
"ravi",
|
||||
"supertrend",
|
||||
@@ -140,6 +144,28 @@ def dx(high: object, low: object, close: object, period: int = 14, offset: int =
|
||||
return _wrap(destination, idx, f"DX_{period}", "dynamics", offset)
|
||||
|
||||
|
||||
def minus_di(high: object, low: object, close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Minus Directional Indicator."""
|
||||
period = int(kwargs.get("length", period))
|
||||
offset = int(offset)
|
||||
h, idx = _arr(high); l, _ = _arr(low); c, _ = _arr(close)
|
||||
n = len(h)
|
||||
destination = _out(n)
|
||||
_check(_lib.qtl_minusdi(_ptr(h), _ptr(h), _ptr(l), _ptr(c), _ptr(destination), period, n, _ptr(destination)))
|
||||
return _wrap(destination, idx, f"MINUS_DI_{period}", "dynamics", offset)
|
||||
|
||||
|
||||
def minus_dm(high: object, low: object, close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Minus Directional Movement."""
|
||||
period = int(kwargs.get("length", period))
|
||||
offset = int(offset)
|
||||
h, idx = _arr(high); l, _ = _arr(low); c, _ = _arr(close)
|
||||
n = len(h)
|
||||
destination = _out(n)
|
||||
_check(_lib.qtl_minusdm(_ptr(h), _ptr(h), _ptr(l), _ptr(c), _ptr(destination), period, n, _ptr(destination)))
|
||||
return _wrap(destination, idx, f"MINUS_DM_{period}", "dynamics", offset)
|
||||
|
||||
|
||||
def ghla(high: object, low: object, close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Gann Hi-Lo Activator."""
|
||||
period = int(kwargs.get("length", period))
|
||||
@@ -206,6 +232,28 @@ def pfe(close: object, period: int = 14, smoothPeriod: int = 5, offset: int = 0,
|
||||
return _wrap(output, idx, f"PFE_{period}", "dynamics", offset)
|
||||
|
||||
|
||||
def plus_di(high: object, low: object, close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Plus Directional Indicator."""
|
||||
period = int(kwargs.get("length", period))
|
||||
offset = int(offset)
|
||||
h, idx = _arr(high); l, _ = _arr(low); c, _ = _arr(close)
|
||||
n = len(h)
|
||||
destination = _out(n)
|
||||
_check(_lib.qtl_plusdi(_ptr(h), _ptr(h), _ptr(l), _ptr(c), _ptr(destination), period, n, _ptr(destination)))
|
||||
return _wrap(destination, idx, f"PLUS_DI_{period}", "dynamics", offset)
|
||||
|
||||
|
||||
def plus_dm(high: object, low: object, close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Plus Directional Movement."""
|
||||
period = int(kwargs.get("length", period))
|
||||
offset = int(offset)
|
||||
h, idx = _arr(high); l, _ = _arr(low); c, _ = _arr(close)
|
||||
n = len(h)
|
||||
destination = _out(n)
|
||||
_check(_lib.qtl_plusdm(_ptr(h), _ptr(h), _ptr(l), _ptr(c), _ptr(destination), period, n, _ptr(destination)))
|
||||
return _wrap(destination, idx, f"PLUS_DM_{period}", "dynamics", offset)
|
||||
|
||||
|
||||
def qstick(open: object, high: object, low: object, close: object, volume: object, period: int = 14, useEma: int = 0, offset: int = 0, **kwargs) -> object:
|
||||
"""QStick."""
|
||||
period = int(kwargs.get("length", period))
|
||||
|
||||
@@ -13,12 +13,14 @@ __all__ = [
|
||||
"fft",
|
||||
"gammadist",
|
||||
"highest",
|
||||
"maxindex",
|
||||
"ifft",
|
||||
"jerk",
|
||||
"lineartrans",
|
||||
"lognormdist",
|
||||
"logtrans",
|
||||
"lowest",
|
||||
"minindex",
|
||||
"normalize",
|
||||
"normdist",
|
||||
"poissondist",
|
||||
@@ -98,6 +100,17 @@ def highest(close: object, period: int = 14, offset: int = 0, **kwargs) -> objec
|
||||
return _wrap(output, idx, f"HIGHEST_{period}", "numerics", offset)
|
||||
|
||||
|
||||
def maxindex(close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Index of Highest Value."""
|
||||
period = int(kwargs.get("length", period))
|
||||
offset = int(offset)
|
||||
src, idx = _arr(close)
|
||||
n = len(src)
|
||||
output = _out(n)
|
||||
_check(_lib.qtl_maxindex(_ptr(src), _ptr(output), n, period))
|
||||
return _wrap(output, idx, f"MAXINDEX_{period}", "numerics", offset)
|
||||
|
||||
|
||||
def ifft(close: object, windowSize: int = 256, numHarmonics: int = 10, offset: int = 0, **kwargs) -> object:
|
||||
"""Inverse FFT."""
|
||||
windowSize = int(windowSize)
|
||||
@@ -166,6 +179,17 @@ def lowest(close: object, period: int = 14, offset: int = 0, **kwargs) -> object
|
||||
return _wrap(output, idx, f"LOWEST_{period}", "numerics", offset)
|
||||
|
||||
|
||||
def minindex(close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Index of Lowest Value."""
|
||||
period = int(kwargs.get("length", period))
|
||||
offset = int(offset)
|
||||
src, idx = _arr(close)
|
||||
n = len(src)
|
||||
output = _out(n)
|
||||
_check(_lib.qtl_minindex(_ptr(src), _ptr(output), n, period))
|
||||
return _wrap(output, idx, f"MININDEX_{period}", "numerics", offset)
|
||||
|
||||
|
||||
def normalize(close: object, period: int = 14, offset: int = 0, **kwargs) -> object:
|
||||
"""Normalization."""
|
||||
period = int(kwargs.get("length", period))
|
||||
|
||||
@@ -18,6 +18,7 @@ __all__ = [
|
||||
"pivotfib",
|
||||
"pivotwood",
|
||||
"psar",
|
||||
"sarext",
|
||||
"swings",
|
||||
"ttm_scalper",
|
||||
]
|
||||
@@ -132,6 +133,24 @@ def psar(open: object, high: object, low: object, close: object, afStart: float
|
||||
return _wrap(output, idx, "PSAR", "reversals", offset)
|
||||
|
||||
|
||||
def sarext(open: object, high: object, low: object, close: object, startValue: float = 0.0, offsetOnReverse: float = 0.0, afInitLong: float = 0.02, afLong: float = 0.02, afMaxLong: float = 0.2, afInitShort: float = 0.02, afShort: float = 0.02, afMaxShort: float = 0.2, offset: int = 0, **kwargs) -> object:
|
||||
"""Parabolic SAR Extended."""
|
||||
startValue = float(startValue)
|
||||
offsetOnReverse = float(offsetOnReverse)
|
||||
afInitLong = float(afInitLong)
|
||||
afLong = float(afLong)
|
||||
afMaxLong = float(afMaxLong)
|
||||
afInitShort = float(afInitShort)
|
||||
afShort = float(afShort)
|
||||
afMaxShort = float(afMaxShort)
|
||||
offset = int(offset)
|
||||
o, idx = _arr(open); h, _ = _arr(high); l, _ = _arr(low); c, _ = _arr(close)
|
||||
n = len(o)
|
||||
output = _out(n)
|
||||
_check(_lib.qtl_sarext(_ptr(o), _ptr(h), _ptr(l), _ptr(c), _ptr(output), n, startValue, offsetOnReverse, afInitLong, afLong, afMaxLong, afInitShort, afShort, afMaxShort))
|
||||
return _wrap(output, idx, "SAREXT", "reversals", offset)
|
||||
|
||||
|
||||
def swings(high: object, low: object, lookback: int = 5, offset: int = 0, **kwargs) -> object:
|
||||
"""Swing High/Low."""
|
||||
lookback = int(lookback)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace QuanTAlib.Python;
|
||||
@@ -5,7 +6,7 @@ namespace QuanTAlib.Python;
|
||||
internal static class ArrayBridge
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static unsafe bool IsNull(double* ptr) => ptr == null;
|
||||
public static bool IsNull(IntPtr ptr) => ptr == IntPtr.Zero;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static int ValidateLength(int n) =>
|
||||
|
||||
@@ -6,6 +6,10 @@ using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Python;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Security Hotspot",
|
||||
"S6640",
|
||||
Justification = "Generated NativeAOT export helpers bridge unmanaged caller-owned buffers into managed series objects. The unsafe context is required by the ABI and remains constrained to pointer-to-span copies over validated inputs.")]
|
||||
public static unsafe partial class Exports
|
||||
{
|
||||
private static TSeries BuildSeries(double* src, int n)
|
||||
@@ -1580,6 +1584,19 @@ public static unsafe partial class Exports
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_maxindex")]
|
||||
public static int QtlMaxindex(double* source, double* output, int n, int period)
|
||||
{
|
||||
if (source == null || output == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
Maxindex.Batch(Src(source, n), Dst(output, n), period);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_mcnma")]
|
||||
public static int QtlMcnma(double* source, double* output, int n, int period)
|
||||
{
|
||||
@@ -1697,6 +1714,57 @@ public static unsafe partial class Exports
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_minindex")]
|
||||
public static int QtlMinindex(double* source, double* output, int n, int period)
|
||||
{
|
||||
if (source == null || output == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
Minindex.Batch(Src(source, n), Dst(output, n), period);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_minusdi")]
|
||||
public static int QtlMinusDi(double* sourceOpen, double* sourceHigh, double* sourceLow, double* sourceClose, double* sourceVolume, int period, int n, double* dst)
|
||||
{
|
||||
if (sourceOpen == null || sourceHigh == null || sourceLow == null || sourceClose == null || sourceVolume == null || dst == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
var sourceBars = BuildBars(sourceOpen, sourceHigh, sourceLow, sourceClose, sourceVolume, n);
|
||||
var result = MinusDi.Batch(sourceBars, period);
|
||||
var values = result.Values;
|
||||
if (values.Length > n) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
var outSpan = Dst(dst, n);
|
||||
outSpan.Fill(double.NaN);
|
||||
values.CopyTo(outSpan);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_minusdm")]
|
||||
public static int QtlMinusDm(double* sourceOpen, double* sourceHigh, double* sourceLow, double* sourceClose, double* sourceVolume, int period, int n, double* dst)
|
||||
{
|
||||
if (sourceOpen == null || sourceHigh == null || sourceLow == null || sourceClose == null || sourceVolume == null || dst == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
var sourceBars = BuildBars(sourceOpen, sourceHigh, sourceLow, sourceClose, sourceVolume, n);
|
||||
var result = MinusDm.Batch(sourceBars, period);
|
||||
var values = result.Values;
|
||||
if (values.Length > n) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
var outSpan = Dst(dst, n);
|
||||
outSpan.Fill(double.NaN);
|
||||
values.CopyTo(outSpan);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_mma")]
|
||||
public static int QtlMma(double* source, double* output, int n, int period)
|
||||
{
|
||||
@@ -2067,6 +2135,44 @@ public static unsafe partial class Exports
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_plusdi")]
|
||||
public static int QtlPlusDi(double* sourceOpen, double* sourceHigh, double* sourceLow, double* sourceClose, double* sourceVolume, int period, int n, double* dst)
|
||||
{
|
||||
if (sourceOpen == null || sourceHigh == null || sourceLow == null || sourceClose == null || sourceVolume == null || dst == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
var sourceBars = BuildBars(sourceOpen, sourceHigh, sourceLow, sourceClose, sourceVolume, n);
|
||||
var result = PlusDi.Batch(sourceBars, period);
|
||||
var values = result.Values;
|
||||
if (values.Length > n) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
var outSpan = Dst(dst, n);
|
||||
outSpan.Fill(double.NaN);
|
||||
values.CopyTo(outSpan);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_plusdm")]
|
||||
public static int QtlPlusDm(double* sourceOpen, double* sourceHigh, double* sourceLow, double* sourceClose, double* sourceVolume, int period, int n, double* dst)
|
||||
{
|
||||
if (sourceOpen == null || sourceHigh == null || sourceLow == null || sourceClose == null || sourceVolume == null || dst == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
var sourceBars = BuildBars(sourceOpen, sourceHigh, sourceLow, sourceClose, sourceVolume, n);
|
||||
var result = PlusDm.Batch(sourceBars, period);
|
||||
var values = result.Values;
|
||||
if (values.Length > n) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
var outSpan = Dst(dst, n);
|
||||
outSpan.Fill(double.NaN);
|
||||
values.CopyTo(outSpan);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_pma")]
|
||||
public static int QtlPma(double* source, double* pmaOutput, double* triggerOutput, int n, int period)
|
||||
{
|
||||
@@ -2554,6 +2660,19 @@ public static unsafe partial class Exports
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_sarext")]
|
||||
public static int QtlSarext(double* open, double* high, double* low, double* close, double* output, int n, double startValue, double offsetOnReverse, double afInitLong, double afLong, double afMaxLong, double afInitShort, double afShort, double afMaxShort)
|
||||
{
|
||||
if (open == null || high == null || low == null || close == null || output == null) return StatusCodes.QTL_ERR_NULL_PTR;
|
||||
if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
try
|
||||
{
|
||||
Sarext.Batch(Src(open, n), Src(high, n), Src(low, n), Src(close, n), Dst(output, n), n, startValue, offsetOnReverse, afInitLong, afLong, afMaxLong, afInitShort, afShort, afMaxShort);
|
||||
return StatusCodes.QTL_OK;
|
||||
}
|
||||
catch { return StatusCodes.QTL_ERR_INTERNAL; }
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "qtl_sdchannel")]
|
||||
public static int QtlSdchannel(double* source, double* middle, double* upper, double* lower, int n, int period, double multiplier)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,10 @@ namespace QuanTAlib.Python;
|
||||
#pragma warning disable CA1031 // catch general exception types — ABI boundary requires catching all
|
||||
#pragma warning disable IDE0060 // unused parameters — some reserved for future use
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Security Hotspot",
|
||||
"S6640",
|
||||
Justification = "NativeAOT unmanaged exports must accept raw caller-owned buffers. Each entry point validates null pointers and lengths before projecting them into spans, and the ABI surface intentionally centralizes the required unsafe context on the export type.")]
|
||||
[SkipLocalsInit]
|
||||
public static unsafe partial class Exports
|
||||
{
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Add length→period alias to all old-style Python wrapper functions.
|
||||
|
||||
Replaces: period = int(period)
|
||||
With: period = int(kwargs.get("length", period))
|
||||
|
||||
Only targets exact standalone `period = int(period)` lines (4-space indent).
|
||||
Does NOT touch compound names like fastPeriod, slowPeriod, etc.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
import glob
|
||||
|
||||
base = os.path.join(os.path.dirname(__file__), '..', 'quantalib')
|
||||
base = os.path.normpath(base)
|
||||
files = sorted(glob.glob(os.path.join(base, '*.py')))
|
||||
total = 0
|
||||
|
||||
for fpath in files:
|
||||
fname = os.path.basename(fpath)
|
||||
if fname.startswith('_'):
|
||||
continue
|
||||
with open(fpath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Match exactly ' period = int(period)' (4-space indent, standalone)
|
||||
pattern = r'^( )period = int\(period\)$'
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
count = len(matches)
|
||||
if count > 0:
|
||||
new_content = re.sub(
|
||||
pattern,
|
||||
r'\1period = int(kwargs.get("length", period))',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
with open(fpath, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
print(f'{fname}: {count} replacements')
|
||||
total += count
|
||||
else:
|
||||
print(f'{fname}: 0 (skipped)')
|
||||
|
||||
print(f'\nTotal: {total} replacements across {len(files)} files')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
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')
|
||||
@@ -1,28 +0,0 @@
|
||||
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')
|
||||
@@ -1,18 +0,0 @@
|
||||
"""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)}")
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Transform new-style functions from `length` → `period` (primary) with `length` as kwargs alias.
|
||||
|
||||
Target: all category module .py files in python/quantalib/ that have functions
|
||||
using bare `length` as a parameter name.
|
||||
|
||||
Rules:
|
||||
1. `def foo(close, length: int = X, ...)` → `def foo(close, period: int = X, ...)`
|
||||
2. `length = int(length)` (standalone assignment) → `period = int(kwargs.get("length", period))`
|
||||
3. Any remaining standalone `length` in the function body → `period`
|
||||
4. Compound names like hpLength, ssLength, minLength, etc. are NOT touched.
|
||||
5. `lengths` (plural) is NOT touched.
|
||||
6. `default_length` in _helpers.py pattern helpers is NOT touched (separate ticket).
|
||||
|
||||
Also fixes _helpers.py pattern helpers (_pa, _pf, _pg2, _ph) the same way.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
QUANTALIB = Path(__file__).resolve().parent.parent / "quantalib"
|
||||
|
||||
# Files to process (category modules + _helpers.py)
|
||||
TARGET_FILES = [
|
||||
"channels.py",
|
||||
"core.py",
|
||||
"cycles.py",
|
||||
"dynamics.py",
|
||||
"errors.py",
|
||||
"filters.py",
|
||||
"momentum.py",
|
||||
"numerics.py",
|
||||
"oscillators.py",
|
||||
"reversals.py",
|
||||
"statistics.py",
|
||||
"trends_fir.py",
|
||||
"trends_iir.py",
|
||||
"volatility.py",
|
||||
"volume.py",
|
||||
"_helpers.py",
|
||||
]
|
||||
|
||||
# Pattern: standalone `length` as a word — NOT preceded or followed by
|
||||
# alphanumeric or underscore (i.e., not part of hpLength, minLength, etc.)
|
||||
# Also NOT `lengths` (plural).
|
||||
STANDALONE_LENGTH = re.compile(r'(?<![a-zA-Z0-9_])length(?![a-zA-Z0-9_])')
|
||||
|
||||
# Pattern for the signature line: `length: int = <default>`
|
||||
SIG_PATTERN = re.compile(r'(?<![a-zA-Z0-9_])length(\s*:\s*int\s*=\s*\d+)')
|
||||
|
||||
# Pattern for the assignment line: `length = int(length)` possibly with `;`
|
||||
ASSIGN_PATTERN = re.compile(
|
||||
r'^(\s*)length\s*=\s*int\(length\)\s*;?\s*'
|
||||
)
|
||||
|
||||
# Pattern for assignment in _helpers.py: `length = int(length) if length is not None else default_length`
|
||||
HELPERS_ASSIGN = re.compile(
|
||||
r'^(\s*)length\s*=\s*int\(length\)\s+if\s+length\s+is\s+not\s+None\s+else\s+default_length'
|
||||
)
|
||||
|
||||
|
||||
def has_standalone_length_param(line: str) -> bool:
|
||||
"""Check if a def line has standalone `length` as a parameter."""
|
||||
if not line.lstrip().startswith("def "):
|
||||
return False
|
||||
# Must have `length` as standalone word (not part of compound)
|
||||
return bool(STANDALONE_LENGTH.search(line))
|
||||
|
||||
|
||||
def transform_file(filepath: Path) -> tuple[int, int]:
|
||||
"""Transform a single file. Returns (functions_changed, lines_changed)."""
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
lines = text.split("\n")
|
||||
new_lines: list[str] = []
|
||||
in_target_func = False
|
||||
func_indent = ""
|
||||
funcs_changed = 0
|
||||
lines_changed = 0
|
||||
assignment_done = False
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.lstrip()
|
||||
|
||||
# Detect start of a function with `length` parameter
|
||||
if stripped.startswith("def ") and has_standalone_length_param(line):
|
||||
in_target_func = True
|
||||
func_indent = line[: len(line) - len(stripped)]
|
||||
assignment_done = False
|
||||
funcs_changed += 1
|
||||
|
||||
# Handle multi-line def (continuation lines)
|
||||
full_def = line
|
||||
while i < len(lines) - 1 and line.rstrip().endswith(","):
|
||||
# Replace standalone length in this line
|
||||
new_line = STANDALONE_LENGTH.sub("period", line)
|
||||
if new_line != line:
|
||||
lines_changed += 1
|
||||
new_lines.append(new_line)
|
||||
i += 1
|
||||
line = lines[i]
|
||||
|
||||
# Last line of def (or single-line def)
|
||||
new_line = STANDALONE_LENGTH.sub("period", line)
|
||||
if new_line != line:
|
||||
lines_changed += 1
|
||||
new_lines.append(new_line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Inside a target function?
|
||||
if in_target_func:
|
||||
# Detect end of function: non-empty line at or less than func indent,
|
||||
# or a new def/class
|
||||
if stripped and not line.startswith(func_indent + " ") and not line.startswith(func_indent + "\t"):
|
||||
if not stripped.startswith('"""') and not stripped.startswith("'"):
|
||||
# Could be the docstring continuation; check differently
|
||||
if stripped.startswith("def ") or stripped.startswith("class ") or (len(line) - len(stripped) <= len(func_indent) and stripped and not stripped.startswith('#')):
|
||||
in_target_func = False
|
||||
|
||||
if in_target_func:
|
||||
# Check for _helpers.py style assignment:
|
||||
# `length = int(length) if length is not None else default_length`
|
||||
m_helpers = HELPERS_ASSIGN.match(line)
|
||||
if m_helpers and not assignment_done:
|
||||
indent = m_helpers.group(1)
|
||||
new_line = f"{indent}period = int(kwargs.get(\"length\", period)) if period is not None else default_length"
|
||||
new_lines.append(new_line)
|
||||
lines_changed += 1
|
||||
assignment_done = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for standard assignment: `length = int(length);`
|
||||
m_assign = ASSIGN_PATTERN.match(line)
|
||||
if m_assign and not assignment_done:
|
||||
indent = m_assign.group(1)
|
||||
# Preserve anything after the semicolon on the same line
|
||||
rest_of_line = ASSIGN_PATTERN.sub("", line)
|
||||
# Check if there's more after (e.g., "; offset = int(offset)")
|
||||
remaining = line[m_assign.end():]
|
||||
new_assignment = f'{indent}period = int(kwargs.get("length", period))'
|
||||
if remaining.strip():
|
||||
new_assignment += "; " + remaining.strip()
|
||||
new_lines.append(new_assignment)
|
||||
lines_changed += 1
|
||||
assignment_done = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Replace any remaining standalone `length` references
|
||||
new_line = STANDALONE_LENGTH.sub("period", line)
|
||||
if new_line != line:
|
||||
lines_changed += 1
|
||||
new_lines.append(new_line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
new_lines.append(line)
|
||||
i += 1
|
||||
|
||||
new_text = "\n".join(new_lines)
|
||||
if new_text != text:
|
||||
filepath.write_text(new_text, encoding="utf-8")
|
||||
|
||||
return funcs_changed, lines_changed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
total_funcs = 0
|
||||
total_lines = 0
|
||||
|
||||
for fname in TARGET_FILES:
|
||||
fpath = QUANTALIB / fname
|
||||
if not fpath.exists():
|
||||
print(f" SKIP {fname} (not found)")
|
||||
continue
|
||||
|
||||
funcs, lines = transform_file(fpath)
|
||||
if funcs > 0:
|
||||
print(f" {fname}: {funcs} functions, {lines} lines changed")
|
||||
total_funcs += funcs
|
||||
total_lines += lines
|
||||
else:
|
||||
print(f" {fname}: no changes")
|
||||
|
||||
print(f"\nTotal: {total_funcs} functions, {total_lines} lines changed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,767 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,419 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
RE_EXPORT_CALL = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.Batch\(")
|
||||
RE_CLASS = re.compile(r"\bpublic\s+(?:sealed\s+|abstract\s+|partial\s+)*class\s+([A-Za-z_][A-Za-z0-9_]*)")
|
||||
RE_BATCH_HEAD = re.compile(r"\bpublic\s+static\s+([A-Za-z0-9_<>,\.\?\[\]\(\)\s]+?)\s+Batch\s*\(")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Param:
|
||||
type_name: str
|
||||
name: str
|
||||
has_default: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Overload:
|
||||
return_type: str
|
||||
params: list[Param]
|
||||
|
||||
|
||||
def split_top_level(s: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
cur: list[str] = []
|
||||
depth_angle = 0
|
||||
depth_paren = 0
|
||||
for ch in s:
|
||||
if ch == "<":
|
||||
depth_angle += 1
|
||||
elif ch == ">":
|
||||
depth_angle = max(0, depth_angle - 1)
|
||||
elif ch == "(":
|
||||
depth_paren += 1
|
||||
elif ch == ")":
|
||||
depth_paren = max(0, depth_paren - 1)
|
||||
|
||||
if ch == "," and depth_angle == 0 and depth_paren == 0:
|
||||
part = "".join(cur).strip()
|
||||
if part:
|
||||
out.append(part)
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
part = "".join(cur).strip()
|
||||
if part:
|
||||
out.append(part)
|
||||
return out
|
||||
|
||||
|
||||
def parse_params(params_text: str) -> list[Param]:
|
||||
params: list[Param] = []
|
||||
if not params_text.strip():
|
||||
return params
|
||||
for raw in split_top_level(params_text):
|
||||
has_default = "=" in raw
|
||||
left = raw.split("=", 1)[0].strip()
|
||||
tokens = left.split()
|
||||
if len(tokens) < 2:
|
||||
continue
|
||||
name = tokens[-1].strip()
|
||||
type_name = " ".join(tokens[:-1]).replace("in ", "").replace("ref ", "").strip()
|
||||
params.append(Param(type_name=type_name, name=name, has_default=has_default))
|
||||
return params
|
||||
|
||||
|
||||
def extract_batch_overloads(text: str) -> list[Overload]:
|
||||
overloads: list[Overload] = []
|
||||
for m in RE_BATCH_HEAD.finditer(text):
|
||||
ret = " ".join(m.group(1).split())
|
||||
i = m.end()
|
||||
depth = 1
|
||||
j = i
|
||||
while j < len(text) and depth > 0:
|
||||
if text[j] == "(":
|
||||
depth += 1
|
||||
elif text[j] == ")":
|
||||
depth -= 1
|
||||
j += 1
|
||||
if depth != 0:
|
||||
continue
|
||||
params_text = text[i : j - 1]
|
||||
overloads.append(Overload(return_type=ret, params=parse_params(params_text)))
|
||||
return overloads
|
||||
|
||||
|
||||
def load_exported_indicators(repo_root: Path) -> set[str]:
|
||||
# Baseline only: compare against hand-authored Exports.cs.
|
||||
# Generated file is overwritten each run and must not affect diff input.
|
||||
exported: set[str] = set()
|
||||
p = repo_root / "python" / "src" / "Exports.cs"
|
||||
if p.exists():
|
||||
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||||
exported.update(RE_EXPORT_CALL.findall(txt))
|
||||
return exported
|
||||
|
||||
|
||||
def load_lib_indicators(repo_root: Path) -> dict[str, Path]:
|
||||
lib = repo_root / "lib"
|
||||
indicators: dict[str, Path] = {}
|
||||
for p in lib.rglob("*.cs"):
|
||||
parts = {x.lower() for x in p.parts}
|
||||
if "bin" in parts or "obj" in parts:
|
||||
continue
|
||||
if p.name.endswith(".Tests.cs"):
|
||||
continue
|
||||
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||||
if "public static" not in txt or "Batch(" not in txt:
|
||||
continue
|
||||
m = RE_CLASS.search(txt)
|
||||
if not m:
|
||||
continue
|
||||
indicators[m.group(1)] = p
|
||||
return indicators
|
||||
|
||||
|
||||
def is_supported_scalar(type_name: str) -> bool:
|
||||
if type_name in {"int", "double", "bool"}:
|
||||
return True
|
||||
if "BatchOutputs" in type_name:
|
||||
return False
|
||||
if type_name in {"TSeries", "TBarSeries", "Span<double>", "ReadOnlySpan<double>", "Span<long>", "ReadOnlySpan<long>"}:
|
||||
return False
|
||||
# enum-like names
|
||||
return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_\.]*\??", type_name))
|
||||
|
||||
|
||||
def is_tuple_return_of_tseries(ret: str) -> bool:
|
||||
if not (ret.startswith("(") and ret.endswith(")")):
|
||||
return False
|
||||
parts = split_top_level(ret[1:-1])
|
||||
if not parts:
|
||||
return False
|
||||
for p in parts:
|
||||
t = p.strip().split()[0]
|
||||
if t != "TSeries":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def tuple_fields(ret: str) -> list[str]:
|
||||
parts = split_top_level(ret[1:-1])
|
||||
fields: list[str] = []
|
||||
for p in parts:
|
||||
toks = p.strip().split()
|
||||
if len(toks) >= 2:
|
||||
fields.append(toks[1])
|
||||
return fields
|
||||
|
||||
|
||||
def choose_overload(overloads: list[Overload]) -> Overload | None:
|
||||
def supported(o: Overload) -> bool:
|
||||
for p in o.params:
|
||||
t = p.type_name
|
||||
if t in {"ReadOnlySpan<double>", "Span<double>", "TSeries", "TBarSeries"}:
|
||||
continue
|
||||
if not is_supported_scalar(t):
|
||||
return False
|
||||
return True
|
||||
|
||||
span_candidates: list[Overload] = []
|
||||
series_candidates: list[Overload] = []
|
||||
tuple_candidates: list[Overload] = []
|
||||
|
||||
for o in overloads:
|
||||
if not supported(o):
|
||||
continue
|
||||
has_in_span = any(p.type_name == "ReadOnlySpan<double>" for p in o.params)
|
||||
has_out_span = any(p.type_name == "Span<double>" for p in o.params)
|
||||
has_series_obj = any(p.type_name in {"TSeries", "TBarSeries"} for p in o.params)
|
||||
|
||||
if o.return_type == "void" and has_in_span and has_out_span:
|
||||
span_candidates.append(o)
|
||||
elif o.return_type == "TSeries" and has_series_obj:
|
||||
series_candidates.append(o)
|
||||
elif is_tuple_return_of_tseries(o.return_type) and has_series_obj:
|
||||
tuple_candidates.append(o)
|
||||
|
||||
# priority: span overloads (supports multi-output), then single TSeries, then tuple TSeries
|
||||
if span_candidates:
|
||||
span_candidates.sort(key=lambda o: (sum(1 for p in o.params if p.type_name == "Span<double>"), -len(o.params)), reverse=True)
|
||||
return span_candidates[0]
|
||||
if series_candidates:
|
||||
series_candidates.sort(key=lambda o: -len(o.params))
|
||||
return series_candidates[0]
|
||||
if tuple_candidates:
|
||||
tuple_candidates.sort(key=lambda o: -len(o.params))
|
||||
return tuple_candidates[0]
|
||||
return None
|
||||
|
||||
|
||||
def map_scalar_to_abi(type_name: str) -> str:
|
||||
if type_name == "double":
|
||||
return "double"
|
||||
# int, bool, enums, nullable enums -> int ABI
|
||||
return "int"
|
||||
|
||||
|
||||
def map_scalar_call(type_name: str, arg_name: str) -> str:
|
||||
if type_name == "double":
|
||||
return arg_name
|
||||
if type_name == "int":
|
||||
return arg_name
|
||||
if type_name == "bool":
|
||||
return f"{arg_name} != 0"
|
||||
# enum or nullable enum
|
||||
return f"({type_name.rstrip('?')}){arg_name}"
|
||||
|
||||
|
||||
def build_wrapper(class_name: str, ov: Overload) -> str:
|
||||
entry = f"qtl_{class_name.lower()}"
|
||||
method = f"Qtl{class_name}"
|
||||
|
||||
sig_parts: list[str] = []
|
||||
null_checks: list[str] = []
|
||||
setup_lines: list[str] = []
|
||||
call_args: list[str] = []
|
||||
|
||||
has_n = False
|
||||
|
||||
# span-based overload
|
||||
if ov.return_type == "void" and any(p.type_name == "ReadOnlySpan<double>" for p in ov.params):
|
||||
for p in ov.params:
|
||||
t = p.type_name
|
||||
if t == "ReadOnlySpan<double>":
|
||||
sig_parts.append(f"double* {p.name}")
|
||||
null_checks.append(f"{p.name} == null")
|
||||
call_args.append(f"Src({p.name}, n)")
|
||||
has_n = True
|
||||
elif t == "Span<double>":
|
||||
sig_parts.append(f"double* {p.name}")
|
||||
null_checks.append(f"{p.name} == null")
|
||||
call_args.append(f"Dst({p.name}, n)")
|
||||
has_n = True
|
||||
else:
|
||||
abi_t = map_scalar_to_abi(t)
|
||||
sig_parts.append(f"{abi_t} {p.name}")
|
||||
call_args.append(map_scalar_call(t, p.name))
|
||||
if has_n:
|
||||
sig_parts.insert(sum(1 for s in sig_parts if s.startswith('double* ')), "int n")
|
||||
|
||||
null_expr = " || ".join(null_checks) if null_checks else "false"
|
||||
lines = [
|
||||
f' [UnmanagedCallersOnly(EntryPoint = "{entry}")]',
|
||||
f" public static int {method}({', '.join(sig_parts)})",
|
||||
" {",
|
||||
f" if ({null_expr}) return StatusCodes.QTL_ERR_NULL_PTR;",
|
||||
" if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;",
|
||||
" try",
|
||||
" {",
|
||||
f" {class_name}.Batch({', '.join(call_args)});",
|
||||
" return StatusCodes.QTL_OK;",
|
||||
" }",
|
||||
" catch { return StatusCodes.QTL_ERR_INTERNAL; }",
|
||||
" }",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
# object-based overloads
|
||||
for p in ov.params:
|
||||
t = p.type_name
|
||||
if t == "TSeries":
|
||||
sig_parts.append(f"double* {p.name}")
|
||||
null_checks.append(f"{p.name} == null")
|
||||
has_n = True
|
||||
setup_lines.append(f" var {p.name}Series = BuildSeries({p.name}, n);")
|
||||
call_args.append(f"{p.name}Series")
|
||||
elif t == "TBarSeries":
|
||||
for fld in ("Open", "High", "Low", "Close", "Volume"):
|
||||
nm = f"{p.name}{fld}"
|
||||
sig_parts.append(f"double* {nm}")
|
||||
null_checks.append(f"{nm} == null")
|
||||
has_n = True
|
||||
setup_lines.append(
|
||||
f" var {p.name}Bars = BuildBars({p.name}Open, {p.name}High, {p.name}Low, {p.name}Close, {p.name}Volume, n);"
|
||||
)
|
||||
call_args.append(f"{p.name}Bars")
|
||||
else:
|
||||
abi_t = map_scalar_to_abi(t)
|
||||
sig_parts.append(f"{abi_t} {p.name}")
|
||||
call_args.append(map_scalar_call(t, p.name))
|
||||
|
||||
if has_n:
|
||||
sig_parts.append("int n")
|
||||
|
||||
if ov.return_type == "TSeries":
|
||||
sig_parts.append("double* dst")
|
||||
null_checks.append("dst == null")
|
||||
elif is_tuple_return_of_tseries(ov.return_type):
|
||||
for f in tuple_fields(ov.return_type):
|
||||
dn = f"dst{f}"
|
||||
sig_parts.append(f"double* {dn}")
|
||||
null_checks.append(f"{dn} == null")
|
||||
|
||||
null_expr = " || ".join(null_checks) if null_checks else "false"
|
||||
lines = [
|
||||
f' [UnmanagedCallersOnly(EntryPoint = "{entry}")]',
|
||||
f" public static int {method}({', '.join(sig_parts)})",
|
||||
" {",
|
||||
f" if ({null_expr}) return StatusCodes.QTL_ERR_NULL_PTR;",
|
||||
]
|
||||
if has_n:
|
||||
lines.append(" if (n <= 0) return StatusCodes.QTL_ERR_INVALID_LENGTH;")
|
||||
lines.extend([" try", " {"])
|
||||
lines.extend(setup_lines)
|
||||
|
||||
call = f"{class_name}.Batch({', '.join(call_args)})"
|
||||
if ov.return_type == "TSeries":
|
||||
lines.extend(
|
||||
[
|
||||
f" var result = {call};",
|
||||
" var values = result.Values;",
|
||||
" if (values.Length > n) return StatusCodes.QTL_ERR_INVALID_LENGTH;",
|
||||
" var outSpan = Dst(dst, n);",
|
||||
" outSpan.Fill(double.NaN);",
|
||||
" values.CopyTo(outSpan);",
|
||||
" return StatusCodes.QTL_OK;",
|
||||
]
|
||||
)
|
||||
elif is_tuple_return_of_tseries(ov.return_type):
|
||||
fields = tuple_fields(ov.return_type)
|
||||
lines.append(f" var result = {call};")
|
||||
for f in fields:
|
||||
dn = f"dst{f}"
|
||||
lines.extend(
|
||||
[
|
||||
f" var values{f} = result.{f}.Values;",
|
||||
f" if (values{f}.Length > n) return StatusCodes.QTL_ERR_INVALID_LENGTH;",
|
||||
f" var outSpan{f} = Dst({dn}, n);",
|
||||
f" outSpan{f}.Fill(double.NaN);",
|
||||
f" values{f}.CopyTo(outSpan{f});",
|
||||
]
|
||||
)
|
||||
lines.append(" return StatusCodes.QTL_OK;")
|
||||
else:
|
||||
lines.append(f" {call};")
|
||||
lines.append(" return StatusCodes.QTL_OK;")
|
||||
|
||||
lines.extend([" }", " catch { return StatusCodes.QTL_ERR_INTERNAL; }", " }"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate(repo_root: Path) -> str:
|
||||
exported = load_exported_indicators(repo_root)
|
||||
lib_indicators = load_lib_indicators(repo_root)
|
||||
missing = sorted(set(lib_indicators.keys()) - exported)
|
||||
|
||||
wrappers: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
for cls in missing:
|
||||
p = lib_indicators[cls]
|
||||
text = p.read_text(encoding="utf-8", errors="ignore")
|
||||
ovs = extract_batch_overloads(text)
|
||||
ov = choose_overload(ovs)
|
||||
if ov is None:
|
||||
skipped.append(cls)
|
||||
continue
|
||||
wrappers.append(build_wrapper(cls, ov))
|
||||
|
||||
header = """// <auto-generated />
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Python;
|
||||
|
||||
public static unsafe partial class Exports
|
||||
{
|
||||
private static TSeries BuildSeries(double* src, int n)
|
||||
{
|
||||
var t = new long[n];
|
||||
var v = new double[n];
|
||||
new ReadOnlySpan<double>(src, n).CopyTo(v);
|
||||
for (int i = 0; i < n; i++) t[i] = i;
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
private static TBarSeries BuildBars(double* open, double* high, double* low, double* close, double* volume, int n)
|
||||
{
|
||||
var t = new long[n];
|
||||
var o = new double[n];
|
||||
var h = new double[n];
|
||||
var l = new double[n];
|
||||
var c = new double[n];
|
||||
var v = new double[n];
|
||||
new ReadOnlySpan<double>(open, n).CopyTo(o);
|
||||
new ReadOnlySpan<double>(high, n).CopyTo(h);
|
||||
new ReadOnlySpan<double>(low, n).CopyTo(l);
|
||||
new ReadOnlySpan<double>(close, n).CopyTo(c);
|
||||
new ReadOnlySpan<double>(volume, n).CopyTo(v);
|
||||
for (int i = 0; i < n; i++) t[i] = i;
|
||||
var bars = new TBarSeries(n);
|
||||
bars.AddRange(t, o, h, l, c, v);
|
||||
return bars;
|
||||
}
|
||||
|
||||
"""
|
||||
skipped_block = ""
|
||||
if skipped:
|
||||
skipped_block = "\n// Skipped (no supported overload found):\n" + "\n".join(f"// - {s}" for s in skipped) + "\n"
|
||||
|
||||
return header + "\n\n".join(wrappers) + skipped_block + "\n}\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
out_file = repo_root / "python" / "src" / "Exports.Generated.cs"
|
||||
out_file.write_text(generate(repo_root), encoding="utf-8")
|
||||
print(f"Wrote {out_file}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,927 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
EXPORT_BATCH_PATTERN = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.Batch\(")
|
||||
PUBLIC_CLASS_PATTERN = re.compile(
|
||||
r"\bpublic\s+(?:sealed\s+|abstract\s+|partial\s+)*class\s+([A-Za-z_][A-Za-z0-9_]*)"
|
||||
)
|
||||
PUBLIC_STATIC_BATCH_PATTERN = re.compile(r"\bpublic\s+static\b[\s\S]{0,1200}?\bBatch\s*\(")
|
||||
|
||||
|
||||
def collect_exported_indicators(exports_dir: Path) -> set[str]:
|
||||
exported: set[str] = set()
|
||||
for p in exports_dir.glob("Exports*.cs"):
|
||||
text = p.read_text(encoding="utf-8", errors="ignore")
|
||||
exported.update(EXPORT_BATCH_PATTERN.findall(text))
|
||||
return exported
|
||||
|
||||
|
||||
def collect_lib_indicators(lib_dir: Path) -> set[str]:
|
||||
indicators: set[str] = set()
|
||||
|
||||
for cs_file in lib_dir.rglob("*.cs"):
|
||||
parts = {p.lower() for p in cs_file.parts}
|
||||
if "bin" in parts or "obj" in parts:
|
||||
continue
|
||||
if cs_file.name.endswith(".Tests.cs"):
|
||||
continue
|
||||
|
||||
text = cs_file.read_text(encoding="utf-8", errors="ignore")
|
||||
if "Batch(" not in text or "public static" not in text:
|
||||
continue
|
||||
|
||||
class_names = PUBLIC_CLASS_PATTERN.findall(text)
|
||||
if not class_names:
|
||||
continue
|
||||
|
||||
if not PUBLIC_STATIC_BATCH_PATTERN.search(text):
|
||||
continue
|
||||
|
||||
indicators.update(class_names)
|
||||
|
||||
return indicators
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate that python/src/Exports.cs covers all indicator classes in lib/ with public static Batch methods."
|
||||
)
|
||||
parser.add_argument("--repo-root", type=Path, default=None)
|
||||
parser.add_argument("--max-print", type=int, default=200)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = args.repo_root or Path(__file__).resolve().parents[2]
|
||||
exports_dir = repo_root / "python" / "src"
|
||||
lib_dir = repo_root / "lib"
|
||||
|
||||
if not exports_dir.exists():
|
||||
print(f"ERROR: missing dir: {exports_dir}")
|
||||
return 2
|
||||
if not lib_dir.exists():
|
||||
print(f"ERROR: missing dir: {lib_dir}")
|
||||
return 2
|
||||
|
||||
exported = collect_exported_indicators(exports_dir)
|
||||
lib_indicators = collect_lib_indicators(lib_dir)
|
||||
|
||||
missing = sorted(lib_indicators - exported)
|
||||
extra = sorted(exported - lib_indicators)
|
||||
|
||||
print(f"EXPORTED_COUNT={len(exported)}")
|
||||
print(f"LIB_INDICATOR_COUNT={len(lib_indicators)}")
|
||||
print(f"MISSING_COUNT={len(missing)}")
|
||||
print(f"EXTRA_COUNT={len(extra)}")
|
||||
|
||||
if missing:
|
||||
print("\nMISSING_EXPORTS:")
|
||||
for name in missing[: args.max_print]:
|
||||
print(name)
|
||||
if len(missing) > args.max_print:
|
||||
print(f"... ({len(missing) - args.max_print} more)")
|
||||
|
||||
if extra:
|
||||
print("\nEXTRA_EXPORT_REFERENCES:")
|
||||
for name in extra[: args.max_print]:
|
||||
print(name)
|
||||
if len(extra) > args.max_print:
|
||||
print(f"... ({len(extra) - args.max_print} more)")
|
||||
|
||||
if missing:
|
||||
print(
|
||||
"\nFAILED: Exports.cs is missing indicators found in /lib. "
|
||||
"Add exports or intentionally exclude in validator policy."
|
||||
)
|
||||
return 1
|
||||
|
||||
print("\nOK: Exports.cs covers all detected /lib indicators.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user