185 lines
4.6 KiB
Python
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)
|