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_POLL_SECONDS=20
SEED_1H_RANGE=730d
SEED_30M_RANGE=60d
SEED_1M_RANGE=8d
# 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`
(`delayed: false`), but serves **no futures history at all** — everything it
knows starts when you connect.
- **Yahoo** has roughly 730 days of hourly data, which is what makes a 200-day
moving average warm at startup rather than in ten months. It lags ~10 minutes.
- **Yahoo** has roughly 730 days of hourly data, 60 days of native 30-minute
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
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.
"""
import os
from datetime import datetime, timezone
from fastapi import APIRouter
@ -13,6 +14,7 @@ router = APIRouter(prefix="/api")
# Coolify injects the deployed commit; absent when running locally.
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
@router.get("/health")
@ -22,4 +24,4 @@ def health():
@router.get("/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_poll_seconds: float = 20
seed_1h_range: str = "730d"
seed_30m_range: str = "60d"
seed_1m_range: str = "8d"
timeframes: str = "1m,5m,15m,30m,1h,1d"
base_timeframes: str = "1m,30m,1d"

View file

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

View file

@ -239,6 +239,12 @@ class Runtime:
await self.stream.seed(
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(
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.
This keeps current advice visible without turning the historical implementation
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
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,
measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`.
## 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
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:
1. Use one plot-relative pointer path for placement, selection and anchor drag.
2. Add touch-sized invisible hit regions without enlarging the visual marks.
3. Keep the first tap's trendline anchor visible with price/time, Cancel and
1. 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
Undo anchor.
4. 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
3. Add a compact sticky mobile tool rail next to the chart.
4. Add a selected-drawing action bar so End here and Delete do not require
right-click or a hardware keyboard.
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
Placement, selection, anchor dragging, comments, and context actions should all
use the same plot-relative pointer conversion. Build mobile hit regions around
painted geometry rather than compensating for different coordinate frames.
Status: completed 2026-08-11. Placement, selection, anchor dragging and context
actions now use the measured plot canvas as their shared pointer frame.
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
desktop and should have page-pixel regression coverage.
@ -144,8 +147,8 @@ invariants rather than arbitrary CSS dimensions.
## Incremental rollout
1. Add mobile diagnostics/tests, unify pointer coordinates, and enlarge
invisible hit regions.
1. Add mobile diagnostics/tests and enlarge invisible hit regions. Pointer
coordinates are already unified for chart drawing interactions.
2. Add persistent first-anchor feedback, Cancel, and Undo anchor.
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.

View file

@ -71,6 +71,7 @@ function loadPrefs() {
createApp({
setup() {
const status = ref({ stream: 'disconnected', bars_held: {} });
const deployment = ref(null);
const price = ref(null);
const prefs = ref(loadPrefs());
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));
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 hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
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();
}
async function refreshDeployment() {
const response = await apiFetch('/api/version');
if (response.ok) deployment.value = await response.json();
}
function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
@ -541,6 +558,7 @@ createApp({
refreshComments();
window.addEventListener('keydown', handleKeydown);
refreshStatus();
refreshDeployment();
connect();
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
});
@ -551,6 +569,6 @@ createApp({
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');

View file

@ -43,6 +43,8 @@ class ConfluenceChart {
this.comments = [];
this.commentNodes = new Map();
this.commentLayer = null;
this.contextLabelLayer = null;
this.contextLabelFrame = null;
this.onCommentToggle = null;
this.onCommentMove = null;
}
@ -78,6 +80,7 @@ class ConfluenceChart {
layout: {
background: { color: getComputedStyle(document.documentElement).getPropertyValue('--chart-bg').trim() },
textColor: getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(),
attributionLogo: false,
},
grid: {
vertLines: { color: 'rgba(128,128,128,.10)' },
@ -112,8 +115,10 @@ class ConfluenceChart {
}),
},
rightPriceScale: { borderVisible: false },
// Daily context lives on the left, intraday on the right.
leftPriceScale: { visible: true, borderVisible: false },
// Every price-bearing series shares this scale. A second autoscaled price
// 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
// hovering beside a low reads a price several ticks away, and everything
// that snaps in this app snaps to extremes. The crosshair tracks the
@ -125,16 +130,6 @@ class ConfluenceChart {
borderUpColor: '#1d6849', borderDownColor: '#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
// 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
@ -153,25 +148,28 @@ class ConfluenceChart {
// 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.
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.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
requestAnimationFrame(() => {
this.observePlotCanvas();
this.renderAnchorHandles();
this.renderComments();
this.renderContextLabels();
});
});
this.resizeObserver.observe(el);
// Every overlay lives in here, and this is positioned over the *plot* — not
// the element, which also contains the price scales. Lightweight Charts
// reports coordinates from the plot's origin, so anchoring the container
// there lets each overlay use those coordinates untranslated. Enabling the
// left price scale moved the plot 66px right and silently displaced the
// snap indicator, the comments and the trendline handles by that much.
// there lets each overlay use those coordinates untranslated. Keep the
// measurement even with no left scale: future layout changes must not
// silently displace snap indicators, comments or trendline handles.
this.overlayLayer = document.createElement('div');
this.overlayLayer.className = 'chart-overlays';
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.className = 'chart-tooltip';
this.overlayLayer.appendChild(this.tooltip);
@ -256,13 +254,14 @@ class ConfluenceChart {
// whatever the new line happens to overlap.
if (this.armedTool) return;
if (!this.onChartClick) return;
const bounds = el.getBoundingClientRect();
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
const time = this.chart.timeScale().coordinateToTime(point.x);
if (time != null) this.onChartClick({ point, time });
const point = this.eventPoint(event);
if (point && this.withinPlot(point) && point.t != null) {
this.onChartClick({ point, time: point.t });
}
};
el.addEventListener('click', this.clickListener);
this.chart.subscribeCrosshairMove(param => {
this.scheduleContextLabels();
if (!param.point || !param.time) {
this.tooltip.hidden = true;
return;
@ -273,6 +272,7 @@ class ConfluenceChart {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles();
this.renderComments();
this.renderContextLabels();
});
}
@ -280,7 +280,6 @@ class ConfluenceChart {
this.bars = bars;
const candleData = bars.map(this.toCandle);
this.candles.setData(candleData);
this.leftMirror.setData(candleData);
this.volume.setData(bars.map(ConfluenceChart.toVolume));
// Anchored by time, not by logical index. A logical index addresses the
// chart's *shared* scale — the union of every series' time points — not
@ -301,6 +300,7 @@ class ConfluenceChart {
requestAnimationFrame(() => this.renderAnchorHandles());
// The bar grid just changed underneath every pinned comment.
this.renderComments();
this.renderContextLabels();
}
updateBar(bar) {
@ -311,11 +311,11 @@ class ConfluenceChart {
const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return;
this.candles.update(this.toCandle(bar));
this.leftMirror.update(this.toCandle(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;
else this.bars.push(bar);
this.renderAnchorHandles();
this.renderContextLabels();
}
setComments(comments) {
@ -448,6 +448,10 @@ class ConfluenceChart {
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
// span the whole chart regardless of scroll and get a price-axis label.
syncPriceLines(levels) {
@ -465,16 +469,13 @@ class ConfluenceChart {
color: ConfluenceChart.levelColor(level),
lineWidth: level.line_width || 1,
lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: true,
title: level.label,
axisLabelVisible: level.kind !== 'horizontal',
title: ConfluenceChart.isContextLevel(level) ? '' : level.label,
};
const existing = this.priceLines.get(level.id);
if (existing) existing.line.applyOptions(options);
else {
// Prior-day levels are daily context and move to the left scale; a
// 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;
const host = this.candles;
this.priceLines.set(level.id, { host, line: host.createPriceLine(options) });
}
}
@ -534,25 +535,27 @@ class ConfluenceChart {
// value between closes while VWAP moves continuously.
const hasPoints = level.kind === 'ma' || level.kind === 'vwap';
const isMa = level.kind === 'ma';
const dailyChart = this.bars[0]?.tf === '1d';
const options = {
color: ConfluenceChart.levelColor(level),
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,
priceLineVisible: false,
lastValueVisible: level.cutoff_t == null,
title: level.label,
lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null,
title: ConfluenceChart.isContextLevel(level) ? '' : level.label,
// Overlays outside the visible price range must not flatten the candles.
autoscaleInfoProvider: () => null,
};
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 = {
series: this.chart.addSeries(LightweightCharts.LineSeries,
{ ...options, priceScaleId: scale }),
{ ...options, priceScaleId: 'right' }),
};
this.levelSeries.set(level.id, entry);
} else {
@ -582,6 +585,50 @@ class ConfluenceChart {
this.pendingView = null;
}
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 ---------------------------------
@ -608,11 +655,9 @@ class ConfluenceChart {
/**
* Pixels between the element's left edge and the plot area.
*
* Lightweight Charts measures coordinates from the plot, not the element, so
* a visible left price scale shifts the two apart. Overlays are positioned
* against the element, so every one of them must add this back. Enabling the
* left scale for the daily labels silently moved the snap indicator, the
* comments and the anchor handles 66px out of place.
* Lightweight Charts measures coordinates from the plot, not the element.
* The helper remains the single source of truth if axes or panes change the
* plot bounds again.
*/
/** Put the overlay container exactly over the plot area. */
syncOverlayLayer() {
@ -630,10 +675,9 @@ class ConfluenceChart {
/**
* 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
* scale wider; the plot canvas does. Observing the element therefore leaves a
* stale offset from the first layout pass, while observing this canvas catches
* both initial settling and later digit-width changes.
* The chart element itself does not resize when an axis label changes the
* plot bounds; the plot canvas does. Observing the canvas catches both initial
* settling and later digit-width changes.
*/
observePlotCanvas() {
const canvas = this.plotCanvas();
@ -645,6 +689,7 @@ class ConfluenceChart {
this.syncOverlayLayer();
this.renderAnchorHandles();
this.renderComments();
this.renderContextLabels();
if (this.armedTool) this.renderGesture();
});
this.plotResizeObserver.observe(canvas);
@ -666,9 +711,10 @@ class ConfluenceChart {
}
eventPoint(event) {
const bounds = this.chartEl.getBoundingClientRect();
const x = event.clientX - bounds.left - this.plotOffsetX();
const y = event.clientY - bounds.top;
const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot) return null;
const x = event.clientX - plot.left;
const y = event.clientY - plot.top;
const price = this.candles.coordinateToPrice(y);
if (price == null) return null;
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? */
withinPlot(point) {
if (!this.chartEl) return true;
const width = this.chartEl.clientWidth;
const height = this.chartEl.clientHeight;
const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot) return true;
const width = plot.width;
const height = plot.height;
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height;
}
@ -1099,21 +1146,23 @@ class ConfluenceChart {
event.preventDefault();
event.stopPropagation();
this.draggingAnchor = { id: this.selectedLineId, anchor };
event.currentTarget.setPointerCapture?.(event.pointerId);
}
moveAnchor(event) {
if (!this.draggingAnchor || !this.chartEl) return;
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
if (!level || !this.bars.length) return;
const bounds = this.chartEl.getBoundingClientRect();
const x = Math.max(0, Math.min(bounds.width, event.clientX - bounds.left));
const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top));
const rawTime = this.chart.timeScale().coordinateToTime(x);
const price = this.candles.coordinateToPrice(y);
if (rawTime == null || price == null) return;
const time = this.bars.reduce((nearest, bar) =>
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest
).t;
const point = this.eventPoint(event);
if (!point || !this.withinPlot(point)) {
this.hideSnapDot();
return;
}
const snapped = this.snapPoint(point);
if (snapped.t == null || snapped.p == null) return;
const time = snapped.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);
if (this.draggingAnchor.anchor === 'start') {
if (time >= level.last_t) return;
@ -1136,6 +1185,7 @@ class ConfluenceChart {
event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
this.draggingAnchor = null;
this.hideSnapDot();
if (level && this.onLineChange) this.onLineChange({ ...level });
}
@ -1145,12 +1195,10 @@ class ConfluenceChart {
);
if (!level || !this.bars.length) return;
event.preventDefault();
const bounds = this.chartEl.getBoundingClientRect();
const x = event.clientX - bounds.left - this.plotOffsetX();
const rawTime = this.chart.timeScale().coordinateToTime(x);
if (rawTime == null) return;
const point = this.eventPoint(event);
if (!point || !this.withinPlot(point) || point.t == null) return;
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;
if (cutoff <= level.last_t) {
this.hideContextMenu();
@ -1158,8 +1206,9 @@ class ConfluenceChart {
}
this.contextCutoff = cutoff;
this.contextMenu.hidden = false;
this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`;
this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`;
const plot = this.plotCanvas().getBoundingClientRect();
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() {
@ -1193,6 +1242,7 @@ class ConfluenceChart {
destroy() {
if (this.resizeObserver) this.resizeObserver.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.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
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-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 class="app-updated" :title="deploymentTitle">UPDATED <b>{{ updatedAt }}</b></span>
</div>
</section>
<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); }
#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; }
.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; }
.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; }
@ -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-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-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 () => {
await withChart(async page => {
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 () => {
await withChart(async 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 () => {
await withChart(async page => {
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;
};
const initialOffset = 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;
});
await probe();
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);
});
});
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(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)