Your segment store
Patternode keeps none of your series, so everything about a series lives on your side: its segments at every level, and for a forming series its edge. The segment store is the reference library for that side. It runs in your process, against your storage; Patternode never sees it.
| Call | Does |
|---|---|
extend(pn, series, bars) | The whole forming-series cycle: reads the stored edge, sends it with the new bars, stores what comes back. Safe to retry |
apply_segmentation(series, result) | Stores a finished series' answer, and its edge if you asked for one |
apply_extension(series, result, expected_version) | Stores an extension: closed segments final, the right edge replaced, the new edge, in one atomic write; refuses a stale writer |
edge(series) | The forward edge: the sealed token, when its last bar closed, the profile version, and the store's version |
right_edge(series) | The open segment at each level, bottom up |
segments(series, level, start, end) | Segments by level and time window |
levels(series) | The levels a series has |
cut(series, start, end, width_px) | What a chart of that width should draw over that window: the finest level wide enough to read. Zoom in and finer levels take over |
trend(series) | Rising, falling or steady at each timescale, from the open segment at levels 1, 2 and 3 |
The Python version keeps a JSON file per series in a directory, one directory per client, written atomically; replace its two storage methods with your database or object store. The TypeScript version keeps them in memory. Both are tested against the mock in CI.
- Python
- TypeScript
examples/python/segment_store.py
"""Your segment store: keep what Patternode returns, query it, and carry forming series forward.
Patternode keeps none of your series. Everything about a series lives here, on your side:
its segments at every level, and for a forming series its edge (the sealed token Patternode
needs to carry on). This module is the reference for that store. It keeps one JSON file per
series under a directory, written atomically; swap the two private methods for your own
database or object store and keep the rest.
from patternode_client import Patternode
from segment_store import SegmentStore
store = SegmentStore("store/exampleco") # one directory per client: the prefix
pn = Patternode()
store.extend(pn, "exu/7f3a/1b02/export-kw", new_bars) # edge + new bars -> new results, stored
store.right_edge("exu/7f3a/1b02/export-kw") # the open segment at each level
store.edge("exu/7f3a/1b02/export-kw") # what Patternode needs next time
store.cut("exu/7f3a/1b02/export-kw", start, end, width_px=600) # what a chart should draw
Standard library only. Times are whatever unit you send (epoch milliseconds by default).
"""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Edge:
token: str # sealed by Patternode; store it and send it back unchanged
last_close_time: int # the next bars must open after this
profile_version: str # pinned for the life of the series
version: int # this store's write counter, for one writer at a time
class StaleWrite(Exception):
"""Another writer extended the series first. Re-read the edge and retry."""
class SegmentStore:
def __init__(self, root: str | os.PathLike):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
# The one workflow: edge + new bars -> new results ---------------------------------
def extend(self, pn, series: str, bars: list, profile: str = "standard") -> dict:
"""Send the stored edge with the new bars, then store what comes back.
Safe to retry: the same edge with the same bars always gives the same answer. Two
writers on one series cannot both win: the second gets StaleWrite.
"""
edge = self.edge(series)
body = {"series": series, "bars": bars}
if edge:
body["edge"] = edge.token
body["profile"] = edge.profile_version
else:
body["profile"] = profile
result = pn.post("/v1/segmentations/extend", body)
self.apply_extension(series, result, expected_version=edge.version if edge else 0)
return result
# Writing ----------------------------------------------------------------------------
def apply_segmentation(self, series: str, result: dict) -> None:
"""Store a finished series' answer: every segment, and its edge if it was asked for."""
doc = {"series": series, "version": self._read(series).get("version", 0) + 1,
"profile_version": result["profile_version"],
"segments": {s["id"]: s for s in result["segments"]}}
if result.get("edge"):
doc["edge"] = result["edge"]
doc["edge_last_close_time"] = result["edge_last_close_time"]
self._write(series, doc)
def apply_extension(self, series: str, result: dict, expected_version: int | None = None) -> None:
"""Store an extension: closed segments are final, the right edge replaces the old one,
and the new edge replaces the old edge, all in one write."""
doc = self._read(series)
if expected_version is not None and doc.get("version", 0) != expected_version:
raise StaleWrite(series)
segments = {k: v for k, v in doc.get("segments", {}).items() if not v["provisional"]}
for s in result["closed"] + result["right_edge"]:
segments[s["id"]] = s
self._write(series, {"series": series, "version": doc.get("version", 0) + 1,
"profile_version": result["profile_version"], "segments": segments,
"edge": result["edge"], "edge_last_close_time": result["edge_last_close_time"]})
# Reading ----------------------------------------------------------------------------
def edge(self, series: str) -> Edge | None:
doc = self._read(series)
if not doc.get("edge"):
return None
return Edge(doc["edge"], doc["edge_last_close_time"], doc["profile_version"], doc["version"])
def right_edge(self, series: str) -> list[dict]:
"""The open segment at each level, bottom up. Provisional: they may still grow."""
return sorted((s for s in self._segments(series) if s["provisional"]), key=lambda s: s["height"])
def levels(self, series: str) -> list[int]:
return sorted({s["height"] for s in self._segments(series)})
def segments(self, series: str, level: int | None = None, start=None, end=None,
final_only: bool = False) -> list[dict]:
"""Segments by level and time window, in time order. A segment is in the window if it overlaps it."""
out = []
for s in self._segments(series):
if level is not None and s["height"] != level:
continue
if final_only and s["provisional"]:
continue
if start is not None and _t(s["close_time"]) < _t(start):
continue
if end is not None and _t(s["open_time"]) > _t(end):
continue
out.append(s)
return sorted(out, key=lambda s: (s["height"], _t(s["open_time"])))
def cut(self, series: str, start, end, width_px: int, min_px: float = 6.0) -> list[dict]:
"""What a chart of width_px pixels over [start, end] should draw: the finest level whose
segments are, at the median, at least min_px wide. Zoom in and finer levels take over."""
span = max(_t(end) - _t(start), 1)
for level in self.levels(series):
segs = self.segments(series, level, start, end)
if not segs:
continue
widths = sorted((_t(s["close_time"]) - _t(s["open_time"])) / span * width_px for s in segs)
if widths[len(widths) // 2] >= min_px:
return segs
top = self.levels(series)
return self.segments(series, top[-1], start, end) if top else []
def trend(self, series: str, levels=(1, 2, 3), steady: float = 0.001) -> dict[int, str]:
"""Rising, falling or steady at each timescale: the open segment at each level."""
out = {}
for s in self.right_edge(series):
if s["height"] in levels:
change = (s["close"] - s["open"]) / abs(s["open"]) if s["open"] else 0.0
out[s["height"]] = "steady" if abs(change) < steady else "rising" if change > 0 else "falling"
return out
def delete(self, series: str) -> None:
self._path(series).unlink(missing_ok=True)
# Storage: replace these two with your database or object store -----------------------
def _read(self, series: str) -> dict:
path = self._path(series)
return json.loads(path.read_text()) if path.exists() else {}
def _write(self, series: str, doc: dict) -> None:
path = self._path(series)
fd, tmp = tempfile.mkstemp(dir=self.root, suffix=".tmp")
with os.fdopen(fd, "w") as f:
json.dump(doc, f)
os.replace(tmp, path) # atomic: segments and edge change together, or not at all
def _path(self, series: str) -> Path:
return self.root / (series.replace("/", "__") + ".json")
def _segments(self, series: str) -> list[dict]:
return list(self._read(series).get("segments", {}).values())
def _t(v) -> int:
return int(v) # us and ns times arrive as strings
examples/typescript/src/segment-store.ts
// Your segment store, in memory: keep what Patternode returns, query it, and carry forming
// series forward. Patternode keeps none of your series; this is where they live. Replace the
// Map with your database and keep the logic. The Python twin is examples/python/segment_store.py.
import type { Patternode } from "./client.ts";
export interface Segment {
id: string; parent: string | null; height: number; order: number; bars: number;
open_time: number | string; open: number; high_time: number | string; high: number;
low_time: number | string; low: number; close_time: number | string; close: number; provisional: boolean;
}
interface Doc { version: number; profile_version: string; segments: Map<string, Segment>; edge?: string; edge_last_close_time?: number | string }
export class SegmentStore {
private docs = new Map<string, Doc>();
/** Send the stored edge with the new bars, then store the answer. Safe to retry. */
async extend(pn: Patternode, series: string, bars: unknown[], profile = "standard"): Promise<any> {
const doc = this.docs.get(series);
const body: any = { series, bars, profile: doc?.profile_version ?? profile };
if (doc?.edge) body.edge = doc.edge;
const r = await pn.post("/v1/segmentations/extend", body);
this.applyExtension(series, r, doc?.version ?? 0);
return r;
}
applyExtension(series: string, r: any, expectedVersion?: number): void {
const doc = this.docs.get(series);
if (expectedVersion !== undefined && (doc?.version ?? 0) !== expectedVersion) throw new Error(`stale write: ${series}`);
const segments = new Map([...(doc?.segments ?? new Map()).entries()].filter(([, s]) => !s.provisional));
for (const s of [...r.closed, ...r.right_edge]) segments.set(s.id, s);
this.docs.set(series, { version: (doc?.version ?? 0) + 1, profile_version: r.profile_version, segments,
edge: r.edge, edge_last_close_time: r.edge_last_close_time });
}
/** What Patternode needs to carry the series on: the sealed edge and where the next bar must start. */
edge(series: string) {
const d = this.docs.get(series);
return d?.edge ? { token: d.edge, lastCloseTime: d.edge_last_close_time, profileVersion: d.profile_version } : undefined;
}
/** The open segment at each level, bottom up. */
rightEdge(series: string): Segment[] {
return [...(this.docs.get(series)?.segments.values() ?? [])].filter((s) => s.provisional).sort((a, b) => a.height - b.height);
}
/** What a chart widthPx wide over [start, end] should draw: the finest level at least minPx wide at the median. */
cut(series: string, start: number, end: number, widthPx: number, minPx = 6): Segment[] {
const all = [...(this.docs.get(series)?.segments.values() ?? [])]
.filter((s) => Number(s.close_time) >= start && Number(s.open_time) <= end);
const levels = [...new Set(all.map((s) => s.height))].sort((a, b) => a - b);
for (const level of levels) {
const segs = all.filter((s) => s.height === level).sort((a, b) => Number(a.open_time) - Number(b.open_time));
const widths = segs.map((s) => ((Number(s.close_time) - Number(s.open_time)) / Math.max(end - start, 1)) * widthPx).sort((a, b) => a - b);
if (widths[Math.floor(widths.length / 2)] >= minPx) return segs;
}
return levels.length ? all.filter((s) => s.height === levels[levels.length - 1]) : [];
}
}