Unify chart geometry and deepen 30m history

This commit is contained in:
Chris Amow 2026-08-11 04:08:02 -05:00
parent dd9d24b419
commit 91677127a0
18 changed files with 434 additions and 125 deletions

View file

@ -4,6 +4,7 @@ SEED_SOURCE=yahoo
YAHOO_SYMBOL=ES=F YAHOO_SYMBOL=ES=F
YAHOO_POLL_SECONDS=20 YAHOO_POLL_SECONDS=20
SEED_1H_RANGE=730d SEED_1H_RANGE=730d
SEED_30M_RANGE=60d
SEED_1M_RANGE=8d SEED_1M_RANGE=8d
# Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then. # Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then.

View file

@ -277,8 +277,10 @@ Both sources run together. This is the intended configuration, not a fallback:
- **Schwab** streams real-time `/ES` minute bars over `CHART_FUTURES` - **Schwab** streams real-time `/ES` minute bars over `CHART_FUTURES`
(`delayed: false`), but serves **no futures history at all** — everything it (`delayed: false`), but serves **no futures history at all** — everything it
knows starts when you connect. knows starts when you connect.
- **Yahoo** has roughly 730 days of hourly data, which is what makes a 200-day - **Yahoo** has roughly 730 days of hourly data, 60 days of native 30-minute
moving average warm at startup rather than in ten months. It lags ~10 minutes. bars, and eight days of minute data. Hourly history makes a 200-day moving
average warm at startup; the native 30-minute seed avoids limiting that chart
to the minute endpoint's eight-day window. Yahoo lags ~10 minutes.
Switch the live feed with `LIVE_SOURCE=schwab`; seeding stays on Yahoo whatever Switch the live feed with `LIVE_SOURCE=schwab`; seeding stays on Yahoo whatever
you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says

View file

@ -6,6 +6,7 @@ commit live yet". Neither endpoint exposes anything about the market data or
the configuration. the configuration.
""" """
import os import os
from datetime import datetime, timezone
from fastapi import APIRouter from fastapi import APIRouter
@ -13,6 +14,7 @@ router = APIRouter(prefix="/api")
# Coolify injects the deployed commit; absent when running locally. # Coolify injects the deployed commit; absent when running locally.
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev") SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
@router.get("/health") @router.get("/health")
@ -22,4 +24,4 @@ def health():
@router.get("/version") @router.get("/version")
def version(): def version():
return {"commit": SOURCE_COMMIT} return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}

View file

@ -25,6 +25,7 @@ class Settings(BaseSettings):
yahoo_symbol: str = "ES=F" yahoo_symbol: str = "ES=F"
yahoo_poll_seconds: float = 20 yahoo_poll_seconds: float = 20
seed_1h_range: str = "730d" seed_1h_range: str = "730d"
seed_30m_range: str = "60d"
seed_1m_range: str = "8d" seed_1m_range: str = "8d"
timeframes: str = "1m,5m,15m,30m,1h,1d" timeframes: str = "1m,5m,15m,30m,1h,1d"
base_timeframes: str = "1m,30m,1d" base_timeframes: str = "1m,30m,1d"

View file

@ -81,8 +81,8 @@ class YahooSource:
*, *,
range_: str | None = None, range_: str | None = None,
) -> list[Bar]: ) -> list[Bar]:
if tf not in (Timeframe.M1, Timeframe.H1): if tf not in (Timeframe.M1, Timeframe.M30, Timeframe.H1):
raise ValueError("YahooSource history supports only 1m and 1h inputs") raise ValueError("YahooSource history supports only 1m, 30m and 1h inputs")
interval = tf.value interval = tf.value
if range_ is not None: if range_ is not None:
return parse_chart( return parse_chart(

View file

@ -239,6 +239,12 @@ class Runtime:
await self.stream.seed( await self.stream.seed(
source, Timeframe.H1, self.settings.seed_1h_range, seed_symbol source, Timeframe.H1, self.settings.seed_1h_range, seed_symbol
) )
# One-hour history cannot reconstruct a 30-minute candle. Yahoo
# supplies roughly 60 days natively; the newer overlap is replaced
# below by bars aggregated from the finer 1m seed.
await self.stream.seed(
source, Timeframe.M30, self.settings.seed_30m_range, seed_symbol
)
await self.stream.seed( await self.stream.seed(
source, Timeframe.M1, self.settings.seed_1m_range, seed_symbol source, Timeframe.M1, self.settings.seed_1m_range, seed_symbol
) )

View file

@ -1825,3 +1825,47 @@ it, the implementation/session log and `docs/mobile_enhance.md` directly from
`AGENTS.md`, which every coding agent reads before working in this repository. `AGENTS.md`, which every coding agent reads before working in this repository.
This keeps current advice visible without turning the historical implementation This keeps current advice visible without turning the historical implementation
plan into an undifferentiated backlog. plan into an undifferentiated backlog.
### 2026-08-11 03:39 CDT — one price scale, left labels and editable-line snapping
The left side was introduced to keep daily moving-average, VWAP and prior-day
labels away from the intraday labels on the right. That decluttering intent was
correct; implementing it as a second Lightweight Charts price scale was not.
Each scale autoscaled independently once its own overlays were attached, so the
same numeric price could occupy a different y-coordinate on each side. Daily
and intraday structure then looked directly comparable while being geometrically
unrelated.
All price-bearing series and flat levels now share the candle series' right
scale. Long-term labels remain on the left as DOM overlays whose y positions are
computed through `candles.priceToCoordinate()`, so they preserve the intended
separation without creating another coordinate system. Context series suppress
their built-in right-side titles. A browser invariant verifies every overlay's
scale id, every flat level's host, and sub-pixel agreement between an MA price
and the candle coordinate for that same price.
Editing a selected trendline had a separate defect: handle dragging used
element-relative x, snapped only to the nearest bar time, and retained a raw
cursor price. Initial placement used plot-relative coordinates and high/low
snapping, so moving an anchor could visibly jump off the bar and change the line
to an unusable slope. Placement, selection, handle dragging and context actions
now all use `eventPoint()` against the measured plot canvas. Handle movement
passes through the same `snapPoint()` high/low rule and displays the same snap
feedback as placement. This also closes the known right-price-axis containment
bug; a page-region test now proves the dot disappears over the actual axis and
returns over the plot.
The 30-minute chart had only about one week of history because it was derived
solely from Yahoo's eight-day 1-minute seed. The 730-day hourly seed cannot
reconstruct half-hour candles. Yahoo's native `30m` interval was verified live
at `range=60d` (2,843 bars on 2026-08-11), so startup now seeds H1/730d,
M30/60d, then M1/8d. The finer minute aggregation replaces the recent overlap;
the native feed supplies older 30-minute bars. Regression tests pin both Yahoo's
native interval request and the startup seed sequence. Final verification: 105
backend tests and 22 browser tests passing.
Daily moving averages had also been rendered with `WithSteps` on every base
timeframe. Holding a daily value constant is correct when projecting it over
intraday candles, but on the daily chart it made the SMA itself look like a
staircase. MA line type is now timeframe-aware: stepped on intraday charts and
simple point-to-point lines on `1d`, with both modes pinned by browser coverage.

View file

@ -1,31 +1,12 @@
# Current recommendations # Current recommendations
Last reviewed: 2026-08-11 03:19 CDT. Last reviewed: 2026-08-11 03:39 CDT.
This file is the short list of work worth considering next. Verified history, This file is the short list of work worth considering next. Verified history,
measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`. measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`.
## Fix next ## Fix next
### Keep snapping out of the price axes
`ConfluenceChart.withinPlot()` currently checks plot-relative coordinates
against `chartEl.clientWidth` and `clientHeight`. The element also contains the
price and time axes, so hovering the right price axis can leave the snap dot
visible at the plot edge.
Recommended fix:
1. Compare against the measured plot canvas width and height.
2. Add a Playwright page-pixel test that moves from a bar into the actual right
axis rectangle and expects the snap dot to disappear, then return over the
plot and expect it to reappear.
3. Reuse the same plot-relative conversion for selection and anchor dragging;
do not add another coordinate frame.
This was discovered by test research but deliberately not committed as a
failing test because it requires production code to correct.
### Show unambiguous market freshness ### Show unambiguous market freshness
The best location is the status strip directly below the chart. Keep the header The best location is the status strip directly below the chart. Keep the header
@ -65,12 +46,11 @@ subtract Yahoo's declared delay before calling delayed data overdue.
The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche: The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche:
1. Use one plot-relative pointer path for placement, selection and anchor drag. 1. Add touch-sized invisible hit regions without enlarging the visual marks.
2. Add touch-sized invisible hit regions without enlarging the visual marks. 2. Keep the first tap's trendline anchor visible with price/time, Cancel and
3. Keep the first tap's trendline anchor visible with price/time, Cancel and
Undo anchor. Undo anchor.
4. Add a compact sticky mobile tool rail next to the chart. 3. Add a compact sticky mobile tool rail next to the chart.
5. Add a selected-drawing action bar so End here and Delete do not require 4. Add a selected-drawing action bar so End here and Delete do not require
right-click or a hardware keyboard. right-click or a hardware keyboard.
Prefer tap-tap trendlines on touch devices. Reserve one-finger vertical movement Prefer tap-tap trendlines on touch devices. Reserve one-finger vertical movement

View file

@ -68,9 +68,12 @@ can remain shortcuts.
### P0: One plot-relative coordinate path ### P0: One plot-relative coordinate path
Placement, selection, anchor dragging, comments, and context actions should all Status: completed 2026-08-11. Placement, selection, anchor dragging and context
use the same plot-relative pointer conversion. Build mobile hit regions around actions now use the measured plot canvas as their shared pointer frame.
painted geometry rather than compensating for different coordinate frames.
Placement, selection, anchor dragging, and context actions use the same
plot-relative pointer conversion. Keep future chart-bound interactions in that
frame and build mobile hit regions around painted geometry.
Complexity: medium. This is a correctness improvement on both mobile and Complexity: medium. This is a correctness improvement on both mobile and
desktop and should have page-pixel regression coverage. desktop and should have page-pixel regression coverage.
@ -144,8 +147,8 @@ invariants rather than arbitrary CSS dimensions.
## Incremental rollout ## Incremental rollout
1. Add mobile diagnostics/tests, unify pointer coordinates, and enlarge 1. Add mobile diagnostics/tests and enlarge invisible hit regions. Pointer
invisible hit regions. coordinates are already unified for chart drawing interactions.
2. Add persistent first-anchor feedback, Cancel, and Undo anchor. 2. Add persistent first-anchor feedback, Cancel, and Undo anchor.
3. Add the mobile tool rail, keyboard-safe sizing, and chart/page gesture policy. 3. Add the mobile tool rail, keyboard-safe sizing, and chart/page gesture policy.
4. Add the selected-drawing action bar and touch-accessible End here flow. 4. Add the selected-drawing action bar and touch-accessible End here flow.

View file

@ -71,6 +71,7 @@ function loadPrefs() {
createApp({ createApp({
setup() { setup() {
const status = ref({ stream: 'disconnected', bars_held: {} }); const status = ref({ stream: 'disconnected', bars_held: {} });
const deployment = ref(null);
const price = ref(null); const price = ref(null);
const prefs = ref(loadPrefs()); const prefs = ref(loadPrefs());
const timeframe = ref(prefs.value.base_tf || '1m'); const timeframe = ref(prefs.value.base_tf || '1m');
@ -110,6 +111,17 @@ createApp({
const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t)); const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t));
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
}); });
const updatedAt = computed(() => {
if (!deployment.value?.started_at) return '—';
return new Intl.DateTimeFormat(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
}).format(new Date(deployment.value.started_at));
});
const deploymentTitle = computed(() => {
if (!deployment.value?.started_at) return '';
const timestamp = new Date(deployment.value.started_at).toLocaleString();
return `Running since ${timestamp} · ${deployment.value.commit}`;
});
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
@ -150,6 +162,11 @@ createApp({
if (response.ok) status.value = await response.json(); if (response.ok) status.value = await response.json();
} }
async function refreshDeployment() {
const response = await apiFetch('/api/version');
if (response.ok) deployment.value = await response.json();
}
function connect() { function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
@ -541,6 +558,7 @@ createApp({
refreshComments(); refreshComments();
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
refreshStatus(); refreshStatus();
refreshDeployment();
connect(); connect();
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000); timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
}); });
@ -551,6 +569,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; return { status, price, barAge, updatedAt, deploymentTitle, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -43,6 +43,8 @@ class ConfluenceChart {
this.comments = []; this.comments = [];
this.commentNodes = new Map(); this.commentNodes = new Map();
this.commentLayer = null; this.commentLayer = null;
this.contextLabelLayer = null;
this.contextLabelFrame = null;
this.onCommentToggle = null; this.onCommentToggle = null;
this.onCommentMove = null; this.onCommentMove = null;
} }
@ -78,6 +80,7 @@ class ConfluenceChart {
layout: { layout: {
background: { color: getComputedStyle(document.documentElement).getPropertyValue('--chart-bg').trim() }, background: { color: getComputedStyle(document.documentElement).getPropertyValue('--chart-bg').trim() },
textColor: getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(), textColor: getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(),
attributionLogo: false,
}, },
grid: { grid: {
vertLines: { color: 'rgba(128,128,128,.10)' }, vertLines: { color: 'rgba(128,128,128,.10)' },
@ -112,8 +115,10 @@ class ConfluenceChart {
}), }),
}, },
rightPriceScale: { borderVisible: false }, rightPriceScale: { borderVisible: false },
// Daily context lives on the left, intraday on the right. // Every price-bearing series shares this scale. A second autoscaled price
leftPriceScale: { visible: true, borderVisible: false }, // axis can place the same numeric price at a different y, which makes
// daily context look comparable to intraday price when it is not.
leftPriceScale: { visible: false },
// Never magnet. The default snaps the crosshair to the bar's *close*, so // Never magnet. The default snaps the crosshair to the bar's *close*, so
// hovering beside a low reads a price several ticks away, and everything // hovering beside a low reads a price several ticks away, and everything
// that snaps in this app snaps to extremes. The crosshair tracks the // that snaps in this app snaps to extremes. The crosshair tracks the
@ -125,16 +130,6 @@ class ConfluenceChart {
borderUpColor: '#1d6849', borderDownColor: '#963737', borderUpColor: '#1d6849', borderDownColor: '#963737',
wickUpColor: '#1d6849', wickDownColor: '#963737', wickUpColor: '#1d6849', wickDownColor: '#963737',
}); });
// A price scale derives its range from the series on it, so levels moved to
// the left would be drawn against a different range and sit at the wrong
// height. This transparent copy of the candles gives the left scale exactly
// the same input as the right, which keeps one price at one y.
this.leftMirror = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
priceScaleId: 'left',
upColor: 'transparent', downColor: 'transparent', borderVisible: false,
wickUpColor: 'transparent', wickDownColor: 'transparent',
lastValueVisible: false, priceLineVisible: false,
});
// Volume as an overlay on its own hidden scale, confined to the bottom // Volume as an overlay on its own hidden scale, confined to the bottom
// fifth. An overlay rather than a pane so it cannot alter the price scale: // fifth. An overlay rather than a pane so it cannot alter the price scale:
// volumes are five figures and prices four, and sharing a scale would // volumes are five figures and prices four, and sharing a scale would
@ -153,25 +148,28 @@ class ConfluenceChart {
// which is where the volume draws and where a cursor tracing swing lows // which is where the volume draws and where a cursor tracing swing lows
// naturally sits — far from any actual price. Give most of it back. // naturally sits — far from any actual price. Give most of it back.
this.chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } }); this.chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
this.chart.priceScale('left').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.observePlotCanvas(); this.observePlotCanvas();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderComments(); this.renderComments();
this.renderContextLabels();
}); });
}); });
this.resizeObserver.observe(el); this.resizeObserver.observe(el);
// Every overlay lives in here, and this is positioned over the *plot* — not // Every overlay lives in here, and this is positioned over the *plot* — not
// the element, which also contains the price scales. Lightweight Charts // the element, which also contains the price scales. Lightweight Charts
// reports coordinates from the plot's origin, so anchoring the container // reports coordinates from the plot's origin, so anchoring the container
// there lets each overlay use those coordinates untranslated. Enabling the // there lets each overlay use those coordinates untranslated. Keep the
// left price scale moved the plot 66px right and silently displaced the // measurement even with no left scale: future layout changes must not
// snap indicator, the comments and the trendline handles by that much. // silently displace snap indicators, comments or trendline handles.
this.overlayLayer = document.createElement('div'); this.overlayLayer = document.createElement('div');
this.overlayLayer.className = 'chart-overlays'; this.overlayLayer.className = 'chart-overlays';
el.appendChild(this.overlayLayer); el.appendChild(this.overlayLayer);
this.contextLabelLayer = document.createElement('div');
this.contextLabelLayer.className = 'chart-context-labels';
this.overlayLayer.appendChild(this.contextLabelLayer);
this.tooltip = document.createElement('div'); this.tooltip = document.createElement('div');
this.tooltip.className = 'chart-tooltip'; this.tooltip.className = 'chart-tooltip';
this.overlayLayer.appendChild(this.tooltip); this.overlayLayer.appendChild(this.tooltip);
@ -256,13 +254,14 @@ class ConfluenceChart {
// whatever the new line happens to overlap. // whatever the new line happens to overlap.
if (this.armedTool) return; if (this.armedTool) return;
if (!this.onChartClick) return; if (!this.onChartClick) return;
const bounds = el.getBoundingClientRect(); const point = this.eventPoint(event);
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; if (point && this.withinPlot(point) && point.t != null) {
const time = this.chart.timeScale().coordinateToTime(point.x); this.onChartClick({ point, time: point.t });
if (time != null) this.onChartClick({ point, time }); }
}; };
el.addEventListener('click', this.clickListener); el.addEventListener('click', this.clickListener);
this.chart.subscribeCrosshairMove(param => { this.chart.subscribeCrosshairMove(param => {
this.scheduleContextLabels();
if (!param.point || !param.time) { if (!param.point || !param.time) {
this.tooltip.hidden = true; this.tooltip.hidden = true;
return; return;
@ -273,6 +272,7 @@ class ConfluenceChart {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderComments(); this.renderComments();
this.renderContextLabels();
}); });
} }
@ -280,7 +280,6 @@ class ConfluenceChart {
this.bars = bars; this.bars = bars;
const candleData = bars.map(this.toCandle); const candleData = bars.map(this.toCandle);
this.candles.setData(candleData); this.candles.setData(candleData);
this.leftMirror.setData(candleData);
this.volume.setData(bars.map(ConfluenceChart.toVolume)); this.volume.setData(bars.map(ConfluenceChart.toVolume));
// Anchored by time, not by logical index. A logical index addresses the // Anchored by time, not by logical index. A logical index addresses the
// chart's *shared* scale — the union of every series' time points — not // chart's *shared* scale — the union of every series' time points — not
@ -301,6 +300,7 @@ class ConfluenceChart {
requestAnimationFrame(() => this.renderAnchorHandles()); requestAnimationFrame(() => this.renderAnchorHandles());
// The bar grid just changed underneath every pinned comment. // The bar grid just changed underneath every pinned comment.
this.renderComments(); this.renderComments();
this.renderContextLabels();
} }
updateBar(bar) { updateBar(bar) {
@ -311,11 +311,11 @@ class ConfluenceChart {
const last = this.bars[this.bars.length - 1]; const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return; if (last && bar.t < last.t) return;
this.candles.update(this.toCandle(bar)); this.candles.update(this.toCandle(bar));
this.leftMirror.update(this.toCandle(bar));
this.volume.update(ConfluenceChart.toVolume(bar)); this.volume.update(ConfluenceChart.toVolume(bar));
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);
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderContextLabels();
} }
setComments(comments) { setComments(comments) {
@ -448,6 +448,10 @@ class ConfluenceChart {
return level.kind === 'horizontal' || (level.kind === 'manual' && level.slope === 0); return level.kind === 'horizontal' || (level.kind === 'manual' && level.slope === 0);
} }
static isContextLevel(level) {
return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal';
}
// Flat levels are drawn as price lines rather than two-point series: they // Flat levels are drawn as price lines rather than two-point series: they
// span the whole chart regardless of scroll and get a price-axis label. // span the whole chart regardless of scroll and get a price-axis label.
syncPriceLines(levels) { syncPriceLines(levels) {
@ -465,16 +469,13 @@ class ConfluenceChart {
color: ConfluenceChart.levelColor(level), color: ConfluenceChart.levelColor(level),
lineWidth: level.line_width || 1, lineWidth: level.line_width || 1,
lineStyle: LightweightCharts.LineStyle.Dashed, lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: true, axisLabelVisible: level.kind !== 'horizontal',
title: level.label, title: ConfluenceChart.isContextLevel(level) ? '' : level.label,
}; };
const existing = this.priceLines.get(level.id); const existing = this.priceLines.get(level.id);
if (existing) existing.line.applyOptions(options); if (existing) existing.line.applyOptions(options);
else { else {
// Prior-day levels are daily context and move to the left scale; a const host = this.candles;
// hand-drawn price level is intraday and keeps the right, which is the
// side being kept clear for it.
const host = level.kind === 'horizontal' ? this.leftMirror : this.candles;
this.priceLines.set(level.id, { host, line: host.createPriceLine(options) }); this.priceLines.set(level.id, { host, line: host.createPriceLine(options) });
} }
} }
@ -534,25 +535,27 @@ class ConfluenceChart {
// value between closes while VWAP moves continuously. // value between closes while VWAP moves continuously.
const hasPoints = level.kind === 'ma' || level.kind === 'vwap'; const hasPoints = level.kind === 'ma' || level.kind === 'vwap';
const isMa = level.kind === 'ma'; const isMa = level.kind === 'ma';
const dailyChart = this.bars[0]?.tf === '1d';
const options = { const options = {
color: ConfluenceChart.levelColor(level), color: ConfluenceChart.levelColor(level),
lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1), lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1),
lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, // A daily value is held constant while projected over intraday bars,
// but on the daily chart each point is a new sample and should connect
// normally rather than drawing a staircase.
lineType: isMa && !dailyChart
? LightweightCharts.LineType.WithSteps
: LightweightCharts.LineType.Simple,
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: level.cutoff_t == null, lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null,
title: level.label, title: ConfluenceChart.isContextLevel(level) ? '' : level.label,
// Overlays outside the visible price range must not flatten the candles. // Overlays outside the visible price range must not flatten the candles.
autoscaleInfoProvider: () => null, autoscaleInfoProvider: () => null,
}; };
if (!entry) { if (!entry) {
// Daily averages and session VWAP are daily/session context, so their
// last-value labels belong on the left. priceScaleId is fixed at
// creation, which is why it is not in the options reapplied below.
const scale = (isMa || level.kind === 'vwap') ? 'left' : 'right';
entry = { entry = {
series: this.chart.addSeries(LightweightCharts.LineSeries, series: this.chart.addSeries(LightweightCharts.LineSeries,
{ ...options, priceScaleId: scale }), { ...options, priceScaleId: 'right' }),
}; };
this.levelSeries.set(level.id, entry); this.levelSeries.set(level.id, entry);
} else { } else {
@ -582,6 +585,50 @@ class ConfluenceChart {
this.pendingView = null; this.pendingView = null;
} }
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderContextLabels();
}
renderContextLabels() {
if (!this.contextLabelLayer || !this.candles) return;
const height = this.plotCanvas()?.getBoundingClientRect().height || 0;
const labels = this.levels
.filter(level => !level.hidden && ConfluenceChart.isContextLevel(level))
.map(level => {
const points = level.points || [];
const value = level.current_p ?? points[points.length - 1]?.[1] ?? level.anchor_p;
const y = value == null ? null : this.candles.priceToCoordinate(value);
return { level, value, y };
})
.filter(item => item.y != null && item.y >= 0 && item.y <= height)
.sort((a, b) => a.y - b.y);
const gap = 18;
const top = 10;
const bottom = Math.max(top, height - 10);
labels.forEach((item, index) => {
item.placedY = index === 0 ? Math.max(top, item.y) : Math.max(item.y, labels[index - 1].placedY + gap);
});
if (labels.length && labels[labels.length - 1].placedY > bottom) {
const shift = labels[labels.length - 1].placedY - bottom;
labels.forEach(item => { item.placedY -= shift; });
}
this.contextLabelLayer.replaceChildren(...labels.map(({ level, value, y, placedY }) => {
const node = document.createElement('div');
node.className = 'chart-context-label';
node.textContent = `${level.label} ${Number(value).toFixed(2)}`;
node.style.top = `${Math.max(top, placedY)}px`;
node.style.borderColor = ConfluenceChart.levelColor(level);
return node;
}));
}
scheduleContextLabels() {
if (this.contextLabelFrame != null) return;
this.contextLabelFrame = requestAnimationFrame(() => {
this.contextLabelFrame = null;
this.renderContextLabels();
});
} }
// --- tool arming and placement gestures --------------------------------- // --- tool arming and placement gestures ---------------------------------
@ -608,11 +655,9 @@ class ConfluenceChart {
/** /**
* Pixels between the element's left edge and the plot area. * Pixels between the element's left edge and the plot area.
* *
* Lightweight Charts measures coordinates from the plot, not the element, so * Lightweight Charts measures coordinates from the plot, not the element.
* a visible left price scale shifts the two apart. Overlays are positioned * The helper remains the single source of truth if axes or panes change the
* against the element, so every one of them must add this back. Enabling the * plot bounds again.
* left scale for the daily labels silently moved the snap indicator, the
* comments and the anchor handles 66px out of place.
*/ */
/** Put the overlay container exactly over the plot area. */ /** Put the overlay container exactly over the plot area. */
syncOverlayLayer() { syncOverlayLayer() {
@ -630,10 +675,9 @@ class ConfluenceChart {
/** /**
* Keep overlays aligned as Lightweight Charts sizes its internal price scales. * Keep overlays aligned as Lightweight Charts sizes its internal price scales.
* *
* The chart element itself does not resize when a price label makes the left * The chart element itself does not resize when an axis label changes the
* scale wider; the plot canvas does. Observing the element therefore leaves a * plot bounds; the plot canvas does. Observing the canvas catches both initial
* stale offset from the first layout pass, while observing this canvas catches * settling and later digit-width changes.
* both initial settling and later digit-width changes.
*/ */
observePlotCanvas() { observePlotCanvas() {
const canvas = this.plotCanvas(); const canvas = this.plotCanvas();
@ -645,6 +689,7 @@ class ConfluenceChart {
this.syncOverlayLayer(); this.syncOverlayLayer();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderComments(); this.renderComments();
this.renderContextLabels();
if (this.armedTool) this.renderGesture(); if (this.armedTool) this.renderGesture();
}); });
this.plotResizeObserver.observe(canvas); this.plotResizeObserver.observe(canvas);
@ -666,9 +711,10 @@ class ConfluenceChart {
} }
eventPoint(event) { eventPoint(event) {
const bounds = this.chartEl.getBoundingClientRect(); const plot = this.plotCanvas()?.getBoundingClientRect();
const x = event.clientX - bounds.left - this.plotOffsetX(); if (!plot) return null;
const y = event.clientY - bounds.top; const x = event.clientX - plot.left;
const y = event.clientY - plot.top;
const price = this.candles.coordinateToPrice(y); const price = this.candles.coordinateToPrice(y);
if (price == null) return null; if (price == null) return null;
const time = this.chart.timeScale().coordinateToTime(x); const time = this.chart.timeScale().coordinateToTime(x);
@ -871,9 +917,10 @@ class ConfluenceChart {
*/ */
/** Is this point over the plotted area, rather than an axis or the sidebar? */ /** Is this point over the plotted area, rather than an axis or the sidebar? */
withinPlot(point) { withinPlot(point) {
if (!this.chartEl) return true; const plot = this.plotCanvas()?.getBoundingClientRect();
const width = this.chartEl.clientWidth; if (!plot) return true;
const height = this.chartEl.clientHeight; const width = plot.width;
const height = plot.height;
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height; return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height;
} }
@ -1099,21 +1146,23 @@ class ConfluenceChart {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.draggingAnchor = { id: this.selectedLineId, anchor }; this.draggingAnchor = { id: this.selectedLineId, anchor };
event.currentTarget.setPointerCapture?.(event.pointerId);
} }
moveAnchor(event) { moveAnchor(event) {
if (!this.draggingAnchor || !this.chartEl) return; if (!this.draggingAnchor || !this.chartEl) return;
const level = this.levels.find(value => value.id === this.draggingAnchor.id); const level = this.levels.find(value => value.id === this.draggingAnchor.id);
if (!level || !this.bars.length) return; if (!level || !this.bars.length) return;
const bounds = this.chartEl.getBoundingClientRect(); const point = this.eventPoint(event);
const x = Math.max(0, Math.min(bounds.width, event.clientX - bounds.left)); if (!point || !this.withinPlot(point)) {
const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top)); this.hideSnapDot();
const rawTime = this.chart.timeScale().coordinateToTime(x); return;
const price = this.candles.coordinateToPrice(y); }
if (rawTime == null || price == null) return; const snapped = this.snapPoint(point);
const time = this.bars.reduce((nearest, bar) => if (snapped.t == null || snapped.p == null) return;
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest const time = snapped.t;
).t; const price = snapped.p;
if (this.snapToBars) this.showSnapDot(snapped, point);
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
if (this.draggingAnchor.anchor === 'start') { if (this.draggingAnchor.anchor === 'start') {
if (time >= level.last_t) return; if (time >= level.last_t) return;
@ -1136,6 +1185,7 @@ class ConfluenceChart {
event.preventDefault(); event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingAnchor.id); const level = this.levels.find(value => value.id === this.draggingAnchor.id);
this.draggingAnchor = null; this.draggingAnchor = null;
this.hideSnapDot();
if (level && this.onLineChange) this.onLineChange({ ...level }); if (level && this.onLineChange) this.onLineChange({ ...level });
} }
@ -1145,12 +1195,10 @@ class ConfluenceChart {
); );
if (!level || !this.bars.length) return; if (!level || !this.bars.length) return;
event.preventDefault(); event.preventDefault();
const bounds = this.chartEl.getBoundingClientRect(); const point = this.eventPoint(event);
const x = event.clientX - bounds.left - this.plotOffsetX(); if (!point || !this.withinPlot(point) || point.t == null) return;
const rawTime = this.chart.timeScale().coordinateToTime(x);
if (rawTime == null) return;
const cutoff = this.bars.reduce((nearest, bar) => const cutoff = this.bars.reduce((nearest, bar) =>
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
).t; ).t;
if (cutoff <= level.last_t) { if (cutoff <= level.last_t) {
this.hideContextMenu(); this.hideContextMenu();
@ -1158,8 +1206,9 @@ class ConfluenceChart {
} }
this.contextCutoff = cutoff; this.contextCutoff = cutoff;
this.contextMenu.hidden = false; this.contextMenu.hidden = false;
this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`; const plot = this.plotCanvas().getBoundingClientRect();
this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`; this.contextMenu.style.left = `${Math.min(point.x, plot.width - 170)}px`;
this.contextMenu.style.top = `${Math.min(point.y, plot.height - 40)}px`;
} }
hideContextMenu() { hideContextMenu() {
@ -1193,6 +1242,7 @@ class ConfluenceChart {
destroy() { destroy() {
if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); if (this.plotResizeObserver) this.plotResizeObserver.disconnect();
if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame);
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);

View file

@ -32,6 +32,7 @@
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span> <span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span> <span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span> <span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
<span class="app-updated" :title="deploymentTitle">UPDATED <b>{{ updatedAt }}</b></span>
</div> </div>
</section> </section>
<aside> <aside>

View file

@ -15,7 +15,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.app-updated { margin-left:auto; white-space:nowrap; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
@ -81,3 +81,4 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; } .chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; }
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; } .chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; } .chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }

View file

@ -25,6 +25,28 @@ test('the viewport opens on the live edge, not in the past', { timeout: 180000 }
}); });
}); });
test('the running deployment time is visible without making the status bar taller',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.waitForFunction(() => {
const node = document.querySelector('.app-updated b');
return node && node.textContent.trim() !== '—';
});
const state = await page.evaluate(() => {
const node = document.querySelector('.app-updated');
return {
text: node.textContent.trim(),
title: node.title,
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
};
});
assert.match(state.text, /^UPDATED\s+\S/);
assert.match(state.title, /^Running since .+ · (dev|[0-9a-f]{7,40})$/);
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
assertNoPageErrors(page, assert);
});
});
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => { test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
await withChart(async page => { await withChart(async page => {
for (const tf of TIMEFRAMES) { for (const tf of TIMEFRAMES) {

View file

@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, assertNoPageErrors } from './helpers.mjs';
test('every price overlay shares the candle price scale',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const state = await page.evaluate(() => {
const c = window.__chart;
const series = [...c.levelSeries.entries()].map(([id, entry]) => ({
id,
scale: entry.series.options().priceScaleId,
context: window.ConfluenceChart.isContextLevel(c.levels.find(level => level.id === id)),
title: entry.series.options().title,
}));
const priceLines = [...c.priceLines.entries()].map(([id, entry]) => ({
id,
usesCandles: entry.host === c.candles,
}));
const average = c.levels.find(level => level.kind === 'ma');
const averageSeries = average ? c.levelSeries.get(average.id)?.series : null;
const value = average?.anchor_p ?? null;
return {
leftVisible: c.chart.priceScale('left').options().visible,
series,
priceLines,
coordinateDifference: averageSeries && value != null
? Math.abs(averageSeries.priceToCoordinate(value) - c.candles.priceToCoordinate(value))
: null,
leftLabels: document.querySelectorAll('.chart-context-label').length,
};
});
assert.equal(state.leftVisible, false, 'an independent left price scale is visible');
assert.ok(state.series.length > 0, 'no overlay series were available to check');
assert.ok(state.series.every(item => item.scale === 'right'),
`overlay series left the candle scale: ${JSON.stringify(state.series)}`);
assert.ok(state.series.filter(item => item.context).every(item => item.title === ''),
`long-term labels still clutter the right: ${JSON.stringify(state.series)}`);
assert.ok(state.leftLabels > 0, 'long-term labels are missing from the left side');
assert.ok(state.priceLines.every(item => item.usesCandles),
`a price line uses another scale: ${JSON.stringify(state.priceLines)}`);
assert.ok(state.coordinateDifference != null && state.coordinateDifference < 0.1,
`the same price differs by ${state.coordinateDifference}px between MA and candles`);
const intradayType = await page.evaluate(() => {
const c = window.__chart;
const average = c.levels.find(level => level.kind === 'ma');
return {
actual: c.levelSeries.get(average.id).series.options().lineType,
expected: LightweightCharts.LineType.WithSteps,
};
});
assert.equal(intradayType.actual, intradayType.expected,
'an intraday daily MA is not held as steps');
await page.click('.timeframes button:text-is("1d")');
await page.waitForTimeout(1500);
const dailyType = await page.evaluate(() => {
const c = window.__chart;
const average = c.levels.find(level => level.kind === 'ma');
return {
actual: c.levelSeries.get(average.id).series.options().lineType,
expected: LightweightCharts.LineType.Simple,
};
});
assert.equal(dailyType.actual, dailyType.expected,
'a daily MA is still rendered as a staircase');
assertNoPageErrors(page, assert);
});
});

View file

@ -114,6 +114,53 @@ test('the snapped extreme decides the side, overriding the dropdown', { timeout:
}); });
}); });
test('dragging an anchor uses the same bar-extreme snapping as placement',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.30, 0.55);
const second = at(box, 0.48, 0.35);
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
await page.waitForFunction(() =>
document.querySelectorAll('.chart-anchor:not([hidden])').length === 2);
const target = await page.evaluate(() => {
const c = window.__chart;
const bar = c.bars[c.bars.length - 35];
const plot = c.plotCanvas().getBoundingClientRect();
return {
t: bar.t,
p: bar.l,
x: plot.left + c.chart.timeScale().timeToCoordinate(bar.t),
y: plot.top + c.candles.priceToCoordinate(bar.l),
};
});
const handle = await page.locator('.chart-anchor[data-anchor="end"]:not([hidden])').boundingBox();
assert.ok(handle, 'the selected trendline has no end handle');
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(900);
const moved = await page.evaluate(() => {
const lines = window.__chart.levels.filter(level => level.kind === 'manual' && level.slope !== 0);
const line = lines.sort((a, b) => b.number - a.number)[0];
return line && {
t: line.last_t,
p: line.anchor_p + line.slope * (line.last_t - line.anchor_t),
};
});
assert.equal(moved.t, target.t, 'the handle moved to a different bar');
assert.ok(Math.abs(moved.p - target.p) < 1e-6,
`the handle landed at ${moved.p}, not the bar low ${target.p}`);
assertNoPageErrors(page, assert);
});
});
test('hovering shows where the anchor will land', { timeout: 180000 }, async () => { test('hovering shows where the anchor will land', { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
const box = await chartBox(page); const box = await chartBox(page);
@ -134,7 +181,7 @@ test('hovering shows where the anchor will land', { timeout: 180000 }, async ()
}); });
}); });
test('the snap dot stays under the cursor when the left scale changes width', test('the snap dot is painted under the cursor in page coordinates',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
await armTool(page, 'Trendline'); await armTool(page, 'Trendline');
@ -165,26 +212,7 @@ test('the snap dot stays under the cursor when the left scale changes width',
return target.offset; return target.offset;
}; };
const initialOffset = await probe(); await probe();
await page.evaluate(() => {
const c = window.__chart;
c.__scaleProbe = c.chart.addSeries(LightweightCharts.LineSeries, {
priceScaleId: 'left', lastValueVisible: true, priceLineVisible: false,
});
c.__scaleProbe.setData([{
time: c.bars[c.bars.length - 1].t,
value: 123456.75,
}]);
});
await page.waitForTimeout(600);
const widenedOffset = await probe();
assert.ok(widenedOffset > initialOffset + 2,
`left scale did not widen: ${initialOffset}px -> ${widenedOffset}px`);
await page.evaluate(() => {
const c = window.__chart;
c.chart.removeSeries(c.__scaleProbe);
delete c.__scaleProbe;
});
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });
@ -282,3 +310,33 @@ test('the snap never leaps to the live edge when the cursor leaves the plot',
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });
test('the right price axis is outside the snapping area',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await armTool(page, 'Trendline');
const regions = await page.evaluate(() => {
const c = window.__chart;
const chart = c.chartEl.getBoundingClientRect();
const plot = c.plotCanvas().getBoundingClientRect();
return {
plot: { x: plot.left + plot.width * 0.65, y: plot.top + plot.height * 0.45 },
axis: { x: plot.right + (chart.right - plot.right) / 2, y: plot.top + plot.height * 0.45 },
axisWidth: chart.right - plot.right,
};
});
assert.ok(regions.axisWidth > 10, `right price axis is only ${regions.axisWidth}px wide`);
const dotHidden = () => page.evaluate(() => document.querySelector('.chart-snap-dot')?.hidden);
await page.mouse.move(regions.plot.x, regions.plot.y);
await page.waitForTimeout(300);
assert.equal(await dotHidden(), false, 'snap dot did not appear over the plot');
await page.mouse.move(regions.axis.x, regions.axis.y);
await page.waitForTimeout(300);
assert.equal(await dotHidden(), true, 'snap dot remained visible over the right price axis');
await page.mouse.move(regions.plot.x, regions.plot.y);
await page.waitForTimeout(300);
assert.equal(await dotHidden(), false, 'snap dot did not return after leaving the price axis');
assertNoPageErrors(page, assert);
});
});

View file

@ -0,0 +1,30 @@
import asyncio
from unittest.mock import AsyncMock
from app.bars.models import Timeframe
from app.config import Settings
from app.runtime import Runtime
def test_start_seeds_native_thirty_minute_history(tmp_path, monkeypatch):
instance = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
source = object()
monkeypatch.setattr("app.runtime.seed_source", lambda settings: source)
instance.stream.seed = AsyncMock()
async def run():
return None
instance.stream.run = run
async def start():
task = await instance.start()
await task
asyncio.run(start())
assert [call.args[1:4] for call in instance.stream.seed.await_args_list] == [
(Timeframe.H1, "730d", "ES=F"),
(Timeframe.M30, "60d", "ES=F"),
(Timeframe.M1, "8d", "ES=F"),
]

View file

@ -38,3 +38,23 @@ async def test_one_minute_history_is_windowed_and_deduplicated():
assert len(calls) == 3 assert len(calls) == 3
assert len(bars) == len({bar.t for bar in parse_chart(payload, Timeframe.M1)}) assert len(bars) == len({bar.t for bar in parse_chart(payload, Timeframe.M1)})
@pytest.mark.asyncio
async def test_thirty_minute_history_uses_yahoos_native_interval():
payload = json.loads(FIXTURE.read_text())
calls = []
async def handler(request: httpx.Request):
calls.append(request)
return httpx.Response(200, json=payload)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
bars = await YahooSource(client=client).history(
"ES=F", Timeframe.M30, range_="60d"
)
assert len(calls) == 1
assert calls[0].url.params["interval"] == "30m"
assert calls[0].url.params["range"] == "60d"
assert bars and all(bar.tf is Timeframe.M30 for bar in bars)