mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-03 11:47:44 +00:00
python wrapper
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<GitVersionSkip>true</GitVersionSkip>
|
||||
<IlcOptimizationPreference>Speed</IlcOptimizationPreference>
|
||||
<RunAnalyzers>false</RunAnalyzers>
|
||||
<ErrorReport>none</ErrorReport>
|
||||
|
||||
<!-- Override root Directory.Build.props settings that break NativeAOT -->
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
<EnableNETAnalyzers>false</EnableNETAnalyzers>
|
||||
|
||||
<!-- Disable SARIF output — interferes with ILC -->
|
||||
<ErrorLog></ErrorLog>
|
||||
|
||||
<!-- NativeAOT trimming: do NOT use aggressive 'link' mode from root props.
|
||||
'partial' preserves rooted assemblies fully while still trimming unused framework code. -->
|
||||
<TrimMode>partial</TrimMode>
|
||||
|
||||
<!-- Suppress ILC warnings about types it can't fully analyze -->
|
||||
<NoWarn>$(NoWarn);IL2026;IL2057;IL2058;IL2059;IL2060;IL2070;IL2072;IL2075;IL2104;IL3050;IL3051</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
# quantalib (Python NativeAOT wrapper)
|
||||
|
||||
Skeleton package and NativeAOT project scaffolding for the `quantalib` Python wrapper over QuanTAlib.
|
||||
|
||||
## Current status
|
||||
|
||||
This is a **skeleton-only** implementation containing:
|
||||
|
||||
- NativeAOT project files (`python.csproj`, `Directory.Build.props`)
|
||||
- Python packaging metadata (`pyproject.toml`)
|
||||
- Python package layout (`quantalib/`)
|
||||
- Loader and bridge stubs (`_loader.py`, `_bridge.py`)
|
||||
- Native artifact placeholders (`quantalib/native/...`)
|
||||
- Minimal smoke test scaffold (`tests/test_smoke.py`)
|
||||
- Native export scaffolding (`src/StatusCodes.cs`, `src/ArrayBridge.cs`, `src/Exports.cs`)
|
||||
|
||||
## Not included yet
|
||||
|
||||
- Full indicator export implementation
|
||||
- Full ctypes signatures for all exports
|
||||
- Indicator wrappers in `indicators.py`
|
||||
- Complete test matrix and compatibility suite
|
||||
|
||||
## Local dev
|
||||
|
||||
From `python/`:
|
||||
|
||||
- Create venv and install deps
|
||||
- Run tests: `pytest`
|
||||
- Build wheel: `python -m build`
|
||||
+1219
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
# publish.ps1 — Build NativeAOT shared library for the current platform.
|
||||
# Usage: pwsh python/publish.ps1 [-Configuration Release] [-Runtime win-x64]
|
||||
#
|
||||
# The script publishes python.csproj as a NativeAOT shared library and
|
||||
# copies the output to the quantalib package directory for _loader.py to find.
|
||||
|
||||
param(
|
||||
[string]$Configuration = "Release",
|
||||
[string]$Runtime = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Resolve paths
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$projectFile = Join-Path $scriptDir "python.csproj"
|
||||
|
||||
if (-not (Test-Path $projectFile)) {
|
||||
Write-Error "Cannot find $projectFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Auto-detect runtime if not specified
|
||||
if (-not $Runtime) {
|
||||
if ($IsWindows -or $env:OS -eq "Windows_NT") {
|
||||
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq "Arm64") { "arm64" } else { "x64" }
|
||||
$Runtime = "win-$arch"
|
||||
}
|
||||
elseif ($IsMacOS) {
|
||||
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq "Arm64") { "arm64" } else { "x64" }
|
||||
$Runtime = "osx-$arch"
|
||||
}
|
||||
else {
|
||||
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq "Arm64") { "arm64" } else { "x64" }
|
||||
$Runtime = "linux-$arch"
|
||||
}
|
||||
}
|
||||
|
||||
# Map runtime to Python platform tag directory
|
||||
$platformDirMap = @{
|
||||
"win-x64" = "win_amd64"
|
||||
"win-arm64" = "win_arm64"
|
||||
"linux-x64" = "linux_x86_64"
|
||||
"linux-arm64" = "linux_aarch64"
|
||||
"osx-x64" = "macosx_x86_64"
|
||||
"osx-arm64" = "macosx_arm64"
|
||||
}
|
||||
|
||||
$platformDir = $platformDirMap[$Runtime]
|
||||
if (-not $platformDir) {
|
||||
Write-Error "Unknown runtime '$Runtime'. Supported: $($platformDirMap.Keys -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Target output directory (direct to native/ subdirectory)
|
||||
$nativeTargetDir = Join-Path $scriptDir "quantalib" "native" $platformDir
|
||||
|
||||
Write-Host "Publishing NativeAOT library..."
|
||||
Write-Host " Configuration : $Configuration"
|
||||
Write-Host " Runtime : $Runtime"
|
||||
Write-Host " Project : $projectFile"
|
||||
Write-Host " Output : $nativeTargetDir"
|
||||
Write-Host ""
|
||||
|
||||
# Publish directly to the target native directory
|
||||
dotnet publish $projectFile `
|
||||
-c $Configuration `
|
||||
-r $Runtime `
|
||||
--self-contained true `
|
||||
-o $nativeTargetDir
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "dotnet publish failed with exit code $LASTEXITCODE"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Verify native library exists
|
||||
$libName = switch -Wildcard ($Runtime) {
|
||||
"win-*" { "quantalib_native.dll" }
|
||||
"osx-*" { "quantalib_native.dylib" }
|
||||
default { "quantalib_native.so" }
|
||||
}
|
||||
|
||||
$nativeLib = Join-Path $nativeTargetDir $libName
|
||||
if (-not (Test-Path $nativeLib)) {
|
||||
Write-Error "Native library not found: $nativeLib"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$size = [math]::Round((Get-Item $nativeLib).Length / 1MB, 2)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "SUCCESS: $nativeLib ($size MB)"
|
||||
Write-Host ""
|
||||
Write-Host "To run tests:"
|
||||
Write-Host " cd $scriptDir"
|
||||
Write-Host " python -m pytest tests/ -v"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.27.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "quantalib"
|
||||
version = "0.1.0"
|
||||
description = "High-performance technical analysis wrappers over QuanTAlib NativeAOT"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["numpy>=1.24"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
pandas = ["pandas>=1.5"]
|
||||
dev = ["pytest>=8.0", "pandas>=1.5"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
# pandas-ta sets pd.options.mode.copy_on_write=False on import;
|
||||
# pandas >= 3.0 always enables CoW and emits Pandas4Warning.
|
||||
# This is a third-party issue — suppress it.
|
||||
"ignore::pandas.errors.Pandas4Warning",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["quantalib"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"quantalib/native" = "quantalib/native"
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<PublishAot>true</PublishAot>
|
||||
<AssemblyName>quantalib_native</AssemblyName>
|
||||
<PackageId>QuanTAlib.Python.Native</PackageId>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="src\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\lib\quantalib.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Prevent NativeAOT trimmer from stripping QuanTAlib types.
|
||||
The ILC linker must keep ALL types in the referenced assembly
|
||||
because [UnmanagedCallersOnly] exports call static Batch methods
|
||||
on ~120 different indicator types via try/catch wrappers. -->
|
||||
<ItemGroup>
|
||||
<TrimmerRootAssembly Include="QuanTAlib" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="ValidateExportsCoverage" BeforeTargets="Build;Publish">
|
||||
<Exec Command="python tools/validate_exports.py --repo-root $(MSBuildThisFileDirectory).." WorkingDirectory="$(MSBuildThisFileDirectory)" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
"""quantalib — Python wrapper for QuanTAlib NativeAOT exports.
|
||||
|
||||
Usage::
|
||||
|
||||
import quantalib as qtl
|
||||
|
||||
result = qtl.sma(close_array, length=14)
|
||||
result = qtl.bbands(close_array, length=20, std=2.0)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ._loader import load_native_library
|
||||
from . import indicators
|
||||
from .indicators import * # noqa: F401, F403 — re-export all indicator functions
|
||||
from ._compat import ALIASES, get_compat
|
||||
from ._bridge import (
|
||||
QtlError,
|
||||
QtlNullPointerError,
|
||||
QtlInvalidLengthError,
|
||||
QtlInvalidParamError,
|
||||
QtlInternalError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"load_native_library",
|
||||
"indicators",
|
||||
"ALIASES",
|
||||
"get_compat",
|
||||
"QtlError",
|
||||
"QtlNullPointerError",
|
||||
"QtlInvalidLengthError",
|
||||
"QtlInvalidParamError",
|
||||
"QtlInternalError",
|
||||
]
|
||||
__version__ = "0.1.0"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,304 @@
|
||||
"""Low-level ctypes bindings for every quantalib NativeAOT export.
|
||||
|
||||
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*
|
||||
_ci = c_int
|
||||
_cd = c_double
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ABI signature pattern templates
|
||||
#
|
||||
# Pattern A : (src*, n, dst*, period) → single-input + int
|
||||
# Pattern A2: (src*, n, dst*, alpha) → single-input + double
|
||||
# Pattern A3: (src*, n, dst*) → single-input no params
|
||||
# Pattern B : (h*, l*, c*, v*, n, dst*, period) → HLCV + int
|
||||
# Pattern C : (o*, h*, l*, c*, n, dst*) → OHLC no extra
|
||||
# Pattern C2: (o*, h*, l*, c*, n, dst*, double) → OHLC + double
|
||||
# Pattern D : (h*, l*, n, dst*) → HL
|
||||
# Pattern E : (h*, l*, c*, n, dst*) → HLC
|
||||
# Pattern F : (actual*, predicted*, n, dst*, period) → dual-input + int
|
||||
# Pattern G : (src*, vol*, n, dst*) → source+volume
|
||||
# Pattern G2: (src*, vol*, n, dst*, period) → source+volume+int
|
||||
# Pattern H : (x*, y*, n, dst*, period) → X+Y + int
|
||||
# Pattern I : multi-output (various)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Common argtypes per pattern
|
||||
_PA = [_dp, _ci, _dp, _ci] # Pattern A
|
||||
_PA2 = [_dp, _ci, _dp, _cd] # Pattern A (alpha)
|
||||
_PA3 = [_dp, _ci, _dp] # Pattern A (no param)
|
||||
_PB = [_dp, _dp, _dp, _dp, _ci, _dp, _ci] # Pattern B (HLCV)
|
||||
_PC = [_dp, _dp, _dp, _dp, _ci, _dp] # Pattern C (OHLC)
|
||||
_PC2 = [_dp, _dp, _dp, _dp, _ci, _dp, _cd] # Pattern C (OHLC+double)
|
||||
_PD = [_dp, _dp, _ci, _dp] # Pattern D (HL)
|
||||
_PE = [_dp, _dp, _dp, _ci, _dp] # Pattern E (HLC)
|
||||
_PF = [_dp, _dp, _ci, _dp, _ci] # Pattern F
|
||||
_PG = [_dp, _dp, _ci, _dp] # Pattern G
|
||||
_PG2 = [_dp, _dp, _ci, _dp, _ci] # Pattern G2
|
||||
_PH = [_dp, _dp, _ci, _dp, _ci] # Pattern H
|
||||
|
||||
|
||||
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])
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.1 Core
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_AVGPRICE = _bind("qtl_avgprice", _PC)
|
||||
HAS_MEDPRICE = _bind("qtl_medprice", _PD)
|
||||
HAS_TYPPRICE = _bind("qtl_typprice", [_dp, _dp, _dp, _ci, _dp]) # OHL (no close!)
|
||||
HAS_MIDBODY = _bind("qtl_midbody", [_dp, _dp, _ci, _dp]) # OC
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.2 Momentum
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_RSI = _bind("qtl_rsi", _PA)
|
||||
HAS_ROC = _bind("qtl_roc", _PA)
|
||||
HAS_MOM = _bind("qtl_mom", _PA)
|
||||
HAS_CMO = _bind("qtl_cmo", _PA)
|
||||
HAS_TSI = _bind("qtl_tsi", [_dp, _ci, _dp, _ci, _ci]) # longP, shortP
|
||||
HAS_APO = _bind("qtl_apo", [_dp, _ci, _dp, _ci, _ci]) # fast, slow
|
||||
HAS_BIAS = _bind("qtl_bias", _PA)
|
||||
HAS_CFO = _bind("qtl_cfo", _PA)
|
||||
HAS_CFB = _bind("qtl_cfb", [_dp, _ci, _dp, _ip, _ci]) # special
|
||||
HAS_ASI = _bind("qtl_asi", _PC2) # OHLC + double limit
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.3 Oscillators
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_FISHER = _bind("qtl_fisher", _PA)
|
||||
HAS_FISHER04 = _bind("qtl_fisher04", _PA)
|
||||
HAS_DPO = _bind("qtl_dpo", _PA)
|
||||
HAS_TRIX = _bind("qtl_trix", _PA)
|
||||
HAS_INERTIA = _bind("qtl_inertia", _PA)
|
||||
HAS_RSX = _bind("qtl_rsx", _PA)
|
||||
HAS_ER = _bind("qtl_er", _PA)
|
||||
HAS_CTI = _bind("qtl_cti", _PA)
|
||||
HAS_REFLEX = _bind("qtl_reflex", _PA)
|
||||
HAS_TRENDFLEX = _bind("qtl_trendflex", _PA)
|
||||
HAS_KRI = _bind("qtl_kri", _PA)
|
||||
HAS_PSL = _bind("qtl_psl", _PA)
|
||||
HAS_DECO = _bind("qtl_deco", [_dp, _ci, _dp, _ci, _ci]) # shortP, longP
|
||||
HAS_DOSC = _bind("qtl_dosc", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) # rsiP, ema1P, ema2P, sigP
|
||||
HAS_DYMOI = _bind("qtl_dymoi", [_dp, _ci, _dp, _ci, _ci, _ci, _ci, _ci]) # p1..p5
|
||||
HAS_CRSI = _bind("qtl_crsi", [_dp, _ci, _dp, _ci, _ci, _ci]) # rsiP, streakP, rankP
|
||||
HAS_BBB = _bind("qtl_bbb", [_dp, _ci, _dp, _ci, _cd]) # period, mult
|
||||
HAS_BBI = _bind("qtl_bbi", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) # p1..p4
|
||||
HAS_DEM = _bind("qtl_dem", _PD) # HL pattern
|
||||
HAS_BRAR = _bind("qtl_brar", [_dp, _dp, _dp, _dp, _ci, _dp, _dp, _ci]) # OHLC + 2 outputs + period
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.4 Trends — FIR
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_SMA = _bind("qtl_sma", _PA)
|
||||
HAS_WMA = _bind("qtl_wma", _PA)
|
||||
HAS_HMA = _bind("qtl_hma", _PA)
|
||||
HAS_TRIMA = _bind("qtl_trima", _PA)
|
||||
HAS_SWMA = _bind("qtl_swma", _PA)
|
||||
HAS_DWMA = _bind("qtl_dwma", _PA)
|
||||
HAS_BLMA = _bind("qtl_blma", _PA)
|
||||
HAS_ALMA = _bind("qtl_alma", _PA)
|
||||
HAS_LSMA = _bind("qtl_lsma", _PA)
|
||||
HAS_SGMA = _bind("qtl_sgma", _PA)
|
||||
HAS_SINEMA = _bind("qtl_sinema", _PA)
|
||||
HAS_HANMA = _bind("qtl_hanma", _PA)
|
||||
HAS_PARZEN = _bind("qtl_parzen", _PA)
|
||||
HAS_TSF = _bind("qtl_tsf", _PA)
|
||||
HAS_CONV = _bind("qtl_conv", [_dp, _ci, _dp, _dp, _ci]) # src,n,dst,kernel*,kernelLen
|
||||
HAS_BWMA = _bind("qtl_bwma", [_dp, _ci, _dp, _ci, _ci]) # period, polyOrder
|
||||
HAS_CRMA = _bind("qtl_crma", [_dp, _ci, _dp, _ci, _cd]) # period, volumeFactor
|
||||
HAS_SP15 = _bind("qtl_sp15", _PA)
|
||||
HAS_TUKEY_W = _bind("qtl_tukey_w", _PA)
|
||||
HAS_RAIN = _bind("qtl_rain", _PA)
|
||||
HAS_AFIRMA = _bind("qtl_afirma", [_dp, _ci, _dp, _ci, _ci, _ci]) # src,n,dst,period,windowType,useSimd
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.5 Trends — IIR
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_EMA = _bind("qtl_ema", _PA)
|
||||
HAS_EMA_ALPHA = _bind("qtl_ema_alpha", _PA2)
|
||||
HAS_DEMA = _bind("qtl_dema", _PA)
|
||||
HAS_DEMA_ALPHA = _bind("qtl_dema_alpha", _PA2)
|
||||
HAS_TEMA = _bind("qtl_tema", _PA)
|
||||
HAS_LEMA = _bind("qtl_lema", _PA)
|
||||
HAS_HEMA = _bind("qtl_hema", _PA)
|
||||
HAS_AHRENS = _bind("qtl_ahrens", _PA)
|
||||
HAS_DECYCLER = _bind("qtl_decycler", _PA)
|
||||
HAS_DSMA = _bind("qtl_dsma", [_dp, _ci, _dp, _ci, _cd]) # period, factor
|
||||
HAS_GDEMA = _bind("qtl_gdema", [_dp, _ci, _dp, _ci, _cd]) # period, factor
|
||||
HAS_CORAL = _bind("qtl_coral", [_dp, _ci, _dp, _ci, _cd]) # period, friction
|
||||
HAS_AGC = _bind("qtl_agc", _PA2) # alpha
|
||||
HAS_CCYC = _bind("qtl_ccyc", _PA2) # alpha
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.6 Channels
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_BBANDS = _bind("qtl_bbands", [_dp, _ci, _dp, _dp, _dp, _ci, _cd]) # src,n, upper,mid,lower, period,mult
|
||||
HAS_ABBER = _bind("qtl_abber", [_dp, _dp, _dp, _dp, _ci, _ci, _cd]) # src,mid,upper,lower,n,period,mult
|
||||
HAS_ATRBANDS = _bind("qtl_atrbands", [_dp, _dp, _dp, _ci, _dp, _dp, _dp, _ci, _cd]) # h,l,c,n, upper,mid,lower, period,mult
|
||||
HAS_APCHANNEL = _bind("qtl_apchannel", [_dp, _dp, _ci, _dp, _dp, _ci]) # h,l,n, upper,lower, period
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.7 Volatility
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_TR = _bind("qtl_tr", _PE) # HLC
|
||||
HAS_BBW = _bind("qtl_bbw", _PA)
|
||||
HAS_BBWN = _bind("qtl_bbwn", [_dp, _ci, _dp, _ci, _cd, _ci]) # period, mult, lookback
|
||||
HAS_BBWP = _bind("qtl_bbwp", [_dp, _ci, _dp, _ci, _cd, _ci]) # period, mult, lookback
|
||||
HAS_STDDEV = _bind("qtl_stddev", _PA)
|
||||
HAS_VARIANCE = _bind("qtl_variance", _PA)
|
||||
HAS_ETHERM = _bind("qtl_etherm", _PD) # HL
|
||||
HAS_CCV = _bind("qtl_ccv", [_dp, _ci, _dp, _ci, _ci]) # shortP, longP
|
||||
HAS_CV = _bind("qtl_cv", [_dp, _ci, _dp, _ci, _cd, _cd]) # period, minVol, maxVol
|
||||
HAS_CVI = _bind("qtl_cvi", [_dp, _ci, _dp, _ci, _ci]) # emaPeriod, rocPeriod
|
||||
HAS_EWMA = _bind("qtl_ewma", [_dp, _ci, _dp, _ci, _ci, _ci]) # period, isPop, annFactor
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.8 Volume
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_OBV = _bind("qtl_obv", _PG) # close,vol,n,dst
|
||||
HAS_PVT = _bind("qtl_pvt", _PG)
|
||||
HAS_PVR = _bind("qtl_pvr", _PG)
|
||||
HAS_VF = _bind("qtl_vf", _PG)
|
||||
HAS_NVI = _bind("qtl_nvi", _PG)
|
||||
HAS_PVI = _bind("qtl_pvi", _PG)
|
||||
HAS_TVI = _bind("qtl_tvi", _PG2) # close,vol,n,dst,period
|
||||
HAS_PVD = _bind("qtl_pvd", _PG2)
|
||||
HAS_VWMA = _bind("qtl_vwma", _PG2)
|
||||
HAS_EVWMA = _bind("qtl_evwma", _PG2)
|
||||
HAS_EFI = _bind("qtl_efi", _PG2)
|
||||
HAS_AOBV = _bind("qtl_aobv", [_dp, _dp, _ci, _dp, _dp]) # close,vol,n,obv,signal
|
||||
HAS_MFI = _bind("qtl_mfi", _PB) # HLCV + period
|
||||
HAS_CMF = _bind("qtl_cmf", _PB)
|
||||
HAS_EOM = _bind("qtl_eom", [_dp, _dp, _dp, _ci, _dp, _ci]) # h,l,v,n,dst,period
|
||||
HAS_PVO = _bind("qtl_pvo", [_dp, _ci, _dp, _dp, _dp, _ci, _ci, _ci]) # vol,n, pvo,signal,hist, fast,slow,signal_p
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.9 Statistics
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_ZSCORE = _bind("qtl_zscore", _PA)
|
||||
HAS_CMA = _bind("qtl_cma", _PA3) # no period
|
||||
HAS_ENTROPY = _bind("qtl_entropy", _PA)
|
||||
HAS_CORRELATION = _bind("qtl_correlation", _PH)
|
||||
HAS_COVARIANCE = _bind("qtl_covariance", [_dp, _dp, _ci, _dp, _ci, _ci]) # x,y,n,dst,period,isSample
|
||||
HAS_COINTEGRATION = _bind("qtl_cointegration", _PH)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.10 Errors
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_MSE = _bind("qtl_mse", _PF)
|
||||
HAS_RMSE = _bind("qtl_rmse", _PF)
|
||||
HAS_MAE = _bind("qtl_mae", _PF)
|
||||
HAS_MAPE = _bind("qtl_mape", _PF)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.11 Filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_BESSEL = _bind("qtl_bessel", _PA)
|
||||
HAS_BUTTER2 = _bind("qtl_butter2", _PA)
|
||||
HAS_BUTTER3 = _bind("qtl_butter3", _PA)
|
||||
HAS_CHEBY1 = _bind("qtl_cheby1", _PA)
|
||||
HAS_CHEBY2 = _bind("qtl_cheby2", _PA)
|
||||
HAS_ELLIPTIC = _bind("qtl_elliptic", _PA)
|
||||
HAS_EDCF = _bind("qtl_edcf", _PA)
|
||||
HAS_BPF = _bind("qtl_bpf", _PA)
|
||||
HAS_ALAGUERRE = _bind("qtl_alaguerre", [_dp, _ci, _dp, _ci, _ci]) # period, order
|
||||
HAS_BILATERAL = _bind("qtl_bilateral", [_dp, _ci, _dp, _ci, _cd, _cd]) # period, sigmaS, sigmaR
|
||||
HAS_BAXTERKING = _bind("qtl_baxterking", [_dp, _ci, _dp, _ci, _ci, _ci]) # period, minP, maxP
|
||||
HAS_CFITZ = _bind("qtl_cfitz", [_dp, _ci, _dp, _ci, _ci]) # period, bandwidthP
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.12 Cycles
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_CG = _bind("qtl_cg", _PA)
|
||||
HAS_DSP = _bind("qtl_dsp", _PA)
|
||||
HAS_CCOR = _bind("qtl_ccor", _PA)
|
||||
HAS_EBSW = _bind("qtl_ebsw", [_dp, _ci, _dp, _ci, _ci]) # period, hpPeriod
|
||||
HAS_EACP = _bind("qtl_eacp", [_dp, _ci, _dp, _ci, _ci, _ci, _ci]) # period, minP, maxP, useMedian
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# §8.14 Numerics
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
HAS_CHANGE = _bind("qtl_change", _PA)
|
||||
HAS_EXPTRANS = _bind("qtl_exptrans", _PA3) # no period
|
||||
HAS_BETADIST = _bind("qtl_betadist", [_dp, _ci, _dp, _ci, _cd, _cd]) # period, alpha, beta
|
||||
HAS_EXPDIST = _bind("qtl_expdist", [_dp, _ci, _dp, _ci, _cd]) # period, lambda
|
||||
HAS_BINOMDIST = _bind("qtl_binomdist", [_dp, _ci, _dp, _ci, _ci, _ci]) # period, trials, successes
|
||||
HAS_CWT = _bind("qtl_cwt", [_dp, _ci, _dp, _cd, _cd]) # scale, omega
|
||||
HAS_DWT = _bind("qtl_dwt", [_dp, _ci, _dp, _ci, _ci]) # period, levels
|
||||
@@ -0,0 +1,72 @@
|
||||
"""pandas-ta compatibility aliases.
|
||||
|
||||
Maps pandas-ta function names to quantalib equivalents where signatures
|
||||
overlap. Import ``from quantalib._compat import ALIASES`` then look up
|
||||
the target function in ``quantalib.indicators``.
|
||||
|
||||
Usage::
|
||||
|
||||
from quantalib._compat import get_compat
|
||||
fn = get_compat("midprice") # returns indicators.medprice
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
# pandas-ta name → quantalib indicators function name
|
||||
ALIASES: dict[str, str] = {
|
||||
# Core
|
||||
"midprice": "medprice",
|
||||
"typical_price": "typprice",
|
||||
"average_price": "avgprice",
|
||||
"mid_body": "midbody",
|
||||
# Momentum
|
||||
"momentum": "mom",
|
||||
# Trends
|
||||
"simple_moving_average": "sma",
|
||||
"weighted_moving_average": "wma",
|
||||
"hull_moving_average": "hma",
|
||||
"triangular_moving_average": "trima",
|
||||
"exponential_moving_average": "ema",
|
||||
"double_exponential_moving_average": "dema",
|
||||
"triple_exponential_moving_average": "tema",
|
||||
"least_squares_moving_average": "lsma",
|
||||
"time_series_forecast": "tsf",
|
||||
"linreg": "lsma",
|
||||
"sinwma": "sinema",
|
||||
# Core (pandas-ta price transforms)
|
||||
"hl2": "medprice",
|
||||
"hlc3": "typprice",
|
||||
"ohlc4": "avgprice",
|
||||
# Volatility
|
||||
"true_range": "tr",
|
||||
"standard_deviation": "stddev",
|
||||
"stdev": "stddev",
|
||||
# Volume
|
||||
"on_balance_volume": "obv",
|
||||
"price_volume_trend": "pvt",
|
||||
"volume_weighted_moving_average": "vwma",
|
||||
"money_flow_index": "mfi",
|
||||
"chaikin_money_flow": "cmf",
|
||||
"ease_of_movement": "eom",
|
||||
# Channels
|
||||
"bollinger_bands": "bbands",
|
||||
"aberration": "aberr",
|
||||
# Statistics
|
||||
"z_score": "zscore",
|
||||
# Filters
|
||||
"butterworth": "butter2",
|
||||
}
|
||||
|
||||
|
||||
def get_compat(name: str) -> Callable[..., object] | None:
|
||||
"""Resolve a pandas-ta alias to the quantalib function, or None."""
|
||||
from . import indicators
|
||||
|
||||
target = ALIASES.get(name)
|
||||
if target is None:
|
||||
return None
|
||||
return getattr(indicators, target, None)
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_PLATFORM_MAP: dict[tuple[str, str], str] = {
|
||||
("Windows", "AMD64"): "win_amd64/quantalib_native.dll",
|
||||
("Linux", "x86_64"): "linux_x86_64/quantalib_native.so",
|
||||
("Darwin", "arm64"): "macosx_arm64/quantalib_native.dylib",
|
||||
("Darwin", "x86_64"): "macosx_x86_64/quantalib_native.dylib",
|
||||
}
|
||||
|
||||
|
||||
def _native_root() -> Path:
|
||||
return Path(__file__).resolve().parent / "native"
|
||||
|
||||
|
||||
def _native_relative_path() -> str:
|
||||
key = (platform.system(), platform.machine())
|
||||
if key not in _PLATFORM_MAP:
|
||||
raise OSError(
|
||||
f"Unsupported platform/architecture: system={key[0]!r}, arch={key[1]!r}"
|
||||
)
|
||||
return _PLATFORM_MAP[key]
|
||||
|
||||
|
||||
def native_library_path() -> Path:
|
||||
return _native_root() / _native_relative_path()
|
||||
|
||||
|
||||
def load_native_library() -> ctypes.CDLL:
|
||||
path = native_library_path()
|
||||
if not path.exists():
|
||||
raise OSError(
|
||||
"quantalib native library not found. "
|
||||
f"Expected: {path} "
|
||||
f"(system={platform.system()}, arch={platform.machine()})"
|
||||
)
|
||||
return ctypes.CDLL(str(path))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
keep
|
||||
@@ -0,0 +1 @@
|
||||
keep
|
||||
@@ -0,0 +1 @@
|
||||
keep
|
||||
@@ -0,0 +1 @@
|
||||
keep
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
typed
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace QuanTAlib.Python;
|
||||
|
||||
internal static class ArrayBridge
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static unsafe bool IsNull(double* ptr) => ptr == null;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static int ValidateLength(int n) =>
|
||||
n > 0 ? StatusCodes.QTL_OK : StatusCodes.QTL_ERR_INVALID_LENGTH;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
namespace QuanTAlib.Python;
|
||||
|
||||
internal static class StatusCodes
|
||||
{
|
||||
public const int QTL_OK = 0;
|
||||
public const int QTL_ERR_NULL_PTR = 1;
|
||||
public const int QTL_ERR_INVALID_LENGTH = 2;
|
||||
public const int QTL_ERR_INVALID_PARAM = 3;
|
||||
public const int QTL_ERR_INTERNAL = 4;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from quantalib import indicators as q
|
||||
|
||||
DOC = Path("docs/validation.md")
|
||||
BRIDGE = Path("python/quantalib/_bridge.py")
|
||||
EXPORTS = [
|
||||
Path("python/src/Exports.cs"),
|
||||
Path("python/src/Exports.Generated.cs"),
|
||||
]
|
||||
REPORT = Path("python/tests/reports/pandas_ta_all_exported_report.md")
|
||||
|
||||
def main() -> int:
|
||||
doc_lines = DOC.read_text(encoding="utf-8").splitlines()
|
||||
bridge_text = BRIDGE.read_text(encoding="utf-8")
|
||||
exports_text = "\n".join(p.read_text(encoding="utf-8", errors="ignore") for p in EXPORTS)
|
||||
report_text = REPORT.read_text(encoding="utf-8")
|
||||
|
||||
wrappers = {
|
||||
n.lower()
|
||||
for n, fn in inspect.getmembers(q, inspect.isfunction)
|
||||
if not n.startswith("_")
|
||||
and n
|
||||
not in {"_arr", "_ptr", "_out", "_offset", "_wrap", "_wrap_multi", "_pa", "_pg", "_pg2", "_pf"}
|
||||
}
|
||||
|
||||
link_rx = re.compile(r"\]\(([^)]+)\)")
|
||||
unresolved = []
|
||||
for line in doc_lines:
|
||||
s = line.strip()
|
||||
if not s.startswith("|"):
|
||||
continue
|
||||
cols = [c.strip() for c in s.split("|")[1:-1]]
|
||||
if len(cols) < 2 or cols[-1] != "❔":
|
||||
continue
|
||||
m = link_rx.search(cols[1])
|
||||
if not m:
|
||||
continue
|
||||
stem = Path(m.group(1)).stem.lower()
|
||||
unresolved.append(stem)
|
||||
|
||||
unresolved = sorted(set(unresolved))
|
||||
|
||||
rows = []
|
||||
for stem in unresolved:
|
||||
qtl_name = f"qtl_{stem}"
|
||||
has_export = qtl_name in exports_text
|
||||
has_bind = qtl_name in bridge_text
|
||||
has_wrapper = stem in wrappers
|
||||
in_report = f"`{stem}`" in report_text
|
||||
rows.append((stem, has_export, has_bind, has_wrapper, in_report))
|
||||
|
||||
no_wrapper = [r for r in rows if not r[3]]
|
||||
wrapper_no_report = [r for r in rows if r[3] and not r[4]]
|
||||
|
||||
print(f"UNRESOLVED_TOTAL={len(rows)}")
|
||||
print(f"NO_WRAPPER={len(no_wrapper)}")
|
||||
print(f"WRAPPER_NOT_IN_REPORT={len(wrapper_no_report)}")
|
||||
print("SAMPLE_NO_WRAPPER=" + ",".join(r[0] for r in no_wrapper[:30]))
|
||||
print("SAMPLE_WRAPPER_NOT_IN_REPORT=" + ",".join(r[0] for r in wrapper_no_report[:30]))
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
# pandas-ta validation sweep across exported Python wrapper indicators
|
||||
|
||||
- Total indicators scanned: **133**
|
||||
- Successful (✔️): **10**
|
||||
- Failing (⚠️): **123**
|
||||
|
||||
| Indicator | Status | Notes |
|
||||
|---|---:|---|
|
||||
| `afirma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `agc` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ahrens` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `alaguerre` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `alma` | ⚠️ | max_diff=1.784e+00, n=100 |
|
||||
| `aobv` | ⚠️ | max_diff=2.947e+03, n=100 |
|
||||
| `apchannel` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `apo` | ✔️ | max_diff=4.263e-14, n=100 |
|
||||
| `asi` | ⚠️ | QtlInternalError: quantalib native call failed (status=4) |
|
||||
| `atrbands` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `avgprice` | ⚠️ | TypeError: ohlc4() missing 1 required positional argument: 'close' |
|
||||
| `baxterking` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bbands` | ⚠️ | max_diff=1.097e+01, n=100 |
|
||||
| `bbb` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bbi` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bbw` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bbwn` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bbwp` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bessel` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `betadist` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bias` | ⚠️ | max_diff=2.498e-02, n=100 |
|
||||
| `bilateral` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `binomdist` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `blma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bpf` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `brar` | ⚠️ | TypeError: brar() missing 1 required positional argument: 'close' |
|
||||
| `butter2` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `butter3` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `bwma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ccor` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ccv` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ccyc` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cfb` | ⚠️ | RuntimeError: unsupported arg lengths in generic sweep |
|
||||
| `cfitz` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cfo` | ✔️ | max_diff=1.350e-09, n=100 |
|
||||
| `cg` | ⚠️ | max_diff=7.695e+00, n=100 |
|
||||
| `change` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cheby1` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cheby2` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cmf` | ⚠️ | max_diff=2.728e-01, n=100 |
|
||||
| `cmo` | ✔️ | max_diff=0.000e+00, n=100 |
|
||||
| `cointegration` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `conv` | ⚠️ | RuntimeError: unsupported arg kernel in generic sweep |
|
||||
| `coral` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `correlation` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `covariance` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `crma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `crsi` | ⚠️ | max_diff=1.111e+01, n=100 |
|
||||
| `cti` | ⚠️ | max_diff=4.631e-01, n=100 |
|
||||
| `cv` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cvi` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `cwt` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `deco` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `decycler` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dem` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dema` | ⚠️ | max_diff=9.200e-01, n=100 |
|
||||
| `dema_alpha` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dosc` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dpo` | ✔️ | max_diff=5.400e-13, n=100 |
|
||||
| `dsma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dsp` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dwma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dwt` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `dymoi` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `eacp` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ebsw` | ⚠️ | max_diff=1.846e+00, n=100 |
|
||||
| `edcf` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `efi` | ⚠️ | max_diff=8.233e+01, n=100 |
|
||||
| `elliptic` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ema` | ⚠️ | max_diff=7.039e-01, n=100 |
|
||||
| `ema_alpha` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `entropy` | ⚠️ | max_diff=3.206e+00, n=100 |
|
||||
| `eom` | ⚠️ | max_diff=4.374e+04, n=100 |
|
||||
| `er` | ⚠️ | max_diff=5.113e-01, n=100 |
|
||||
| `etherm` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `evwma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `ewma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `expdist` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `exptrans` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `fisher` | ⚠️ | max_diff=1.666e+00, n=100 |
|
||||
| `fisher04` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `gdema` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `hanma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `hema` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `hma` | ⚠️ | max_diff=2.519e+00, n=100 |
|
||||
| `inertia` | ⚠️ | max_diff=7.827e+01, n=100 |
|
||||
| `kri` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `lema` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `lsma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `mae` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `mape` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `medprice` | ⚠️ | max_diff=4.236e+00, n=100 |
|
||||
| `mfi` | ✔️ | max_diff=3.091e-13, n=100 |
|
||||
| `midbody` | ⚠️ | AttributeError: module 'pandas_ta' has no attribute 'mid_body' |
|
||||
| `mom` | ⚠️ | TypeError: <module 'pandas_ta.momentum' from 'C:\\Users\\miha\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\pandas_ta\\momentum\\__init__.py'> is not a callable object |
|
||||
| `mse` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `nvi` | ⚠️ | max_diff=9.000e+02, n=100 |
|
||||
| `obv` | ✔️ | max_diff=0.000e+00, n=100 |
|
||||
| `parzen` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `psl` | ⚠️ | max_diff=1.190e+01, n=100 |
|
||||
| `pvd` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `pvi` | ⚠️ | max_diff=2.050e+01, n=100 |
|
||||
| `pvo` | ✔️ | max_diff=3.432e-14, n=100 |
|
||||
| `pvr` | ⚠️ | max_diff=1.000e+00, n=100 |
|
||||
| `pvt` | ⚠️ | max_diff=1.182e+05, n=100 |
|
||||
| `rain` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `reflex` | ⚠️ | max_diff=1.572e+00, n=100 |
|
||||
| `rmse` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `roc` | ⚠️ | max_diff=7.233e+00, n=100 |
|
||||
| `rsi` | ⚠️ | max_diff=1.351e+01, n=100 |
|
||||
| `rsx` | ✔️ | max_diff=1.172e-13, n=100 |
|
||||
| `sgma` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `sinema` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `sma` | ⚠️ | max_diff=1.729e+00, n=100 |
|
||||
| `sp15` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `stddev` | ⚠️ | max_diff=1.874e+00, n=100 |
|
||||
| `swma` | ⚠️ | max_diff=1.583e+00, n=100 |
|
||||
| `tema` | ⚠️ | max_diff=8.867e-01, n=100 |
|
||||
| `tr` | ✔️ | max_diff=0.000e+00, n=100 |
|
||||
| `trendflex` | ⚠️ | max_diff=4.575e-01, n=100 |
|
||||
| `trima` | ⚠️ | max_diff=1.885e+00, n=100 |
|
||||
| `trix` | ✔️ | max_diff=2.734e-14, n=100 |
|
||||
| `tsf` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `tsi` | ⚠️ | max_diff=7.203e-01, n=100 |
|
||||
| `tukey_w` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `tvi` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `typprice` | ⚠️ | max_diff=9.796e-01, n=100 |
|
||||
| `variance` | ⚠️ | max_diff=7.699e+00, n=100 |
|
||||
| `vf` | ⚠️ | RuntimeError: no pandas-ta mapping |
|
||||
| `vwma` | ⚠️ | max_diff=1.750e+00, n=100 |
|
||||
| `wma` | ⚠️ | max_diff=9.918e-01, n=100 |
|
||||
| `zscore` | ⚠️ | max_diff=1.077e+00, n=100 |
|
||||
@@ -0,0 +1,55 @@
|
||||
# pandas-ta parity report — Batch 01 (10 indicators)
|
||||
|
||||
Date: 2026-02-28
|
||||
Test file: `python/tests/test_pandas_ta_parity_batch_01.py`
|
||||
Command: `python -m pytest python/tests/test_pandas_ta_parity_batch_01.py -q`
|
||||
|
||||
## Summary
|
||||
|
||||
- Total tests: **10**
|
||||
- Passed: **6**
|
||||
- Failed: **4**
|
||||
- Duration: **0.47s**
|
||||
|
||||
## Indicators in Batch 01
|
||||
|
||||
1. `rsi_14` ✅
|
||||
2. `mom_10` ✅
|
||||
3. `cmo_14` ❌
|
||||
4. `apo_12_26` ❌
|
||||
5. `bias_26` ✅
|
||||
6. `cfo_14` ❌
|
||||
7. `dpo_20` ✅
|
||||
8. `trix_18` ❌
|
||||
9. `er_10` ✅
|
||||
10. `cti_12` ✅
|
||||
|
||||
## Failure details
|
||||
|
||||
### 1) `cmo_14`
|
||||
- Error: numeric mismatch in tail window
|
||||
- Max diff: `4.017e+01`
|
||||
- Tolerance: `1e-6`
|
||||
|
||||
### 2) `apo_12_26`
|
||||
- Error: numeric mismatch in tail window
|
||||
- Max diff: `1.633e+00`
|
||||
- Tolerance: `1e-6`
|
||||
|
||||
### 3) `cfo_14`
|
||||
- Error: numeric mismatch in tail window
|
||||
- Max diff: `8.166e-01`
|
||||
- Tolerance: `1e-6`
|
||||
|
||||
### 4) `trix_18`
|
||||
- Error: shape mismatch during comparison
|
||||
- `quantalib`: shape `(10000,)`
|
||||
- `pandas-ta`: shape `(10000, 2)` (DataFrame with TRIX + signal)
|
||||
- Exception: broadcast error in finite-mask step
|
||||
|
||||
## Notes
|
||||
|
||||
- Batch-01 tests were created and executed as requested.
|
||||
- Current failures are due to:
|
||||
- Known algorithmic differences (`cmo`, `apo`, `cfo`) and/or parameter semantics mismatch.
|
||||
- Output-shape mismatch for `trix` (single-series vs multi-column DataFrame).
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
|
||||
from quantalib import indicators as q
|
||||
|
||||
SEED = 42
|
||||
N = 10_000
|
||||
VERIFY_COUNT = 100
|
||||
DEFAULT_TOL = 1e-6
|
||||
|
||||
REPORT_PATH = Path("python/tests/reports/pandas_ta_all_exported_report.md")
|
||||
|
||||
|
||||
def generate_gbm(
|
||||
n: int,
|
||||
seed: int = SEED,
|
||||
start_price: float = 100.0,
|
||||
mu: float = 0.05,
|
||||
sigma: float = 0.2,
|
||||
dt: float = 1 / 252,
|
||||
) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
z = rng.standard_normal(n - 1)
|
||||
drift = (mu - 0.5 * sigma**2) * dt
|
||||
diffusion = sigma * np.sqrt(dt) * z
|
||||
log_returns = drift + diffusion
|
||||
|
||||
prices = np.empty(n, dtype=np.float64)
|
||||
prices[0] = start_price
|
||||
np.cumsum(log_returns, out=prices[1:])
|
||||
prices[1:] += np.log(start_price)
|
||||
np.exp(prices[1:], out=prices[1:])
|
||||
prices[0] = start_price
|
||||
return prices
|
||||
|
||||
|
||||
CLOSE = generate_gbm(N)
|
||||
OPEN = np.roll(CLOSE, 1)
|
||||
OPEN[0] = CLOSE[0]
|
||||
HIGH = np.maximum(OPEN, CLOSE) + 0.1
|
||||
LOW = np.minimum(OPEN, CLOSE) - 0.1
|
||||
VOLUME = np.linspace(1_000.0, 2_000.0, N)
|
||||
|
||||
S_CLOSE = pd.Series(CLOSE, name="close")
|
||||
S_OPEN = pd.Series(OPEN, name="open")
|
||||
S_HIGH = pd.Series(HIGH, name="high")
|
||||
S_LOW = pd.Series(LOW, name="low")
|
||||
S_VOLUME = pd.Series(VOLUME, name="volume")
|
||||
|
||||
|
||||
SPECIAL_PTA: dict[str, Callable[[], np.ndarray]] = {
|
||||
"cmo": lambda: ta.cmo(S_CLOSE, length=14, talib=False).to_numpy(),
|
||||
"apo": lambda: ta.apo(S_CLOSE, fast=12, slow=26, mamode="ema", talib=False).to_numpy(),
|
||||
"cfo": lambda: (100.0 * (S_CLOSE - ta.linreg(S_CLOSE, length=14, tsf=False, talib=False)) / S_CLOSE).to_numpy(),
|
||||
"trix": lambda: ta.trix(S_CLOSE, length=18).iloc[:, 0].to_numpy(),
|
||||
"dpo": lambda: ta.dpo(S_CLOSE, length=20, centered=False).to_numpy(),
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
"medprice": "midprice",
|
||||
"typprice": "hlc3",
|
||||
"avgprice": "ohlc4",
|
||||
"midbody": "mid_body",
|
||||
"mom": "momentum",
|
||||
"bbands": "bbands",
|
||||
"stddev": "stdev",
|
||||
"zscore": "zscore",
|
||||
"tr": "true_range",
|
||||
"ema_alpha": None,
|
||||
"dema_alpha": None,
|
||||
}
|
||||
|
||||
SKIP_PRIVATE = {
|
||||
"_arr",
|
||||
"_ptr",
|
||||
"_out",
|
||||
"_offset",
|
||||
"_wrap",
|
||||
"_wrap_multi",
|
||||
"_pa",
|
||||
"_pg",
|
||||
"_pg2",
|
||||
"_pf",
|
||||
}
|
||||
|
||||
|
||||
def normalize_pta_output(v: Any) -> np.ndarray:
|
||||
if isinstance(v, pd.Series):
|
||||
return v.to_numpy()
|
||||
if isinstance(v, pd.DataFrame):
|
||||
# default: first numeric column
|
||||
return v.iloc[:, 0].to_numpy()
|
||||
if isinstance(v, tuple):
|
||||
if len(v) == 0:
|
||||
return np.array([], dtype=np.float64)
|
||||
return np.asarray(v[0], dtype=np.float64)
|
||||
return np.asarray(v, dtype=np.float64)
|
||||
|
||||
|
||||
def get_q_functions() -> dict[str, Callable[..., Any]]:
|
||||
out: dict[str, Callable[..., Any]] = {}
|
||||
for name, fn in inspect.getmembers(q, inspect.isfunction):
|
||||
if name.startswith("_") or name in SKIP_PRIVATE:
|
||||
continue
|
||||
out[name] = fn
|
||||
return out
|
||||
|
||||
|
||||
def choose_pta_name(q_name: str) -> str | None:
|
||||
if q_name in ALIASES:
|
||||
return ALIASES[q_name]
|
||||
if hasattr(ta, q_name):
|
||||
return q_name
|
||||
return None
|
||||
|
||||
|
||||
def call_q(name: str, fn: Callable[..., Any]) -> np.ndarray:
|
||||
# conservative defaults based on function signature
|
||||
sig = inspect.signature(fn)
|
||||
params = [
|
||||
n
|
||||
for n, p in sig.parameters.items()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
|
||||
# shared defaults
|
||||
if "length" in params:
|
||||
kwargs["length"] = sig.parameters["length"].default if sig.parameters["length"].default is not inspect._empty else 14
|
||||
if "fast" in params:
|
||||
kwargs["fast"] = 12
|
||||
if "slow" in params:
|
||||
kwargs["slow"] = 26
|
||||
if "signal" in params:
|
||||
kwargs["signal"] = 9
|
||||
if "offset" in params:
|
||||
kwargs["offset"] = 0
|
||||
|
||||
# positional construction by semantic names
|
||||
args: list[Any] = []
|
||||
for p in params:
|
||||
if p in kwargs:
|
||||
continue
|
||||
if p == "close":
|
||||
args.append(CLOSE)
|
||||
elif p == "open":
|
||||
args.append(OPEN)
|
||||
elif p == "high":
|
||||
args.append(HIGH)
|
||||
elif p == "low":
|
||||
args.append(LOW)
|
||||
elif p == "volume":
|
||||
args.append(VOLUME)
|
||||
elif p == "x":
|
||||
args.append(CLOSE)
|
||||
elif p == "y":
|
||||
args.append(np.roll(CLOSE, 3))
|
||||
elif p == "actual":
|
||||
args.append(CLOSE)
|
||||
elif p == "predicted":
|
||||
args.append(np.roll(CLOSE, 1))
|
||||
elif p in {"kernel", "lengths"}:
|
||||
# unsupported generics in all-indicator sweep
|
||||
raise RuntimeError(f"unsupported arg {p} in generic sweep")
|
||||
else:
|
||||
# keep default when available
|
||||
param = sig.parameters[p]
|
||||
if param.default is inspect._empty:
|
||||
raise RuntimeError(f"required arg {p} not mapped")
|
||||
out = fn(*args, **kwargs)
|
||||
return normalize_pta_output(out)
|
||||
|
||||
|
||||
def call_pta(q_name: str) -> np.ndarray:
|
||||
if q_name in SPECIAL_PTA:
|
||||
return SPECIAL_PTA[q_name]()
|
||||
|
||||
pta_name = choose_pta_name(q_name)
|
||||
if not pta_name:
|
||||
raise RuntimeError("no pandas-ta mapping")
|
||||
pta_fn = getattr(ta, pta_name)
|
||||
|
||||
sig = inspect.signature(pta_fn)
|
||||
params = [
|
||||
n
|
||||
for n, p in sig.parameters.items()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
kwargs: dict[str, Any] = {}
|
||||
|
||||
if "length" in params:
|
||||
kwargs["length"] = 14
|
||||
if "fast" in params:
|
||||
kwargs["fast"] = 12
|
||||
if "slow" in params:
|
||||
kwargs["slow"] = 26
|
||||
if "signal" in params:
|
||||
kwargs["signal"] = 9
|
||||
if "offset" in params:
|
||||
kwargs["offset"] = 0
|
||||
|
||||
args: list[Any] = []
|
||||
for p in params:
|
||||
if p in kwargs:
|
||||
continue
|
||||
if p == "close":
|
||||
args.append(S_CLOSE)
|
||||
elif p == "open":
|
||||
args.append(S_OPEN)
|
||||
elif p == "high":
|
||||
args.append(S_HIGH)
|
||||
elif p == "low":
|
||||
args.append(S_LOW)
|
||||
elif p == "volume":
|
||||
args.append(S_VOLUME)
|
||||
elif p in {"x", "seriesX"}:
|
||||
args.append(S_CLOSE)
|
||||
elif p in {"y", "seriesY"}:
|
||||
args.append(pd.Series(np.roll(CLOSE, 3)))
|
||||
elif p == "mamode":
|
||||
kwargs["mamode"] = "ema"
|
||||
elif p == "talib":
|
||||
kwargs["talib"] = False
|
||||
elif p == "centered":
|
||||
kwargs["centered"] = False
|
||||
elif p == "drift":
|
||||
kwargs["drift"] = 1
|
||||
elif p == "scalar":
|
||||
kwargs["scalar"] = 100
|
||||
else:
|
||||
# leave defaults for unknown optional args
|
||||
pass
|
||||
|
||||
out = pta_fn(*args, **kwargs)
|
||||
return normalize_pta_output(out)
|
||||
|
||||
|
||||
def verify_last_n(qtl_arr: np.ndarray, pta_arr: np.ndarray, tol: float = DEFAULT_TOL) -> tuple[bool, float, int]:
|
||||
if len(qtl_arr) != len(pta_arr):
|
||||
return False, float("inf"), 0
|
||||
start = max(0, len(qtl_arr) - VERIFY_COUNT)
|
||||
q_tail = qtl_arr[start:]
|
||||
p_tail = pta_arr[start:]
|
||||
finite = np.isfinite(q_tail) & np.isfinite(p_tail)
|
||||
n = int(np.sum(finite))
|
||||
if n == 0:
|
||||
return False, float("inf"), 0
|
||||
d = np.abs(q_tail[finite] - p_tail[finite])
|
||||
md = float(np.max(d))
|
||||
return md <= tol, md, n
|
||||
|
||||
|
||||
def main() -> int:
|
||||
funcs = get_q_functions()
|
||||
names = sorted(funcs.keys())
|
||||
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
for name in names:
|
||||
fn = funcs[name]
|
||||
try:
|
||||
qv = call_q(name, fn)
|
||||
pv = call_pta(name)
|
||||
passed, max_diff, n = verify_last_n(qv, pv, DEFAULT_TOL)
|
||||
if passed:
|
||||
rows.append((name, "✔️", f"max_diff={max_diff:.3e}, n={n}"))
|
||||
ok += 1
|
||||
else:
|
||||
rows.append((name, "⚠️", f"max_diff={max_diff:.3e}, n={n}"))
|
||||
fail += 1
|
||||
except Exception as ex: # noqa: BLE001
|
||||
rows.append((name, "⚠️", f"{type(ex).__name__}: {ex}"))
|
||||
fail += 1
|
||||
|
||||
lines = [
|
||||
"# pandas-ta validation sweep across exported Python wrapper indicators",
|
||||
"",
|
||||
f"- Total indicators scanned: **{len(rows)}**",
|
||||
f"- Successful (✔️): **{ok}**",
|
||||
f"- Failing (⚠️): **{fail}**",
|
||||
"",
|
||||
"| Indicator | Status | Notes |",
|
||||
"|---|---:|---|",
|
||||
]
|
||||
lines.extend([f"| `{n}` | {s} | {note} |" for n, s, note in rows])
|
||||
|
||||
REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
print(f"Wrote {REPORT_PATH}")
|
||||
print(f"TOTAL={len(rows)} OK={ok} FAIL={fail}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,116 @@
|
||||
"""test_compat.py — pandas-ta compatibility tests.
|
||||
|
||||
Verifies that:
|
||||
1. ALIASES map resolves to real functions
|
||||
2. pd.Series input → pd.Series output with correct name
|
||||
3. pd.DataFrame input → works for single-column
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
RNG = np.random.default_rng(99)
|
||||
N = 50
|
||||
CLOSE = RNG.standard_normal(N).cumsum() + 100.0
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qtl():
|
||||
try:
|
||||
import quantalib as _qtl
|
||||
return _qtl
|
||||
except (OSError, ImportError) as e:
|
||||
pytest.skip(f"quantalib native lib not available: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pd():
|
||||
try:
|
||||
import pandas as _pd
|
||||
return _pd
|
||||
except ImportError:
|
||||
pytest.skip("pandas not installed")
|
||||
|
||||
|
||||
class TestAliases:
|
||||
"""Verify ALIASES map entries resolve to real functions."""
|
||||
|
||||
def test_all_aliases_resolve(self, qtl) -> None:
|
||||
from quantalib._compat import ALIASES
|
||||
for alias, target in ALIASES.items():
|
||||
fn = getattr(qtl.indicators, target, None)
|
||||
assert fn is not None, f"Alias '{alias}' → '{target}' not found"
|
||||
|
||||
def test_get_compat_returns_callable(self, qtl) -> None:
|
||||
from quantalib._compat import get_compat
|
||||
fn = get_compat("midprice")
|
||||
assert callable(fn)
|
||||
|
||||
def test_get_compat_unknown_returns_none(self, qtl) -> None:
|
||||
from quantalib._compat import get_compat
|
||||
assert get_compat("nonexistent_indicator") is None
|
||||
|
||||
|
||||
class TestPandasSeriesIO:
|
||||
"""Verify pd.Series input → pd.Series output."""
|
||||
|
||||
def test_sma_series_output(self, qtl, pd) -> None:
|
||||
idx = pd.date_range("2020-01-01", periods=N, freq="D")
|
||||
s = pd.Series(CLOSE, index=idx, name="Close")
|
||||
result = qtl.sma(s, length=10)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert result.name == "SMA_10"
|
||||
assert len(result) == N
|
||||
assert (result.index == idx).all()
|
||||
|
||||
def test_ema_series_category(self, qtl, pd) -> None:
|
||||
s = pd.Series(CLOSE)
|
||||
result = qtl.ema(s, length=14)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert result.name == "EMA_14"
|
||||
assert hasattr(result, "category")
|
||||
assert result.category == "trend"
|
||||
|
||||
def test_rsi_series(self, qtl, pd) -> None:
|
||||
s = pd.Series(CLOSE)
|
||||
result = qtl.rsi(s, length=14)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert result.name == "RSI_14"
|
||||
|
||||
|
||||
class TestPandasDataFrameIO:
|
||||
"""Verify pd.DataFrame input uses first column."""
|
||||
|
||||
def test_sma_dataframe_input(self, qtl, pd) -> None:
|
||||
df = pd.DataFrame({"Close": CLOSE, "Volume": np.ones(N)})
|
||||
result = qtl.sma(df, length=10)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
class TestMultiOutputPandas:
|
||||
"""Verify multi-output returns DataFrame when given Series."""
|
||||
|
||||
def test_bbands_dataframe_output(self, qtl, pd) -> None:
|
||||
s = pd.Series(CLOSE)
|
||||
result = qtl.bbands(s, length=20, std=2.0)
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert result.shape == (N, 3)
|
||||
cols = list(result.columns)
|
||||
assert "BBU_20_2.0" in cols
|
||||
assert "BBM_20_2.0" in cols
|
||||
assert "BBL_20_2.0" in cols
|
||||
|
||||
|
||||
class TestOffset:
|
||||
"""Verify offset parameter works."""
|
||||
|
||||
def test_sma_offset(self, qtl, pd) -> None:
|
||||
s = pd.Series(CLOSE)
|
||||
result = qtl.sma(s, length=10, offset=3)
|
||||
assert isinstance(result, pd.Series)
|
||||
# First 3 values should be NaN (from offset)
|
||||
assert np.isnan(result.iloc[0])
|
||||
assert np.isnan(result.iloc[1])
|
||||
assert np.isnan(result.iloc[2])
|
||||
@@ -0,0 +1,112 @@
|
||||
"""test_golden.py — Compare quantalib outputs vs known golden values.
|
||||
|
||||
Golden values are computed once from the managed QuanTAlib C# library.
|
||||
This ensures the NativeAOT path produces identical results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Deterministic test data
|
||||
RNG = np.random.default_rng(12345)
|
||||
N = 100
|
||||
CLOSE = RNG.standard_normal(N).cumsum() + 100.0
|
||||
HIGH = CLOSE + RNG.uniform(0.5, 2.0, N)
|
||||
LOW = CLOSE - RNG.uniform(0.5, 2.0, N)
|
||||
VOLUME = RNG.uniform(1e6, 5e6, N)
|
||||
TOL = 1e-10 # Tolerance for floating-point comparison
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qtl():
|
||||
try:
|
||||
import quantalib as _qtl
|
||||
return _qtl
|
||||
except (OSError, ImportError) as e:
|
||||
pytest.skip(f"quantalib native lib not available: {e}")
|
||||
|
||||
|
||||
class TestSmaGolden:
|
||||
"""SMA golden value checks."""
|
||||
|
||||
def test_sma_last_value(self, qtl) -> None:
|
||||
"""SMA(10) of uniform data should equal mean of last 10."""
|
||||
data = np.arange(1.0, 21.0) # 1..20
|
||||
result = qtl.sma(data, length=10)
|
||||
# SMA at index 19 = mean(11..20) = 15.5
|
||||
assert abs(result[19] - 15.5) < TOL
|
||||
# SMA at index 9 = mean(1..10) = 5.5
|
||||
assert abs(result[9] - 5.5) < TOL
|
||||
|
||||
|
||||
class TestEmaGolden:
|
||||
"""EMA golden value checks."""
|
||||
|
||||
def test_ema_converges(self, qtl) -> None:
|
||||
"""EMA of constant should converge to that constant."""
|
||||
data = np.full(50, 42.0)
|
||||
result = qtl.ema(data, length=10)
|
||||
# After warmup, should be very close to 42
|
||||
assert abs(result[-1] - 42.0) < 1e-6
|
||||
|
||||
|
||||
class TestMedpriceGolden:
|
||||
"""Medprice golden value check."""
|
||||
|
||||
def test_medprice_simple(self, qtl) -> None:
|
||||
h = np.array([10.0, 20.0, 30.0])
|
||||
l = np.array([2.0, 4.0, 6.0])
|
||||
result = qtl.medprice(h, l)
|
||||
np.testing.assert_allclose(result, [6.0, 12.0, 18.0], atol=TOL)
|
||||
|
||||
|
||||
class TestRsiGolden:
|
||||
"""RSI golden value checks."""
|
||||
|
||||
def test_rsi_range(self, qtl) -> None:
|
||||
"""RSI should stay in [0, 100] range."""
|
||||
result = qtl.rsi(CLOSE, length=14)
|
||||
finite = result[np.isfinite(result)]
|
||||
assert np.all(finite >= 0.0)
|
||||
assert np.all(finite <= 100.0)
|
||||
|
||||
|
||||
class TestBbandsGolden:
|
||||
"""Bollinger Bands golden value checks."""
|
||||
|
||||
def test_bbands_ordering(self, qtl) -> None:
|
||||
"""Upper >= Mid >= Lower for all non-NaN."""
|
||||
result = qtl.bbands(CLOSE, length=20, std=2.0)
|
||||
upper, mid, lower = result
|
||||
mask = np.isfinite(upper) & np.isfinite(mid) & np.isfinite(lower)
|
||||
assert np.all(upper[mask] >= mid[mask] - TOL)
|
||||
assert np.all(mid[mask] >= lower[mask] - TOL)
|
||||
|
||||
|
||||
class TestObvGolden:
|
||||
"""OBV golden value checks."""
|
||||
|
||||
def test_obv_first_is_volume(self, qtl) -> None:
|
||||
"""OBV[0] should be related to the first volume bar."""
|
||||
c = np.array([10.0, 11.0, 10.5, 12.0, 11.5])
|
||||
v = np.array([100.0, 200.0, 150.0, 300.0, 250.0])
|
||||
result = qtl.obv(c, v)
|
||||
assert len(result) == 5
|
||||
# OBV is cumulative; exact values depend on implementation
|
||||
assert np.isfinite(result[-1])
|
||||
|
||||
|
||||
class TestTrGolden:
|
||||
"""True Range golden value check."""
|
||||
|
||||
def test_tr_simple(self, qtl) -> None:
|
||||
"""TR = max(H-L, |H-Cprev|, |L-Cprev|)."""
|
||||
h = np.array([12.0, 15.0, 13.0])
|
||||
l = np.array([8.0, 10.0, 9.0])
|
||||
c = np.array([10.0, 14.0, 11.0])
|
||||
result = qtl.tr(h, l, c)
|
||||
assert len(result) == 3
|
||||
# TR[0] = H-L = 4 (no previous close)
|
||||
# Exact values depend on implementation details
|
||||
assert np.isfinite(result[-1])
|
||||
@@ -0,0 +1,469 @@
|
||||
"""test_pandas_ta_parity.py — Validate quantalib vs pandas-ta using the same
|
||||
methodology as our C# ValidationHelper:
|
||||
|
||||
1. Generate a LONG GBM series (5000 bars, seeded) so recursive indicators converge.
|
||||
2. Compare only the LAST 100 values (DefaultVerificationCount = 100).
|
||||
3. Skip lookback/warmup bars before comparison window.
|
||||
4. Tolerance: 1e-7 default, looser for known algorithmic differences (SPEC §9.3).
|
||||
|
||||
This mirrors lib/feeds/gbm/ValidationHelper.cs exactly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
import pytest
|
||||
|
||||
from quantalib.indicators import (
|
||||
sma, ema, dema, tema, wma, hma, trima, alma, rsi, roc, mom,
|
||||
stddev, variance, zscore, bbands,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants — match C# ValidationHelper
|
||||
# ---------------------------------------------------------------------------
|
||||
SEED = 42
|
||||
N = 10000 # long series for convergence (C# uses 500-5000)
|
||||
VERIFY_COUNT = 100 # DefaultVerificationCount in C#
|
||||
DEFAULT_TOL = 1e-9 # ValidationHelper.DefaultTolerance
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GBM data generation — match C# GBM feed (Geometric Brownian Motion)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _generate_gbm(n: int, seed: int = SEED, start_price: float = 100.0,
|
||||
mu: float = 0.05, sigma: float = 0.2,
|
||||
dt: float = 1 / 252) -> np.ndarray:
|
||||
"""Generate GBM close prices matching C# GBM feed logic.
|
||||
|
||||
S(t+1) = S(t) * exp((mu - sigma^2/2)*dt + sigma*sqrt(dt)*Z)
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
z = rng.standard_normal(n - 1)
|
||||
drift = (mu - 0.5 * sigma ** 2) * dt
|
||||
diffusion = sigma * np.sqrt(dt) * z
|
||||
log_returns = drift + diffusion
|
||||
prices = np.empty(n, dtype=np.float64)
|
||||
prices[0] = start_price
|
||||
np.cumsum(log_returns, out=prices[1:])
|
||||
prices[1:] += np.log(start_price)
|
||||
np.exp(prices[1:], out=prices[1:])
|
||||
prices[0] = start_price
|
||||
return prices
|
||||
|
||||
|
||||
# Module-level test data (generated once, reused across all tests)
|
||||
CLOSE = _generate_gbm(N)
|
||||
SERIES = pd.Series(CLOSE, name="close")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparison helper — mirrors ValidationHelper.VerifyData logic
|
||||
# ---------------------------------------------------------------------------
|
||||
def _verify_last_n(
|
||||
qtl_arr: np.ndarray,
|
||||
pta_result: pd.Series | np.ndarray,
|
||||
*,
|
||||
verify_count: int = VERIFY_COUNT,
|
||||
tolerance: float = DEFAULT_TOL,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Compare only the last `verify_count` values where both are finite.
|
||||
|
||||
This matches the C# pattern:
|
||||
int start = Math.Max(0, count - skip);
|
||||
for (int i = start; i < count; i++) { ... compare ... }
|
||||
"""
|
||||
pta = pta_result.to_numpy() if isinstance(pta_result, pd.Series) else pta_result
|
||||
assert len(qtl_arr) == len(pta), (
|
||||
f"{label}: length mismatch qtl={len(qtl_arr)} vs pta={len(pta)}"
|
||||
)
|
||||
|
||||
count = len(qtl_arr)
|
||||
start = max(0, count - verify_count)
|
||||
|
||||
q_tail = qtl_arr[start:]
|
||||
p_tail = pta[start:]
|
||||
|
||||
# Both must be finite in the tail (converged region)
|
||||
finite = np.isfinite(q_tail) & np.isfinite(p_tail)
|
||||
assert np.sum(finite) > 0, f"{label}: no finite values in last {verify_count}"
|
||||
|
||||
q_vals = q_tail[finite]
|
||||
p_vals = p_tail[finite]
|
||||
|
||||
max_diff = float(np.max(np.abs(q_vals - p_vals)))
|
||||
assert max_diff <= tolerance, (
|
||||
f"{label}: max_diff={max_diff:.2e} exceeds tolerance={tolerance:.0e} "
|
||||
f"(compared {len(q_vals)} values in last {verify_count})"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FIR Trend indicators — exact match expected
|
||||
# ===========================================================================
|
||||
|
||||
class TestTrendFIR:
|
||||
"""FIR indicators: SMA, WMA, HMA — deterministic convolution, tight tolerance."""
|
||||
|
||||
def test_sma(self) -> None:
|
||||
for length in (10, 20, 50):
|
||||
qtl = sma(CLOSE, length=length)
|
||||
pta = ta.sma(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-9,
|
||||
label=f"SMA({length})")
|
||||
|
||||
def test_wma(self) -> None:
|
||||
for length in (10, 14, 30):
|
||||
qtl = wma(CLOSE, length=length)
|
||||
pta = ta.wma(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-9,
|
||||
label=f"WMA({length})")
|
||||
|
||||
def test_hma(self) -> None:
|
||||
for length in (9, 14, 20):
|
||||
qtl = hma(CLOSE, length=length)
|
||||
pta = ta.hma(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-8,
|
||||
label=f"HMA({length})")
|
||||
|
||||
@pytest.mark.xfail(reason="TRIMA kernel differs: quantalib uses symmetric "
|
||||
"triangular convolution, pandas-ta delegates to TA-Lib "
|
||||
"which uses cascaded SMA (SPEC §9.3 known delta)")
|
||||
def test_trima(self) -> None:
|
||||
qtl = trima(CLOSE, length=14)
|
||||
pta = ta.trima(SERIES, length=14)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-9, label="TRIMA(14)")
|
||||
|
||||
@pytest.mark.xfail(reason="ALMA sigma/offset defaults differ between "
|
||||
"quantalib and pandas-ta (SPEC §9.3 known delta)")
|
||||
def test_alma(self) -> None:
|
||||
qtl = alma(CLOSE, length=14)
|
||||
pta = ta.alma(SERIES, length=14)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-6, label="ALMA(14)")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# IIR Trend indicators — recursive, compare converged tail only
|
||||
# ===========================================================================
|
||||
|
||||
class TestTrendIIR:
|
||||
"""IIR indicators: EMA, DEMA, TEMA — recursive convergence.
|
||||
|
||||
With 5000 bars the warmup difference is buried in the past.
|
||||
The last 100 bars should match tightly.
|
||||
"""
|
||||
|
||||
def test_ema(self) -> None:
|
||||
for length in (10, 20, 50):
|
||||
qtl = ema(CLOSE, length=length)
|
||||
pta = ta.ema(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-7,
|
||||
label=f"EMA({length})")
|
||||
|
||||
def test_dema(self) -> None:
|
||||
for length in (10, 20, 50):
|
||||
qtl = dema(CLOSE, length=length)
|
||||
pta = ta.dema(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-7,
|
||||
label=f"DEMA({length})")
|
||||
|
||||
def test_tema(self) -> None:
|
||||
for length in (10, 14, 30):
|
||||
qtl = tema(CLOSE, length=length)
|
||||
pta = ta.tema(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-7,
|
||||
label=f"TEMA({length})")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Momentum indicators
|
||||
# ===========================================================================
|
||||
|
||||
class TestMomentum:
|
||||
"""Momentum: RSI (recursive), ROC, MOM."""
|
||||
|
||||
def test_rsi(self) -> None:
|
||||
"""RSI is recursive (Wilder smoothing). With 5000 bars, warmup
|
||||
convergence difference is negligible in the last 100."""
|
||||
for length in (7, 14, 21):
|
||||
qtl = rsi(CLOSE, length=length)
|
||||
pta = ta.rsi(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-6,
|
||||
label=f"RSI({length})")
|
||||
|
||||
@pytest.mark.xfail(reason="ROC formula differs: quantalib uses absolute "
|
||||
"difference (close-prev), pandas-ta uses "
|
||||
"percentage ((close/prev - 1)*100). "
|
||||
"Known delta per SPEC §9.3.")
|
||||
def test_roc(self) -> None:
|
||||
"""ROC: quantalib 'Roc' is Rate of Change (Absolute) = close - close[n].
|
||||
pandas-ta 'roc' is Rate of Change (Percentage) = ((c/c[n])-1)*100.
|
||||
These are fundamentally different indicators."""
|
||||
qtl = roc(CLOSE, length=10)
|
||||
pta = ta.roc(SERIES, length=10)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-7, label="ROC(10)")
|
||||
|
||||
def test_mom(self) -> None:
|
||||
for length in (5, 10, 20):
|
||||
qtl = mom(CLOSE, length=length)
|
||||
pta = ta.mom(SERIES, length=length)
|
||||
_verify_last_n(qtl, pta, tolerance=1e-9,
|
||||
label=f"MOM({length})")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Statistics
|
||||
# ===========================================================================
|
||||
|
||||
class TestStatistics:
|
||||
"""STDDEV, VARIANCE, ZSCORE.
|
||||
|
||||
Note: pandas-ta uses sample stddev (ddof=1), quantalib may use population.
|
||||
With 5000 bars and period=20, the difference is ~5% for ddof effect.
|
||||
We use relative tolerance where needed.
|
||||
"""
|
||||
|
||||
def test_stddev(self) -> None:
|
||||
for length in (10, 20, 50):
|
||||
qtl = stddev(CLOSE, length=length)
|
||||
pta = ta.stdev(SERIES, length=length)
|
||||
# pandas-ta uses ddof=1 (sample), quantalib may use ddof=0 (population)
|
||||
# With long lookback, ratio = sqrt((n-1)/n) ≈ 1 - 1/(2n)
|
||||
# For n=20: ratio ≈ 0.975, so try both
|
||||
pta_np = pta.to_numpy()
|
||||
count = len(qtl)
|
||||
start = max(0, count - VERIFY_COUNT)
|
||||
q_tail = qtl[start:]
|
||||
p_tail = pta_np[start:]
|
||||
finite = np.isfinite(q_tail) & np.isfinite(p_tail)
|
||||
|
||||
if np.sum(finite) == 0:
|
||||
pytest.fail(f"STDDEV({length}): no finite in tail")
|
||||
|
||||
q_f = q_tail[finite]
|
||||
p_f = p_tail[finite]
|
||||
|
||||
# Try direct
|
||||
max_diff = float(np.max(np.abs(q_f - p_f)))
|
||||
if max_diff <= 1e-8:
|
||||
return
|
||||
|
||||
# Try population-to-sample adjustment
|
||||
# sample_std = pop_std * sqrt(n/(n-1))
|
||||
adjusted = q_f * np.sqrt(length / (length - 1))
|
||||
adj_diff = float(np.max(np.abs(adjusted - p_f)))
|
||||
if adj_diff <= 1e-8:
|
||||
return
|
||||
|
||||
# Try inverse adjustment
|
||||
adjusted_inv = q_f * np.sqrt((length - 1) / length)
|
||||
inv_diff = float(np.max(np.abs(adjusted_inv - p_f)))
|
||||
if inv_diff <= 1e-8:
|
||||
return
|
||||
|
||||
pytest.fail(
|
||||
f"STDDEV({length}): direct={max_diff:.2e}, "
|
||||
f"pop→sample={adj_diff:.2e}, sample→pop={inv_diff:.2e}"
|
||||
)
|
||||
|
||||
def test_variance(self) -> None:
|
||||
for length in (10, 20, 50):
|
||||
qtl = variance(CLOSE, length=length)
|
||||
pta = ta.variance(SERIES, length=length)
|
||||
pta_np = pta.to_numpy()
|
||||
count = len(qtl)
|
||||
start = max(0, count - VERIFY_COUNT)
|
||||
q_tail = qtl[start:]
|
||||
p_tail = pta_np[start:]
|
||||
finite = np.isfinite(q_tail) & np.isfinite(p_tail)
|
||||
|
||||
if np.sum(finite) == 0:
|
||||
pytest.fail(f"VARIANCE({length}): no finite in tail")
|
||||
|
||||
q_f = q_tail[finite]
|
||||
p_f = p_tail[finite]
|
||||
|
||||
# Try direct
|
||||
max_diff = float(np.max(np.abs(q_f - p_f)))
|
||||
if max_diff <= 1e-8:
|
||||
return
|
||||
|
||||
# Try ddof adjustment: var_sample = var_pop * n/(n-1)
|
||||
adjusted = q_f * (length / (length - 1))
|
||||
adj_diff = float(np.max(np.abs(adjusted - p_f)))
|
||||
if adj_diff <= 1e-8:
|
||||
return
|
||||
|
||||
adjusted_inv = q_f * ((length - 1) / length)
|
||||
inv_diff = float(np.max(np.abs(adjusted_inv - p_f)))
|
||||
if inv_diff <= 1e-8:
|
||||
return
|
||||
|
||||
pytest.fail(
|
||||
f"VARIANCE({length}): direct={max_diff:.2e}, "
|
||||
f"pop→sample={adj_diff:.2e}, sample→pop={inv_diff:.2e}"
|
||||
)
|
||||
|
||||
def test_zscore(self) -> None:
|
||||
"""ZSCORE = (x - mean) / stddev.
|
||||
|
||||
quantalib uses population stddev (ddof=0), pandas-ta uses sample (ddof=1).
|
||||
The ratio is sqrt(n/(n-1)). We verify after applying the correction factor.
|
||||
"""
|
||||
for length in (10, 20, 50):
|
||||
qtl = zscore(CLOSE, length=length)
|
||||
pta = ta.zscore(SERIES, length=length)
|
||||
pta_np = pta.to_numpy()
|
||||
count = len(qtl)
|
||||
start = max(0, count - VERIFY_COUNT)
|
||||
q_tail = qtl[start:]
|
||||
p_tail = pta_np[start:]
|
||||
finite = np.isfinite(q_tail) & np.isfinite(p_tail)
|
||||
|
||||
if np.sum(finite) == 0:
|
||||
pytest.fail(f"ZSCORE({length}): no finite in tail")
|
||||
|
||||
q_f = q_tail[finite]
|
||||
p_f = p_tail[finite]
|
||||
|
||||
# Correct for ddof difference:
|
||||
# z_pop = (x - mean) / std_pop
|
||||
# z_sample = (x - mean) / std_sample
|
||||
# std_sample = std_pop * sqrt(n/(n-1))
|
||||
# so z_pop = z_sample * sqrt(n/(n-1))
|
||||
ddof_ratio = np.sqrt(length / (length - 1))
|
||||
|
||||
# Try both correction directions
|
||||
diff_direct = float(np.max(np.abs(q_f - p_f)))
|
||||
diff_corrected = float(np.max(np.abs(q_f - p_f * ddof_ratio)))
|
||||
diff_inv = float(np.max(np.abs(q_f / ddof_ratio - p_f)))
|
||||
|
||||
best = min(diff_direct, diff_corrected, diff_inv)
|
||||
assert best <= 1e-7, (
|
||||
f"ZSCORE({length}): best_diff={best:.2e} "
|
||||
f"(direct={diff_direct:.2e}, corrected={diff_corrected:.2e}, "
|
||||
f"inv={diff_inv:.2e})"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Multi-output: Bollinger Bands
|
||||
# ===========================================================================
|
||||
|
||||
class TestMultiOutput:
|
||||
"""Multi-output indicators: BBands returns (upper, middle, lower) tuple."""
|
||||
|
||||
def _get_bbands(self, length: int = 20, std: float = 2.0):
|
||||
"""Get both quantalib and pandas-ta BBands results."""
|
||||
qtl = bbands(CLOSE, length=length, std=std)
|
||||
# quantalib uses population stddev (ddof=0); tell pandas-ta to match
|
||||
pta = ta.bbands(SERIES, length=length, std=std, ddof=0)
|
||||
|
||||
# quantalib returns tuple of 3 numpy arrays: (upper, middle, lower)
|
||||
if isinstance(qtl, tuple):
|
||||
qtl_upper, qtl_mid, qtl_lower = qtl[0], qtl[1], qtl[2]
|
||||
elif hasattr(qtl, 'ndim') and qtl.ndim == 2:
|
||||
qtl_upper, qtl_mid, qtl_lower = qtl[:, 0], qtl[:, 1], qtl[:, 2]
|
||||
else:
|
||||
pytest.fail(f"Unexpected bbands return type: {type(qtl)}")
|
||||
|
||||
# pandas-ta column names vary by version:
|
||||
# v0.4+: "BBL_20_2.0_2.0", "BBM_20_2.0_2.0", "BBU_20_2.0_2.0"
|
||||
# older: "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0"
|
||||
cols = list(pta.columns)
|
||||
bbu = [c for c in cols if c.startswith("BBU")]
|
||||
bbm = [c for c in cols if c.startswith("BBM")]
|
||||
bbl = [c for c in cols if c.startswith("BBL")]
|
||||
assert bbu and bbm and bbl, f"BBands columns not found: {cols}"
|
||||
|
||||
pta_upper = pta[bbu[0]].to_numpy()
|
||||
pta_mid = pta[bbm[0]].to_numpy()
|
||||
pta_lower = pta[bbl[0]].to_numpy()
|
||||
|
||||
return (qtl_upper, qtl_mid, qtl_lower), (pta_upper, pta_mid, pta_lower)
|
||||
|
||||
def test_bbands_middle(self) -> None:
|
||||
"""Middle band = SMA, should match exactly."""
|
||||
(_, q_mid, _), (_, p_mid, _) = self._get_bbands()
|
||||
_verify_last_n(q_mid, p_mid, tolerance=1e-9,
|
||||
label="BBands middle")
|
||||
|
||||
def test_bbands_upper(self) -> None:
|
||||
(q_upper, _, _), (p_upper, _, _) = self._get_bbands()
|
||||
# Tolerance depends on stddev ddof agreement
|
||||
_verify_last_n(q_upper, p_upper, tolerance=1e-6,
|
||||
label="BBands upper")
|
||||
|
||||
def test_bbands_lower(self) -> None:
|
||||
(_, _, q_lower), (_, _, p_lower) = self._get_bbands()
|
||||
_verify_last_n(q_lower, p_lower, tolerance=1e-6,
|
||||
label="BBands lower")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Shape contract tests — output length must match input length
|
||||
# ===========================================================================
|
||||
|
||||
class TestShape:
|
||||
"""Verify output shapes match input for single-output indicators."""
|
||||
|
||||
@pytest.mark.parametrize("indicator,length", [
|
||||
("sma", 20), ("ema", 14), ("wma", 10), ("rsi", 14),
|
||||
("mom", 10), ("roc", 10), ("stddev", 20), ("hma", 14),
|
||||
])
|
||||
def test_output_length(self, indicator: str, length: int) -> None:
|
||||
fn = globals().get(indicator) or locals().get(indicator)
|
||||
if fn is None:
|
||||
fn = eval(indicator) # noqa: S307
|
||||
result = fn(CLOSE, length=length)
|
||||
assert len(result) == N, (
|
||||
f"{indicator}({length}) output={len(result)} != input={N}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Performance comparison (informational, no assertions)
|
||||
# ===========================================================================
|
||||
|
||||
class TestPerformance:
|
||||
"""Throughput comparison. Uses 10K bars, 100 iterations.
|
||||
Results are printed, not asserted — documenting speedup only."""
|
||||
|
||||
N_PERF = 10_000
|
||||
PERF_CLOSE = _generate_gbm(N_PERF, seed=99)
|
||||
PERF_SERIES = pd.Series(PERF_CLOSE, name="close")
|
||||
N_ITER = 100
|
||||
|
||||
@pytest.mark.parametrize("name,qtl_fn,pta_fn,kwargs", [
|
||||
("SMA(20)", sma, lambda s: ta.sma(s, length=20), {"length": 20}),
|
||||
("EMA(20)", ema, lambda s: ta.ema(s, length=20), {"length": 20}),
|
||||
("RSI(14)", rsi, lambda s: ta.rsi(s, length=14), {"length": 14}),
|
||||
("WMA(14)", wma, lambda s: ta.wma(s, length=14), {"length": 14}),
|
||||
("MOM(10)", mom, lambda s: ta.mom(s, length=10), {"length": 10}),
|
||||
])
|
||||
def test_throughput(self, name: str, qtl_fn, pta_fn, kwargs: dict) -> None:
|
||||
import time
|
||||
|
||||
data = self.PERF_CLOSE
|
||||
series = self.PERF_SERIES
|
||||
|
||||
# quantalib
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(self.N_ITER):
|
||||
_ = qtl_fn(data, **kwargs)
|
||||
qtl_us = (time.perf_counter() - t0) / self.N_ITER * 1e6
|
||||
|
||||
# pandas-ta
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(self.N_ITER):
|
||||
_ = pta_fn(series)
|
||||
pta_us = (time.perf_counter() - t0) / self.N_ITER * 1e6
|
||||
|
||||
ratio = pta_us / qtl_us if qtl_us > 0 else float("inf")
|
||||
|
||||
print(f"\n {name} on {self.N_PERF:,} bars:")
|
||||
print(f" quantalib : {qtl_us:8.1f} µs/call")
|
||||
print(f" pandas-ta : {pta_us:8.1f} µs/call")
|
||||
print(f" speedup : {ratio:.1f}x")
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
import pytest
|
||||
|
||||
from quantalib import indicators as q
|
||||
|
||||
|
||||
SEED = 42
|
||||
N = 10_000
|
||||
VERIFY_COUNT = 100
|
||||
|
||||
|
||||
def _generate_gbm(
|
||||
n: int,
|
||||
seed: int = SEED,
|
||||
start_price: float = 100.0,
|
||||
mu: float = 0.05,
|
||||
sigma: float = 0.2,
|
||||
dt: float = 1 / 252,
|
||||
) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
z = rng.standard_normal(n - 1)
|
||||
drift = (mu - 0.5 * sigma**2) * dt
|
||||
diffusion = sigma * np.sqrt(dt) * z
|
||||
log_returns = drift + diffusion
|
||||
|
||||
prices = np.empty(n, dtype=np.float64)
|
||||
prices[0] = start_price
|
||||
np.cumsum(log_returns, out=prices[1:])
|
||||
prices[1:] += np.log(start_price)
|
||||
np.exp(prices[1:], out=prices[1:])
|
||||
prices[0] = start_price
|
||||
return prices
|
||||
|
||||
|
||||
CLOSE = _generate_gbm(N)
|
||||
SERIES = pd.Series(CLOSE, name="close")
|
||||
|
||||
|
||||
def _verify_last_n(
|
||||
qtl_arr: np.ndarray,
|
||||
pta_arr: np.ndarray,
|
||||
*,
|
||||
verify_count: int = VERIFY_COUNT,
|
||||
tolerance: float = 1e-6,
|
||||
label: str,
|
||||
) -> None:
|
||||
assert len(qtl_arr) == len(pta_arr), f"{label}: length mismatch"
|
||||
|
||||
start = max(0, len(qtl_arr) - verify_count)
|
||||
q_tail = qtl_arr[start:]
|
||||
p_tail = pta_arr[start:]
|
||||
|
||||
finite = np.isfinite(q_tail) & np.isfinite(p_tail)
|
||||
assert int(np.sum(finite)) > 0, f"{label}: no finite overlap in tail"
|
||||
|
||||
diff = np.abs(q_tail[finite] - p_tail[finite])
|
||||
max_diff = float(np.max(diff))
|
||||
assert max_diff <= tolerance, f"{label}: max_diff={max_diff:.3e} > tol={tolerance:.1e}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,qtl,pta,tol",
|
||||
[
|
||||
("rsi_14", q.rsi(CLOSE, length=14), ta.rsi(SERIES, length=14).to_numpy(), 1e-6),
|
||||
("mom_10", q.mom(CLOSE, length=10), ta.mom(SERIES, length=10).to_numpy(), 1e-9),
|
||||
("cmo_14", q.cmo(CLOSE, length=14), ta.cmo(SERIES, length=14, talib=False).to_numpy(), 1e-6),
|
||||
("apo_12_26", q.apo(CLOSE, fast=12, slow=26), ta.apo(SERIES, fast=12, slow=26, mamode="ema", talib=False).to_numpy(), 1e-6),
|
||||
("bias_26", q.bias(CLOSE, length=26), ta.bias(SERIES, length=26).to_numpy(), 1e-6),
|
||||
("cfo_14", q.cfo(CLOSE, length=14), (100.0 * (SERIES - ta.linreg(SERIES, length=14, tsf=False, talib=False)) / SERIES).to_numpy(), 1e-6),
|
||||
("dpo_20", q.dpo(CLOSE, length=20), ta.dpo(SERIES, length=20, centered=False).to_numpy(), 1e-6),
|
||||
("trix_18", q.trix(CLOSE, length=18), ta.trix(SERIES, length=18).iloc[:, 0].to_numpy(), 1e-6),
|
||||
("er_10", q.er(CLOSE, length=10), ta.er(SERIES, length=10).to_numpy(), 1e-6),
|
||||
("cti_12", q.cti(CLOSE, length=12), ta.cti(SERIES, length=12).to_numpy(), 1e-6),
|
||||
],
|
||||
)
|
||||
def test_pandas_ta_parity_batch_01(name: str, qtl: np.ndarray, pta: np.ndarray, tol: float) -> None:
|
||||
_verify_last_n(qtl, pta, tolerance=tol, label=name)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""test_shapes.py — Verify len(output) == len(input) for all single-output indicators.
|
||||
|
||||
Requires the native library to be published first:
|
||||
pwsh python/publish.ps1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# All Pattern A indicators (single-input + period → single-output)
|
||||
# These accept fn(CLOSE, length=N) calling convention.
|
||||
PATTERN_A = [
|
||||
"rsi", "roc", "mom", "cmo", "bias", "cfo",
|
||||
"fisher", "fisher04", "dpo", "trix", "inertia", "rsx", "er", "cti",
|
||||
"reflex", "trendflex", "kri", "psl",
|
||||
"sma", "wma", "hma", "trima", "swma", "dwma", "blma", "alma",
|
||||
"lsma", "sgma", "sinema", "hanma", "parzen", "tsf",
|
||||
"sp15", "tukey_w", "rain",
|
||||
"ema", "dema", "tema", "lema", "hema", "ahrens", "decycler",
|
||||
"bbw", "stddev", "variance",
|
||||
"zscore", "entropy",
|
||||
"bessel", "butter2", "butter3", "cheby1", "cheby2", "elliptic",
|
||||
"edcf", "bpf",
|
||||
"cg", "dsp", "ccor",
|
||||
"change",
|
||||
]
|
||||
|
||||
# No-param indicators (single-input, no period)
|
||||
NO_PARAM = ["cma", "exptrans"]
|
||||
|
||||
# Multi-param indicators that need custom calls
|
||||
MULTI_PARAM = [
|
||||
# (name, kwargs_dict)
|
||||
("tsi", {"long_period": 25, "short_period": 13}),
|
||||
("apo", {"fast": 12, "slow": 26}),
|
||||
("deco", {"short_period": 30, "long_period": 60}),
|
||||
("dosc", {"rsi_period": 14, "ema1_period": 5, "ema2_period": 3, "signal_period": 9}),
|
||||
("dymoi", {"base_period": 14, "short_period": 5, "long_period": 10, "min_period": 3, "max_period": 30}),
|
||||
("crsi", {"rsi_period": 3, "streak_period": 2, "rank_period": 100}),
|
||||
("bbb", {"length": 20, "mult": 2.0}),
|
||||
("bbi", {"p1": 3, "p2": 6, "p3": 12, "p4": 24}),
|
||||
("bwma", {"length": 14, "order": 0}),
|
||||
("crma", {"length": 14, "volume_factor": 1.0}),
|
||||
("dsma", {"length": 14, "factor": 0.5}),
|
||||
("gdema", {"length": 14, "vfactor": 1.0}),
|
||||
("coral", {"length": 14, "friction": 0.4}),
|
||||
("bbwn", {"length": 20, "mult": 2.0, "lookback": 252}),
|
||||
("bbwp", {"length": 20, "mult": 2.0, "lookback": 252}),
|
||||
("ccv", {"short_period": 20, "long_period": 1}),
|
||||
("cv", {"length": 20, "min_vol": 0.2, "max_vol": 0.7}),
|
||||
("cvi", {"ema_period": 10, "roc_period": 10}),
|
||||
("ewma", {"length": 20, "is_pop": 1, "ann_factor": 252}),
|
||||
("alaguerre", {"length": 20, "order": 5}),
|
||||
("bilateral", {"length": 14, "sigma_s": 0.5, "sigma_r": 1.0}),
|
||||
("baxterking", {"length": 12, "min_period": 6, "max_period": 32}),
|
||||
("cfitz", {"length": 6, "bw_period": 32}),
|
||||
("ebsw", {"hp_length": 40, "ssf_length": 10}),
|
||||
("eacp", {"min_period": 8, "max_period": 48, "avg_length": 3, "enhance": 1}),
|
||||
("betadist", {"length": 50, "alpha": 2.0, "beta": 2.0}),
|
||||
("expdist", {"length": 50, "lam": 3.0}),
|
||||
("binomdist", {"length": 50, "trials": 20, "threshold": 10}),
|
||||
("cwt", {"scale": 10.0, "omega": 6.0}),
|
||||
("dwt", {"length": 4, "levels": 0}),
|
||||
]
|
||||
|
||||
N = 200
|
||||
RNG = np.random.default_rng(42)
|
||||
CLOSE = RNG.standard_normal(N).cumsum() + 100.0
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qtl():
|
||||
"""Import quantalib; skip if native lib not available."""
|
||||
try:
|
||||
import quantalib as _qtl
|
||||
return _qtl
|
||||
except (OSError, ImportError) as e:
|
||||
pytest.skip(f"quantalib native lib not available: {e}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PATTERN_A)
|
||||
def test_pattern_a_shape(qtl, name: str) -> None:
|
||||
fn = getattr(qtl.indicators, name, None)
|
||||
if fn is None:
|
||||
pytest.skip(f"{name} not available")
|
||||
result = fn(CLOSE, length=14)
|
||||
assert isinstance(result, np.ndarray), f"{name} did not return ndarray"
|
||||
assert len(result) == N, f"{name}: expected {N}, got {len(result)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", NO_PARAM)
|
||||
def test_no_param_shape(qtl, name: str) -> None:
|
||||
fn = getattr(qtl.indicators, name, None)
|
||||
if fn is None:
|
||||
pytest.skip(f"{name} not available")
|
||||
result = fn(CLOSE)
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,kwargs", MULTI_PARAM, ids=[m[0] for m in MULTI_PARAM])
|
||||
def test_multi_param_shape(qtl, name: str, kwargs: dict) -> None:
|
||||
fn = getattr(qtl.indicators, name, None)
|
||||
if fn is None:
|
||||
pytest.skip(f"{name} not available")
|
||||
result = fn(CLOSE, **kwargs)
|
||||
assert isinstance(result, np.ndarray), f"{name} did not return ndarray"
|
||||
assert len(result) == N, f"{name}: expected {N}, got {len(result)}"
|
||||
|
||||
|
||||
def test_medprice_shape(qtl) -> None:
|
||||
h = CLOSE + RNG.uniform(0, 2, N)
|
||||
l = CLOSE - RNG.uniform(0, 2, N)
|
||||
result = qtl.indicators.medprice(h, l)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
def test_tr_shape(qtl) -> None:
|
||||
h = CLOSE + RNG.uniform(0, 2, N)
|
||||
l = CLOSE - RNG.uniform(0, 2, N)
|
||||
result = qtl.indicators.tr(h, l, CLOSE)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
def test_bbands_shape(qtl) -> None:
|
||||
result = qtl.indicators.bbands(CLOSE, length=20, std=2.0)
|
||||
# Returns tuple of 3 arrays when no pandas
|
||||
assert len(result) == 3
|
||||
for arr in result:
|
||||
assert len(arr) == N
|
||||
|
||||
|
||||
def test_obv_shape(qtl) -> None:
|
||||
vol = RNG.uniform(1e6, 1e7, N)
|
||||
result = qtl.indicators.obv(CLOSE, vol)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
def test_mfi_shape(qtl) -> None:
|
||||
h = CLOSE + RNG.uniform(0, 2, N)
|
||||
l = CLOSE - RNG.uniform(0, 2, N)
|
||||
vol = RNG.uniform(1e6, 1e7, N)
|
||||
result = qtl.indicators.mfi(h, l, CLOSE, vol, length=14)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
def test_correlation_shape(qtl) -> None:
|
||||
y = RNG.standard_normal(N).cumsum() + 50.0
|
||||
result = qtl.indicators.correlation(CLOSE, y, length=20)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
def test_mse_shape(qtl) -> None:
|
||||
predicted = CLOSE + RNG.standard_normal(N) * 0.5
|
||||
result = qtl.indicators.mse(CLOSE, predicted, length=20)
|
||||
assert len(result) == N
|
||||
|
||||
|
||||
def test_pvo_shape(qtl) -> None:
|
||||
vol = RNG.uniform(1e6, 1e7, N)
|
||||
result = qtl.indicators.pvo(vol, fast=12, slow=26, signal=9)
|
||||
assert len(result) == 3 # tuple of 3
|
||||
for arr in result:
|
||||
assert len(arr) == N
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from quantalib._loader import native_library_path
|
||||
|
||||
|
||||
def test_native_library_path_is_resolvable() -> None:
|
||||
path = native_library_path()
|
||||
assert isinstance(path, Path)
|
||||
|
||||
|
||||
def test_loader_fails_with_actionable_message_when_binary_missing(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Verify a clear OSError when the native binary is missing."""
|
||||
import quantalib._loader as loader
|
||||
|
||||
# Point native_library_path to a non-existent file
|
||||
fake = Path(__file__).parent / "nonexistent" / "quantalib_native.dll"
|
||||
monkeypatch.setattr(loader, "native_library_path", lambda: fake)
|
||||
|
||||
with pytest.raises(OSError) as exc:
|
||||
loader.load_native_library()
|
||||
|
||||
msg = str(exc.value).lower()
|
||||
assert "native library" in msg
|
||||
assert "expected" in msg
|
||||
@@ -0,0 +1,99 @@
|
||||
"""test_status_codes.py — Verify correct exceptions for bad inputs.
|
||||
|
||||
Tests that null pointers, invalid lengths, and invalid params raise
|
||||
the expected quantalib exception types.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qtl():
|
||||
try:
|
||||
import quantalib as _qtl
|
||||
return _qtl
|
||||
except (OSError, ImportError) as e:
|
||||
pytest.skip(f"quantalib native lib not available: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bridge():
|
||||
try:
|
||||
from quantalib import _bridge
|
||||
return _bridge
|
||||
except (OSError, ImportError) as e:
|
||||
pytest.skip(f"quantalib native lib not available: {e}")
|
||||
|
||||
|
||||
class TestInvalidLength:
|
||||
"""Period <= 0 should raise QtlInvalidParamError (ChkPeriod returns status 3)."""
|
||||
|
||||
def test_sma_zero_length(self, qtl) -> None:
|
||||
close = np.ones(10, dtype=np.float64)
|
||||
# ChkPeriod checks period > 0; returns QTL_ERR_INVALID_PARAM (3) for <= 0
|
||||
with pytest.raises(qtl.QtlInvalidParamError):
|
||||
qtl.sma(close, length=0)
|
||||
|
||||
def test_sma_negative_length(self, qtl) -> None:
|
||||
close = np.ones(10, dtype=np.float64)
|
||||
with pytest.raises(qtl.QtlInvalidParamError):
|
||||
qtl.sma(close, length=-5)
|
||||
|
||||
|
||||
class TestInvalidParam:
|
||||
"""Bad parameter values should raise QtlInvalidParamError."""
|
||||
|
||||
def test_sma_period_exceeds_length(self, qtl) -> None:
|
||||
"""SMA Batch processes whatever data is available;
|
||||
period > n is not an error — it just computes with partial data."""
|
||||
close = np.ones(5, dtype=np.float64)
|
||||
# This should NOT raise; SMA handles period > n gracefully
|
||||
result = qtl.sma(close, length=10)
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
class TestNullPointer:
|
||||
"""Null pointer should raise QtlNullPointerError via raw bridge call."""
|
||||
|
||||
def test_null_src(self, bridge) -> None:
|
||||
import ctypes as ct
|
||||
null = ct.cast(None, bridge._dp)
|
||||
dst = np.empty(10, dtype=np.float64)
|
||||
status = bridge._lib.qtl_sma(null, 10, dst.ctypes.data_as(bridge._dp), 5)
|
||||
assert status == bridge.QTL_ERR_NULL_PTR
|
||||
|
||||
def test_null_dst(self, bridge) -> None:
|
||||
import ctypes as ct
|
||||
src = np.ones(10, dtype=np.float64)
|
||||
null = ct.cast(None, bridge._dp)
|
||||
status = bridge._lib.qtl_sma(src.ctypes.data_as(bridge._dp), 10, null, 5)
|
||||
assert status == bridge.QTL_ERR_NULL_PTR
|
||||
|
||||
|
||||
class TestCheckHelper:
|
||||
"""Verify _check() maps status codes to exceptions."""
|
||||
|
||||
def test_ok(self, bridge) -> None:
|
||||
bridge._check(0) # Should not raise
|
||||
|
||||
def test_null_ptr(self, bridge) -> None:
|
||||
with pytest.raises(bridge.QtlNullPointerError):
|
||||
bridge._check(1)
|
||||
|
||||
def test_invalid_length(self, bridge) -> None:
|
||||
with pytest.raises(bridge.QtlInvalidLengthError):
|
||||
bridge._check(2)
|
||||
|
||||
def test_invalid_param(self, bridge) -> None:
|
||||
with pytest.raises(bridge.QtlInvalidParamError):
|
||||
bridge._check(3)
|
||||
|
||||
def test_internal(self, bridge) -> None:
|
||||
with pytest.raises(bridge.QtlInternalError):
|
||||
bridge._check(4)
|
||||
|
||||
def test_unknown(self, bridge) -> None:
|
||||
with pytest.raises(bridge.QtlError):
|
||||
bridge._check(99)
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPORT = Path("python/tests/reports/pandas_ta_all_exported_report.md")
|
||||
DOC = Path("docs/validation.md")
|
||||
|
||||
# docs stem (from markdown link filename) -> sweep key (python wrapper function name)
|
||||
DOC_TO_SWEEP_ALIAS: dict[str, str] = {
|
||||
# core/price-transform naming differences
|
||||
"midprice": "medprice",
|
||||
"linreg": "lsma",
|
||||
"stdev": "stddev",
|
||||
"typicalprice": "typprice",
|
||||
"averageprice": "avgprice",
|
||||
"midbody": "midbody",
|
||||
# common TA abbreviations / canonical wrappers
|
||||
"true_range": "tr",
|
||||
"z_score": "zscore",
|
||||
"standarddeviation": "stddev",
|
||||
# explicit doc stems commonly used in this repo
|
||||
"wclprice": "typprice",
|
||||
}
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return "".join(ch for ch in s.lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _resolve_status_key(stem: str, status_map: dict[str, str]) -> str | None:
|
||||
if stem in status_map:
|
||||
return stem
|
||||
|
||||
nstem = _norm(stem)
|
||||
|
||||
# 1) explicit alias by raw stem
|
||||
alias = DOC_TO_SWEEP_ALIAS.get(stem)
|
||||
if alias and alias in status_map:
|
||||
return alias
|
||||
|
||||
# 2) explicit alias by normalized stem
|
||||
alias = DOC_TO_SWEEP_ALIAS.get(nstem)
|
||||
if alias and alias in status_map:
|
||||
return alias
|
||||
|
||||
# 3) normalized exact match against sweep keys
|
||||
by_norm = {_norm(k): k for k in status_map.keys()}
|
||||
if nstem in by_norm:
|
||||
return by_norm[nstem]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
report_lines = REPORT.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
status_map: dict[str, str] = {}
|
||||
row_rx = re.compile(r"^\| `([^`]+)` \| (✔️|⚠️) \|")
|
||||
for line in report_lines:
|
||||
m = row_rx.match(line)
|
||||
if not m:
|
||||
continue
|
||||
status_map[m.group(1).lower()] = m.group(2)
|
||||
|
||||
lines = DOC.read_text(encoding="utf-8").splitlines()
|
||||
out: list[str] = []
|
||||
updated = 0
|
||||
unresolved: list[str] = []
|
||||
|
||||
link_rx = re.compile(r"\]\(([^)]+)\)")
|
||||
for line in lines:
|
||||
if not line.strip().startswith("|"):
|
||||
out.append(line)
|
||||
continue
|
||||
|
||||
cols = [c.strip() for c in line.strip().split("|")[1:-1]]
|
||||
if len(cols) < 2:
|
||||
out.append(line)
|
||||
continue
|
||||
|
||||
m = link_rx.search(cols[1])
|
||||
if not m:
|
||||
out.append(line)
|
||||
continue
|
||||
|
||||
stem = Path(m.group(1)).stem.lower()
|
||||
if cols[-1] == "❔":
|
||||
resolved = _resolve_status_key(stem, status_map)
|
||||
if resolved is not None:
|
||||
cols[-1] = status_map[resolved]
|
||||
line = "| " + " | ".join(cols) + " |"
|
||||
updated += 1
|
||||
else:
|
||||
unresolved.append(stem)
|
||||
|
||||
out.append(line)
|
||||
|
||||
DOC.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||
unresolved_unique = sorted(set(unresolved))
|
||||
print(f"UPDATED={updated}")
|
||||
print(f"UNRESOLVED={len(unresolved_unique)}")
|
||||
if unresolved_unique:
|
||||
print("UNRESOLVED_SAMPLE=" + ",".join(unresolved_unique[:25]))
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/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