Files
2026-07-09 05:08:16 +08:00

185 lines
4.6 KiB
Python

from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import numpy as np
import pytest
import ferro_ta
ROOT = Path(__file__).resolve().parents[2]
WASM_DIR = ROOT / "wasm"
PKG_JS = WASM_DIR / "pkg" / "ferro_ta_wasm.js"
SCRIPT = WASM_DIR / "conformance_node.js"
def _write_node_conformance_script(path: Path) -> None:
path.write_text(
"""
const wasm = require("./node/ferro_ta_wasm.js");
function toArray(x) {
return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v)));
}
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.1, 45.42, 45.84, 46.08, 45.89, 46.03, 46.21, 46.02, 45.78]);
const high = new Float64Array([44.71, 44.5, 44.6, 44.09, 44.79, 45.2, 45.44, 45.73, 46.01, 46.44, 46.21, 46.39, 46.53, 46.3, 46.12]);
const low = new Float64Array([43.9, 43.8, 43.9, 43.2, 43.9, 44.2, 44.6, 44.8, 45.2, 45.5, 45.4, 45.5, 45.7, 45.6, 45.4]);
const volume = new Float64Array([1200, 1320, 1250, 1460, 1500, 1670, 1720, 1810, 1900, 2020, 1980, 2100, 2170, 2140, 2080]);
const payload = {
sma: toArray(wasm.sma(close, 5)),
ema: toArray(wasm.ema(close, 5)),
wma: toArray(wasm.wma(close, 5)),
rsi: toArray(wasm.rsi(close, 5)),
adx: toArray(wasm.adx(high, low, close, 5)),
mfi: toArray(wasm.mfi(high, low, close, volume, 5)),
};
process.stdout.write(JSON.stringify(payload));
""".strip()
+ "\n",
encoding="utf-8",
)
def _run_node_conformance() -> dict[str, list[float | None]]:
if shutil.which("node") is None:
pytest.skip("node is required for wasm/node conformance test")
if not PKG_JS.exists():
pytest.skip(
"wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`"
)
_write_node_conformance_script(SCRIPT)
try:
out = subprocess.check_output(
["node", str(SCRIPT)],
cwd=WASM_DIR,
text=True,
)
finally:
if SCRIPT.exists():
SCRIPT.unlink()
return json.loads(out)
def _to_jsonable(arr: np.ndarray) -> list[float | None]:
vals = np.asarray(arr, dtype=np.float64)
return [None if np.isnan(x) else float(x) for x in vals]
def _assert_close_with_null_nan(
actual: list[float | None],
expected: list[float | None],
*,
atol: float,
) -> None:
assert len(actual) == len(expected)
a = np.array([np.nan if v is None else float(v) for v in actual], dtype=np.float64)
e = np.array(
[np.nan if v is None else float(v) for v in expected], dtype=np.float64
)
np.testing.assert_allclose(a, e, atol=atol, rtol=0.0, equal_nan=True)
def test_wasm_node_matches_python_core_indicators() -> None:
close = np.array(
[
44.34,
44.09,
44.15,
43.61,
44.33,
44.83,
45.10,
45.42,
45.84,
46.08,
45.89,
46.03,
46.21,
46.02,
45.78,
],
dtype=np.float64,
)
high = np.array(
[
44.71,
44.50,
44.60,
44.09,
44.79,
45.20,
45.44,
45.73,
46.01,
46.44,
46.21,
46.39,
46.53,
46.30,
46.12,
],
dtype=np.float64,
)
low = np.array(
[
43.90,
43.80,
43.90,
43.20,
43.90,
44.20,
44.60,
44.80,
45.20,
45.50,
45.40,
45.50,
45.70,
45.60,
45.40,
],
dtype=np.float64,
)
volume = np.array(
[
1200.0,
1320.0,
1250.0,
1460.0,
1500.0,
1670.0,
1720.0,
1810.0,
1900.0,
2020.0,
1980.0,
2100.0,
2170.0,
2140.0,
2080.0,
],
dtype=np.float64,
)
node_payload = _run_node_conformance()
py_expected = {
"sma": _to_jsonable(ferro_ta.SMA(close, 5)),
"ema": _to_jsonable(ferro_ta.EMA(close, 5)),
"wma": _to_jsonable(ferro_ta.WMA(close, 5)),
"rsi": _to_jsonable(ferro_ta.RSI(close, 5)),
"adx": _to_jsonable(ferro_ta.ADX(high, low, close, 5)),
"mfi": _to_jsonable(ferro_ta.MFI(high, low, close, volume, 5)),
}
for name, expected in py_expected.items():
assert name in node_payload
_assert_close_with_null_nan(node_payload[name], expected, atol=1e-9)