slope in tooltip
This commit is contained in:
parent
cc25871032
commit
44cec38750
7 changed files with 245 additions and 2 deletions
|
|
@ -1175,3 +1175,17 @@ hours so a new refresh token is written to the volume. Status grows
|
||||||
`needs_login` when the error is a dead grant; the header then offers
|
`needs_login` when the error is a dead grant; the header then offers
|
||||||
**reconnect**, which starts the existing `/api/qt` flow and writes the token
|
**reconnect**, which starts the existing `/api/qt` flow and writes the token
|
||||||
on callback instead of asking anyone to paste a URL into a prompt.
|
on callback instead of asking anyone to paste a URL into a prompt.
|
||||||
|
|
||||||
|
### 2026-08-18 — SPY open/close
|
||||||
|
|
||||||
|
Intraday charts can draw faint vertical lines at 09:30 and 16:00 ET on
|
||||||
|
weekdays — cash hours, not the CME session. They are a Lightweight Charts
|
||||||
|
series primitive on the candle pane (`attachPrimitive`), the same extension
|
||||||
|
point as `createPriceLine`, not another DOM overlay. Off on the daily chart.
|
||||||
|
Layer toggle **SPY open/close**, on by default.
|
||||||
|
|
||||||
|
### 2026-08-18 — Trendline slope in the tooltip
|
||||||
|
|
||||||
|
Hovering a sloped line now shows signed points per hour (`+1.25 /h`,
|
||||||
|
`-0.40 /h`). Degrees follow the zoom; points per bar follow the timeframe.
|
||||||
|
The stored slope is still price per second. Flat levels omit the line.
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ async function apiFetch(url, options = {}) {
|
||||||
|
|
||||||
const defaultPrefs = {
|
const defaultPrefs = {
|
||||||
base_tf: '1m',
|
base_tf: '1m',
|
||||||
enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, drawings: true, manual: true, auto: false, horizontal: true, vwap: true },
|
enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, drawings: true, manual: true, auto: false, horizontal: true, vwap: true, rth: true },
|
||||||
hidden_levels_score: false,
|
hidden_levels_score: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1155,6 +1155,7 @@ createApp({
|
||||||
localStorage.setItem('chart-layer-prefs', JSON.stringify(prefs.value));
|
localStorage.setItem('chart-layer-prefs', JSON.stringify(prefs.value));
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
syncComments();
|
syncComments();
|
||||||
|
if (chartApi) chartApi.setRthEnabled(prefs.value.enabled.rth);
|
||||||
sendPrefs();
|
sendPrefs();
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
|
@ -1225,6 +1226,7 @@ createApp({
|
||||||
// guessed at: __chart.lineData(level) against timeToCoordinate().
|
// guessed at: __chart.lineData(level) against timeToCoordinate().
|
||||||
window.__chart = chartApi;
|
window.__chart = chartApi;
|
||||||
chartApi.create(document.getElementById('chart'));
|
chartApi.create(document.getElementById('chart'));
|
||||||
|
chartApi.setRthEnabled(prefs.value.enabled.rth);
|
||||||
chartApi.setClickHandler(handleChartClick);
|
chartApi.setClickHandler(handleChartClick);
|
||||||
chartApi.setToolCompleteHandler(handleToolComplete);
|
chartApi.setToolCompleteHandler(handleToolComplete);
|
||||||
chartApi.setLineChangeHandler(line => {
|
chartApi.setLineChangeHandler(line => {
|
||||||
|
|
|
||||||
185
static/chart.js
185
static/chart.js
|
|
@ -1,3 +1,77 @@
|
||||||
|
// Vertical session lines via Lightweight Charts' series primitive — canvas,
|
||||||
|
// not a DOM overlay. createPriceLine is horizontal only; attachPrimitive is
|
||||||
|
// the matching hook for a time.
|
||||||
|
class RthLinesPrimitive {
|
||||||
|
constructor() {
|
||||||
|
this._times = [];
|
||||||
|
this._styles = [];
|
||||||
|
this._xs = [];
|
||||||
|
this._chart = null;
|
||||||
|
this._requestUpdate = null;
|
||||||
|
this._view = {
|
||||||
|
update: () => this._sync(),
|
||||||
|
renderer: () => this._renderer,
|
||||||
|
};
|
||||||
|
this._renderer = { draw: target => this._draw(target) };
|
||||||
|
}
|
||||||
|
|
||||||
|
attached(param) {
|
||||||
|
this._chart = param.chart;
|
||||||
|
this._requestUpdate = param.requestUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
detached() {
|
||||||
|
this._chart = null;
|
||||||
|
this._requestUpdate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMarks(marks) {
|
||||||
|
this._times = marks.map(mark => mark.t);
|
||||||
|
this._styles = marks.map(mark => mark.kind);
|
||||||
|
if (this._requestUpdate) this._requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAllViews() {
|
||||||
|
this._view.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
paneViews() {
|
||||||
|
return [this._view];
|
||||||
|
}
|
||||||
|
|
||||||
|
_sync() {
|
||||||
|
if (!this._chart) {
|
||||||
|
this._xs = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scale = this._chart.timeScale();
|
||||||
|
this._xs = this._times.map(time => scale.timeToCoordinate(time));
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw(target) {
|
||||||
|
if (!this._xs.length) return;
|
||||||
|
target.useBitmapCoordinateSpace(scope => {
|
||||||
|
const ctx = scope.context;
|
||||||
|
const height = scope.bitmapSize.height;
|
||||||
|
const width = Math.max(1, Math.round(scope.horizontalPixelRatio));
|
||||||
|
ctx.save();
|
||||||
|
for (let i = 0; i < this._xs.length; i += 1) {
|
||||||
|
const x = this._xs[i];
|
||||||
|
if (x == null) continue;
|
||||||
|
const px = Math.round(x * scope.horizontalPixelRatio);
|
||||||
|
ctx.strokeStyle = 'rgba(44,41,36,0.20)';
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.setLineDash(this._styles[i] === 'close' ? [3 * width, 4 * width] : []);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + 0.5, 0);
|
||||||
|
ctx.lineTo(px + 0.5, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ConfluenceChart {
|
class ConfluenceChart {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.chart = null;
|
this.chart = null;
|
||||||
|
|
@ -58,6 +132,8 @@ class ConfluenceChart {
|
||||||
this.toolUpListener = null;
|
this.toolUpListener = null;
|
||||||
this.pendingView = null;
|
this.pendingView = null;
|
||||||
this.overlayLayer = null;
|
this.overlayLayer = null;
|
||||||
|
this.rthPrimitive = null;
|
||||||
|
this.rthEnabled = true;
|
||||||
this.futureSpace = null;
|
this.futureSpace = null;
|
||||||
this.snapDot = null;
|
this.snapDot = null;
|
||||||
this.snapLabel = null;
|
this.snapLabel = null;
|
||||||
|
|
@ -246,6 +322,8 @@ class ConfluenceChart {
|
||||||
// blank — you could watch two trendlines converge and not know when. These
|
// blank — you could watch two trendlines converge and not know when. These
|
||||||
// points carry no value and draw nothing; they exist so the axis has
|
// points carry no value and draw nothing; they exist so the axis has
|
||||||
// something to label and so timeToCoordinate answers out there.
|
// something to label and so timeToCoordinate answers out there.
|
||||||
|
this.rthPrimitive = new RthLinesPrimitive();
|
||||||
|
this.candles.attachPrimitive(this.rthPrimitive);
|
||||||
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
|
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||||
priceScaleId: 'right',
|
priceScaleId: 'right',
|
||||||
color: 'rgba(0,0,0,0)',
|
color: 'rgba(0,0,0,0)',
|
||||||
|
|
@ -460,6 +538,7 @@ class ConfluenceChart {
|
||||||
};
|
};
|
||||||
this.chart.timeScale().setVisibleRange(this.pendingView);
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
||||||
}
|
}
|
||||||
|
this.syncRthLines();
|
||||||
this.scheduleOverlays(true);
|
this.scheduleOverlays(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -577,6 +656,7 @@ class ConfluenceChart {
|
||||||
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
|
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
|
||||||
else this.bars.push(bar);
|
else this.bars.push(bar);
|
||||||
if (advanced) this.extendManualLineSeries(last, bar);
|
if (advanced) this.extendManualLineSeries(last, bar);
|
||||||
|
if (advanced) this.syncRthLines();
|
||||||
this.scheduleOverlays(true);
|
this.scheduleOverlays(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1864,6 +1944,7 @@ class ConfluenceChart {
|
||||||
const color = this.describeDrawingColor?.(line.color);
|
const color = this.describeDrawingColor?.(line.color);
|
||||||
this.tooltip.textContent = [
|
this.tooltip.textContent = [
|
||||||
`#${line.number} ${line.label}`,
|
`#${line.number} ${line.label}`,
|
||||||
|
ConfluenceChart.slopeLabel(line),
|
||||||
color?.name,
|
color?.name,
|
||||||
color?.annotation,
|
color?.annotation,
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
|
|
@ -2509,6 +2590,110 @@ class ConfluenceChart {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRthEnabled(enabled) {
|
||||||
|
this.rthEnabled = enabled;
|
||||||
|
this.syncRthLines();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRthLines() {
|
||||||
|
if (!this.rthPrimitive) return;
|
||||||
|
if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') {
|
||||||
|
this.rthPrimitive.setMarks([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = this.bars[0].t;
|
||||||
|
const last = this.bars[this.bars.length - 1].t;
|
||||||
|
const marks = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const mark of ConfluenceChart.rthInstants(first, last)) {
|
||||||
|
const time = this.barContaining(mark.t);
|
||||||
|
if (time == null || seen.has(time)) continue;
|
||||||
|
seen.add(time);
|
||||||
|
marks.push({ t: time, kind: mark.kind });
|
||||||
|
}
|
||||||
|
this.rthPrimitive.setMarks(marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
barContaining(time) {
|
||||||
|
const bars = this.bars;
|
||||||
|
if (!bars.length || time < bars[0].t) return null;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = bars.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi + 1) >> 1;
|
||||||
|
if (bars[mid].t <= time) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return bars[lo].t;
|
||||||
|
}
|
||||||
|
|
||||||
|
static easternParts(epochSec) {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
hour12: false,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).formatToParts(new Date(epochSec * 1000))
|
||||||
|
.filter(part => part.type !== 'literal')
|
||||||
|
.map(part => [part.type, part.value]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
year: Number(parts.year),
|
||||||
|
month: Number(parts.month),
|
||||||
|
day: Number(parts.day),
|
||||||
|
hour: Number(parts.hour) % 24,
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static epochAtEastern(year, month, day, hour, minute) {
|
||||||
|
let guess = Date.UTC(year, month - 1, day, hour + 4, minute);
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
const got = ConfluenceChart.easternParts(guess / 1000);
|
||||||
|
const delta = Date.UTC(year, month - 1, day, hour, minute)
|
||||||
|
- Date.UTC(got.year, got.month - 1, got.day, got.hour, got.minute);
|
||||||
|
if (delta === 0) break;
|
||||||
|
guess += delta;
|
||||||
|
}
|
||||||
|
return Math.floor(guess / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
static rthInstants(fromT, toT) {
|
||||||
|
const marks = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let cursor = fromT - 12 * 3600;
|
||||||
|
const stop = toT + 12 * 3600;
|
||||||
|
while (cursor <= stop) {
|
||||||
|
const parts = ConfluenceChart.easternParts(cursor);
|
||||||
|
const key = `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
|
||||||
|
if (!seen.has(key) && weekday !== 0 && weekday !== 6) {
|
||||||
|
seen.add(key);
|
||||||
|
marks.push({
|
||||||
|
t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 9, 30),
|
||||||
|
kind: 'open',
|
||||||
|
});
|
||||||
|
marks.push({
|
||||||
|
t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 16, 0),
|
||||||
|
kind: 'close',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor += 12 * 3600;
|
||||||
|
}
|
||||||
|
return marks.filter(mark => mark.t >= fromT - 3600 && mark.t <= toT + 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
static slopeLabel(line) {
|
||||||
|
if (ConfluenceChart.isFlat(line) || !line.slope) return '';
|
||||||
|
const perHour = line.slope * 3600;
|
||||||
|
const decimals = Math.abs(perHour) >= 10 ? 1 : 2;
|
||||||
|
return `${perHour > 0 ? '+' : ''}${perHour.toFixed(decimals)} /h`;
|
||||||
|
}
|
||||||
|
|
||||||
toCandle(bar) {
|
toCandle(bar) {
|
||||||
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
<div class="layer-group">
|
<div class="layer-group">
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.rth"><span class="swatch rth"></span>SPY open/close</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="layer-group">
|
<div class="layer-group">
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.drawings"><span class="swatch drawings"></span>Drawings</label>
|
<label><input type="checkbox" v-model="prefs.enabled.drawings"><span class="swatch drawings"></span>Drawings</label>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
||||||
.color-popover { grid-template-columns:1fr; width:178px; gap:3px; }
|
.color-popover { grid-template-columns:1fr; width:178px; gap:3px; }
|
||||||
.color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); }
|
.color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); }
|
||||||
.level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }
|
.level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }
|
||||||
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:grid; grid-template-columns:repeat(3, max-content); gap:7px 10px; padding-left:20px; }.periods .period { display:flex; align-items:center; gap:2px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
|
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:grid; grid-template-columns:repeat(3, max-content); gap:7px 10px; padding-left:20px; }.periods .period { display:flex; align-items:center; gap:2px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); }
|
||||||
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
||||||
|
|
||||||
/* Tool palette: the head arms the tool, the body configures what it creates. */
|
/* Tool palette: the head arms the tool, the body configures what it creates. */
|
||||||
|
|
|
||||||
|
|
@ -293,3 +293,18 @@ test('filtered selections can hide and show drawings without deleting them',
|
||||||
assertNoPageErrors(page, assert);
|
assertNoPageErrors(page, assert);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('trendline slope in the tooltip is signed points per hour',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const labels = await page.evaluate(() => ({
|
||||||
|
up: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }),
|
||||||
|
down: ConfluenceChart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }),
|
||||||
|
flat: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 0 }),
|
||||||
|
}));
|
||||||
|
assert.equal(labels.up, '+2.00 /h');
|
||||||
|
assert.equal(labels.down, '-0.50 /h');
|
||||||
|
assert.equal(labels.flat, '');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
26
tests/e2e/rth.test.mjs
Normal file
26
tests/e2e/rth.test.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { withChart, assertNoPageErrors } from './helpers.mjs';
|
||||||
|
|
||||||
|
test('SPY open and close land on weekday Eastern hours, never the weekend',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const marks = await page.evaluate(() => {
|
||||||
|
const monday = Date.UTC(2026, 7, 17, 0, 0) / 1000;
|
||||||
|
const sunday = Date.UTC(2026, 7, 16, 12, 0) / 1000;
|
||||||
|
return {
|
||||||
|
monday: ConfluenceChart.rthInstants(monday, monday + 24 * 3600),
|
||||||
|
sunday: ConfluenceChart.rthInstants(sunday, sunday + 6 * 3600),
|
||||||
|
dst: ConfluenceChart.epochAtEastern(2026, 3, 9, 9, 30),
|
||||||
|
open: ConfluenceChart.epochAtEastern(2026, 8, 17, 9, 30),
|
||||||
|
close: ConfluenceChart.epochAtEastern(2026, 8, 17, 16, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(marks.open, 1786973400);
|
||||||
|
assert.equal(marks.close, 1786996800);
|
||||||
|
assert.equal(marks.dst, 1773063000);
|
||||||
|
assert.deepEqual(marks.monday.map(mark => mark.kind), ['open', 'close']);
|
||||||
|
assert.equal(marks.sunday.length, 0);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue