slope calc fix

This commit is contained in:
Chris Amow 2026-08-24 01:02:51 -05:00
parent 18585e42cd
commit 01acdce045
3 changed files with 57 additions and 5 deletions

View file

@ -1224,8 +1224,13 @@ 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.
`-0.40 /h`). The first implementation multiplied persisted wall-clock slope by
3,600. That disagreed between parallel duplicates when one endpoint span crossed
a settlement/weekend gap: duplication preserves source-bar geometry, not elapsed
wall time. The tooltip now derives price change per canonical source bar and
converts by that source bucket's active duration. Parallel duplicates therefore
show the same rate. The stored slope remains price per second so persisted
endpoints are unchanged. Flat levels omit the line.
### 2026-08-18 — Events is the sent-alert log

View file

@ -2235,7 +2235,7 @@ class ConfluenceChart {
const color = this.describeDrawingColor?.(line.color);
this.tooltip.textContent = [
`#${line.number} ${line.label}`,
ConfluenceChart.slopeLabel(line),
this.slopeLabel(line),
color?.name,
color?.annotation,
].filter(Boolean).join('\n');
@ -3016,9 +3016,24 @@ class ConfluenceChart {
return marks.filter(mark => mark.t >= fromT - 3600 && mark.t <= toT + 3600);
}
static slopeLabel(line) {
slopePerHour(line) {
const source = this.sourceSeries(line);
if (source) {
const start = ConfluenceChart.timeframeIndexAt(source, line.anchor_t);
const end = ConfluenceChart.timeframeIndexAt(source, line.last_t);
const duration = start == null || end == null
? null : ConfluenceChart.sourceDuration(source, Math.floor((start + end) / 2));
if (duration > 0 && end !== start) {
const endPrice = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
return (endPrice - line.anchor_p) / (end - start) * 3600 / duration;
}
}
return line.slope * 3600;
}
slopeLabel(line) {
if (ConfluenceChart.isFlat(line) || !line.slope) return '';
const perHour = line.slope * 3600;
const perHour = this.slopePerHour(line);
const decimals = Math.abs(perHour) >= 10 ? 1 : 2;
return `${perHour > 0 ? '+' : ''}${perHour.toFixed(decimals)} /h`;
}

View file

@ -112,6 +112,38 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
});
});
test('parallel duplicates keep the same source-space hourly slope across a gap',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const halfHour = 30 * 60;
const weekend = 49 * 3600;
const times = [
0, halfHour, halfHour + weekend, 2 * halfHour + weekend,
3 * halfHour + weekend, 4 * halfHour + weekend,
];
c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times, duration: halfHour } } });
const line = (anchor_t, last_t) => ({
kind: 'manual', tf: '30m', anchor_t, anchor_p: 100, last_t,
slope: 2 / (last_t - anchor_t),
});
const acrossGap = line(times[0], times[2]);
const contiguous = line(times[2], times[4]);
return {
raw: [acrossGap.slope * 3600, contiguous.slope * 3600],
canonical: [c.slopePerHour(acrossGap), c.slopePerHour(contiguous)],
labels: [c.slopeLabel(acrossGap), c.slopeLabel(contiguous)],
};
});
assert.notEqual(result.raw[0], result.raw[1], 'fixture did not cross a wall-clock gap');
assert.deepEqual(result.canonical, [2, 2]);
assert.deepEqual(result.labels, ['+2.00 /h', '+2.00 /h']);
assertNoPageErrors(page, assert);
});
});
test('future 5m projections keep their slope after the settlement break',
{ timeout: 180000 }, async () => {
await withChart(async page => {