examples: migrate to the native data layer (drop ws/coder-websocket/jackson/jsonlite) (#316)

Stacked on #315 (the native Binance REST fetcher). Retarget to `main` once #315 merges.

Migrates the runnable examples off third-party data-I/O packages onto Wickra's
native data layer (`CandleReader`, `Resampler`, `BinanceFeed`, `fetch_*klines`).

## Third-party packages removed (the zero-dep selling point)
- **Node**: `ws` (live feed → BinanceFeed) — dropped from package.json + lockfile
- **Go**: `github.com/coder/websocket` — dropped from go.mod / go.sum (`go mod tidy`)
- **Java**: `jackson-databind` (live feed + REST fetch) — dropped from pom.xml
- **R**: `jsonlite` + `websocket` + `later` — dropped from the README notes

Each language's CSV loading now goes through `CandleReader`, manual resampling
through `Resampler`, the live feed through `BinanceFeed`, and (Java/R) the REST
download through the native fetcher.

## Verification
Ran the offline examples per language against the bundled data — backtest and
multi_timeframe produce identical output across Python / Node / Go / Java / R
(e.g. ATR(14) last 345.1010; 1h→5m resamples to 240 bars, →15m to 80 bars).

C# / C / WASM (stdlib-only, no third-party deps to remove) follow in this branch.

Note: the streaming `strategy_*` examples have pre-existing candle-indicator
runtime bugs (CI only syntax-smokes them); the CSV migration preserves their
shape and leaves those bugs for a separate fix.
This commit is contained in:
kingchenc
2026-06-17 01:49:11 +02:00
committed by GitHub
parent 2ae76bb90e
commit 677ea37402
40 changed files with 576 additions and 1102 deletions
+19 -46
View File
@@ -14,20 +14,14 @@ indicators are wired correctly without pulling in pandas or a charting stack.
from __future__ import annotations
import argparse
import csv
import sys
from dataclasses import dataclass
from typing import List
import numpy as np
import wickra as ta
# Columns the OHLCV layout requires; the CSV header must name every one.
REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume")
@dataclass
class History:
timestamp: np.ndarray
@@ -39,51 +33,30 @@ class History:
def read_history(path: str) -> History:
"""Load an OHLCV CSV into typed NumPy columns.
"""Load an OHLCV CSV into typed NumPy columns with Wickra's native
``CandleReader`` — no manual CSV parsing.
``CandleReader`` validates the header (``timestamp,open,high,low,close,volume``),
tolerates a UTF-8 BOM and surrounding whitespace, and raises ``ValueError`` on a
missing column or a non-numeric / invalid OHLC row.
Raises:
ValueError: if the file has no header, is missing a required column,
holds no data rows, or contains a non-numeric value (the message
pinpoints the offending row and column instead of surfacing an
opaque ``KeyError`` or NumPy ``ValueError``).
ValueError: if the CSV header or a data row is malformed.
"""
rows: List[List[str]] = []
with open(path, newline="") as f:
reader = csv.DictReader(f)
if reader.fieldnames is None:
raise ValueError(f"{path}: CSV has no header row")
missing = [c for c in REQUIRED_COLUMNS if c not in reader.fieldnames]
if missing:
raise ValueError(
f"{path}: CSV is missing required column(s): "
f"{', '.join(missing)}; found: {', '.join(reader.fieldnames)}"
)
for row in reader:
rows.append([row[c] for c in REQUIRED_COLUMNS])
if not rows:
with open(path, encoding="utf-8") as f:
candles = ta.CandleReader(f.read()).read()
if not candles:
raise ValueError(f"{path}: CSV has a header but no data rows")
try:
arr = np.array(rows, dtype=np.float64)
except (TypeError, ValueError) as exc:
# NumPy's own message names neither the row nor the column — locate
# the first non-numeric cell and report it precisely.
for line_no, row in enumerate(rows, start=2):
for col, value in zip(REQUIRED_COLUMNS, row):
try:
float(value)
except (TypeError, ValueError):
raise ValueError(
f"{path}: row {line_no} column '{col}' is not "
f"numeric: {value!r}"
) from exc
raise # could not localise — surface the original error
# CandleReader yields (open, high, low, close, volume, timestamp) tuples.
# Transpose into contiguous 1-D columns (batch() needs C-contiguous arrays).
o, h, l, c, v, ts = (np.array(col, dtype=np.float64) for col in zip(*candles))
return History(
timestamp=arr[:, 0].astype(np.int64),
open=arr[:, 1],
high=arr[:, 2],
low=arr[:, 3],
close=arr[:, 4],
volume=arr[:, 5],
timestamp=ts.astype(np.int64),
open=o,
high=h,
low=l,
close=c,
volume=v,
)
+43 -81
View File
@@ -1,61 +1,42 @@
"""Live Binance feed: stream Binance kline ticks incremental indicators signals.
"""Live Binance feed: stream Binance kline ticks -> incremental indicators -> signals.
This example connects to Binance's public WebSocket feed (no API key needed)
and runs RSI / MACD / Bollinger Bands on the close prices coming in. When the
RSI crosses common overbought / oversold thresholds *and* the MACD histogram
confirms the direction, a `Signal` event is printed. No orders are placed.
This example streams Binance's public kline feed (no API key needed) through
Wickra's **native** ``BinanceFeed`` and runs RSI / MACD / Bollinger Bands on the
close prices coming in. When the RSI crosses common overbought / oversold
thresholds *and* the MACD histogram confirms the direction, a signal is printed.
No orders are placed.
Run with::
There is **no third-party dependency** — the WebSocket client is built into
Wickra. Just::
python -m examples.python.live_binance --symbol BTCUSDT --interval 1m
Dependencies::
pip install websockets
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import re
import signal
import sys
from dataclasses import dataclass
from typing import Optional
import wickra as ta
try:
import websockets
except ImportError:
print("This example needs the `websockets` package: pip install websockets", file=sys.stderr)
raise
BINANCE_WS = "wss://stream.binance.com:9443/stream"
# Kline intervals the public Binance WebSocket API recognises.
VALID_INTERVALS = frozenset(
{
"1s", "1m", "3m", "5m", "15m", "30m",
"1h", "2h", "4h", "6h", "8h", "12h",
"1d", "3d", "1w", "1M",
}
)
# Binance kline interval -> the integer code BinanceFeed expects (the same order
# in every Wickra binding).
INTERVAL_CODES = {
"1s": 0, "1m": 1, "3m": 2, "5m": 3, "15m": 4, "30m": 5,
"1h": 6, "2h": 7, "4h": 8, "6h": 9, "8h": 10, "12h": 11,
"1d": 12, "3d": 13, "1w": 14, "1M": 15,
}
# A Binance symbol is strictly alphanumeric (e.g. BTCUSDT).
_SYMBOL_RE = re.compile(r"^[A-Za-z0-9]+$")
def validate_args(symbol: str, interval: str) -> None:
"""Reject a symbol or interval that is not safe to splice into the WS URL.
Both values are interpolated straight into the stream name and URL, so an
unexpected value would otherwise produce a confusing connection failure.
Validating up front keeps the URL well-formed without needing to escape it.
def validate_args(symbol: str, interval: str) -> int:
"""Validate the symbol/interval and return the interval's integer code.
Raises:
ValueError: if ``symbol`` is not strictly alphanumeric, or ``interval``
@@ -63,14 +44,14 @@ def validate_args(symbol: str, interval: str) -> None:
"""
if not _SYMBOL_RE.match(symbol):
raise ValueError(
f"invalid --symbol {symbol!r}: expected only letters and digits, "
"e.g. BTCUSDT"
f"invalid --symbol {symbol!r}: expected only letters and digits, e.g. BTCUSDT"
)
if interval not in VALID_INTERVALS:
if interval not in INTERVAL_CODES:
raise ValueError(
f"invalid --interval {interval!r}: expected one of "
+ ", ".join(sorted(VALID_INTERVALS))
+ ", ".join(INTERVAL_CODES)
)
return INTERVAL_CODES[interval]
@dataclass
@@ -120,38 +101,28 @@ def emit_signal(snap: Snapshot, log: logging.Logger) -> None:
)
async def run(symbol: str, interval: str) -> None:
stream = f"{symbol.lower()}@kline_{interval}"
url = f"{BINANCE_WS}?streams={stream}"
def run(symbol: str, interval_code: int) -> None:
log = logging.getLogger("wickra-live")
state = StrategyState()
log.info("Connecting to %s", url)
async with websockets.connect(url, ping_interval=20) as ws:
log.info("Connected, listening for %s klines", stream)
async for raw in ws:
envelope = json.loads(raw)
payload = envelope.get("data", {})
k = payload.get("k", {})
if not k or "c" not in k:
# Subscription acks, heartbeats and error frames carry no
# kline payload — skip them instead of crashing on
# float(None) the moment the stream opens.
log.debug("skipping non-kline frame: %s", raw)
continue
close = float(k.get("c"))
is_closed = bool(k.get("x"))
snap = state.update(close)
log.info(
"%s close=%.4f rsi=%s hist=%s bb=%s",
"BAR" if is_closed else "tick",
close,
f"{snap.rsi:.1f}" if snap.rsi is not None else "--",
f"{snap.macd_hist:+.4f}" if snap.macd_hist is not None else "--",
f"{snap.bb_lower:.2f}/{snap.bb_middle:.2f}/{snap.bb_upper:.2f}"
if snap.bb_upper is not None
else "--",
)
emit_signal(snap, log)
feed = ta.BinanceFeed(symbol, interval_code)
log.info("Streaming %s klines from Binance", symbol)
while True:
event = feed.next(1000) # blocks up to 1s; None on timeout (Ctrl+C between polls)
if event is None:
continue
_symbol, _open, _high, _low, close, _volume, _open_time, is_closed = event
snap = state.update(close)
log.info(
"%s close=%.4f rsi=%s hist=%s bb=%s",
"BAR" if is_closed else "tick",
close,
f"{snap.rsi:.1f}" if snap.rsi is not None else "--",
f"{snap.macd_hist:+.4f}" if snap.macd_hist is not None else "--",
f"{snap.bb_lower:.2f}/{snap.bb_middle:.2f}/{snap.bb_upper:.2f}"
if snap.bb_upper is not None
else "--",
)
emit_signal(snap, log)
def parse_args() -> argparse.Namespace:
@@ -165,7 +136,7 @@ def parse_args() -> argparse.Namespace:
def main() -> int:
args = parse_args()
try:
validate_args(args.symbol, args.interval)
interval_code = validate_args(args.symbol, args.interval)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
@@ -173,19 +144,10 @@ def main() -> int:
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
loop = asyncio.new_event_loop()
# Translate Ctrl+C into a clean loop stop.
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, loop.stop)
except NotImplementedError:
pass # Windows does not support add_signal_handler for SIGTERM.
try:
loop.run_until_complete(run(args.symbol, args.interval))
run(args.symbol, interval_code)
except KeyboardInterrupt:
pass
finally:
loop.close()
return 0
+34 -94
View File
@@ -1,9 +1,9 @@
"""Compute indicators on multiple timeframes from a single 1-minute feed.
Wickra exposes resampling on the Rust side (in `wickra-data`); from Python we
roll up bars manually using NumPy because most users already have their data
in a NumPy array and don't need the streaming-resample infrastructure for
offline analysis.
Loads the 1-minute history with Wickra's native ``CandleReader`` and rolls it up
to coarser timeframes with the native ``Resampler`` — no manual CSV parsing and
no hand-written bucketing. NumPy is used only to run the batch indicators over
each resampled series.
Run with::
@@ -13,104 +13,45 @@ Run with::
from __future__ import annotations
import argparse
import csv
from typing import Iterable, List, Tuple
from typing import List, Tuple
import numpy as np
import wickra as ta
# Columns the OHLCV layout requires; the CSV header must name every one.
REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume")
# A candle as the data layer yields it: (open, high, low, close, volume, timestamp).
Candle = Tuple[float, float, float, float, float, int]
def read_csv(path: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Read an OHLCV CSV into typed columns.
Raises:
ValueError: if the file has no header, is missing a required column,
holds no data rows, or contains a non-numeric value (the message
names the offending row).
"""
ts, o, h, l, c, v = [], [], [], [], [], []
with open(path, newline="") as f:
reader = csv.DictReader(f)
if reader.fieldnames is None:
raise ValueError(f"{path}: CSV has no header row")
missing = [col for col in REQUIRED_COLUMNS if col not in reader.fieldnames]
if missing:
raise ValueError(
f"{path}: CSV is missing required column(s): "
f"{', '.join(missing)}; found: {', '.join(reader.fieldnames)}"
)
for line_no, row in enumerate(reader, start=2):
try:
ts.append(int(row["timestamp"]))
o.append(float(row["open"]))
h.append(float(row["high"]))
l.append(float(row["low"]))
c.append(float(row["close"]))
v.append(float(row["volume"]))
except (TypeError, ValueError) as exc:
raise ValueError(
f"{path}: row {line_no} has a non-numeric value: {exc}"
) from exc
if not ts:
raise ValueError(f"{path}: CSV has a header but no data rows")
return (
np.asarray(ts, dtype=np.int64),
np.asarray(o, dtype=np.float64),
np.asarray(h, dtype=np.float64),
np.asarray(l, dtype=np.float64),
np.asarray(c, dtype=np.float64),
np.asarray(v, dtype=np.float64),
)
def load(path: str) -> List[Candle]:
"""Read an OHLCV CSV into candles with ``CandleReader`` (validates the
``timestamp,open,high,low,close,volume`` header; raises ``ValueError`` on a
malformed header or row)."""
with open(path, encoding="utf-8") as f:
return ta.CandleReader(f.read()).read()
def resample(
ts: np.ndarray,
o: np.ndarray,
h: np.ndarray,
l: np.ndarray,
c: np.ndarray,
v: np.ndarray,
bucket: int,
) -> Tuple[np.ndarray, ...]:
"""Aggregate bar-level columns into coarser buckets of size `bucket`.
Raises:
ValueError: if the input series is empty.
"""
if ts.size == 0:
raise ValueError("resample received an empty series")
bucket_index = (ts // bucket).astype(np.int64)
boundaries = np.diff(bucket_index, prepend=bucket_index[0] - 1) != 0
group_ids = np.cumsum(boundaries) - 1
n_groups = int(group_ids.max()) + 1
new_ts = np.empty(n_groups, dtype=np.int64)
new_o = np.empty(n_groups, dtype=np.float64)
new_h = np.empty(n_groups, dtype=np.float64)
new_l = np.empty(n_groups, dtype=np.float64)
new_c = np.empty(n_groups, dtype=np.float64)
new_v = np.empty(n_groups, dtype=np.float64)
for g in range(n_groups):
mask = group_ids == g
new_ts[g] = ts[mask][0]
new_o[g] = o[mask][0]
new_h[g] = h[mask].max()
new_l[g] = l[mask].min()
new_c[g] = c[mask][-1]
new_v[g] = v[mask].sum()
return new_ts, new_o, new_h, new_l, new_c, new_v
def resample(candles: List[Candle], bucket_ms: int) -> List[Candle]:
"""Aggregate 1-minute candles into ``bucket_ms``-sized candles with the
native streaming ``Resampler``."""
r = ta.Resampler(bucket_ms)
out: List[Candle] = []
for o, h, l, c, v, ts in candles:
agg = r.update(o, h, l, c, v, ts)
if agg is not None:
out.append(agg)
tail = r.flush()
if tail is not None:
out.append(tail)
return out
def summarize(label: str, close: np.ndarray, high: np.ndarray, low: np.ndarray) -> None:
if close.size == 0:
def summarize(label: str, candles: List[Candle]) -> None:
if not candles:
print(f" {label:<10} (empty series — nothing to summarize)")
return
# Transpose into contiguous 1-D columns (batch() needs C-contiguous arrays).
_o, high, low, close, _v, _ts = (np.array(col, dtype=np.float64) for col in zip(*candles))
rsi = ta.RSI(14).batch(close)
macd = ta.MACD().batch(close)
adx = ta.ADX(14).batch(high, low, close)
@@ -130,16 +71,15 @@ def main() -> int:
p.add_argument("path", help="Path to a 1-minute OHLCV CSV (timestamps in milliseconds)")
args = p.parse_args()
ts, o, h, l, c, v = read_csv(args.path)
candles = load(args.path)
one_minute = 60_000
print(f"Multi-timeframe view of {args.path}")
summarize("1m", c, h, l)
summarize("1m", candles)
for label, bucket in [("5m", 5), ("15m", 15), ("1h", 60), ("4h", 240), ("1d", 1440)]:
if ts.size < bucket:
if len(candles) < bucket:
continue
rs_ts, rs_o, rs_h, rs_l, rs_c, rs_v = resample(ts, o, h, l, c, v, bucket * one_minute)
summarize(label, rs_c, rs_h, rs_l)
summarize(label, resample(candles, bucket * one_minute))
return 0
+8 -13
View File
@@ -18,7 +18,6 @@ daily bars give an interpretable 6-month-low lookback (~180 bars).
from __future__ import annotations
import csv
import math
from collections import deque
from pathlib import Path
@@ -34,18 +33,14 @@ SQUEEZE_LOOKBACK = 180
def load_candles(path: Path) -> list[dict[str, float]]:
with path.open() as fh:
reader = csv.DictReader(fh)
return [
{
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"]),
}
for r in reader
]
# Native CandleReader: validates the header, tolerates a UTF-8 BOM and field
# whitespace, and raises ValueError on a malformed row. No third-party CSV.
candles = ta.CandleReader(path.read_text(encoding="utf-8")).read()
# CandleReader yields (open, high, low, close, volume, timestamp) tuples.
return [
{"open": o, "high": h, "low": l, "close": c, "volume": v}
for o, h, l, c, v, _ts in candles
]
def print_summary(
+8 -13
View File
@@ -15,7 +15,6 @@ Uses the checked-in ``examples/data/btcusdt-1h.csv`` dataset.
from __future__ import annotations
import csv
import math
from pathlib import Path
@@ -26,18 +25,14 @@ ADX_FLOOR = 20.0
def load_candles(path: Path) -> list[dict[str, float]]:
with path.open() as fh:
reader = csv.DictReader(fh)
return [
{
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"]),
}
for r in reader
]
# Native CandleReader: validates the header, tolerates a UTF-8 BOM and field
# whitespace, and raises ValueError on a malformed row. No third-party CSV.
candles = ta.CandleReader(path.read_text(encoding="utf-8")).read()
# CandleReader yields (open, high, low, close, volume, timestamp) tuples.
return [
{"open": o, "high": h, "low": l, "close": c, "volume": v}
for o, h, l, c, v, _ts in candles
]
def print_summary(
+8 -13
View File
@@ -17,7 +17,6 @@ Uses the checked-in ``examples/data/btcusdt-1h.csv`` dataset.
from __future__ import annotations
import csv
import math
from pathlib import Path
@@ -30,18 +29,14 @@ OVERBOUGHT = 70.0
def load_candles(path: Path) -> list[dict[str, float]]:
with path.open() as fh:
reader = csv.DictReader(fh)
return [
{
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"]),
}
for r in reader
]
# Native CandleReader: validates the header, tolerates a UTF-8 BOM and field
# whitespace, and raises ValueError on a malformed row. No third-party CSV.
candles = ta.CandleReader(path.read_text(encoding="utf-8")).read()
# CandleReader yields (open, high, low, close, volume, timestamp) tuples.
return [
{"open": o, "high": h, "low": l, "close": c, "volume": v}
for o, h, l, c, v, _ts in candles
]
def print_summary(