oakscriptpy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oakscriptpy/plot_.py ADDED
@@ -0,0 +1,49 @@
1
+ """Plot helper — prepares data for charting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class TimeValuePair:
12
+ time: int | str
13
+ value: float
14
+
15
+
16
+ @dataclass
17
+ class PlotResult:
18
+ id: str
19
+ data: list[TimeValuePair]
20
+ options: dict[str, Any]
21
+
22
+
23
+ def plot(
24
+ values: list[float | None],
25
+ times: list[int | str],
26
+ color: str | None = None,
27
+ title: str | None = None,
28
+ line_width: int | None = None,
29
+ line_style: str | None = None,
30
+ offset: int = 0,
31
+ ) -> list[TimeValuePair]:
32
+ result: list[TimeValuePair] = []
33
+ for i, v in enumerate(values):
34
+ if v is None or (isinstance(v, float) and math.isnan(v)):
35
+ continue
36
+ time_index = i + offset
37
+ if time_index < 0 or time_index >= len(times):
38
+ continue
39
+ result.append(TimeValuePair(time=times[time_index], value=v))
40
+ return result
41
+
42
+
43
+ def create_plot(
44
+ id: str,
45
+ values: list[float | None],
46
+ times: list[int | str],
47
+ **options: Any,
48
+ ) -> PlotResult:
49
+ return PlotResult(id=id, data=plot(values, times, **options), options=options)
@@ -0,0 +1,60 @@
1
+ """Polyline namespace — mirrors PineScript polyline.* functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ from ._types import ChartPoint, Polyline
8
+
9
+ _polyline_id_counter = 0
10
+ _all_polylines: list[Polyline] = []
11
+
12
+
13
+ def _generate_id() -> str:
14
+ global _polyline_id_counter
15
+ _polyline_id_counter += 1
16
+ return f"polyline_{_polyline_id_counter}"
17
+
18
+
19
+ def new(
20
+ points: list[ChartPoint],
21
+ curved: bool = False,
22
+ closed: bool = False,
23
+ xloc: str = "bar_index",
24
+ line_color: str | int = "#2196F3",
25
+ fill_color: str | int | None = None,
26
+ line_style: str = "solid",
27
+ line_width: int = 1,
28
+ force_overlay: bool = False,
29
+ ) -> Polyline:
30
+ if curved:
31
+ warnings.warn("polyline.new(): curved polylines not yet supported. Using straight lines.")
32
+ if line_width < 1:
33
+ line_width = 1
34
+ pl = Polyline(
35
+ id=_generate_id(), points=list(points), curved=curved, closed=closed,
36
+ xloc=xloc, line_color=line_color, fill_color=fill_color,
37
+ line_style=line_style, line_width=line_width, force_overlay=force_overlay,
38
+ )
39
+ _all_polylines.append(pl)
40
+ return pl
41
+
42
+
43
+ def delete(id: Polyline) -> None:
44
+ for i, p in enumerate(_all_polylines):
45
+ if p.id == id.id:
46
+ _all_polylines.pop(i)
47
+ return
48
+
49
+
50
+ def get_all() -> list[Polyline]:
51
+ return list(_all_polylines)
52
+
53
+
54
+ def clear_all() -> None:
55
+ _all_polylines.clear()
56
+
57
+
58
+ def reset_id_counter() -> None:
59
+ global _polyline_id_counter
60
+ _polyline_id_counter = 0
oakscriptpy/runtime.py ADDED
@@ -0,0 +1,150 @@
1
+ """Runtime module — global context management, plot, and hline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Any, Callable
8
+
9
+ from .runtime_types import OakScriptContext
10
+
11
+ _context: OakScriptContext | None = None
12
+ _calculate_fn: Callable[[], None] | None = None
13
+ _active_plots: list[dict[str, Any]] = []
14
+ _plot_counter: int = 0
15
+
16
+
17
+ def set_context(ctx: OakScriptContext) -> None:
18
+ global _context, _plot_counter
19
+ _context = ctx
20
+ _plot_counter = 0
21
+ clear_plots()
22
+
23
+
24
+ def clear_context() -> None:
25
+ global _context, _calculate_fn, _plot_counter
26
+ clear_plots()
27
+ _context = None
28
+ _calculate_fn = None
29
+ _plot_counter = 0
30
+
31
+
32
+ def get_context() -> OakScriptContext | None:
33
+ return _context
34
+
35
+
36
+ def register_calculate(fn: Callable[[], None]) -> None:
37
+ global _calculate_fn
38
+ _calculate_fn = fn
39
+
40
+
41
+ def recalculate() -> None:
42
+ global _plot_counter
43
+ if _calculate_fn is not None:
44
+ clear_plots()
45
+ _plot_counter = 0
46
+ _calculate_fn()
47
+
48
+
49
+ def clear_plots() -> None:
50
+ if _context is not None:
51
+ for p in _active_plots:
52
+ try:
53
+ _context.chart.remove_series(p["series"])
54
+ except Exception:
55
+ pass
56
+ _active_plots.clear()
57
+
58
+
59
+ def _get_series_type(style: str | None) -> str:
60
+ if style in ("histogram", "columns"):
61
+ return "histogram"
62
+ if style in ("area", "areabr"):
63
+ return "area"
64
+ return "line"
65
+
66
+
67
+ def _get_line_style(style: str | None) -> int:
68
+ if style == "dotted":
69
+ return 1
70
+ if style == "dashed":
71
+ return 2
72
+ return 0
73
+
74
+
75
+ def plot(
76
+ series: list[float],
77
+ title: str | None = None,
78
+ color: str | None = None,
79
+ linewidth: int | None = None,
80
+ style: str | None = None,
81
+ colors: list[str] | None = None,
82
+ ) -> str:
83
+ global _plot_counter
84
+ if _context is None:
85
+ raise RuntimeError("OakScript context not set. Call set_context() before plotting.")
86
+
87
+ suffix = ""
88
+ if title:
89
+ suffix = "_" + re.sub(r"\s+", "_", title)
90
+ plot_id = f"plot_{_plot_counter}{suffix}"
91
+ _plot_counter += 1
92
+
93
+ series_type = _get_series_type(style)
94
+ options: dict[str, Any] = {}
95
+ if color is not None:
96
+ options["color"] = color
97
+ if linewidth is not None:
98
+ options["lineWidth"] = linewidth
99
+ options["lineStyle"] = _get_line_style(style)
100
+
101
+ series_handle = _context.chart.add_series(series_type, options)
102
+
103
+ data: list[dict[str, Any]] = []
104
+ times = _context.ohlcv.time
105
+ for i, value in enumerate(series):
106
+ if i < len(times) and not math.isnan(value):
107
+ point: dict[str, Any] = {"time": times[i], "value": value}
108
+ if colors and i < len(colors) and colors[i]:
109
+ point["color"] = colors[i]
110
+ data.append(point)
111
+
112
+ series_handle.set_data(data)
113
+ _active_plots.append({"id": plot_id, "series": series_handle})
114
+ return plot_id
115
+
116
+
117
+ def hline(
118
+ price: float,
119
+ title: str | None = None,
120
+ color: str | None = None,
121
+ linestyle: str | None = None,
122
+ linewidth: int | None = None,
123
+ ) -> str:
124
+ global _plot_counter
125
+ if _context is None:
126
+ raise RuntimeError("OakScript context not set. Call set_context() before creating hlines.")
127
+
128
+ suffix = ""
129
+ if title:
130
+ suffix = "_" + re.sub(r"\s+", "_", title)
131
+ hline_id = f"hline_{_plot_counter}{suffix}"
132
+ _plot_counter += 1
133
+
134
+ options: dict[str, Any] = {}
135
+ if color is not None:
136
+ options["color"] = color
137
+ if linewidth is not None:
138
+ options["lineWidth"] = linewidth
139
+ options["lineStyle"] = _get_line_style(linestyle)
140
+
141
+ series_handle = _context.chart.add_series("line", options)
142
+ data = [{"time": t, "value": price} for t in _context.ohlcv.time]
143
+ series_handle.set_data(data)
144
+
145
+ _active_plots.append({"id": hline_id, "series": series_handle})
146
+ return hline_id
147
+
148
+
149
+ def get_active_plots() -> list[dict[str, Any]]:
150
+ return list(_active_plots)
@@ -0,0 +1,52 @@
1
+ """Runtime type definitions — Protocol classes for adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable, Protocol
7
+
8
+
9
+ class SeriesHandle(Protocol):
10
+ def set_data(self, data: list[dict[str, Any]]) -> None: ...
11
+
12
+
13
+ class ChartAdapter(Protocol):
14
+ def add_series(self, type: str, options: dict[str, Any] | None = None) -> SeriesHandle: ...
15
+ def remove_series(self, series: SeriesHandle) -> None: ...
16
+
17
+
18
+ @dataclass
19
+ class InputConfig:
20
+ id: str
21
+ type: str # 'int' | 'float' | 'bool' | 'string' | 'source' | 'color'
22
+ defval: Any
23
+ title: str | None = None
24
+ min: float | None = None
25
+ max: float | None = None
26
+ step: float | None = None
27
+ options: list[str] | None = None
28
+
29
+
30
+ class InputAdapter(Protocol):
31
+ def register_input(self, config: InputConfig) -> Any: ...
32
+ def get_value(self, id: str) -> Any: ...
33
+ def set_value(self, id: str, value: Any) -> None: ...
34
+ def on_input_change(self, callback: Callable[[str, Any], None]) -> None: ...
35
+
36
+
37
+ @dataclass
38
+ class OhlcvData:
39
+ time: list[int]
40
+ open: list[float]
41
+ high: list[float]
42
+ low: list[float]
43
+ close: list[float]
44
+ volume: list[float]
45
+
46
+
47
+ @dataclass
48
+ class OakScriptContext:
49
+ chart: Any # ChartAdapter
50
+ inputs: Any # InputAdapter
51
+ ohlcv: OhlcvData
52
+ bar_index: int = 0
oakscriptpy/series.py ADDED
@@ -0,0 +1,292 @@
1
+ """Series + BarData classes — lazy time-series computation with cache invalidation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, Callable
7
+
8
+ from ._types import Bar
9
+
10
+ SeriesExtractor = Callable[[Bar, int, list[Bar]], float]
11
+
12
+
13
+ class BarData:
14
+ """Versioned wrapper around list[Bar] for cache invalidation.
15
+
16
+ Tracks a version number that increments whenever the data is mutated.
17
+ Series objects reference this to detect when cached values need recomputation.
18
+ """
19
+
20
+ __slots__ = ("_bars", "_version")
21
+
22
+ def __init__(self, bars: list[Bar] | None = None) -> None:
23
+ self._bars: list[Bar] = bars if bars is not None else []
24
+ self._version: int = 0
25
+
26
+ @property
27
+ def version(self) -> int:
28
+ return self._version
29
+
30
+ @property
31
+ def bars(self) -> list[Bar]:
32
+ return self._bars
33
+
34
+ @property
35
+ def length(self) -> int:
36
+ return len(self._bars)
37
+
38
+ def push(self, bar: Bar) -> None:
39
+ self._bars.append(bar)
40
+ self._version += 1
41
+
42
+ def pop(self) -> Bar | None:
43
+ if self._bars:
44
+ bar = self._bars.pop()
45
+ self._version += 1
46
+ return bar
47
+ return None
48
+
49
+ def set(self, index: int, bar: Bar) -> None:
50
+ if 0 <= index < len(self._bars):
51
+ self._bars[index] = bar
52
+ self._version += 1
53
+
54
+ def update_last(self, bar: Bar) -> None:
55
+ if self._bars:
56
+ self._bars[-1] = bar
57
+ self._version += 1
58
+
59
+ def set_all(self, bars: list[Bar]) -> None:
60
+ self._bars = bars
61
+ self._version += 1
62
+
63
+ def invalidate(self) -> None:
64
+ self._version += 1
65
+
66
+ def at(self, index: int) -> Bar | None:
67
+ if 0 <= index < len(self._bars):
68
+ return self._bars[index]
69
+ return None
70
+
71
+ @staticmethod
72
+ def from_bars(bars: list[Bar]) -> BarData:
73
+ return BarData(bars)
74
+
75
+
76
+ class Series:
77
+ """Lazy time-series with operator overloading and automatic cache invalidation.
78
+
79
+ Operations on Series return new Series with composed extractors.
80
+ Values are only computed when to_array() or get() is called.
81
+ """
82
+
83
+ __slots__ = ("_extractor", "_data_source", "_cached", "_cached_version")
84
+
85
+ def __init__(self, data: list[Bar] | BarData, extractor: SeriesExtractor) -> None:
86
+ self._data_source: BarData = data if isinstance(data, BarData) else BarData(data)
87
+ self._extractor: SeriesExtractor = extractor
88
+ self._cached: list[float] | None = None
89
+ self._cached_version: int = -1
90
+
91
+ @property
92
+ def bars(self) -> list[Bar]:
93
+ return self._data_source.bars
94
+
95
+ @property
96
+ def bar_data(self) -> BarData:
97
+ return self._data_source
98
+
99
+ # --- Factory Methods ---
100
+
101
+ @staticmethod
102
+ def from_bars(bars: list[Bar] | BarData, field: str) -> Series:
103
+ def extractor(bar: Bar, _i: int, _data: list[Bar]) -> float:
104
+ v = getattr(bar, field, None)
105
+ return float("nan") if v is None else float(v)
106
+ return Series(bars, extractor)
107
+
108
+ @staticmethod
109
+ def constant(bars: list[Bar] | BarData, value: float) -> Series:
110
+ return Series(bars, lambda _b, _i, _d: value)
111
+
112
+ @staticmethod
113
+ def from_array(bars: list[Bar] | BarData, values: list[float]) -> Series:
114
+ def extractor(_b: Bar, i: int, _d: list[Bar]) -> float:
115
+ return values[i] if i < len(values) else float("nan")
116
+ return Series(bars, extractor)
117
+
118
+ # --- Arithmetic (Python operator overloading) ---
119
+
120
+ def _binop(self, other: Series | float | int, op: Callable[[float, float], float]) -> Series:
121
+ if isinstance(other, Series):
122
+ ext = self._extractor
123
+ other_ext = other._extractor
124
+ return Series(self._data_source, lambda b, i, d: op(ext(b, i, d), other_ext(b, i, d)))
125
+ else:
126
+ val = float(other)
127
+ ext = self._extractor
128
+ return Series(self._data_source, lambda b, i, d: op(ext(b, i, d), val))
129
+
130
+ def _rbinop(self, other: float | int, op: Callable[[float, float], float]) -> Series:
131
+ val = float(other)
132
+ ext = self._extractor
133
+ return Series(self._data_source, lambda b, i, d: op(val, ext(b, i, d)))
134
+
135
+ def __add__(self, other: Series | float | int) -> Series:
136
+ return self._binop(other, lambda a, b: a + b)
137
+
138
+ def __radd__(self, other: float | int) -> Series:
139
+ return self._rbinop(other, lambda a, b: a + b)
140
+
141
+ def __sub__(self, other: Series | float | int) -> Series:
142
+ return self._binop(other, lambda a, b: a - b)
143
+
144
+ def __rsub__(self, other: float | int) -> Series:
145
+ return self._rbinop(other, lambda a, b: a - b)
146
+
147
+ def __mul__(self, other: Series | float | int) -> Series:
148
+ return self._binop(other, lambda a, b: a * b)
149
+
150
+ def __rmul__(self, other: float | int) -> Series:
151
+ return self._rbinop(other, lambda a, b: a * b)
152
+
153
+ def __truediv__(self, other: Series | float | int) -> Series:
154
+ return self._binop(other, lambda a, b: a / b if b != 0 else float("nan"))
155
+
156
+ def __rtruediv__(self, other: float | int) -> Series:
157
+ return self._rbinop(other, lambda a, b: a / b if b != 0 else float("nan"))
158
+
159
+ def __mod__(self, other: Series | float | int) -> Series:
160
+ return self._binop(other, lambda a, b: a % b if b != 0 else float("nan"))
161
+
162
+ def __rmod__(self, other: float | int) -> Series:
163
+ return self._rbinop(other, lambda a, b: a % b if b != 0 else float("nan"))
164
+
165
+ def __neg__(self) -> Series:
166
+ ext = self._extractor
167
+ return Series(self._data_source, lambda b, i, d: -ext(b, i, d))
168
+
169
+ def __abs__(self) -> Series:
170
+ ext = self._extractor
171
+ return Series(self._data_source, lambda b, i, d: abs(ext(b, i, d)))
172
+
173
+ # --- Comparison (return Series with 1/0) ---
174
+
175
+ def __gt__(self, other: Series | float | int) -> Series: # type: ignore[override]
176
+ return self._binop(other, lambda a, b: 1.0 if a > b else 0.0)
177
+
178
+ def __ge__(self, other: Series | float | int) -> Series: # type: ignore[override]
179
+ return self._binop(other, lambda a, b: 1.0 if a >= b else 0.0)
180
+
181
+ def __lt__(self, other: Series | float | int) -> Series: # type: ignore[override]
182
+ return self._binop(other, lambda a, b: 1.0 if a < b else 0.0)
183
+
184
+ def __le__(self, other: Series | float | int) -> Series: # type: ignore[override]
185
+ return self._binop(other, lambda a, b: 1.0 if a <= b else 0.0)
186
+
187
+ def eq(self, other: Series | float | int) -> Series:
188
+ return self._binop(other, lambda a, b: 1.0 if a == b else 0.0)
189
+
190
+ def neq(self, other: Series | float | int) -> Series:
191
+ return self._binop(other, lambda a, b: 1.0 if a != b else 0.0)
192
+
193
+ # --- Logical (keywords in Python, so use methods) ---
194
+
195
+ def and_(self, other: Series | float | int) -> Series:
196
+ if isinstance(other, Series):
197
+ ext = self._extractor
198
+ other_ext = other._extractor
199
+ return Series(self._data_source, lambda b, i, d: 1.0 if ext(b, i, d) and other_ext(b, i, d) else 0.0)
200
+ else:
201
+ val = float(other)
202
+ ext = self._extractor
203
+ return Series(self._data_source, lambda b, i, d: 1.0 if ext(b, i, d) and val else 0.0)
204
+
205
+ def or_(self, other: Series | float | int) -> Series:
206
+ if isinstance(other, Series):
207
+ ext = self._extractor
208
+ other_ext = other._extractor
209
+ return Series(self._data_source, lambda b, i, d: 1.0 if ext(b, i, d) or other_ext(b, i, d) else 0.0)
210
+ else:
211
+ val = float(other)
212
+ ext = self._extractor
213
+ return Series(self._data_source, lambda b, i, d: 1.0 if ext(b, i, d) or val else 0.0)
214
+
215
+ def not_(self) -> Series:
216
+ ext = self._extractor
217
+ return Series(self._data_source, lambda b, i, d: 1.0 if not ext(b, i, d) else 0.0)
218
+
219
+ # --- Conditional Selection ---
220
+
221
+ def iff(self, true_value: Series | float | int, false_value: Series | float | int) -> Series:
222
+ """Bar-by-bar conditional selection (ternary operator for Series)."""
223
+ ext = self._extractor
224
+ tv_is_series = isinstance(true_value, Series)
225
+ fv_is_series = isinstance(false_value, Series)
226
+ tv_ext = true_value._extractor if tv_is_series else None
227
+ fv_ext = false_value._extractor if fv_is_series else None
228
+ tv_num = 0.0 if tv_is_series else float(true_value)
229
+ fv_num = 0.0 if fv_is_series else float(false_value)
230
+
231
+ def _iff(b: Bar, i: int, d: list[Bar]) -> float:
232
+ cond = ext(b, i, d)
233
+ is_truthy = cond != 0 and not math.isnan(cond)
234
+ if is_truthy:
235
+ return tv_ext(b, i, d) if tv_ext is not None else tv_num
236
+ else:
237
+ return fv_ext(b, i, d) if fv_ext is not None else fv_num
238
+
239
+ return Series(self._data_source, _iff)
240
+
241
+ # --- Offset/History ---
242
+
243
+ def offset(self, n: int) -> Series:
244
+ """Access previous bars (like close[1] in PineScript)."""
245
+ ext = self._extractor
246
+
247
+ def _offset(_b: Bar, i: int, d: list[Bar]) -> float:
248
+ target = i - n
249
+ if target < 0 or target >= len(d):
250
+ return float("nan")
251
+ return ext(d[target], target, d)
252
+
253
+ return Series(self._data_source, _offset)
254
+
255
+ # --- Computation & Access ---
256
+
257
+ def to_array(self) -> list[float]:
258
+ if self._cached is not None and self._cached_version == self._data_source.version:
259
+ return self._cached
260
+ bars = self._data_source.bars
261
+ self._cached = [self._extractor(bar, i, bars) for i, bar in enumerate(bars)]
262
+ self._cached_version = self._data_source.version
263
+ return self._cached
264
+
265
+ def materialize(self) -> Series:
266
+ """Eagerly compute values and break closure chain for memory efficiency."""
267
+ values = list(self.to_array())
268
+ return Series.from_array(self._data_source, values)
269
+
270
+ def _invalidate(self) -> None:
271
+ self._cached = None
272
+ self._cached_version = -1
273
+
274
+ def get(self, index: int) -> float:
275
+ values = self.to_array()
276
+ return values[index] if 0 <= index < len(values) else float("nan")
277
+
278
+ def last(self) -> float:
279
+ values = self.to_array()
280
+ return values[-1] if values else float("nan")
281
+
282
+ def length(self) -> int:
283
+ return self._data_source.length
284
+
285
+ def to_time_value_pairs(self) -> list[dict[str, Any]]:
286
+ values = self.to_array()
287
+ bars = self._data_source.bars
288
+ return [
289
+ {"time": bar.time, "value": v}
290
+ for bar, v in zip(bars, values)
291
+ if not math.isnan(v)
292
+ ]