Unify chart geometry and deepen 30m history
This commit is contained in:
parent
dd9d24b419
commit
91677127a0
18 changed files with 434 additions and 125 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
184
static/chart.js
184
static/chart.js
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
70
tests/e2e/price-scale.test.mjs
Normal file
70
tests/e2e/price-scale.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
30
tests/test_runtime_start.py
Normal file
30
tests/test_runtime_start.py
Normal 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"),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue