mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-04 20:17:43 +00:00
python wrapper
This commit is contained in:
@@ -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