@matchstick-trading/primitives 0.0.10
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.
- package/AGENTS.md +44 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +201 -0
- package/README.md +636 -0
- package/dist/Chart.d.ts +18 -0
- package/dist/Chart.js +6 -0
- package/dist/ChartParts.d.ts +151 -0
- package/dist/ChartParts.js +389 -0
- package/dist/CsvDrop.d.ts +8 -0
- package/dist/CsvDrop.js +5 -0
- package/dist/CsvDropParts.d.ts +33 -0
- package/dist/CsvDropParts.js +77 -0
- package/dist/Cursor.d.ts +6 -0
- package/dist/Cursor.js +5 -0
- package/dist/CursorParts.d.ts +51 -0
- package/dist/CursorParts.js +40 -0
- package/dist/DepthProfile.d.ts +13 -0
- package/dist/DepthProfile.js +5 -0
- package/dist/DepthProfileParts.d.ts +45 -0
- package/dist/DepthProfileParts.js +96 -0
- package/dist/DiffTable.d.ts +13 -0
- package/dist/DiffTable.js +5 -0
- package/dist/DiffTableParts.d.ts +57 -0
- package/dist/DiffTableParts.js +103 -0
- package/dist/Drawings.d.ts +13 -0
- package/dist/Drawings.js +5 -0
- package/dist/DrawingsParts.d.ts +194 -0
- package/dist/DrawingsParts.js +775 -0
- package/dist/Fullscreen.d.ts +7 -0
- package/dist/Fullscreen.js +5 -0
- package/dist/FullscreenParts.d.ts +54 -0
- package/dist/FullscreenParts.js +250 -0
- package/dist/Ladder.d.ts +13 -0
- package/dist/Ladder.js +5 -0
- package/dist/LadderParts.d.ts +49 -0
- package/dist/LadderParts.js +56 -0
- package/dist/Menu.d.ts +11 -0
- package/dist/Menu.js +5 -0
- package/dist/MenuParts.d.ts +52 -0
- package/dist/MenuParts.js +202 -0
- package/dist/Notice.d.ts +18 -0
- package/dist/Notice.js +9 -0
- package/dist/Panels.d.ts +9 -0
- package/dist/Panels.js +5 -0
- package/dist/PanelsParts.d.ts +62 -0
- package/dist/PanelsParts.js +387 -0
- package/dist/Receipt.d.ts +11 -0
- package/dist/Receipt.js +5 -0
- package/dist/ReceiptParts.d.ts +45 -0
- package/dist/ReceiptParts.js +71 -0
- package/dist/Scrubber.d.ts +9 -0
- package/dist/Scrubber.js +5 -0
- package/dist/ScrubberParts.d.ts +74 -0
- package/dist/ScrubberParts.js +126 -0
- package/dist/Selection.d.ts +14 -0
- package/dist/Selection.js +7 -0
- package/dist/SelectionParts.d.ts +63 -0
- package/dist/SelectionParts.js +208 -0
- package/dist/Studies.d.ts +9 -0
- package/dist/Studies.js +5 -0
- package/dist/StudiesParts.d.ts +45 -0
- package/dist/StudiesParts.js +153 -0
- package/dist/Submenu.d.ts +7 -0
- package/dist/Submenu.js +4 -0
- package/dist/SymbolSearch.d.ts +10 -0
- package/dist/SymbolSearch.js +5 -0
- package/dist/SymbolSearchParts.d.ts +39 -0
- package/dist/SymbolSearchParts.js +86 -0
- package/dist/Tape.d.ts +15 -0
- package/dist/Tape.js +5 -0
- package/dist/TapeParts.d.ts +69 -0
- package/dist/TapeParts.js +139 -0
- package/dist/Toolbar.d.ts +8 -0
- package/dist/Toolbar.js +5 -0
- package/dist/ToolbarParts.d.ts +61 -0
- package/dist/ToolbarParts.js +122 -0
- package/dist/Transport.d.ts +15 -0
- package/dist/Transport.js +5 -0
- package/dist/TransportParts.d.ts +52 -0
- package/dist/TransportParts.js +47 -0
- package/dist/VolumeProfile.d.ts +7 -0
- package/dist/VolumeProfile.js +5 -0
- package/dist/VolumeProfileParts.d.ts +37 -0
- package/dist/VolumeProfileParts.js +57 -0
- package/dist/accessible-name.d.ts +26 -0
- package/dist/accessible-name.js +14 -0
- package/dist/attributes.d.ts +8 -0
- package/dist/attributes.js +9 -0
- package/dist/core/adapter.d.ts +38 -0
- package/dist/core/adapter.js +64 -0
- package/dist/core/attributes.d.ts +36 -0
- package/dist/core/attributes.js +22 -0
- package/dist/core/candles.d.ts +6 -0
- package/dist/core/candles.js +19 -0
- package/dist/core/chart-gesture.d.ts +109 -0
- package/dist/core/chart-gesture.js +161 -0
- package/dist/core/chart-scale.d.ts +141 -0
- package/dist/core/chart-scale.js +302 -0
- package/dist/core/csv-bars.d.ts +54 -0
- package/dist/core/csv-bars.js +325 -0
- package/dist/core/depth-profile.d.ts +43 -0
- package/dist/core/depth-profile.js +66 -0
- package/dist/core/diff-table.d.ts +57 -0
- package/dist/core/diff-table.js +77 -0
- package/dist/core/drawing-fields.d.ts +11 -0
- package/dist/core/drawing-fields.js +15 -0
- package/dist/core/drawings.d.ts +194 -0
- package/dist/core/drawings.js +403 -0
- package/dist/core/fullscreen.d.ts +32 -0
- package/dist/core/fullscreen.js +16 -0
- package/dist/core/index.d.ts +41 -0
- package/dist/core/index.js +23 -0
- package/dist/core/ladder-keys.d.ts +35 -0
- package/dist/core/ladder-keys.js +43 -0
- package/dist/core/ladder.d.ts +65 -0
- package/dist/core/ladder.js +93 -0
- package/dist/core/panels.d.ts +72 -0
- package/dist/core/panels.js +151 -0
- package/dist/core/receipt-rules.d.ts +42 -0
- package/dist/core/receipt-rules.js +20 -0
- package/dist/core/scrubber-gesture.d.ts +66 -0
- package/dist/core/scrubber-gesture.js +69 -0
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +33 -0
- package/dist/core/series.d.ts +161 -0
- package/dist/core/series.js +331 -0
- package/dist/core/studies.d.ts +80 -0
- package/dist/core/studies.js +237 -0
- package/dist/core/symbol-search.d.ts +89 -0
- package/dist/core/symbol-search.js +223 -0
- package/dist/core/tape.d.ts +77 -0
- package/dist/core/tape.js +136 -0
- package/dist/core/volume-profile.d.ts +47 -0
- package/dist/core/volume-profile.js +86 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +42 -0
- package/dist/ladder-keys.d.ts +16 -0
- package/dist/ladder-keys.js +86 -0
- package/dist/panels-context.d.ts +47 -0
- package/dist/panels-context.js +3 -0
- package/dist/selection-context.d.ts +41 -0
- package/dist/selection-context.js +4 -0
- package/examples/csv-to-chart-frame.mjs +17 -0
- package/examples/studies.mjs +12 -0
- package/llms.txt +16 -0
- package/package.json +67 -0
- package/src/Chart.tsx +7 -0
- package/src/ChartParts.tsx +529 -0
- package/src/CsvDrop.tsx +6 -0
- package/src/CsvDropParts.tsx +128 -0
- package/src/Cursor.tsx +6 -0
- package/src/CursorParts.tsx +77 -0
- package/src/DepthProfile.tsx +6 -0
- package/src/DepthProfileParts.tsx +154 -0
- package/src/DiffTable.tsx +6 -0
- package/src/DiffTableParts.tsx +160 -0
- package/src/Drawings.tsx +6 -0
- package/src/DrawingsParts.tsx +1080 -0
- package/src/Fullscreen.tsx +6 -0
- package/src/FullscreenParts.tsx +318 -0
- package/src/Ladder.tsx +6 -0
- package/src/LadderParts.tsx +114 -0
- package/src/Menu.tsx +6 -0
- package/src/MenuParts.tsx +364 -0
- package/src/Notice.tsx +27 -0
- package/src/Panels.tsx +6 -0
- package/src/PanelsParts.tsx +454 -0
- package/src/Receipt.tsx +6 -0
- package/src/ReceiptParts.tsx +118 -0
- package/src/Scrubber.tsx +6 -0
- package/src/ScrubberParts.tsx +207 -0
- package/src/Selection.tsx +12 -0
- package/src/SelectionParts.tsx +251 -0
- package/src/Studies.tsx +6 -0
- package/src/StudiesParts.tsx +226 -0
- package/src/Submenu.tsx +5 -0
- package/src/SymbolSearch.tsx +6 -0
- package/src/SymbolSearchParts.tsx +166 -0
- package/src/Tape.tsx +6 -0
- package/src/TapeParts.tsx +208 -0
- package/src/Toolbar.tsx +6 -0
- package/src/ToolbarParts.tsx +179 -0
- package/src/Transport.tsx +6 -0
- package/src/TransportParts.tsx +128 -0
- package/src/VolumeProfile.tsx +6 -0
- package/src/VolumeProfileParts.tsx +106 -0
- package/src/accessible-name.tsx +34 -0
- package/src/attributes.ts +12 -0
- package/src/core/adapter.ts +91 -0
- package/src/core/attributes.ts +51 -0
- package/src/core/candles.ts +22 -0
- package/src/core/chart-gesture.ts +228 -0
- package/src/core/chart-scale.ts +368 -0
- package/src/core/csv-bars.ts +328 -0
- package/src/core/depth-profile.ts +84 -0
- package/src/core/diff-table.ts +105 -0
- package/src/core/drawing-fields.ts +17 -0
- package/src/core/drawings.ts +478 -0
- package/src/core/fullscreen.ts +32 -0
- package/src/core/index.ts +42 -0
- package/src/core/ladder-keys.ts +61 -0
- package/src/core/ladder.ts +156 -0
- package/src/core/panels.ts +177 -0
- package/src/core/receipt-rules.ts +45 -0
- package/src/core/scrubber-gesture.ts +102 -0
- package/src/core/selection.ts +41 -0
- package/src/core/series.ts +374 -0
- package/src/core/studies.ts +260 -0
- package/src/core/symbol-search.ts +256 -0
- package/src/core/tape.ts +159 -0
- package/src/core/volume-profile.ts +116 -0
- package/src/index.ts +83 -0
- package/src/ladder-keys.ts +83 -0
- package/src/panels-context.ts +46 -0
- package/src/selection-context.ts +38 -0
- package/src/tokens.css +235 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// A series with a clock. A point says when it is (an ISO-8601 UTC instant) and what it is: a finite number, or one of five
|
|
2
|
+
// reasons there is no number. Zero is a value; NaN and infinity are refused, never drawn as gaps. The rules here are plain
|
|
3
|
+
// functions: no calendar (a calendar is the host's), no repair (a disagreement is reported, never fixed), no network.
|
|
4
|
+
// The shape follows the series data contract draft; `report` and `alignSeries` are the two pure readers of it.
|
|
5
|
+
export const POINT_KINDS = ['value', 'missing', 'market-closed', 'not-applicable', 'halted', 'unknown'];
|
|
6
|
+
const ISO_UTC = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?Z$/;
|
|
7
|
+
/** An ISO-8601 UTC instant whose calendar date is real: `Date.parse` alone would turn 2026-02-30 into March 2. */
|
|
8
|
+
const isIso = (s) => {
|
|
9
|
+
if (typeof s !== 'string')
|
|
10
|
+
return false;
|
|
11
|
+
const m = ISO_UTC.exec(s);
|
|
12
|
+
if (!m || !Number.isFinite(Date.parse(s)))
|
|
13
|
+
return false;
|
|
14
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
15
|
+
return d.getUTCFullYear() === Number(m[1]) && d.getUTCMonth() === Number(m[2]) - 1 && d.getUTCDate() === Number(m[3]);
|
|
16
|
+
};
|
|
17
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
18
|
+
function unknownKeys(obj, allowed, path, issues) {
|
|
19
|
+
for (const k of Object.keys(obj))
|
|
20
|
+
if (!allowed.includes(k))
|
|
21
|
+
issues.push({ path: [...path, k], message: `unknown key "${k}"` });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check a value at the boundary (a recorded session, an adapter's output, a hand-built set). Every problem is returned
|
|
25
|
+
* in one pass, not only the first. The input is not changed.
|
|
26
|
+
*/
|
|
27
|
+
export function validateSeriesSet(input) {
|
|
28
|
+
const issues = [];
|
|
29
|
+
if (!isRecord(input))
|
|
30
|
+
return { ok: false, issues: [{ path: [], message: 'expected an object' }] };
|
|
31
|
+
unknownKeys(input, ['schemaVersion', 'alignment', 'window', 'series'], [], issues);
|
|
32
|
+
if (input.schemaVersion !== '0')
|
|
33
|
+
issues.push({ path: ['schemaVersion'], message: 'must be "0"' });
|
|
34
|
+
const al = input.alignment;
|
|
35
|
+
if (!isRecord(al))
|
|
36
|
+
issues.push({ path: ['alignment'], message: 'expected an object' });
|
|
37
|
+
else {
|
|
38
|
+
unknownKeys(al, ['mode', 'toleranceMs', 'primary'], ['alignment'], issues);
|
|
39
|
+
if (al.mode !== 'exact' && al.mode !== 'asof' && al.mode !== 'none')
|
|
40
|
+
issues.push({ path: ['alignment', 'mode'], message: 'must be "exact", "asof" or "none"' });
|
|
41
|
+
if (al.toleranceMs !== undefined && !(typeof al.toleranceMs === 'number' && Number.isInteger(al.toleranceMs) && al.toleranceMs >= 0))
|
|
42
|
+
issues.push({ path: ['alignment', 'toleranceMs'], message: 'must be a whole number of milliseconds, 0 or more' });
|
|
43
|
+
if (al.mode === 'asof' && al.toleranceMs === undefined)
|
|
44
|
+
issues.push({ path: ['alignment', 'toleranceMs'], message: 'required when mode is asof' });
|
|
45
|
+
if (typeof al.primary !== 'string' || al.primary === '')
|
|
46
|
+
issues.push({ path: ['alignment', 'primary'], message: 'must name a series' });
|
|
47
|
+
}
|
|
48
|
+
const w = input.window;
|
|
49
|
+
if (w !== undefined) {
|
|
50
|
+
if (!isRecord(w))
|
|
51
|
+
issues.push({ path: ['window'], message: 'expected an object' });
|
|
52
|
+
else {
|
|
53
|
+
unknownKeys(w, ['start', 'end'], ['window'], issues);
|
|
54
|
+
if (!isIso(w.start))
|
|
55
|
+
issues.push({ path: ['window', 'start'], message: 'must be an ISO-8601 UTC time like 2026-01-05T14:30:00Z' });
|
|
56
|
+
if (!isIso(w.end))
|
|
57
|
+
issues.push({ path: ['window', 'end'], message: 'must be an ISO-8601 UTC time like 2026-01-05T14:30:00Z' });
|
|
58
|
+
if (isIso(w.start) && isIso(w.end) && Date.parse(w.end) <= Date.parse(w.start))
|
|
59
|
+
issues.push({ path: ['window', 'end'], message: 'end must be after start (the range is [start, end))' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const list = input.series;
|
|
63
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
64
|
+
issues.push({ path: ['series'], message: 'expected at least one series' });
|
|
65
|
+
else {
|
|
66
|
+
const ids = [];
|
|
67
|
+
for (let i = 0; i < list.length; i++) {
|
|
68
|
+
const s = list[i]; // by index: an empty slot in a sparse array is read as undefined, not skipped
|
|
69
|
+
const at = ['series', i];
|
|
70
|
+
if (!isRecord(s)) {
|
|
71
|
+
issues.push({ path: at, message: 'expected an object' });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
unknownKeys(s, ['id', 'label', 'unit', 'calendar', 'points'], at, issues);
|
|
75
|
+
if (typeof s.id !== 'string' || s.id === '')
|
|
76
|
+
issues.push({ path: [...at, 'id'], message: 'must be a non-empty string' });
|
|
77
|
+
else
|
|
78
|
+
ids.push(s.id);
|
|
79
|
+
if (typeof s.label !== 'string' || s.label === '')
|
|
80
|
+
issues.push({ path: [...at, 'label'], message: 'must be a non-empty string' });
|
|
81
|
+
if (s.unit !== undefined && typeof s.unit !== 'string')
|
|
82
|
+
issues.push({ path: [...at, 'unit'], message: 'must be a string' });
|
|
83
|
+
if (s.calendar !== undefined) {
|
|
84
|
+
const c = s.calendar;
|
|
85
|
+
if (!isRecord(c))
|
|
86
|
+
issues.push({ path: [...at, 'calendar'], message: 'expected an object' });
|
|
87
|
+
else {
|
|
88
|
+
unknownKeys(c, ['id', 'version', 'tz'], [...at, 'calendar'], issues);
|
|
89
|
+
if (typeof c.id !== 'string' || c.id === '')
|
|
90
|
+
issues.push({ path: [...at, 'calendar', 'id'], message: 'must be a non-empty string' });
|
|
91
|
+
if (typeof c.tz !== 'string' || c.tz === '')
|
|
92
|
+
issues.push({ path: [...at, 'calendar', 'tz'], message: 'must be a non-empty string' });
|
|
93
|
+
if (c.version !== undefined && typeof c.version !== 'string')
|
|
94
|
+
issues.push({ path: [...at, 'calendar', 'version'], message: 'must be a string' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(s.points)) {
|
|
98
|
+
issues.push({ path: [...at, 'points'], message: 'expected a list' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
102
|
+
for (let j = 0; j < s.points.length; j++) {
|
|
103
|
+
const p = s.points[j];
|
|
104
|
+
const pat = [...at, 'points', j];
|
|
105
|
+
if (!isRecord(p)) {
|
|
106
|
+
issues.push({ path: pat, message: 'expected an object' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const kind = p.kind;
|
|
110
|
+
if (typeof kind !== 'string' || !POINT_KINDS.includes(kind)) {
|
|
111
|
+
issues.push({ path: [...pat, 'kind'], message: `must be one of ${POINT_KINDS.join(', ')}` });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
unknownKeys(p, kind === 'value' ? ['t', 'kind', 'v'] : ['t', 'kind'], pat, issues);
|
|
115
|
+
if (!isIso(p.t))
|
|
116
|
+
issues.push({ path: [...pat, 't'], message: 'must be an ISO-8601 UTC time like 2026-01-05T14:30:00Z' });
|
|
117
|
+
else {
|
|
118
|
+
const ms = Date.parse(p.t);
|
|
119
|
+
if (ms <= last)
|
|
120
|
+
issues.push({ path: [...pat, 't'], message: 'timestamps must strictly increase' });
|
|
121
|
+
last = ms;
|
|
122
|
+
}
|
|
123
|
+
if (kind === 'value' && !(typeof p.v === 'number' && Number.isFinite(p.v)))
|
|
124
|
+
issues.push({ path: [...pat, 'v'], message: 'a value must be a finite number (NaN and infinity are refused)' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (new Set(ids).size !== ids.length)
|
|
128
|
+
issues.push({ path: ['series'], message: 'series ids must be unique' });
|
|
129
|
+
if (isRecord(al) && typeof al.primary === 'string' && al.primary !== '' && !ids.includes(al.primary))
|
|
130
|
+
issues.push({ path: ['alignment', 'primary'], message: 'primary must name a series' });
|
|
131
|
+
}
|
|
132
|
+
return issues.length === 0 ? { ok: true, value: input } : { ok: false, issues };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `validateSeriesSet` as a Standard Schema (v1), so any validator-aware tool can run it at a boundary, for example
|
|
136
|
+
* `check(seriesSetSchema, data)` from `@fieldtest/core`. Imports nothing.
|
|
137
|
+
*/
|
|
138
|
+
export const seriesSetSchema = {
|
|
139
|
+
'~standard': {
|
|
140
|
+
version: 1,
|
|
141
|
+
vendor: 'matchstick-primitives',
|
|
142
|
+
validate(value) {
|
|
143
|
+
const r = validateSeriesSet(value);
|
|
144
|
+
return r.ok ? { value: r.value } : { issues: r.issues.map((i) => ({ message: i.message, path: i.path })) };
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const inWindow = (set, ms) => !set.window || (ms >= Date.parse(set.window.start) && ms < Date.parse(set.window.end));
|
|
149
|
+
/** Index of the last time in `times` (ascending) that is at or before `t`, or -1. */
|
|
150
|
+
function atOrBefore(times, t) {
|
|
151
|
+
let lo = 0;
|
|
152
|
+
let hi = times.length - 1;
|
|
153
|
+
let found = -1;
|
|
154
|
+
while (lo <= hi) {
|
|
155
|
+
const mid = (lo + hi) >> 1;
|
|
156
|
+
if (times[mid] <= t) {
|
|
157
|
+
found = mid;
|
|
158
|
+
lo = mid + 1;
|
|
159
|
+
}
|
|
160
|
+
else
|
|
161
|
+
hi = mid - 1;
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Where the series in a set disagree, read on the primary's timestamps. In order: every `unknown` point in the window
|
|
167
|
+
* (`unclassified`), a series that stops before the window ends (`feed-stopped`), then for each primary point and
|
|
168
|
+
* each other series, no point within tolerance (`no-point`) or two different kinds (`gap-kind-differs`).
|
|
169
|
+
*
|
|
170
|
+
* `not-applicable` against anything is expected (a warm-up) and is not reported. `unknown` against another gap kind
|
|
171
|
+
* cannot disagree, but `unknown` against a value does. `halted` is an ordinary kind. With mode `none` there is no
|
|
172
|
+
* comparison across series. The set is not changed; run `validateSeriesSet` first for a set from outside.
|
|
173
|
+
*/
|
|
174
|
+
export function report(set) {
|
|
175
|
+
const out = [];
|
|
176
|
+
const primary = set.series.find((s) => s.id === set.alignment.primary);
|
|
177
|
+
for (const s of set.series)
|
|
178
|
+
for (const q of s.points)
|
|
179
|
+
if (q.kind === 'unknown' && inWindow(set, Date.parse(q.t)))
|
|
180
|
+
out.push({ t: q.t, kind: 'unclassified', series: [s.id], detail: `${s.id}: absent, source does not say why` });
|
|
181
|
+
if (set.window) {
|
|
182
|
+
const end = Date.parse(set.window.end);
|
|
183
|
+
for (const s of set.series) {
|
|
184
|
+
const inside = s.points.filter((q) => Date.parse(q.t) < end); // a point after the range cannot hide a feed that stopped inside it
|
|
185
|
+
const ts = inside.map((q) => Date.parse(q.t));
|
|
186
|
+
const gaps = ts.slice(1).map((x, i) => x - ts[i]).sort((x, y) => x - y);
|
|
187
|
+
const step = gaps[Math.floor(gaps.length / 2)] ?? 0; // the usual interval: the median
|
|
188
|
+
const last = ts.length ? ts[ts.length - 1] : undefined;
|
|
189
|
+
if (last === undefined || last < end - step) {
|
|
190
|
+
const lastText = last === undefined ? 'none' : inside[inside.length - 1].t;
|
|
191
|
+
out.push({ t: last === undefined ? set.window.start : lastText, kind: 'feed-stopped', series: [s.id], detail: `${s.id} last point ${lastText} is more than one interval (${step} ms) before the range end ${set.window.end} (exclusive)` });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!primary || set.alignment.mode === 'none')
|
|
196
|
+
return out;
|
|
197
|
+
const tolerance = set.alignment.mode === 'asof' ? set.alignment.toleranceMs ?? 0 : 0;
|
|
198
|
+
const isGap = (k) => k !== 'value';
|
|
199
|
+
for (const s of set.series) {
|
|
200
|
+
if (s === primary)
|
|
201
|
+
continue;
|
|
202
|
+
const times = s.points.map((q) => Date.parse(q.t));
|
|
203
|
+
for (const pt of primary.points) {
|
|
204
|
+
const t = Date.parse(pt.t);
|
|
205
|
+
if (!inWindow(set, t))
|
|
206
|
+
continue;
|
|
207
|
+
const at = atOrBefore(times, t);
|
|
208
|
+
if (at < 0 || t - times[at] > tolerance) {
|
|
209
|
+
out.push({ t: pt.t, kind: 'no-point', series: [primary.id, s.id], detail: `${s.id} has no point within ${tolerance} ms` });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const other = s.points[at];
|
|
213
|
+
const same = other.kind === pt.kind;
|
|
214
|
+
const warmup = other.kind === 'not-applicable' || pt.kind === 'not-applicable';
|
|
215
|
+
const dontKnow = (other.kind === 'unknown' || pt.kind === 'unknown') && isGap(other.kind) && isGap(pt.kind);
|
|
216
|
+
if (!same && !warmup && !dontKnow)
|
|
217
|
+
out.push({ t: pt.t, kind: 'gap-kind-differs', series: [primary.id, s.id], detail: `${primary.id}=${pt.kind}, ${s.id}=${other.kind}` });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Put a series on the bars' clock: for each bar time, the point at that instant (`exact`) or the latest point at or
|
|
224
|
+
* before it within the tolerance (`asof`). A bar with no point gets `null` and the state `no-point`. Nothing is carried
|
|
225
|
+
* across a gap kind and no zero is invented. `none` is read as `exact`.
|
|
226
|
+
*/
|
|
227
|
+
export function alignSeries(barTimes, series, options = {}) {
|
|
228
|
+
const tolerance = options.mode === 'asof' ? Math.max(0, options.toleranceMs ?? 0) : 0;
|
|
229
|
+
const times = series.points.map((q) => Date.parse(q.t));
|
|
230
|
+
const values = new Array(barTimes.length).fill(null);
|
|
231
|
+
const states = new Array(barTimes.length).fill('no-point');
|
|
232
|
+
for (let i = 0; i < barTimes.length; i++) {
|
|
233
|
+
const at = atOrBefore(times, barTimes[i]);
|
|
234
|
+
if (at < 0 || barTimes[i] - times[at] > tolerance)
|
|
235
|
+
continue;
|
|
236
|
+
const p = series.points[at];
|
|
237
|
+
states[i] = p.kind;
|
|
238
|
+
if (p.kind === 'value')
|
|
239
|
+
values[i] = p.v;
|
|
240
|
+
}
|
|
241
|
+
return { id: series.id, label: series.label, values, states };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Turn a value-per-bar array (a study, or anything made by position) into points on the bars' clock. A number is a
|
|
245
|
+
* value; `null`, NaN and infinity become `gapKind` (default `not-applicable`, which is what a warm-up is).
|
|
246
|
+
*/
|
|
247
|
+
export function pointsFromValues(barTimes, values, gapKind = 'not-applicable') {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (let i = 0; i < barTimes.length; i++) {
|
|
250
|
+
const v = values[i];
|
|
251
|
+
const at = barTimes[i];
|
|
252
|
+
const t = new Date(typeof at === 'number' ? at : at.timestamp).toISOString();
|
|
253
|
+
out.push(typeof v === 'number' && Number.isFinite(v) ? { t, kind: 'value', v } : { t, kind: gapKind });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
const NO_VALUES = [];
|
|
258
|
+
/**
|
|
259
|
+
* The values to draw for one series on these bars. `points` are placed by time (see `alignSeries`); `values` are read by
|
|
260
|
+
* position. Problems are returned in words and the series is still drawn as far as it can be: a length that does not
|
|
261
|
+
* match the bars, a series with no value on any bar, points out of time order.
|
|
262
|
+
*/
|
|
263
|
+
export function resolveSeries(barTimes, input, options = {}) {
|
|
264
|
+
const problems = [];
|
|
265
|
+
const name = `"${input.label || input.id}"`;
|
|
266
|
+
let values;
|
|
267
|
+
let states;
|
|
268
|
+
if (input.points !== undefined) {
|
|
269
|
+
if (!Array.isArray(input.points)) {
|
|
270
|
+
problems.push(`${name} has points that are not a list, so it is not drawn.`);
|
|
271
|
+
values = NO_VALUES;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Only a usable point goes on: an object with a readable time, a known kind, and a finite number on a value.
|
|
275
|
+
const usable = [];
|
|
276
|
+
let bad = 0;
|
|
277
|
+
for (let i = 0; i < input.points.length; i++) {
|
|
278
|
+
const p = input.points[i];
|
|
279
|
+
const ok = typeof p === 'object' && p !== null && isIso(p.t)
|
|
280
|
+
&& POINT_KINDS.includes(p.kind) && (p.kind !== 'value' || (typeof p.v === 'number' && Number.isFinite(p.v)));
|
|
281
|
+
if (ok)
|
|
282
|
+
usable.push(p);
|
|
283
|
+
else {
|
|
284
|
+
bad++;
|
|
285
|
+
// A rejected point with a readable time stays as an unknown, so an as-of alignment cannot carry the value before it across.
|
|
286
|
+
if (typeof p === 'object' && p !== null && isIso(p.t))
|
|
287
|
+
usable.push({ t: p.t, kind: 'unknown' });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (bad > 0)
|
|
291
|
+
problems.push(`${name} has ${bad} point${bad === 1 ? '' : 's'} that cannot be used (a missing time, a time that is not ISO-8601 UTC ending in Z, an unknown kind, or a value that is not a finite number), so ${bad === 1 ? 'it was' : 'they were'} left out.`);
|
|
292
|
+
let prev = Number.NEGATIVE_INFINITY;
|
|
293
|
+
let ordered = true;
|
|
294
|
+
for (const p of usable) {
|
|
295
|
+
const t = Date.parse(p.t);
|
|
296
|
+
if (t <= prev) {
|
|
297
|
+
ordered = false;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
prev = t;
|
|
301
|
+
}
|
|
302
|
+
if (!ordered) {
|
|
303
|
+
problems.push(`${name} has points that are not in strictly increasing time order, so it is not drawn.`);
|
|
304
|
+
values = NO_VALUES;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
const aligned = alignSeries(barTimes, { id: input.id, label: input.label, points: usable }, options);
|
|
308
|
+
values = aligned.values;
|
|
309
|
+
states = aligned.states;
|
|
310
|
+
if (usable.length > 0 && aligned.states.every((s) => s === 'no-point'))
|
|
311
|
+
problems.push(`${name} has no point on any bar. Point times are ISO-8601 UTC text, for example 2026-01-05T14:30:00Z, and must fall on the bars' times.`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
values = input.values ?? NO_VALUES;
|
|
317
|
+
if (input.values !== undefined && input.values.length !== barTimes.length)
|
|
318
|
+
problems.push(`${name} has ${input.values.length} values for ${barTimes.length} bars. Values are read by position, so the extra or missing ones are ignored. Pass points with times instead.`);
|
|
319
|
+
}
|
|
320
|
+
if (!problems.some((p) => !p.includes('cannot be used')) && !values.some((v) => typeof v === 'number' && Number.isFinite(v)))
|
|
321
|
+
problems.push(`${name} has no value on any bar.`);
|
|
322
|
+
return { values, states, problems };
|
|
323
|
+
}
|
|
324
|
+
/** The ids used by more than one series, once each. */
|
|
325
|
+
export function duplicateIds(items) {
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
const dup = new Set();
|
|
328
|
+
for (const { id } of items)
|
|
329
|
+
(seen.has(id) ? dup : seen).add(id);
|
|
330
|
+
return [...dup];
|
|
331
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart studies as plain functions: moving averages, Bollinger bands, VWAP, RSI and ATR. Each takes the bars and
|
|
3
|
+
* returns one value per bar, `null` while the study is still warming up (or where a bar's input is not a finite
|
|
4
|
+
* number). Standard textbook definitions only. Nothing here throws: a bad period is rounded to a whole number of at
|
|
5
|
+
* least 1, and empty input gives an empty array.
|
|
6
|
+
*/
|
|
7
|
+
import type { CandleBar, ChartViewport } from './chart-scale.js';
|
|
8
|
+
/** One value per bar, `null` where the study has no value yet. */
|
|
9
|
+
export type Series = Array<number | null>;
|
|
10
|
+
/** Which price of a bar a study reads. hl2 is (high + low) / 2, hlc3 adds the close, ohlc4 averages all four. */
|
|
11
|
+
export type StudySource = 'open' | 'high' | 'low' | 'close' | 'hl2' | 'hlc3' | 'ohlc4';
|
|
12
|
+
/** A bar's price for `source`; NaN when a needed field is not finite. */
|
|
13
|
+
export declare function sourcePrice(bar: CandleBar, source?: StudySource): number;
|
|
14
|
+
/** Simple moving average: the mean of the last `period` prices. Null until a full window of finite prices exists. */
|
|
15
|
+
export declare function sma(bars: readonly CandleBar[], period: number, source?: StudySource): Series;
|
|
16
|
+
/**
|
|
17
|
+
* Exponential moving average with weight 2 / (period + 1). It starts from the simple average of the first `period`
|
|
18
|
+
* prices, so the first value sits at index period - 1. A bar with no finite price gets null and the average carries
|
|
19
|
+
* on from the next good bar.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ema(bars: readonly CandleBar[], period: number, source?: StudySource): Series;
|
|
22
|
+
/** Weighted moving average: the newest price in the window counts `period` times, the oldest once. */
|
|
23
|
+
export declare function wma(bars: readonly CandleBar[], period: number, source?: StudySource): Series;
|
|
24
|
+
export interface BollingerBands {
|
|
25
|
+
middle: Series;
|
|
26
|
+
upper: Series;
|
|
27
|
+
lower: Series;
|
|
28
|
+
}
|
|
29
|
+
/** Bollinger bands on the close: a simple average, and `mult` population standard deviations above and below it. */
|
|
30
|
+
export declare function bollinger(bars: readonly CandleBar[], period?: number, mult?: number): BollingerBands;
|
|
31
|
+
export interface VwapOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Where the running total restarts: 'session' at each UTC day boundary (moved by `sessionStartUtcMinute`), 'all' never,
|
|
34
|
+
* or a timestamp: bars before it have no value and the total starts on the first bar at or after it.
|
|
35
|
+
*/
|
|
36
|
+
anchor?: 'session' | 'all' | number;
|
|
37
|
+
/** Minutes after 00:00 UTC at which a session begins. Default 0. */
|
|
38
|
+
sessionStartUtcMinute?: number;
|
|
39
|
+
/** Band widths in volume-weighted standard deviations, for example [1, 2]. */
|
|
40
|
+
bands?: readonly number[];
|
|
41
|
+
}
|
|
42
|
+
export interface VwapBand {
|
|
43
|
+
mult: number;
|
|
44
|
+
upper: Series;
|
|
45
|
+
lower: Series;
|
|
46
|
+
}
|
|
47
|
+
export interface VwapResult {
|
|
48
|
+
vwap: Series;
|
|
49
|
+
bands: VwapBand[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Volume-weighted average price on the typical price (high + low + close) / 3, restarting at the anchor. A bar with
|
|
53
|
+
* no volume yet in its run has no value. Bands sit `mult` volume-weighted standard deviations of the typical price
|
|
54
|
+
* around the line.
|
|
55
|
+
*/
|
|
56
|
+
export declare function vwap(bars: readonly CandleBar[], options?: VwapOptions): VwapResult;
|
|
57
|
+
/**
|
|
58
|
+
* Relative strength index with Wilder smoothing, 0 to 100. The first value sits at index `period` (it needs
|
|
59
|
+
* `period` price changes). All gains and no losses is 100; no movement at all is 50.
|
|
60
|
+
*/
|
|
61
|
+
export declare function rsi(bars: readonly CandleBar[], period?: number): Series;
|
|
62
|
+
/**
|
|
63
|
+
* Average true range with Wilder smoothing. A bar's true range is the largest of high - low, |high - previous close|
|
|
64
|
+
* and |low - previous close| (just high - low for the first bar). The first value sits at index period - 1.
|
|
65
|
+
*/
|
|
66
|
+
export declare function atr(bars: readonly CandleBar[], period?: number): Series;
|
|
67
|
+
/** The whole-bar indexes a viewport shows, clamped to the series; last < first when nothing is visible. Same rule as the chart frame. */
|
|
68
|
+
export declare function visibleIndexes(viewport: ChartViewport, total: number): {
|
|
69
|
+
first: number;
|
|
70
|
+
last: number;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The [low, high] of the study values over bars first..last, for `Chart.Root`'s `include` so a line outside the candle
|
|
74
|
+
* range stays in view. With no value to show it returns [Infinity, -Infinity], which the chart ignores.
|
|
75
|
+
*/
|
|
76
|
+
export declare function studyRange(visible: {
|
|
77
|
+
first: number;
|
|
78
|
+
last: number;
|
|
79
|
+
}, ...series: ReadonlyArray<ReadonlyArray<number | null>>): [number, number];
|
|
80
|
+
//# sourceMappingURL=studies.d.ts.map
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/** A bar's price for `source`; NaN when a needed field is not finite. */
|
|
2
|
+
export function sourcePrice(bar, source = 'close') {
|
|
3
|
+
switch (source) {
|
|
4
|
+
case 'open': return bar.open;
|
|
5
|
+
case 'high': return bar.high;
|
|
6
|
+
case 'low': return bar.low;
|
|
7
|
+
case 'hl2': return (bar.high + bar.low) / 2;
|
|
8
|
+
case 'hlc3': return (bar.high + bar.low + bar.close) / 3;
|
|
9
|
+
case 'ohlc4': return (bar.open + bar.high + bar.low + bar.close) / 4;
|
|
10
|
+
default: return bar.close;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const whole = (period, fallback) => (Number.isFinite(period) ? Math.max(1, Math.floor(period)) : fallback);
|
|
14
|
+
const blank = (length) => new Array(length).fill(null);
|
|
15
|
+
/** Simple moving average: the mean of the last `period` prices. Null until a full window of finite prices exists. */
|
|
16
|
+
export function sma(bars, period, source = 'close') {
|
|
17
|
+
const n = whole(period, 1);
|
|
18
|
+
const out = blank(bars.length);
|
|
19
|
+
const prices = bars.map((b) => sourcePrice(b, source));
|
|
20
|
+
for (let i = n - 1; i < prices.length; i++) {
|
|
21
|
+
let sum = 0;
|
|
22
|
+
let ok = true;
|
|
23
|
+
for (let k = i - n + 1; k <= i; k++) {
|
|
24
|
+
if (!Number.isFinite(prices[k])) {
|
|
25
|
+
ok = false;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
sum += prices[k];
|
|
29
|
+
}
|
|
30
|
+
if (ok)
|
|
31
|
+
out[i] = sum / n;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Exponential moving average with weight 2 / (period + 1). It starts from the simple average of the first `period`
|
|
37
|
+
* prices, so the first value sits at index period - 1. A bar with no finite price gets null and the average carries
|
|
38
|
+
* on from the next good bar.
|
|
39
|
+
*/
|
|
40
|
+
export function ema(bars, period, source = 'close') {
|
|
41
|
+
const n = whole(period, 1);
|
|
42
|
+
const out = blank(bars.length);
|
|
43
|
+
const alpha = 2 / (n + 1);
|
|
44
|
+
let previous = null;
|
|
45
|
+
let seed = 0;
|
|
46
|
+
let seen = 0;
|
|
47
|
+
for (let i = 0; i < bars.length; i++) {
|
|
48
|
+
const price = sourcePrice(bars[i], source);
|
|
49
|
+
if (!Number.isFinite(price))
|
|
50
|
+
continue;
|
|
51
|
+
if (previous === null) {
|
|
52
|
+
seed += price;
|
|
53
|
+
seen++;
|
|
54
|
+
if (seen === n) {
|
|
55
|
+
previous = seed / n;
|
|
56
|
+
out[i] = previous;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
previous = previous + alpha * (price - previous);
|
|
61
|
+
out[i] = previous;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Weighted moving average: the newest price in the window counts `period` times, the oldest once. */
|
|
67
|
+
export function wma(bars, period, source = 'close') {
|
|
68
|
+
const n = whole(period, 1);
|
|
69
|
+
const out = blank(bars.length);
|
|
70
|
+
const prices = bars.map((b) => sourcePrice(b, source));
|
|
71
|
+
const weights = (n * (n + 1)) / 2;
|
|
72
|
+
for (let i = n - 1; i < prices.length; i++) {
|
|
73
|
+
let sum = 0;
|
|
74
|
+
let ok = true;
|
|
75
|
+
for (let k = 0; k < n; k++) {
|
|
76
|
+
const price = prices[i - n + 1 + k];
|
|
77
|
+
if (!Number.isFinite(price)) {
|
|
78
|
+
ok = false;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
sum += price * (k + 1);
|
|
82
|
+
}
|
|
83
|
+
if (ok)
|
|
84
|
+
out[i] = sum / weights;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Bollinger bands on the close: a simple average, and `mult` population standard deviations above and below it. */
|
|
89
|
+
export function bollinger(bars, period = 20, mult = 2) {
|
|
90
|
+
const n = whole(period, 20);
|
|
91
|
+
const k = Number.isFinite(mult) ? mult : 2;
|
|
92
|
+
const middle = sma(bars, n, 'close');
|
|
93
|
+
const upper = blank(bars.length);
|
|
94
|
+
const lower = blank(bars.length);
|
|
95
|
+
for (let i = 0; i < bars.length; i++) {
|
|
96
|
+
const mean = middle[i];
|
|
97
|
+
if (mean === null)
|
|
98
|
+
continue;
|
|
99
|
+
let squares = 0;
|
|
100
|
+
for (let j = i - n + 1; j <= i; j++)
|
|
101
|
+
squares += (bars[j].close - mean) ** 2;
|
|
102
|
+
const deviation = Math.sqrt(squares / n);
|
|
103
|
+
upper[i] = mean + k * deviation;
|
|
104
|
+
lower[i] = mean - k * deviation;
|
|
105
|
+
}
|
|
106
|
+
return { middle, upper, lower };
|
|
107
|
+
}
|
|
108
|
+
const DAY_MS = 86400000;
|
|
109
|
+
/**
|
|
110
|
+
* Volume-weighted average price on the typical price (high + low + close) / 3, restarting at the anchor. A bar with
|
|
111
|
+
* no volume yet in its run has no value. Bands sit `mult` volume-weighted standard deviations of the typical price
|
|
112
|
+
* around the line.
|
|
113
|
+
*/
|
|
114
|
+
export function vwap(bars, options = {}) {
|
|
115
|
+
const { anchor = 'session', bands = [] } = options;
|
|
116
|
+
const offset = (Number.isFinite(options.sessionStartUtcMinute) ? options.sessionStartUtcMinute : 0) * 60000;
|
|
117
|
+
const mults = bands.filter((m) => Number.isFinite(m));
|
|
118
|
+
const line = blank(bars.length);
|
|
119
|
+
const edges = mults.map((mult) => ({ mult, upper: blank(bars.length), lower: blank(bars.length) }));
|
|
120
|
+
let session = Number.NaN;
|
|
121
|
+
let volume = 0;
|
|
122
|
+
let money = 0;
|
|
123
|
+
let squares = 0;
|
|
124
|
+
for (let i = 0; i < bars.length; i++) {
|
|
125
|
+
const bar = bars[i];
|
|
126
|
+
if (typeof anchor === 'number' && bar.timestamp < anchor)
|
|
127
|
+
continue;
|
|
128
|
+
if (anchor === 'session') {
|
|
129
|
+
const day = Math.floor((bar.timestamp - offset) / DAY_MS);
|
|
130
|
+
if (day !== session) {
|
|
131
|
+
session = day;
|
|
132
|
+
volume = 0;
|
|
133
|
+
money = 0;
|
|
134
|
+
squares = 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const price = sourcePrice(bar, 'hlc3');
|
|
138
|
+
if (Number.isFinite(price) && Number.isFinite(bar.volume) && bar.volume > 0) {
|
|
139
|
+
volume += bar.volume;
|
|
140
|
+
money += bar.volume * price;
|
|
141
|
+
squares += bar.volume * price * price;
|
|
142
|
+
}
|
|
143
|
+
if (volume <= 0)
|
|
144
|
+
continue;
|
|
145
|
+
const mean = money / volume;
|
|
146
|
+
line[i] = mean;
|
|
147
|
+
const deviation = Math.sqrt(Math.max(0, squares / volume - mean * mean));
|
|
148
|
+
for (const edge of edges) {
|
|
149
|
+
edge.upper[i] = mean + edge.mult * deviation;
|
|
150
|
+
edge.lower[i] = mean - edge.mult * deviation;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { vwap: line, bands: edges };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Relative strength index with Wilder smoothing, 0 to 100. The first value sits at index `period` (it needs
|
|
157
|
+
* `period` price changes). All gains and no losses is 100; no movement at all is 50.
|
|
158
|
+
*/
|
|
159
|
+
export function rsi(bars, period = 14) {
|
|
160
|
+
const n = whole(period, 14);
|
|
161
|
+
const out = blank(bars.length);
|
|
162
|
+
let gain = 0;
|
|
163
|
+
let loss = 0;
|
|
164
|
+
const score = () => (gain === 0 && loss === 0 ? 50 : loss === 0 ? 100 : 100 - 100 / (1 + gain / loss));
|
|
165
|
+
for (let i = 1; i < bars.length; i++) {
|
|
166
|
+
const change = bars[i].close - bars[i - 1].close;
|
|
167
|
+
if (!Number.isFinite(change))
|
|
168
|
+
return out; // a gap in the data ends the study rather than guessing
|
|
169
|
+
const up = Math.max(0, change);
|
|
170
|
+
const down = Math.max(0, -change);
|
|
171
|
+
if (i <= n) {
|
|
172
|
+
gain += up / n;
|
|
173
|
+
loss += down / n;
|
|
174
|
+
if (i === n)
|
|
175
|
+
out[i] = score();
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
gain = (gain * (n - 1) + up) / n;
|
|
179
|
+
loss = (loss * (n - 1) + down) / n;
|
|
180
|
+
out[i] = score();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Average true range with Wilder smoothing. A bar's true range is the largest of high - low, |high - previous close|
|
|
187
|
+
* and |low - previous close| (just high - low for the first bar). The first value sits at index period - 1.
|
|
188
|
+
*/
|
|
189
|
+
export function atr(bars, period = 14) {
|
|
190
|
+
const n = whole(period, 14);
|
|
191
|
+
const out = blank(bars.length);
|
|
192
|
+
let average = 0;
|
|
193
|
+
for (let i = 0; i < bars.length; i++) {
|
|
194
|
+
const bar = bars[i];
|
|
195
|
+
const previous = i > 0 ? bars[i - 1].close : bar.close;
|
|
196
|
+
const range = Math.max(bar.high - bar.low, Math.abs(bar.high - previous), Math.abs(bar.low - previous));
|
|
197
|
+
if (!Number.isFinite(range))
|
|
198
|
+
return out;
|
|
199
|
+
if (i < n) {
|
|
200
|
+
average += range / n;
|
|
201
|
+
if (i === n - 1)
|
|
202
|
+
out[i] = average;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
average = (average * (n - 1) + range) / n;
|
|
206
|
+
out[i] = average;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/** The whole-bar indexes a viewport shows, clamped to the series; last < first when nothing is visible. Same rule as the chart frame. */
|
|
212
|
+
export function visibleIndexes(viewport, total) {
|
|
213
|
+
const start = Number.isFinite(viewport.start) ? viewport.start : 0;
|
|
214
|
+
const end = Number.isFinite(viewport.end) ? viewport.end : start + 1;
|
|
215
|
+
return { first: Math.max(0, Math.floor(start)), last: Math.min(total - 1, Math.ceil(Math.max(end, start + 1)) - 1) };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The [low, high] of the study values over bars first..last, for `Chart.Root`'s `include` so a line outside the candle
|
|
219
|
+
* range stays in view. With no value to show it returns [Infinity, -Infinity], which the chart ignores.
|
|
220
|
+
*/
|
|
221
|
+
export function studyRange(visible, ...series) {
|
|
222
|
+
let low = Infinity;
|
|
223
|
+
let high = -Infinity;
|
|
224
|
+
for (const values of series) {
|
|
225
|
+
const end = Math.min(visible.last, values.length - 1);
|
|
226
|
+
for (let i = Math.max(0, visible.first); i <= end; i++) {
|
|
227
|
+
const value = values[i];
|
|
228
|
+
if (value === null || !Number.isFinite(value))
|
|
229
|
+
continue;
|
|
230
|
+
if (value < low)
|
|
231
|
+
low = value;
|
|
232
|
+
if (value > high)
|
|
233
|
+
high = value;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return [low, high];
|
|
237
|
+
}
|