@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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a CSV file the user drops in into chart bars, so a chart can show the user's own data with no feed behind
|
|
3
|
+
* it. It reads the common shapes: a header row with names in any case and order (Date, Open, High, Low, Close,
|
|
4
|
+
* Adj Close, Volume, ...), separate Date and Time columns, a header-less file in time-open-high-low-close-volume
|
|
5
|
+
* order, comma, semicolon or tab delimiters, and decimal commas in semicolon or tab files.
|
|
6
|
+
*
|
|
7
|
+
* Times are read as UTC: an ISO date or date-time (an explicit offset is honoured), or a Unix time. A Unix time is read
|
|
8
|
+
* in the first unit that puts it before the year 2100: seconds, then milliseconds, then microseconds; anything later is
|
|
9
|
+
* refused. So a millisecond time in the first seven weeks of 1970 would be misread as seconds. Day-first or month-first dates like 02/01/2024 are refused rather than guessed. A row that doesn't
|
|
10
|
+
* make sense (a missing number, a high below the close, a repeated time) is reported by line, never fixed; the rest
|
|
11
|
+
* of the file still loads. Nothing here throws.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CandleBar } from './chart-scale.js';
|
|
15
|
+
|
|
16
|
+
export interface CsvBarsError {
|
|
17
|
+
/** The 1-based line in the file. 0 for the file as a whole. */
|
|
18
|
+
line: number;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `sorted`: the file was newest first. `no-volume`: no volume column, so volume is 0. `no-header`: read by position. */
|
|
23
|
+
export type CsvBarsNote = 'sorted' | 'no-volume' | 'no-header';
|
|
24
|
+
|
|
25
|
+
/** A column besides time and the prices that holds only numbers (an indicator, a spread), read beside the bars. */
|
|
26
|
+
export interface CsvExtraColumn {
|
|
27
|
+
/** The header text as written. */
|
|
28
|
+
header: string;
|
|
29
|
+
/** One entry per kept bar, in time order: a number, or `null` when the cell said nothing (empty, `-`, `NA`, `NaN`, `null`). */
|
|
30
|
+
values: Array<number | null>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CsvBarsResult {
|
|
34
|
+
bars: CandleBar[];
|
|
35
|
+
errors: CsvBarsError[];
|
|
36
|
+
notes: CsvBarsNote[];
|
|
37
|
+
/** Only when `extraColumns` was asked for. */
|
|
38
|
+
extras?: CsvExtraColumn[];
|
|
39
|
+
/** Only when `extraColumns` was asked for: headers of columns left out because a cell was not a number or a "no value" word. */
|
|
40
|
+
skippedColumns?: string[];
|
|
41
|
+
/** How many bars had a time written without a zone (so `utcOffsetMinutes` applied to them). */
|
|
42
|
+
zoneless?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CsvBarsOptions {
|
|
46
|
+
/** More data rows than this are refused, so a wrong file can't freeze the page. Default 500,000. */
|
|
47
|
+
maxRows?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Minutes east of UTC for times written without a zone (a time with an explicit offset or `Z`, and a Unix time, keep
|
|
50
|
+
* their own meaning). Default 0: UTC. For a file in New York winter time, pass -300. A fixed offset only; a file that
|
|
51
|
+
* crosses a daylight-saving change needs the zone written on each row.
|
|
52
|
+
*/
|
|
53
|
+
utcOffsetMinutes?: number;
|
|
54
|
+
/** Also read every other all-number column as a series beside the bars. Default false. */
|
|
55
|
+
extraColumns?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Field = 'time' | 'date' | 'clock' | 'open' | 'high' | 'low' | 'close' | 'volume';
|
|
59
|
+
|
|
60
|
+
const NAMES: Record<Field, string[]> = {
|
|
61
|
+
time: ['timestamp', 'datetime', 'date time', 'open time', 'time', 'unix', 'ts'],
|
|
62
|
+
date: ['date', 'datum', 'day'],
|
|
63
|
+
clock: ['time'],
|
|
64
|
+
open: ['open', 'o', 'open price'],
|
|
65
|
+
high: ['high', 'h', 'max', 'high price'],
|
|
66
|
+
low: ['low', 'l', 'min', 'low price'],
|
|
67
|
+
close: ['close', 'c', 'last', 'close price'],
|
|
68
|
+
volume: ['volume', 'vol', 'v'],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const normalizeName = (cell: string) => cell.replace(/^/, '').trim().toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
72
|
+
|
|
73
|
+
function splitLine(line: string, delimiter: string): string[] {
|
|
74
|
+
if (!line.includes('"')) return line.split(delimiter).map((c) => c.trim());
|
|
75
|
+
const cells: string[] = [];
|
|
76
|
+
let cell = '';
|
|
77
|
+
let quoted = false;
|
|
78
|
+
for (let i = 0; i < line.length; i++) {
|
|
79
|
+
const ch = line[i];
|
|
80
|
+
if (quoted) {
|
|
81
|
+
if (ch === '"' && line[i + 1] === '"') { cell += '"'; i++; }
|
|
82
|
+
else if (ch === '"') quoted = false;
|
|
83
|
+
else cell += ch;
|
|
84
|
+
} else if (ch === '"') quoted = true;
|
|
85
|
+
else if (ch === delimiter) { cells.push(cell.trim()); cell = ''; }
|
|
86
|
+
else cell += ch;
|
|
87
|
+
}
|
|
88
|
+
cells.push(cell.trim());
|
|
89
|
+
return cells;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Splits the file into records at line breaks outside quotes, keeping each record's 1-based starting line. */
|
|
93
|
+
function splitRecords(text: string): Array<{ text: string; line: number }> {
|
|
94
|
+
if (!text.includes('"')) return text.split(/\r\n|\n|\r/).map((t, i) => ({ text: t, line: i + 1 }));
|
|
95
|
+
const records: Array<{ text: string; line: number }> = [];
|
|
96
|
+
let start = 0;
|
|
97
|
+
let line = 1;
|
|
98
|
+
let startLine = 1;
|
|
99
|
+
let quoted = false;
|
|
100
|
+
for (let i = 0; i <= text.length; i++) {
|
|
101
|
+
const ch = text[i];
|
|
102
|
+
if (ch === '"') quoted = !quoted; // an escaped "" flips twice
|
|
103
|
+
else if (i === text.length || ((ch === '\n' || ch === '\r') && !quoted)) {
|
|
104
|
+
records.push({ text: text.slice(start, i), line: startLine });
|
|
105
|
+
if (ch === '\r' && text[i + 1] === '\n') i++;
|
|
106
|
+
start = i + 1;
|
|
107
|
+
startLine = ++line;
|
|
108
|
+
} else if (ch === '\n' || (ch === '\r' && text[i + 1] !== '\n')) line++;
|
|
109
|
+
}
|
|
110
|
+
return records;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The delimiter that splits the first row the most. A semicolon or tab wins a near tie with the comma, because in
|
|
115
|
+
* those files every number can carry a decimal comma: `1704205800;1,1;2,2;0,5;1,5;10,0` has as many commas as fields.
|
|
116
|
+
*/
|
|
117
|
+
function pickDelimiter(line: string): string {
|
|
118
|
+
const count = (d: string) => line.split(d).length - 1;
|
|
119
|
+
const commas = count(',');
|
|
120
|
+
const other = [';', '\t'].map((d) => [d, count(d)] as const).reduce((best, c) => (c[1] > best[1] ? c : best));
|
|
121
|
+
return other[1] > 0 && other[1] + 1 >= commas ? other[0] : ',';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readNumber(cell: string | undefined, decimalComma: boolean): number | null {
|
|
125
|
+
if (cell === undefined || cell === '') return null;
|
|
126
|
+
const text = decimalComma ? cell.replace(',', '.') : cell;
|
|
127
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) return null;
|
|
128
|
+
const n = Number(text);
|
|
129
|
+
return Number.isFinite(n) ? n : null; // 1e999
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const YEAR_2100 = Date.UTC(2100, 0, 1);
|
|
133
|
+
|
|
134
|
+
/** Unix seconds, milliseconds or microseconds: the first unit that lands before 2100 (see the file comment). */
|
|
135
|
+
function fromEpoch(n: number): number | null {
|
|
136
|
+
for (const ms of [n * 1000, n, n / 1000]) if (ms < YEAR_2100) return Math.round(ms);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ISO = /^(\d{4})[-/]?(\d{2})[-/]?(\d{2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2})(\.\d+)?)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/i;
|
|
141
|
+
const CLOCK = /^(\d{1,2}):?(\d{2})(?::?(\d{2}))?$/;
|
|
142
|
+
|
|
143
|
+
/** A time written without a zone: no Z or offset after a clock time (a Unix time has none to give). */
|
|
144
|
+
function zoneless(cell: string | undefined): boolean {
|
|
145
|
+
const c = (cell ?? '').trim();
|
|
146
|
+
if (/^\d+(\.\d+)?$/.test(c) && c.length !== 8) return false;
|
|
147
|
+
return !/[T ]\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$/i.test(c);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readTime(cell: string | undefined, clock?: string, naiveOffsetMinutes = 0): number | null {
|
|
151
|
+
if (!cell) return null;
|
|
152
|
+
if (/^\d+(\.\d+)?$/.test(cell) && cell.length !== 8) return fromEpoch(Number(cell));
|
|
153
|
+
const m = ISO.exec(clock ? `${cell} ${clock.replace(CLOCK, (_, h, mi, s) => `${h}:${mi}${s ? `:${s}` : ''}`)}` : cell);
|
|
154
|
+
if (!m) return null;
|
|
155
|
+
const [, y, mo, d, h = '0', mi = '0', s = '0', frac = '', zone] = m;
|
|
156
|
+
const month = Number(mo) - 1;
|
|
157
|
+
if (month < 0 || month > 11 || Number(d) < 1 || Number(d) > 31 || Number(h) > 23 || Number(mi) > 59 || Number(s) > 59) return null;
|
|
158
|
+
let t = Date.UTC(Number(y), month, Number(d), Number(h), Number(mi), Number(s), Math.round(Number(`0${frac}`) * 1000));
|
|
159
|
+
if (new Date(t).getUTCDate() !== Number(d)) return null; // 2024-02-30
|
|
160
|
+
if (zone && zone.toUpperCase() !== 'Z') {
|
|
161
|
+
const sign = zone[0] === '-' ? -1 : 1;
|
|
162
|
+
const digits = zone.slice(1).replace(':', '');
|
|
163
|
+
const [zh, zm] = [Number(digits.slice(0, 2)), Number(digits.slice(2))];
|
|
164
|
+
if (zh > 23 || zm > 59) return null; // +99:99
|
|
165
|
+
t -= sign * (zh * 60 + zm) * 60_000;
|
|
166
|
+
} else if (!zone) t -= naiveOffsetMinutes * 60_000;
|
|
167
|
+
return t;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mapColumns(header: string[]): Partial<Record<Field, number>> {
|
|
171
|
+
const names = header.map(normalizeName);
|
|
172
|
+
const find = (aliases: string[], taken: Set<number>) => {
|
|
173
|
+
for (const alias of aliases) {
|
|
174
|
+
const i = names.findIndex((n, idx) => n === alias && !taken.has(idx));
|
|
175
|
+
if (i >= 0) return i;
|
|
176
|
+
}
|
|
177
|
+
return -1;
|
|
178
|
+
};
|
|
179
|
+
const taken = new Set<number>();
|
|
180
|
+
const map: Partial<Record<Field, number>> = {};
|
|
181
|
+
for (const field of ['open', 'high', 'low', 'close'] as const) {
|
|
182
|
+
const i = find(NAMES[field], taken);
|
|
183
|
+
if (i >= 0) { map[field] = i; taken.add(i); }
|
|
184
|
+
}
|
|
185
|
+
// An exact name first, so `Volume USD, Volume` charts Volume; then any other vol... column.
|
|
186
|
+
let vol = find(NAMES.volume, taken);
|
|
187
|
+
if (vol < 0) vol = names.findIndex((n, idx) => !taken.has(idx) && n.startsWith('vol'));
|
|
188
|
+
if (vol >= 0) { map.volume = vol; taken.add(vol); }
|
|
189
|
+
const date = find(NAMES.date, taken);
|
|
190
|
+
const clock = date >= 0 ? find(NAMES.clock, taken) : -1;
|
|
191
|
+
if (date >= 0 && clock >= 0) { map.date = date; map.clock = clock; }
|
|
192
|
+
else {
|
|
193
|
+
// A full timestamp beats a date-only column, so intraday rows keep their times.
|
|
194
|
+
const time = find(NAMES.time, taken);
|
|
195
|
+
if (time >= 0 || date >= 0) map.time = time >= 0 ? time : date;
|
|
196
|
+
}
|
|
197
|
+
return map;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseBarsCsv(text: string, options: CsvBarsOptions = {}): CsvBarsResult {
|
|
201
|
+
const maxRows = options.maxRows ?? 500_000;
|
|
202
|
+
const errors: CsvBarsError[] = [];
|
|
203
|
+
const notes: CsvBarsNote[] = [];
|
|
204
|
+
const lines = splitRecords(text);
|
|
205
|
+
const firstLine = lines.findIndex((l) => l.text.trim() !== '');
|
|
206
|
+
if (firstLine < 0) return { bars: [], errors: [{ line: 0, message: 'The file is empty.' }], notes };
|
|
207
|
+
|
|
208
|
+
const delimiter = pickDelimiter(lines[firstLine]!.text);
|
|
209
|
+
const decimalComma = delimiter !== ',';
|
|
210
|
+
const first = splitLine(lines[firstLine]!.text, delimiter);
|
|
211
|
+
// No header when the first row is already a bar: a time (ISO or Unix) and four prices.
|
|
212
|
+
const offset = Number.isFinite(options.utcOffsetMinutes) ? (options.utcOffsetMinutes as number) : 0;
|
|
213
|
+
const headerless = first.length >= 5 && readTime(first[0]) !== null && first.slice(1, 5).every((c) => readNumber(c, decimalComma) !== null);
|
|
214
|
+
let map: Partial<Record<Field, number>>;
|
|
215
|
+
if (headerless) {
|
|
216
|
+
map = { time: 0, open: 1, high: 2, low: 3, close: 4, volume: first.length > 5 ? 5 : undefined };
|
|
217
|
+
notes.push('no-header');
|
|
218
|
+
} else {
|
|
219
|
+
map = mapColumns(first);
|
|
220
|
+
const missing: string[] = (['open', 'high', 'low', 'close'] as const).filter((f) => map[f] === undefined);
|
|
221
|
+
if (map.time === undefined && map.date === undefined) missing.unshift('time');
|
|
222
|
+
if (missing.length > 0) {
|
|
223
|
+
return { bars: [], errors: [{ line: lines[firstLine]!.line, message: `No column for ${missing.join(', ')}. The header row needs time (or date), open, high, low and close.` }], notes };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (map.volume === undefined) notes.push('no-volume');
|
|
227
|
+
|
|
228
|
+
let naive = 0;
|
|
229
|
+
const rows: Array<{ bar: CandleBar; line: number; cells: string[] }> = [];
|
|
230
|
+
const start = headerless ? firstLine : firstLine + 1;
|
|
231
|
+
let dataRows = 0;
|
|
232
|
+
for (let i = start; i < lines.length; i++) {
|
|
233
|
+
const { text: raw, line } = lines[i]!;
|
|
234
|
+
if (raw.trim() === '') continue;
|
|
235
|
+
if (++dataRows > maxRows) {
|
|
236
|
+
errors.push({ line: 0, message: `The file has more than ${maxRows.toLocaleString('en-US')} rows; only the first ${maxRows.toLocaleString('en-US')} were read.` });
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
const cells = splitLine(raw, delimiter);
|
|
240
|
+
// With separate Date and Time columns, a row without its time is refused rather than read as midnight.
|
|
241
|
+
const timestamp = map.date !== undefined
|
|
242
|
+
? (cells[map.clock!] ? readTime(cells[map.date], cells[map.clock!], offset) : null)
|
|
243
|
+
: readTime(cells[map.time!], undefined, offset);
|
|
244
|
+
if (timestamp === null) {
|
|
245
|
+
errors.push({ line, message: 'The time is not a date in year-month-day order or a Unix time.' });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (map.date !== undefined || zoneless(cells[map.time!])) naive++;
|
|
249
|
+
const [open, high, low, close] = (['open', 'high', 'low', 'close'] as const).map((f) => readNumber(cells[map[f]!], decimalComma));
|
|
250
|
+
const volume = map.volume === undefined ? 0 : readNumber(cells[map.volume], decimalComma);
|
|
251
|
+
if (open === null || high === null || low === null || close === null || volume === null) {
|
|
252
|
+
errors.push({ line, message: 'A price or the volume is not a number.' });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (high < Math.max(open, close, low) || low > Math.min(open, close)) {
|
|
256
|
+
errors.push({ line, message: 'The high is below another price, or the low is above one.' });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (volume < 0) {
|
|
260
|
+
errors.push({ line, message: 'The volume is negative.' });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
rows.push({ bar: { timestamp, open, high, low, close, volume }, line, cells });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const descending = rows.length > 1 && rows[0]!.bar.timestamp > rows[rows.length - 1]!.bar.timestamp;
|
|
267
|
+
if (descending) notes.push('sorted');
|
|
268
|
+
// Stable by line, so the first of two rows with the same time is the one kept.
|
|
269
|
+
rows.sort((a, b) => a.bar.timestamp - b.bar.timestamp || a.line - b.line);
|
|
270
|
+
const bars: CandleBar[] = [];
|
|
271
|
+
const kept: typeof rows = [];
|
|
272
|
+
for (let i = 0; i < rows.length; i++) {
|
|
273
|
+
if (i > 0 && rows[i]!.bar.timestamp === rows[i - 1]!.bar.timestamp) {
|
|
274
|
+
errors.push({ line: rows[i]!.line, message: 'This time is already in the file on an earlier line.' });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
bars.push(rows[i]!.bar);
|
|
278
|
+
kept.push(rows[i]!);
|
|
279
|
+
}
|
|
280
|
+
if (!descending && rows.some((r, i) => i > 0 && r.line < rows[i - 1]!.line)) notes.push('sorted');
|
|
281
|
+
errors.sort((a, b) => a.line - b.line);
|
|
282
|
+
const result: CsvBarsResult = { bars, errors, notes };
|
|
283
|
+
result.zoneless = naive;
|
|
284
|
+
if (options.extraColumns && !headerless) {
|
|
285
|
+
const skipped: string[] = [];
|
|
286
|
+
result.extras = readExtraColumns(first, map, kept, decimalComma, skipped);
|
|
287
|
+
result.skippedColumns = skipped;
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const NO_VALUE = /^(-|--|na|n\/a|nan|null|none)$/i;
|
|
293
|
+
|
|
294
|
+
/** Every column the bars did not use whose cells are all numbers or "no value" words (an all-"no value" column is kept); a column with any other text is left out. */
|
|
295
|
+
function readExtraColumns(header: string[], map: Partial<Record<Field, number>>, rows: Array<{ cells: string[] }>, decimalComma: boolean, skipped: string[]): CsvExtraColumn[] {
|
|
296
|
+
const used = new Set(Object.values(map).filter((i): i is number => i !== undefined));
|
|
297
|
+
const out: CsvExtraColumn[] = [];
|
|
298
|
+
header.forEach((name, col) => {
|
|
299
|
+
if (used.has(col) || name.trim() === '') return;
|
|
300
|
+
const values: Array<number | null> = [];
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
const cell = (row.cells[col] ?? '').trim();
|
|
303
|
+
if (cell === '' || NO_VALUE.test(cell)) { values.push(null); continue; }
|
|
304
|
+
const n = readNumber(cell, decimalComma);
|
|
305
|
+
if (n === null) { skipped.push(name.trim()); return; } // text in the column: not a series, and the notes say so
|
|
306
|
+
values.push(n);
|
|
307
|
+
}
|
|
308
|
+
if (values.length > 0) out.push({ header: name, values }); // a named column that is all "no value" stays, as unknown, rather than vanishing
|
|
309
|
+
});
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One plain sentence or two about a parse, for a status line: how many bars loaded and what was skipped. */
|
|
314
|
+
export function describeBarsCsv(result: CsvBarsResult, fileName = 'the file'): string {
|
|
315
|
+
const { bars, errors, notes } = result;
|
|
316
|
+
const fileWide = errors.find((e) => e.line === 0 && bars.length === 0) ?? (bars.length === 0 ? errors[0] : undefined);
|
|
317
|
+
if (bars.length === 0) return `No bars loaded from ${fileName}. ${fileWide?.message ?? 'No rows could be read.'}`;
|
|
318
|
+
const parts = [`Loaded ${bars.length.toLocaleString('en-US')} bar${bars.length === 1 ? '' : 's'} from ${fileName}.`];
|
|
319
|
+
const rows = errors.filter((e) => e.line > 0);
|
|
320
|
+
if (rows.length > 0) {
|
|
321
|
+
const shown = rows.slice(0, 3).map((e) => `line ${e.line}: ${e.message.replace(/\.$/, '').replace(/^\w/, (c) => c.toLowerCase())}`).join('; ');
|
|
322
|
+
parts.push(`Skipped ${rows.length.toLocaleString('en-US')} row${rows.length === 1 ? '' : 's'} (${shown}${rows.length > 3 ? '; …' : ''}).`);
|
|
323
|
+
}
|
|
324
|
+
for (const e of errors.filter((e) => e.line === 0)) parts.push(e.message);
|
|
325
|
+
if (notes.includes('sorted')) parts.push('The rows were out of time order, so they were sorted.');
|
|
326
|
+
if (notes.includes('no-volume')) parts.push('There is no volume column, so volume shows as 0.');
|
|
327
|
+
return parts.join(' ');
|
|
328
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The geometry and keys of the depth profile as plain functions. Prices and sizes arrive and stay
|
|
3
|
+
* exact decimal strings. A number is made from one only to place a point on the plot, and never shown.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface DepthLevel {
|
|
7
|
+
side: 'bid' | 'ask';
|
|
8
|
+
/** Exact decimal string, as recorded. */
|
|
9
|
+
price: string;
|
|
10
|
+
/** Cumulative size from the best price out to this level, as an exact decimal string. */
|
|
11
|
+
cumulative: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The id of a level, the same `side:price` the ladder rows use. */
|
|
15
|
+
export const depthLevelId = (level: Pick<DepthLevel, 'side' | 'price'>): string => `${level.side}:${level.price}`;
|
|
16
|
+
|
|
17
|
+
export const DEPTH_WIDTH = 600;
|
|
18
|
+
export const DEPTH_HEIGHT = 180;
|
|
19
|
+
|
|
20
|
+
export interface DepthGeometry {
|
|
21
|
+
/** Bids from the lowest price up to the best bid, then asks from the best ask up: left to right on the plot. */
|
|
22
|
+
ordered: DepthLevel[];
|
|
23
|
+
x: (price: string) => number;
|
|
24
|
+
y: (cumulative: string) => number;
|
|
25
|
+
/** SVG polygon points for each side; empty when the side has no levels. */
|
|
26
|
+
bidPoints: string;
|
|
27
|
+
askPoints: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `levels` come best price first on each side (bids descending, asks ascending), as the book's depth does. The scale
|
|
32
|
+
* runs from the lowest to the highest price present and from 0 to the largest cumulative size (at least 1).
|
|
33
|
+
*/
|
|
34
|
+
export function depthGeometry(levels: readonly DepthLevel[]): DepthGeometry {
|
|
35
|
+
const bids = levels.filter((l) => l.side === 'bid');
|
|
36
|
+
const asks = levels.filter((l) => l.side === 'ask');
|
|
37
|
+
const ordered = [...bids].reverse().concat(asks);
|
|
38
|
+
let minPrice = Infinity;
|
|
39
|
+
let maxPrice = -Infinity;
|
|
40
|
+
let maxSize = 1;
|
|
41
|
+
for (const level of ordered) {
|
|
42
|
+
const price = Number(level.price);
|
|
43
|
+
if (price < minPrice) minPrice = price;
|
|
44
|
+
if (price > maxPrice) maxPrice = price;
|
|
45
|
+
maxSize = Math.max(maxSize, Number(level.cumulative));
|
|
46
|
+
}
|
|
47
|
+
const x = (price: string) => (maxPrice === minPrice ? DEPTH_WIDTH / 2 : ((Number(price) - minPrice) / (maxPrice - minPrice)) * DEPTH_WIDTH);
|
|
48
|
+
const y = (cumulative: string) => DEPTH_HEIGHT - (Number(cumulative) / maxSize) * (DEPTH_HEIGHT - 12);
|
|
49
|
+
const polygon = (side: readonly DepthLevel[]) => {
|
|
50
|
+
if (side.length === 0) return '';
|
|
51
|
+
const line = side.map((l) => `${x(l.price)},${y(l.cumulative)}`).join(' ');
|
|
52
|
+
return `${x(side[0].price)},${DEPTH_HEIGHT} ${line} ${x(side[side.length - 1].price)},${DEPTH_HEIGHT}`;
|
|
53
|
+
};
|
|
54
|
+
return { ordered, x, y, bidPoints: polygon([...bids].reverse()), askPoints: polygon(asks) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type DepthKeyAction = 'previous' | 'next' | 'first' | 'last' | 'clear';
|
|
58
|
+
|
|
59
|
+
export function resolveDepthKey(event: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): DepthKeyAction | null {
|
|
60
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return null;
|
|
61
|
+
switch (event.key) {
|
|
62
|
+
case 'ArrowLeft': return 'previous';
|
|
63
|
+
case 'ArrowRight': return 'next';
|
|
64
|
+
case 'Home': return 'first';
|
|
65
|
+
case 'End': return 'last';
|
|
66
|
+
case 'Escape': return 'clear';
|
|
67
|
+
default: return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The index the crosshair moves to, or null for none. From no crosshair the first move lands on the best bid, the
|
|
73
|
+
* level next to the spread, whichever way the key points. It stops at the ends and never wraps.
|
|
74
|
+
*/
|
|
75
|
+
export function nextDepthIndex(ordered: readonly DepthLevel[], current: number | null, action: DepthKeyAction): number | null {
|
|
76
|
+
if (ordered.length === 0 || action === 'clear') return null;
|
|
77
|
+
if (action === 'first') return 0;
|
|
78
|
+
if (action === 'last') return ordered.length - 1;
|
|
79
|
+
if (current === null) {
|
|
80
|
+
const bestBid = ordered.map((l) => l.side).lastIndexOf('bid');
|
|
81
|
+
return bestBid >= 0 ? bestBid : 0;
|
|
82
|
+
}
|
|
83
|
+
return Math.min(ordered.length - 1, Math.max(0, current + (action === 'next' ? 1 : -1)));
|
|
84
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of the diff table as plain functions. Prices, sizes and deltas arrive and stay exact
|
|
3
|
+
* decimal strings; they are only compared, never turned into numbers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DiffChange = 'added' | 'removed' | 'modified' | 'unchanged';
|
|
7
|
+
export type DiffSort = 'book' | 'delta' | 'price';
|
|
8
|
+
|
|
9
|
+
export interface DiffTableRow {
|
|
10
|
+
side: 'bid' | 'ask';
|
|
11
|
+
/** Exact decimal string, as recorded. */
|
|
12
|
+
price: string;
|
|
13
|
+
qtyA: string;
|
|
14
|
+
qtyB: string;
|
|
15
|
+
/** Signed exact decimal string: qtyB minus qtyA. */
|
|
16
|
+
delta: string;
|
|
17
|
+
change: DiffChange;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The id of a row, the same `side:price` the ladder rows and the depth levels use. */
|
|
21
|
+
export const diffRowId = (row: Pick<DiffTableRow, 'side' | 'price'>): string => `${row.side}:${row.price}`;
|
|
22
|
+
|
|
23
|
+
const DECIMAL = /^(-?)(\d+)(?:\.(\d+))?$/;
|
|
24
|
+
|
|
25
|
+
function scaled(value: string, places: number): bigint {
|
|
26
|
+
const match = DECIMAL.exec(value);
|
|
27
|
+
if (!match) throw new Error(`Not an exact decimal: ${value}`);
|
|
28
|
+
const [, sign, whole, fraction = ''] = match;
|
|
29
|
+
const magnitude = BigInt(whole + fraction.padEnd(places, '0'));
|
|
30
|
+
return sign ? -magnitude : magnitude;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Orders two exact decimal strings, signed or not, without a float in between. */
|
|
34
|
+
export function compareExact(left: string, right: string): number {
|
|
35
|
+
const places = Math.max(left.split('.')[1]?.length ?? 0, right.split('.')[1]?.length ?? 0);
|
|
36
|
+
const a = scaled(left, places);
|
|
37
|
+
const b = scaled(right, places);
|
|
38
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const magnitude = (value: string): string => (value.startsWith('-') ? value.slice(1) : value);
|
|
42
|
+
|
|
43
|
+
/** The next sort in the cycle: as the book gives them, largest change first, highest price first. */
|
|
44
|
+
export const nextDiffSort = (sort: DiffSort): DiffSort => (sort === 'book' ? 'delta' : sort === 'delta' ? 'price' : 'book');
|
|
45
|
+
|
|
46
|
+
/** A stable sort: rows that compare equal keep the order they came in. */
|
|
47
|
+
export function sortDiffRows(rows: readonly DiffTableRow[], sort: DiffSort): DiffTableRow[] {
|
|
48
|
+
if (sort === 'book') return [...rows];
|
|
49
|
+
const compare = sort === 'delta'
|
|
50
|
+
? (a: DiffTableRow, b: DiffTableRow) => compareExact(magnitude(b.delta), magnitude(a.delta))
|
|
51
|
+
: (a: DiffTableRow, b: DiffTableRow) => compareExact(b.price, a.price);
|
|
52
|
+
return rows.map((row, index) => ({ row, index })).sort((a, b) => compare(a.row, b.row) || a.index - b.index).map((e) => e.row);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Rows hold their order while the pointer is over the table (a row that moves under the pointer is a row clicked by
|
|
57
|
+
* mistake). Rows already shown keep their place and take their new values; rows that are new join at the end; rows
|
|
58
|
+
* that are gone leave.
|
|
59
|
+
*/
|
|
60
|
+
export function holdDiffOrder(held: readonly string[], next: readonly DiffTableRow[]): DiffTableRow[] {
|
|
61
|
+
const byId = new Map(next.map((row) => [diffRowId(row), row]));
|
|
62
|
+
const kept = held.flatMap((id) => { const row = byId.get(id); return row ? [row] : []; });
|
|
63
|
+
const known = new Set(held);
|
|
64
|
+
return kept.concat(next.filter((row) => !known.has(diffRowId(row))));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type DiffMove = 'next' | 'prev' | 'first' | 'last';
|
|
68
|
+
export type DiffKeyAction =
|
|
69
|
+
| { type: 'move'; to: DiffMove }
|
|
70
|
+
| { type: 'cursor'; which: 'a' | 'b' }
|
|
71
|
+
| { type: 'sort' }
|
|
72
|
+
| { type: 'select' };
|
|
73
|
+
|
|
74
|
+
interface KeyLike { key: string; ctrlKey: boolean; metaKey: boolean; altKey: boolean }
|
|
75
|
+
|
|
76
|
+
/** Arrow keys, Home and End move between rows; `a` and `b` ask for the A or B cursor control; `s` cycles the sort. */
|
|
77
|
+
export function resolveDiffKey(event: KeyLike): DiffKeyAction | null {
|
|
78
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return null;
|
|
79
|
+
switch (event.key) {
|
|
80
|
+
case 'ArrowDown': return { type: 'move', to: 'next' };
|
|
81
|
+
case 'ArrowUp': return { type: 'move', to: 'prev' };
|
|
82
|
+
case 'Home': return { type: 'move', to: 'first' };
|
|
83
|
+
case 'End': return { type: 'move', to: 'last' };
|
|
84
|
+
case 'a': return { type: 'cursor', which: 'a' };
|
|
85
|
+
case 'b': return { type: 'cursor', which: 'b' };
|
|
86
|
+
case 's': return { type: 'sort' };
|
|
87
|
+
case 'Enter':
|
|
88
|
+
case ' ': return { type: 'select' };
|
|
89
|
+
default: return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The row index a move lands on; the ends stop (no wrap). With no current row, next and first land on 0. */
|
|
94
|
+
export function nextDiffIndex(count: number, current: number, to: DiffMove): number {
|
|
95
|
+
if (count === 0) return -1;
|
|
96
|
+
if (to === 'first') return 0;
|
|
97
|
+
if (to === 'last') return count - 1;
|
|
98
|
+
if (current < 0) return to === 'next' ? 0 : count - 1;
|
|
99
|
+
return Math.min(count - 1, Math.max(0, current + (to === 'next' ? 1 : -1)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A delta with its sign shown: `+5`, `−3` (a true minus), `0`. */
|
|
103
|
+
export const signedDelta = (delta: string): string => (delta.startsWith('-') || delta === '0' ? delta.replace('-', '−') : `+${delta}`);
|
|
104
|
+
|
|
105
|
+
export const DIFF_CHANGE_LABEL: Record<DiffChange, string> = { added: 'Added', removed: 'Removed', modified: 'Changed', unchanged: 'Same' };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { barsFromFirst, timeOfBarFromLast } from './drawings.js';
|
|
2
|
+
import type { CandleBar } from './chart-scale.js';
|
|
3
|
+
|
|
4
|
+
// Text field conversions for the drawing edit form: pure string and number rules, so they work without React.
|
|
5
|
+
export const toTimeField = (timestamp: number) => new Date(timestamp).toISOString().slice(0, 19);
|
|
6
|
+
export const fromTimeField = (value: string) => (value.trim() === '' ? NaN : Date.parse(`${value.trim()}Z`));
|
|
7
|
+
/** The Bar field's words: a whole number when the time is on a bar, two decimals when it falls between bars. */
|
|
8
|
+
export const barField = (n: number | null) => (n === null ? '' : Number.isInteger(Math.round(n * 1e6) / 1e6) ? String(Math.round(n * 1e6) / 1e6) : n.toFixed(2));
|
|
9
|
+
/** A whole number, with an optional minus sign, or null. */
|
|
10
|
+
export const parseBarField = (value: string): number | null => (/^\s*-?\d+\s*$/.test(value) ? Number(value.trim()) : null);
|
|
11
|
+
/** The secondary count, from the first bar: "bar 188 of 200 from the first". */
|
|
12
|
+
export function barNote(bars: readonly CandleBar[], typedBar: number | null, time: number): string {
|
|
13
|
+
const at = typedBar !== null ? timeOfBarFromLast(bars, typedBar) : time;
|
|
14
|
+
const n = at === null ? null : barsFromFirst(bars, at);
|
|
15
|
+
return n === null || !Number.isFinite(n) ? '' : `Bar ${barField(n)} of ${bars.length} from the first`;
|
|
16
|
+
}
|
|
17
|
+
export const fromPriceField = (value: string) => (value.trim() === '' ? NaN : Number(value.trim()));
|