@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,374 @@
|
|
|
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
|
+
|
|
6
|
+
/** Why a point has no number. `value` has one. */
|
|
7
|
+
export type PointKind = 'value' | 'missing' | 'market-closed' | 'not-applicable' | 'halted' | 'unknown';
|
|
8
|
+
|
|
9
|
+
/** One point on a series. `t` is ISO-8601 UTC, for example `2026-01-05T14:30:00Z`. Only `value` carries `v`. */
|
|
10
|
+
export type SeriesPoint =
|
|
11
|
+
| { t: string; kind: 'value'; v: number }
|
|
12
|
+
| { t: string; kind: Exclude<PointKind, 'value'> };
|
|
13
|
+
|
|
14
|
+
/** Which calendar decided `market-closed`. A label: the host fills it and nothing here resolves it. */
|
|
15
|
+
export interface SeriesCalendar {
|
|
16
|
+
id: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
tz: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DataSeries {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
unit?: string;
|
|
25
|
+
calendar?: SeriesCalendar;
|
|
26
|
+
/** Strictly increasing in time. */
|
|
27
|
+
points: SeriesPoint[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `exact`: same instant. `asof`: the latest point at or before, within `toleranceMs`. `none`: no cross-series comparison. */
|
|
31
|
+
export type AlignMode = 'exact' | 'asof' | 'none';
|
|
32
|
+
|
|
33
|
+
export interface SeriesSet {
|
|
34
|
+
schemaVersion: '0';
|
|
35
|
+
alignment: { mode: AlignMode; toleranceMs?: number; primary: string };
|
|
36
|
+
/** Half-open `[start, end)`: a point at `end` is outside it. */
|
|
37
|
+
window?: { start: string; end: string };
|
|
38
|
+
series: DataSeries[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const POINT_KINDS: readonly PointKind[] = ['value', 'missing', 'market-closed', 'not-applicable', 'halted', 'unknown'];
|
|
42
|
+
|
|
43
|
+
/** One problem found at the boundary, with the path to it (`series.1.points.3.t`). */
|
|
44
|
+
export interface SeriesIssue {
|
|
45
|
+
path: (string | number)[];
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SeriesValidation = { ok: true; value: SeriesSet } | { ok: false; issues: SeriesIssue[] };
|
|
50
|
+
|
|
51
|
+
const ISO_UTC = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(\.\d+)?Z$/;
|
|
52
|
+
/** An ISO-8601 UTC instant whose calendar date is real: `Date.parse` alone would turn 2026-02-30 into March 2. */
|
|
53
|
+
const isIso = (s: unknown): s is string => {
|
|
54
|
+
if (typeof s !== 'string') return false;
|
|
55
|
+
const m = ISO_UTC.exec(s);
|
|
56
|
+
if (!m || !Number.isFinite(Date.parse(s))) return false;
|
|
57
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
58
|
+
return d.getUTCFullYear() === Number(m[1]) && d.getUTCMonth() === Number(m[2]) - 1 && d.getUTCDate() === Number(m[3]);
|
|
59
|
+
};
|
|
60
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
61
|
+
|
|
62
|
+
function unknownKeys(obj: Record<string, unknown>, allowed: readonly string[], path: (string | number)[], issues: SeriesIssue[]) {
|
|
63
|
+
for (const k of Object.keys(obj)) if (!allowed.includes(k)) issues.push({ path: [...path, k], message: `unknown key "${k}"` });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check a value at the boundary (a recorded session, an adapter's output, a hand-built set). Every problem is returned
|
|
68
|
+
* in one pass, not only the first. The input is not changed.
|
|
69
|
+
*/
|
|
70
|
+
export function validateSeriesSet(input: unknown): SeriesValidation {
|
|
71
|
+
const issues: SeriesIssue[] = [];
|
|
72
|
+
if (!isRecord(input)) return { ok: false, issues: [{ path: [], message: 'expected an object' }] };
|
|
73
|
+
unknownKeys(input, ['schemaVersion', 'alignment', 'window', 'series'], [], issues);
|
|
74
|
+
if (input.schemaVersion !== '0') issues.push({ path: ['schemaVersion'], message: 'must be "0"' });
|
|
75
|
+
|
|
76
|
+
const al = input.alignment;
|
|
77
|
+
if (!isRecord(al)) issues.push({ path: ['alignment'], message: 'expected an object' });
|
|
78
|
+
else {
|
|
79
|
+
unknownKeys(al, ['mode', 'toleranceMs', 'primary'], ['alignment'], issues);
|
|
80
|
+
if (al.mode !== 'exact' && al.mode !== 'asof' && al.mode !== 'none') issues.push({ path: ['alignment', 'mode'], message: 'must be "exact", "asof" or "none"' });
|
|
81
|
+
if (al.toleranceMs !== undefined && !(typeof al.toleranceMs === 'number' && Number.isInteger(al.toleranceMs) && al.toleranceMs >= 0)) issues.push({ path: ['alignment', 'toleranceMs'], message: 'must be a whole number of milliseconds, 0 or more' });
|
|
82
|
+
if (al.mode === 'asof' && al.toleranceMs === undefined) issues.push({ path: ['alignment', 'toleranceMs'], message: 'required when mode is asof' });
|
|
83
|
+
if (typeof al.primary !== 'string' || al.primary === '') issues.push({ path: ['alignment', 'primary'], message: 'must name a series' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const w = input.window;
|
|
87
|
+
if (w !== undefined) {
|
|
88
|
+
if (!isRecord(w)) issues.push({ path: ['window'], message: 'expected an object' });
|
|
89
|
+
else {
|
|
90
|
+
unknownKeys(w, ['start', 'end'], ['window'], issues);
|
|
91
|
+
if (!isIso(w.start)) issues.push({ path: ['window', 'start'], message: 'must be an ISO-8601 UTC time like 2026-01-05T14:30:00Z' });
|
|
92
|
+
if (!isIso(w.end)) issues.push({ path: ['window', 'end'], message: 'must be an ISO-8601 UTC time like 2026-01-05T14:30:00Z' });
|
|
93
|
+
if (isIso(w.start) && isIso(w.end) && Date.parse(w.end) <= Date.parse(w.start)) issues.push({ path: ['window', 'end'], message: 'end must be after start (the range is [start, end))' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const list = input.series;
|
|
98
|
+
if (!Array.isArray(list) || list.length === 0) issues.push({ path: ['series'], message: 'expected at least one series' });
|
|
99
|
+
else {
|
|
100
|
+
const ids: string[] = [];
|
|
101
|
+
for (let i = 0; i < list.length; i++) {
|
|
102
|
+
const s: unknown = list[i]; // by index: an empty slot in a sparse array is read as undefined, not skipped
|
|
103
|
+
const at = ['series', i];
|
|
104
|
+
if (!isRecord(s)) { issues.push({ path: at, message: 'expected an object' }); continue; }
|
|
105
|
+
unknownKeys(s, ['id', 'label', 'unit', 'calendar', 'points'], at, issues);
|
|
106
|
+
if (typeof s.id !== 'string' || s.id === '') issues.push({ path: [...at, 'id'], message: 'must be a non-empty string' });
|
|
107
|
+
else ids.push(s.id);
|
|
108
|
+
if (typeof s.label !== 'string' || s.label === '') issues.push({ path: [...at, 'label'], message: 'must be a non-empty string' });
|
|
109
|
+
if (s.unit !== undefined && typeof s.unit !== 'string') issues.push({ path: [...at, 'unit'], message: 'must be a string' });
|
|
110
|
+
if (s.calendar !== undefined) {
|
|
111
|
+
const c = s.calendar;
|
|
112
|
+
if (!isRecord(c)) issues.push({ path: [...at, 'calendar'], message: 'expected an object' });
|
|
113
|
+
else {
|
|
114
|
+
unknownKeys(c, ['id', 'version', 'tz'], [...at, 'calendar'], issues);
|
|
115
|
+
if (typeof c.id !== 'string' || c.id === '') issues.push({ path: [...at, 'calendar', 'id'], message: 'must be a non-empty string' });
|
|
116
|
+
if (typeof c.tz !== 'string' || c.tz === '') issues.push({ path: [...at, 'calendar', 'tz'], message: 'must be a non-empty string' });
|
|
117
|
+
if (c.version !== undefined && typeof c.version !== 'string') issues.push({ path: [...at, 'calendar', 'version'], message: 'must be a string' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(s.points)) { issues.push({ path: [...at, 'points'], message: 'expected a list' }); continue; }
|
|
121
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
122
|
+
for (let j = 0; j < s.points.length; j++) {
|
|
123
|
+
const p: unknown = s.points[j];
|
|
124
|
+
const pat = [...at, 'points', j];
|
|
125
|
+
if (!isRecord(p)) { issues.push({ path: pat, message: 'expected an object' }); continue; }
|
|
126
|
+
const kind = p.kind;
|
|
127
|
+
if (typeof kind !== 'string' || !POINT_KINDS.includes(kind as PointKind)) { issues.push({ path: [...pat, 'kind'], message: `must be one of ${POINT_KINDS.join(', ')}` }); continue; }
|
|
128
|
+
unknownKeys(p, kind === 'value' ? ['t', 'kind', 'v'] : ['t', 'kind'], pat, issues);
|
|
129
|
+
if (!isIso(p.t)) issues.push({ path: [...pat, 't'], message: 'must be an ISO-8601 UTC time like 2026-01-05T14:30:00Z' });
|
|
130
|
+
else {
|
|
131
|
+
const ms = Date.parse(p.t);
|
|
132
|
+
if (ms <= last) issues.push({ path: [...pat, 't'], message: 'timestamps must strictly increase' });
|
|
133
|
+
last = ms;
|
|
134
|
+
}
|
|
135
|
+
if (kind === 'value' && !(typeof p.v === 'number' && Number.isFinite(p.v))) issues.push({ path: [...pat, 'v'], message: 'a value must be a finite number (NaN and infinity are refused)' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (new Set(ids).size !== ids.length) issues.push({ path: ['series'], message: 'series ids must be unique' });
|
|
139
|
+
if (isRecord(al) && typeof al.primary === 'string' && al.primary !== '' && !ids.includes(al.primary)) issues.push({ path: ['alignment', 'primary'], message: 'primary must name a series' });
|
|
140
|
+
}
|
|
141
|
+
return issues.length === 0 ? { ok: true, value: input as unknown as SeriesSet } : { ok: false, issues };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The outcome shape Standard Schema v1 uses, kept local so the core imports nothing. */
|
|
145
|
+
type StandardResult<T> = { value: T; issues?: undefined } | { issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<string | number> }>; value?: undefined };
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `validateSeriesSet` as a Standard Schema (v1), so any validator-aware tool can run it at a boundary, for example
|
|
149
|
+
* `check(seriesSetSchema, data)` from `@fieldtest/core`. Imports nothing.
|
|
150
|
+
*/
|
|
151
|
+
export const seriesSetSchema: {
|
|
152
|
+
readonly '~standard': { readonly version: 1; readonly vendor: 'matchstick-primitives'; readonly validate: (value: unknown) => StandardResult<SeriesSet>; readonly types?: { readonly input: SeriesSet; readonly output: SeriesSet } };
|
|
153
|
+
} = {
|
|
154
|
+
'~standard': {
|
|
155
|
+
version: 1,
|
|
156
|
+
vendor: 'matchstick-primitives',
|
|
157
|
+
validate(value) {
|
|
158
|
+
const r = validateSeriesSet(value);
|
|
159
|
+
return r.ok ? { value: r.value } : { issues: r.issues.map((i) => ({ message: i.message, path: i.path })) };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** What `report` finds. It reads and never repairs. */
|
|
165
|
+
export type FindingKind = 'gap-kind-differs' | 'no-point' | 'feed-stopped' | 'unclassified';
|
|
166
|
+
|
|
167
|
+
export interface SeriesFinding {
|
|
168
|
+
/** An ISO time, on the primary's grid for the comparison findings. */
|
|
169
|
+
t: string;
|
|
170
|
+
kind: FindingKind;
|
|
171
|
+
/** The series involved: the primary first, then the other, for a comparison. */
|
|
172
|
+
series: string[];
|
|
173
|
+
detail: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const inWindow = (set: SeriesSet, ms: number) => !set.window || (ms >= Date.parse(set.window.start) && ms < Date.parse(set.window.end));
|
|
177
|
+
|
|
178
|
+
/** Index of the last time in `times` (ascending) that is at or before `t`, or -1. */
|
|
179
|
+
function atOrBefore(times: readonly number[], t: number): number {
|
|
180
|
+
let lo = 0;
|
|
181
|
+
let hi = times.length - 1;
|
|
182
|
+
let found = -1;
|
|
183
|
+
while (lo <= hi) {
|
|
184
|
+
const mid = (lo + hi) >> 1;
|
|
185
|
+
if (times[mid] <= t) { found = mid; lo = mid + 1; } else hi = mid - 1;
|
|
186
|
+
}
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Where the series in a set disagree, read on the primary's timestamps. In order: every `unknown` point in the window
|
|
192
|
+
* (`unclassified`), a series that stops before the window ends (`feed-stopped`), then for each primary point and
|
|
193
|
+
* each other series, no point within tolerance (`no-point`) or two different kinds (`gap-kind-differs`).
|
|
194
|
+
*
|
|
195
|
+
* `not-applicable` against anything is expected (a warm-up) and is not reported. `unknown` against another gap kind
|
|
196
|
+
* cannot disagree, but `unknown` against a value does. `halted` is an ordinary kind. With mode `none` there is no
|
|
197
|
+
* comparison across series. The set is not changed; run `validateSeriesSet` first for a set from outside.
|
|
198
|
+
*/
|
|
199
|
+
export function report(set: SeriesSet): SeriesFinding[] {
|
|
200
|
+
const out: SeriesFinding[] = [];
|
|
201
|
+
const primary = set.series.find((s) => s.id === set.alignment.primary);
|
|
202
|
+
for (const s of set.series)
|
|
203
|
+
for (const q of s.points)
|
|
204
|
+
if (q.kind === 'unknown' && inWindow(set, Date.parse(q.t))) out.push({ t: q.t, kind: 'unclassified', series: [s.id], detail: `${s.id}: absent, source does not say why` });
|
|
205
|
+
|
|
206
|
+
if (set.window) {
|
|
207
|
+
const end = Date.parse(set.window.end);
|
|
208
|
+
for (const s of set.series) {
|
|
209
|
+
const inside = s.points.filter((q) => Date.parse(q.t) < end); // a point after the range cannot hide a feed that stopped inside it
|
|
210
|
+
const ts = inside.map((q) => Date.parse(q.t));
|
|
211
|
+
const gaps = ts.slice(1).map((x, i) => x - ts[i]).sort((x, y) => x - y);
|
|
212
|
+
const step = gaps[Math.floor(gaps.length / 2)] ?? 0; // the usual interval: the median
|
|
213
|
+
const last = ts.length ? ts[ts.length - 1] : undefined;
|
|
214
|
+
if (last === undefined || last < end - step) {
|
|
215
|
+
const lastText = last === undefined ? 'none' : inside[inside.length - 1].t;
|
|
216
|
+
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)` });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!primary || set.alignment.mode === 'none') return out;
|
|
222
|
+
const tolerance = set.alignment.mode === 'asof' ? set.alignment.toleranceMs ?? 0 : 0;
|
|
223
|
+
const isGap = (k: PointKind) => k !== 'value';
|
|
224
|
+
for (const s of set.series) {
|
|
225
|
+
if (s === primary) continue;
|
|
226
|
+
const times = s.points.map((q) => Date.parse(q.t));
|
|
227
|
+
for (const pt of primary.points) {
|
|
228
|
+
const t = Date.parse(pt.t);
|
|
229
|
+
if (!inWindow(set, t)) continue;
|
|
230
|
+
const at = atOrBefore(times, t);
|
|
231
|
+
if (at < 0 || t - times[at] > tolerance) { out.push({ t: pt.t, kind: 'no-point', series: [primary.id, s.id], detail: `${s.id} has no point within ${tolerance} ms` }); continue; }
|
|
232
|
+
const other = s.points[at];
|
|
233
|
+
const same = other.kind === pt.kind;
|
|
234
|
+
const warmup = other.kind === 'not-applicable' || pt.kind === 'not-applicable';
|
|
235
|
+
const dontKnow = (other.kind === 'unknown' || pt.kind === 'unknown') && isGap(other.kind) && isGap(pt.kind);
|
|
236
|
+
if (!same && !warmup && !dontKnow) out.push({ t: pt.t, kind: 'gap-kind-differs', series: [primary.id, s.id], detail: `${primary.id}=${pt.kind}, ${s.id}=${other.kind}` });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** What a series is at one bar: a number, a kind of nothing, or `no-point` when the series has no point there. */
|
|
243
|
+
export type AlignedState = PointKind | 'no-point';
|
|
244
|
+
|
|
245
|
+
export interface AlignedSeries {
|
|
246
|
+
id: string;
|
|
247
|
+
label: string;
|
|
248
|
+
/** One entry per bar; `null` for any bar without a finite value. Zero stays zero. */
|
|
249
|
+
values: Array<number | null>;
|
|
250
|
+
/** One entry per bar: why `values` is null there, or `value`. */
|
|
251
|
+
states: AlignedState[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface AlignOptions {
|
|
255
|
+
mode?: AlignMode;
|
|
256
|
+
/** For `asof`: how far back a point may be and still count. Milliseconds. */
|
|
257
|
+
toleranceMs?: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Put a series on the bars' clock: for each bar time, the point at that instant (`exact`) or the latest point at or
|
|
262
|
+
* before it within the tolerance (`asof`). A bar with no point gets `null` and the state `no-point`. Nothing is carried
|
|
263
|
+
* across a gap kind and no zero is invented. `none` is read as `exact`.
|
|
264
|
+
*/
|
|
265
|
+
export function alignSeries(barTimes: readonly number[], series: DataSeries, options: AlignOptions = {}): AlignedSeries {
|
|
266
|
+
const tolerance = options.mode === 'asof' ? Math.max(0, options.toleranceMs ?? 0) : 0;
|
|
267
|
+
const times = series.points.map((q) => Date.parse(q.t));
|
|
268
|
+
const values: Array<number | null> = new Array(barTimes.length).fill(null);
|
|
269
|
+
const states: AlignedState[] = new Array(barTimes.length).fill('no-point');
|
|
270
|
+
for (let i = 0; i < barTimes.length; i++) {
|
|
271
|
+
const at = atOrBefore(times, barTimes[i]);
|
|
272
|
+
if (at < 0 || barTimes[i] - times[at] > tolerance) continue;
|
|
273
|
+
const p = series.points[at];
|
|
274
|
+
states[i] = p.kind;
|
|
275
|
+
if (p.kind === 'value') values[i] = p.v;
|
|
276
|
+
}
|
|
277
|
+
return { id: series.id, label: series.label, values, states };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Turn a value-per-bar array (a study, or anything made by position) into points on the bars' clock. A number is a
|
|
282
|
+
* value; `null`, NaN and infinity become `gapKind` (default `not-applicable`, which is what a warm-up is).
|
|
283
|
+
*/
|
|
284
|
+
export function pointsFromValues(barTimes: ReadonlyArray<number | { timestamp: number }>, values: ReadonlyArray<number | null | undefined>, gapKind: Exclude<PointKind, 'value'> = 'not-applicable'): SeriesPoint[] {
|
|
285
|
+
const out: SeriesPoint[] = [];
|
|
286
|
+
for (let i = 0; i < barTimes.length; i++) {
|
|
287
|
+
const v = values[i];
|
|
288
|
+
const at = barTimes[i]!;
|
|
289
|
+
const t = new Date(typeof at === 'number' ? at : at.timestamp).toISOString();
|
|
290
|
+
out.push(typeof v === 'number' && Number.isFinite(v) ? { t, kind: 'value', v } : { t, kind: gapKind });
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A series as a part receives it: on its own clock (`points`), or by position (`values`, the shim). */
|
|
296
|
+
export interface SeriesInput {
|
|
297
|
+
id: string;
|
|
298
|
+
label: string;
|
|
299
|
+
points?: ReadonlyArray<SeriesPoint>;
|
|
300
|
+
/** Kept for now so existing code keeps drawing: one value per bar by position. It will be removed in a later version. */
|
|
301
|
+
values?: ReadonlyArray<number | null>;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface ResolvedSeries {
|
|
305
|
+
/** One entry per bar; `null` for none. */
|
|
306
|
+
values: ReadonlyArray<number | null>;
|
|
307
|
+
/** Why a bar has no value; absent for the position shim, which cannot say. */
|
|
308
|
+
states?: AlignedState[];
|
|
309
|
+
/** Plain sentences about what is wrong with the input, empty when nothing is. Nothing is repaired. */
|
|
310
|
+
problems: string[];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const NO_VALUES: ReadonlyArray<number | null> = [];
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The values to draw for one series on these bars. `points` are placed by time (see `alignSeries`); `values` are read by
|
|
317
|
+
* position. Problems are returned in words and the series is still drawn as far as it can be: a length that does not
|
|
318
|
+
* match the bars, a series with no value on any bar, points out of time order.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveSeries(barTimes: readonly number[], input: SeriesInput, options: AlignOptions = {}): ResolvedSeries {
|
|
321
|
+
const problems: string[] = [];
|
|
322
|
+
const name = `"${input.label || input.id}"`;
|
|
323
|
+
let values: ReadonlyArray<number | null>;
|
|
324
|
+
let states: AlignedState[] | undefined;
|
|
325
|
+
if (input.points !== undefined) {
|
|
326
|
+
if (!Array.isArray(input.points)) {
|
|
327
|
+
problems.push(`${name} has points that are not a list, so it is not drawn.`);
|
|
328
|
+
values = NO_VALUES;
|
|
329
|
+
} else {
|
|
330
|
+
// Only a usable point goes on: an object with a readable time, a known kind, and a finite number on a value.
|
|
331
|
+
const usable: SeriesPoint[] = [];
|
|
332
|
+
let bad = 0;
|
|
333
|
+
for (let i = 0; i < input.points.length; i++) {
|
|
334
|
+
const p = input.points[i] as SeriesPoint | null | undefined;
|
|
335
|
+
const ok = typeof p === 'object' && p !== null && isIso(p.t)
|
|
336
|
+
&& (POINT_KINDS as readonly string[]).includes(p.kind) && (p.kind !== 'value' || (typeof p.v === 'number' && Number.isFinite(p.v)));
|
|
337
|
+
if (ok) usable.push(p as SeriesPoint);
|
|
338
|
+
else {
|
|
339
|
+
bad++;
|
|
340
|
+
// A rejected point with a readable time stays as an unknown, so an as-of alignment cannot carry the value before it across.
|
|
341
|
+
if (typeof p === 'object' && p !== null && isIso(p.t)) usable.push({ t: p.t, kind: 'unknown' });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (bad > 0) 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.`);
|
|
345
|
+
let prev = Number.NEGATIVE_INFINITY;
|
|
346
|
+
let ordered = true;
|
|
347
|
+
for (const p of usable) {
|
|
348
|
+
const t = Date.parse(p.t);
|
|
349
|
+
if (t <= prev) { ordered = false; break; }
|
|
350
|
+
prev = t;
|
|
351
|
+
}
|
|
352
|
+
if (!ordered) { problems.push(`${name} has points that are not in strictly increasing time order, so it is not drawn.`); values = NO_VALUES; }
|
|
353
|
+
else {
|
|
354
|
+
const aligned = alignSeries(barTimes, { id: input.id, label: input.label, points: usable }, options);
|
|
355
|
+
values = aligned.values;
|
|
356
|
+
states = aligned.states;
|
|
357
|
+
if (usable.length > 0 && aligned.states.every((s) => s === 'no-point')) 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.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
values = input.values ?? NO_VALUES;
|
|
362
|
+
if (input.values !== undefined && input.values.length !== barTimes.length) 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.`);
|
|
363
|
+
}
|
|
364
|
+
if (!problems.some((p) => !p.includes('cannot be used')) && !values.some((v) => typeof v === 'number' && Number.isFinite(v))) problems.push(`${name} has no value on any bar.`);
|
|
365
|
+
return { values, states, problems };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The ids used by more than one series, once each. */
|
|
369
|
+
export function duplicateIds(items: ReadonlyArray<{ id: string }>): string[] {
|
|
370
|
+
const seen = new Set<string>();
|
|
371
|
+
const dup = new Set<string>();
|
|
372
|
+
for (const { id } of items) (seen.has(id) ? dup : seen).add(id);
|
|
373
|
+
return [...dup];
|
|
374
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
|
|
9
|
+
/** One value per bar, `null` where the study has no value yet. */
|
|
10
|
+
export type Series = Array<number | null>;
|
|
11
|
+
|
|
12
|
+
/** Which price of a bar a study reads. hl2 is (high + low) / 2, hlc3 adds the close, ohlc4 averages all four. */
|
|
13
|
+
export type StudySource = 'open' | 'high' | 'low' | 'close' | 'hl2' | 'hlc3' | 'ohlc4';
|
|
14
|
+
|
|
15
|
+
/** A bar's price for `source`; NaN when a needed field is not finite. */
|
|
16
|
+
export function sourcePrice(bar: CandleBar, source: StudySource = 'close'): number {
|
|
17
|
+
switch (source) {
|
|
18
|
+
case 'open': return bar.open;
|
|
19
|
+
case 'high': return bar.high;
|
|
20
|
+
case 'low': return bar.low;
|
|
21
|
+
case 'hl2': return (bar.high + bar.low) / 2;
|
|
22
|
+
case 'hlc3': return (bar.high + bar.low + bar.close) / 3;
|
|
23
|
+
case 'ohlc4': return (bar.open + bar.high + bar.low + bar.close) / 4;
|
|
24
|
+
default: return bar.close;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const whole = (period: number, fallback: number) => (Number.isFinite(period) ? Math.max(1, Math.floor(period)) : fallback);
|
|
29
|
+
const blank = (length: number): Series => new Array<number | null>(length).fill(null);
|
|
30
|
+
|
|
31
|
+
/** Simple moving average: the mean of the last `period` prices. Null until a full window of finite prices exists. */
|
|
32
|
+
export function sma(bars: readonly CandleBar[], period: number, source: StudySource = 'close'): Series {
|
|
33
|
+
const n = whole(period, 1);
|
|
34
|
+
const out = blank(bars.length);
|
|
35
|
+
const prices = bars.map((b) => sourcePrice(b, source));
|
|
36
|
+
for (let i = n - 1; i < prices.length; i++) {
|
|
37
|
+
let sum = 0;
|
|
38
|
+
let ok = true;
|
|
39
|
+
for (let k = i - n + 1; k <= i; k++) {
|
|
40
|
+
if (!Number.isFinite(prices[k])) { ok = false; break; }
|
|
41
|
+
sum += prices[k];
|
|
42
|
+
}
|
|
43
|
+
if (ok) out[i] = sum / n;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Exponential moving average with weight 2 / (period + 1). It starts from the simple average of the first `period`
|
|
50
|
+
* prices, so the first value sits at index period - 1. A bar with no finite price gets null and the average carries
|
|
51
|
+
* on from the next good bar.
|
|
52
|
+
*/
|
|
53
|
+
export function ema(bars: readonly CandleBar[], period: number, source: StudySource = 'close'): Series {
|
|
54
|
+
const n = whole(period, 1);
|
|
55
|
+
const out = blank(bars.length);
|
|
56
|
+
const alpha = 2 / (n + 1);
|
|
57
|
+
let previous: number | null = null;
|
|
58
|
+
let seed = 0;
|
|
59
|
+
let seen = 0;
|
|
60
|
+
for (let i = 0; i < bars.length; i++) {
|
|
61
|
+
const price = sourcePrice(bars[i], source);
|
|
62
|
+
if (!Number.isFinite(price)) continue;
|
|
63
|
+
if (previous === null) {
|
|
64
|
+
seed += price;
|
|
65
|
+
seen++;
|
|
66
|
+
if (seen === n) { previous = seed / n; out[i] = previous; }
|
|
67
|
+
} else {
|
|
68
|
+
previous = previous + alpha * (price - previous);
|
|
69
|
+
out[i] = previous;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Weighted moving average: the newest price in the window counts `period` times, the oldest once. */
|
|
76
|
+
export function wma(bars: readonly CandleBar[], period: number, source: StudySource = 'close'): Series {
|
|
77
|
+
const n = whole(period, 1);
|
|
78
|
+
const out = blank(bars.length);
|
|
79
|
+
const prices = bars.map((b) => sourcePrice(b, source));
|
|
80
|
+
const weights = (n * (n + 1)) / 2;
|
|
81
|
+
for (let i = n - 1; i < prices.length; i++) {
|
|
82
|
+
let sum = 0;
|
|
83
|
+
let ok = true;
|
|
84
|
+
for (let k = 0; k < n; k++) {
|
|
85
|
+
const price = prices[i - n + 1 + k];
|
|
86
|
+
if (!Number.isFinite(price)) { ok = false; break; }
|
|
87
|
+
sum += price * (k + 1);
|
|
88
|
+
}
|
|
89
|
+
if (ok) out[i] = sum / weights;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface BollingerBands {
|
|
95
|
+
middle: Series;
|
|
96
|
+
upper: Series;
|
|
97
|
+
lower: Series;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Bollinger bands on the close: a simple average, and `mult` population standard deviations above and below it. */
|
|
101
|
+
export function bollinger(bars: readonly CandleBar[], period = 20, mult = 2): BollingerBands {
|
|
102
|
+
const n = whole(period, 20);
|
|
103
|
+
const k = Number.isFinite(mult) ? mult : 2;
|
|
104
|
+
const middle = sma(bars, n, 'close');
|
|
105
|
+
const upper = blank(bars.length);
|
|
106
|
+
const lower = blank(bars.length);
|
|
107
|
+
for (let i = 0; i < bars.length; i++) {
|
|
108
|
+
const mean = middle[i];
|
|
109
|
+
if (mean === null) continue;
|
|
110
|
+
let squares = 0;
|
|
111
|
+
for (let j = i - n + 1; j <= i; j++) squares += (bars[j].close - mean) ** 2;
|
|
112
|
+
const deviation = Math.sqrt(squares / n);
|
|
113
|
+
upper[i] = mean + k * deviation;
|
|
114
|
+
lower[i] = mean - k * deviation;
|
|
115
|
+
}
|
|
116
|
+
return { middle, upper, lower };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface VwapOptions {
|
|
120
|
+
/**
|
|
121
|
+
* Where the running total restarts: 'session' at each UTC day boundary (moved by `sessionStartUtcMinute`), 'all' never,
|
|
122
|
+
* or a timestamp: bars before it have no value and the total starts on the first bar at or after it.
|
|
123
|
+
*/
|
|
124
|
+
anchor?: 'session' | 'all' | number;
|
|
125
|
+
/** Minutes after 00:00 UTC at which a session begins. Default 0. */
|
|
126
|
+
sessionStartUtcMinute?: number;
|
|
127
|
+
/** Band widths in volume-weighted standard deviations, for example [1, 2]. */
|
|
128
|
+
bands?: readonly number[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface VwapBand {
|
|
132
|
+
mult: number;
|
|
133
|
+
upper: Series;
|
|
134
|
+
lower: Series;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface VwapResult {
|
|
138
|
+
vwap: Series;
|
|
139
|
+
bands: VwapBand[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DAY_MS = 86_400_000;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Volume-weighted average price on the typical price (high + low + close) / 3, restarting at the anchor. A bar with
|
|
146
|
+
* no volume yet in its run has no value. Bands sit `mult` volume-weighted standard deviations of the typical price
|
|
147
|
+
* around the line.
|
|
148
|
+
*/
|
|
149
|
+
export function vwap(bars: readonly CandleBar[], options: VwapOptions = {}): VwapResult {
|
|
150
|
+
const { anchor = 'session', bands = [] } = options;
|
|
151
|
+
const offset = (Number.isFinite(options.sessionStartUtcMinute) ? options.sessionStartUtcMinute! : 0) * 60_000;
|
|
152
|
+
const mults = bands.filter((m) => Number.isFinite(m));
|
|
153
|
+
const line = blank(bars.length);
|
|
154
|
+
const edges = mults.map((mult) => ({ mult, upper: blank(bars.length), lower: blank(bars.length) }));
|
|
155
|
+
let session = Number.NaN;
|
|
156
|
+
let volume = 0;
|
|
157
|
+
let money = 0;
|
|
158
|
+
let squares = 0;
|
|
159
|
+
for (let i = 0; i < bars.length; i++) {
|
|
160
|
+
const bar = bars[i];
|
|
161
|
+
if (typeof anchor === 'number' && bar.timestamp < anchor) continue;
|
|
162
|
+
if (anchor === 'session') {
|
|
163
|
+
const day = Math.floor((bar.timestamp - offset) / DAY_MS);
|
|
164
|
+
if (day !== session) { session = day; volume = 0; money = 0; squares = 0; }
|
|
165
|
+
}
|
|
166
|
+
const price = sourcePrice(bar, 'hlc3');
|
|
167
|
+
if (Number.isFinite(price) && Number.isFinite(bar.volume) && bar.volume > 0) {
|
|
168
|
+
volume += bar.volume;
|
|
169
|
+
money += bar.volume * price;
|
|
170
|
+
squares += bar.volume * price * price;
|
|
171
|
+
}
|
|
172
|
+
if (volume <= 0) continue;
|
|
173
|
+
const mean = money / volume;
|
|
174
|
+
line[i] = mean;
|
|
175
|
+
const deviation = Math.sqrt(Math.max(0, squares / volume - mean * mean));
|
|
176
|
+
for (const edge of edges) {
|
|
177
|
+
edge.upper[i] = mean + edge.mult * deviation;
|
|
178
|
+
edge.lower[i] = mean - edge.mult * deviation;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { vwap: line, bands: edges };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Relative strength index with Wilder smoothing, 0 to 100. The first value sits at index `period` (it needs
|
|
186
|
+
* `period` price changes). All gains and no losses is 100; no movement at all is 50.
|
|
187
|
+
*/
|
|
188
|
+
export function rsi(bars: readonly CandleBar[], period = 14): Series {
|
|
189
|
+
const n = whole(period, 14);
|
|
190
|
+
const out = blank(bars.length);
|
|
191
|
+
let gain = 0;
|
|
192
|
+
let loss = 0;
|
|
193
|
+
const score = () => (gain === 0 && loss === 0 ? 50 : loss === 0 ? 100 : 100 - 100 / (1 + gain / loss));
|
|
194
|
+
for (let i = 1; i < bars.length; i++) {
|
|
195
|
+
const change = bars[i].close - bars[i - 1].close;
|
|
196
|
+
if (!Number.isFinite(change)) return out; // a gap in the data ends the study rather than guessing
|
|
197
|
+
const up = Math.max(0, change);
|
|
198
|
+
const down = Math.max(0, -change);
|
|
199
|
+
if (i <= n) {
|
|
200
|
+
gain += up / n;
|
|
201
|
+
loss += down / n;
|
|
202
|
+
if (i === n) out[i] = score();
|
|
203
|
+
} else {
|
|
204
|
+
gain = (gain * (n - 1) + up) / n;
|
|
205
|
+
loss = (loss * (n - 1) + down) / n;
|
|
206
|
+
out[i] = score();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Average true range with Wilder smoothing. A bar's true range is the largest of high - low, |high - previous close|
|
|
214
|
+
* and |low - previous close| (just high - low for the first bar). The first value sits at index period - 1.
|
|
215
|
+
*/
|
|
216
|
+
export function atr(bars: readonly CandleBar[], period = 14): Series {
|
|
217
|
+
const n = whole(period, 14);
|
|
218
|
+
const out = blank(bars.length);
|
|
219
|
+
let average = 0;
|
|
220
|
+
for (let i = 0; i < bars.length; i++) {
|
|
221
|
+
const bar = bars[i];
|
|
222
|
+
const previous = i > 0 ? bars[i - 1].close : bar.close;
|
|
223
|
+
const range = Math.max(bar.high - bar.low, Math.abs(bar.high - previous), Math.abs(bar.low - previous));
|
|
224
|
+
if (!Number.isFinite(range)) return out;
|
|
225
|
+
if (i < n) {
|
|
226
|
+
average += range / n;
|
|
227
|
+
if (i === n - 1) out[i] = average;
|
|
228
|
+
} else {
|
|
229
|
+
average = (average * (n - 1) + range) / n;
|
|
230
|
+
out[i] = average;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The whole-bar indexes a viewport shows, clamped to the series; last < first when nothing is visible. Same rule as the chart frame. */
|
|
237
|
+
export function visibleIndexes(viewport: ChartViewport, total: number): { first: number; last: number } {
|
|
238
|
+
const start = Number.isFinite(viewport.start) ? viewport.start : 0;
|
|
239
|
+
const end = Number.isFinite(viewport.end) ? viewport.end : start + 1;
|
|
240
|
+
return { first: Math.max(0, Math.floor(start)), last: Math.min(total - 1, Math.ceil(Math.max(end, start + 1)) - 1) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The [low, high] of the study values over bars first..last, for `Chart.Root`'s `include` so a line outside the candle
|
|
245
|
+
* range stays in view. With no value to show it returns [Infinity, -Infinity], which the chart ignores.
|
|
246
|
+
*/
|
|
247
|
+
export function studyRange(visible: { first: number; last: number }, ...series: ReadonlyArray<ReadonlyArray<number | null>>): [number, number] {
|
|
248
|
+
let low = Infinity;
|
|
249
|
+
let high = -Infinity;
|
|
250
|
+
for (const values of series) {
|
|
251
|
+
const end = Math.min(visible.last, values.length - 1);
|
|
252
|
+
for (let i = Math.max(0, visible.first); i <= end; i++) {
|
|
253
|
+
const value = values[i];
|
|
254
|
+
if (value === null || !Number.isFinite(value)) continue;
|
|
255
|
+
if (value < low) low = value;
|
|
256
|
+
if (value > high) high = value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return [low, high];
|
|
260
|
+
}
|