- Drag a selected trendline body to reposition the entire line.

- Duplicate and delete from the line context menu.
     - Copies shift ten bars right.
     - Default names are up and down; copies become up 2, down 2, etc.
     - Exact local data receipt time including seconds.
     - Deployment timestamp removed.
     - Test cleanup no longer deletes drawings created from your browser.
     - JWT password session flow.
This commit is contained in:
Chris Amow 2026-08-11 05:46:09 -05:00
parent 4488c7d4b9
commit 3b3c06a1f3
20 changed files with 784 additions and 115 deletions

View file

@ -36,4 +36,5 @@ NTFY_SERVER=https://ntfy.sh
# Blank = no auth (fine locally). In production this is set in Coolify, not
# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws.
CHART_AUTH_TOKEN=
CHART_PASSWORD=
REPLAY_FILE=

View file

@ -2,8 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
COPY . .

View file

@ -232,22 +232,26 @@ curl -s -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status
# want: "source":"schwab","delay_minutes":0
```
## Access token
## Browser password and API token
`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave
it blank and the app is wide open, which is what you want locally — nothing
prompts. Set it and every request needs the token, as the `X-Chart-Token`
header or a `?token=` query parameter (WebSocket handshakes can't carry
headers, hence the second form).
prompts. Scripts can supply it as the `X-Chart-Token` header or a `?token=`
query parameter.
In production the token lives in **Coolify's environment variables**, not in
Set `CHART_PASSWORD` to a human-friendly passphrase for browser access. The
browser posts it once to `/api/login`; the server returns a signed, HttpOnly,
30-day HS256 JWT cookie used by both API requests and the WebSocket. The opaque
API token is never returned to or stored by the browser. If `CHART_PASSWORD` is
temporarily absent, the login accepts `CHART_AUTH_TOKEN` as a migration fallback.
Existing browsers that stored a token under the old flow exchange it once for a
session and remove it from `localStorage`. `POST /api/logout` clears the session.
In production both secrets live in **Coolify's environment variables**, not in
this repo and not in `.env` — that file is gitignored and never exists in the
built container. Coolify re-injects its env vars into every container it
builds, so the token survives redeploys and reboots.
The browser asks for it once on the first 401 and keeps it in `localStorage`.
To clear it: `localStorage.removeItem('chart-token')`.
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
polls the latter from whatever machine you pushed from, and neither reveals
anything about the market data or the configuration.

View file

@ -57,7 +57,7 @@ class ManualLine:
def default_label(self) -> str:
if self.horizontal:
return f"@ {self.anchor_p:.2f}"
return f"{self.tf.value} {self.side.value}"
return "up" if self.side is Side.SUPPORT else "down"
def to_level(self) -> Level:
return Level(

View file

@ -1,11 +1,28 @@
import hashlib
import secrets
import time
import jwt
from fastapi import HTTPException, Request, status
from jwt import InvalidTokenError
SESSION_COOKIE = "chart-session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30
def configured_settings(app):
runtime = getattr(app.state, "runtime", None)
return runtime.settings if runtime else None
def configured_token(app) -> str:
runtime = getattr(app.state, "runtime", None)
return runtime.settings.chart_auth_token if runtime else ""
settings = configured_settings(app)
return settings.chart_auth_token if settings else ""
def configured_password(app) -> str:
settings = configured_settings(app)
return settings.chart_password if settings else ""
def token_matches(app, presented: str) -> bool:
@ -16,15 +33,68 @@ def token_matches(app, presented: str) -> bool:
"""
want = configured_token(app)
if not want:
return True
return secrets.compare_digest(presented or "", want)
return not configured_password(app)
return secrets.compare_digest((presented or "").encode(), want.encode())
def require_token(request: Request) -> None:
def password_matches(app, presented: str) -> bool:
# Falling back to the token avoids locking out a deployment while
# CHART_PASSWORD is being added. Once set, only the friendly password logs
# a browser in; the opaque token remains valid for direct API clients.
want = configured_password(app) or configured_token(app)
return bool(want) and secrets.compare_digest(
(presented or "").encode(), want.encode()
)
def session_secret(app) -> bytes:
configured = configured_token(app) or configured_password(app)
return hashlib.sha256(configured.encode()).digest() if configured else b""
def create_session(app, now: int | None = None) -> str:
secret = session_secret(app)
if not secret:
return ""
issued = now if now is not None else int(time.time())
return jwt.encode(
{"sub": "shared", "iat": issued, "exp": issued + SESSION_MAX_AGE},
secret,
algorithm="HS256",
)
def session_principal(app, presented: str) -> str | None:
secret = session_secret(app)
if not secret or not presented:
return None
try:
payload = jwt.decode(
presented,
secret,
algorithms=["HS256"],
options={"require": ["sub", "iat", "exp"]},
)
except InvalidTokenError:
return None
principal = payload.get("sub")
return principal if isinstance(principal, str) and principal else None
def session_matches(app, presented: str) -> bool:
return session_principal(app, presented) is not None
def require_token(request: Request) -> str:
presented = request.headers.get("x-chart-token") or request.query_params.get("token", "")
if not token_matches(request.app, presented):
session = request.cookies.get(SESSION_COOKIE, "")
if token_matches(request.app, presented):
return "api-token" if configured_token(request.app) else "anonymous"
principal = session_principal(request.app, session)
if principal is not None:
return principal
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Missing or invalid chart token",
headers={"WWW-Authenticate": "X-Chart-Token"},
"Authentication required",
headers={"WWW-Authenticate": "Session, X-Chart-Token"},
)

View file

@ -1,14 +1,23 @@
"""Endpoints that stay reachable without a token.
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, so
requiring the token here would mean carrying it around just to answer "is my
commit live yet". Neither endpoint exposes anything about the market data or
the configuration.
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, and
the browser needs /api/login before it has a session, so these routes stay
outside the protected API router.
"""
import os
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Request, Response, status
from pydantic import BaseModel
from app.api.deps import (
SESSION_COOKIE,
SESSION_MAX_AGE,
configured_token,
create_session,
password_matches,
token_matches,
)
router = APIRouter(prefix="/api")
@ -17,6 +26,10 @@ SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
class LoginRequest(BaseModel):
password: str
@router.get("/health")
def health():
return {"status": "ok", "service": "chart"}
@ -25,3 +38,28 @@ def health():
@router.get("/version")
def version():
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(credentials: LoginRequest, request: Request, response: Response):
presented_token = request.headers.get("x-chart-token", "")
token_login = bool(configured_token(request.app)) and token_matches(
request.app, presented_token
)
if not token_login and not password_matches(request.app, credentials.password):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect password")
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0]
response.set_cookie(
SESSION_COOKIE,
create_session(request.app),
max_age=SESSION_MAX_AGE,
httponly=True,
secure=request.url.scheme == "https" or forwarded_proto == "https",
samesite="strict",
path="/",
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response):
response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict")

View file

@ -28,6 +28,8 @@ class LineCreate(BaseModel):
hidden: bool = False
color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$")
line_width: int = Field(2, ge=1, le=4)
cutoff_t: int | None = None
armed: bool = True
class PriceAlertCreate(BaseModel):
@ -125,6 +127,8 @@ def create_line(request: Request, payload: LineCreate):
hidden=payload.hidden,
color=payload.color,
line_width=payload.line_width,
cutoff_t=payload.cutoff_t,
armed=payload.armed,
)
runtime = request.app.state.runtime
line = runtime.manual_lines.add(line)

View file

@ -1,14 +1,21 @@
import asyncio
from urllib.parse import urlsplit
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import token_matches
from app.api.deps import SESSION_COOKIE, session_matches, token_matches
from app.bars.models import Timeframe
from app.analysis.confluence import cluster_levels
router = APIRouter()
def same_origin(websocket: WebSocket) -> bool:
origin = websocket.headers.get("origin", "")
host = websocket.headers.get("host", "")
return bool(origin and host) and urlsplit(origin).netloc == host
def level_enabled(level, enabled: dict) -> bool:
kind = level.kind.value
if kind == "ma":
@ -54,9 +61,14 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
# Browsers cannot set headers on a WebSocket handshake, so the token comes
# in as a query parameter here. 1008 = policy violation.
if not token_matches(websocket.app, websocket.query_params.get("token", "")):
# Browsers automatically include the HttpOnly session cookie in the
# handshake. Query-token support remains for non-browser clients and for
# tabs migrating from the previous localStorage-based login.
token_ok = token_matches(websocket.app, websocket.query_params.get("token", ""))
session_ok = same_origin(websocket) and session_matches(
websocket.app, websocket.cookies.get(SESSION_COOKIE, "")
)
if not token_ok and not session_ok:
await websocket.close(code=1008, reason="Missing or invalid chart token")
return
await websocket.accept()

View file

@ -57,6 +57,7 @@ class Settings(BaseSettings):
ntfy_topic: str = ""
ntfy_server: str = "https://ntfy.sh"
chart_auth_token: str = ""
chart_password: str = ""
replay_file: Path | None = None
@property

View file

@ -2,5 +2,6 @@ fastapi
uvicorn[standard]
httpx
pydantic-settings
PyJWT
# Live futures stream; imported only when LIVE_SOURCE=schwab.
schwab-py

View file

@ -1,25 +1,58 @@
const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
// Shared access token. Blank when the server runs without CHART_AUTH_TOKEN,
// which is the normal local-development case — nothing prompts.
// Keep an existing token long enough to migrate an already-open browser to the
// password session. Successful login removes it; new browsers never store the
// API token at all.
const TOKEN_KEY = 'chart-token';
let authToken = localStorage.getItem(TOKEN_KEY) || '';
// Reconnects and the status poll both hit 401s, so without this a visitor
// who cancels gets asked again every couple of seconds.
let promptDeclined = false;
let loginPromise = null;
function promptForToken() {
if (promptDeclined) return false;
const entered = window.prompt('Access token for this chart', '');
async function migrateLegacyToken() {
if (!authToken) return;
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Chart-Token': authToken },
body: JSON.stringify({ password: '' }),
});
if (!response.ok) return;
authToken = '';
localStorage.removeItem(TOKEN_KEY);
}
async function runPasswordLogin() {
while (!promptDeclined) {
const entered = window.prompt('Password for this chart', '');
if (entered === null) {
promptDeclined = true;
return false;
}
authToken = entered.trim();
localStorage.setItem(TOKEN_KEY, authToken);
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: entered }),
});
if (!response.ok) {
window.alert('Incorrect password');
continue;
}
authToken = '';
localStorage.removeItem(TOKEN_KEY);
return true;
}
return false;
}
async function loginWithPassword() {
if (promptDeclined) return false;
if (!loginPromise) {
loginPromise = runPasswordLogin().finally(() => { loginPromise = null; });
}
return loginPromise;
}
async function apiFetch(url, options = {}) {
const send = () => fetch(url, {
@ -30,7 +63,7 @@ async function apiFetch(url, options = {}) {
});
const response = await send();
// A 401 means nothing was written, so retrying the same request is safe.
if (response.status === 401 && promptForToken()) return send();
if (response.status === 401 && await loginWithPassword()) return send();
return response;
}
@ -71,9 +104,7 @@ function loadPrefs() {
createApp({
setup() {
const status = ref({ stream: 'disconnected', bars_held: {} });
const deployment = ref(null);
const dataReceivedAt = ref(null);
const dataReceiptVerb = ref('LOADED');
const price = ref(null);
const prefs = ref(loadPrefs());
const timeframe = ref(prefs.value.base_tf || '1m');
@ -113,28 +144,11 @@ 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 deployedAt = computed(() => {
if (!deployment.value?.started_at) return '—';
const dataUpdatedAt = computed(() => {
if (dataReceivedAt.value == null) return '—';
return new Intl.DateTimeFormat(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
}).format(new Date(deployment.value.started_at));
});
const deployMeta = computed(() => {
if (!deployment.value) return '—';
return `${deployment.value.commit.slice(0, 8)} · ${deployedAt.value}`;
});
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 dataAge = computed(() => {
if (dataReceivedAt.value == null) return 'WAITING';
const seconds = Math.max(0, Math.floor((now.value - dataReceivedAt.value) / 1000));
if (seconds < 5) return 'JUST NOW';
if (seconds < 60) return `${seconds}s AGO`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m AGO`;
return `${Math.floor(seconds / 3600)}h AGO`;
hour: 'numeric', minute: '2-digit', second: '2-digit',
}).format(new Date(dataReceivedAt.value));
});
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
@ -176,11 +190,6 @@ 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)}` : '';
@ -194,7 +203,6 @@ createApp({
const message = JSON.parse(data);
if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now();
dataReceiptVerb.value = 'LOADED';
chartApi.setBars(message.bars);
levels.value = message.levels || [];
syncVisibleLevels();
@ -207,7 +215,6 @@ createApp({
// outright. The event carries its timeframe, so honour it.
if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now();
dataReceiptVerb.value = 'UPDATED';
chartApi.updateBar(message.bar);
price.value = message.bar.c;
status.value.last_bar_t = message.bar.t;
@ -359,7 +366,7 @@ createApp({
const temporaryId = `tmp_${Date.now()}`;
const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side,
weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${side}`,
weight: 1, score: 1, label: drawName.value || (side === 'support' ? 'up' : 'down'),
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
color: drawColor.value, line_width: drawWidth.value,
@ -410,6 +417,56 @@ createApp({
await deleteLines([id]);
}
function shiftTimeByBars(time, count = 10) {
const bars = chartApi?.bars || [];
if (bars.length < 2) return time;
let nearest = 0;
for (let index = 1; index < bars.length; index += 1) {
if (Math.abs(bars[index].t - time) < Math.abs(bars[nearest].t - time)) nearest = index;
}
const target = nearest + count;
if (target < bars.length) return bars[target].t;
const last = bars.length - 1;
const step = bars[last].t - bars[last - 1].t;
return bars[last].t + (target - last) * step;
}
function duplicateName(line) {
const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/);
const base = match[1];
let number = match[2] ? Number(match[2]) + 1 : 2;
const labels = new Set(manualLines.value.map(value => value.label));
while (labels.has(`${base} ${number}`)) number += 1;
return `${base} ${number}`;
}
async function duplicateLine(line) {
const anchorT = shiftTimeByBars(line.anchor_t);
const endT = shiftTimeByBars(line.last_t);
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: line.tf, side: line.side,
anchor_t: anchorT, anchor_p: line.anchor_p,
end_t: endT, end_p: endP,
note: duplicateName(line), hidden: line.hidden,
color: line.color || '#65b7cf', line_width: line.line_width || 2,
cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line.cutoff_t),
armed: line.armed,
}),
});
if (!response.ok) {
console.error(`Unable to duplicate line: HTTP ${response.status}`);
return;
}
const saved = await response.json();
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
selectedLine.value = saved.id;
selectedLines.value = [saved.id];
syncVisibleLevels();
}
async function deleteLines(ids) {
if (!ids.length) return;
const deleting = new Set(ids);
@ -474,7 +531,7 @@ createApp({
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
anchor_t: line.anchor_t, anchor_p: line.anchor_p,
slope: line.slope, last_t: line.last_t,
slope: line.slope, last_t: line.last_t, cutoff_t: line.cutoff_t,
}),
});
if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; }
@ -560,7 +617,7 @@ createApp({
if (chartApi) chartApi.setSnap(value);
});
onMounted(() => {
onMounted(async () => {
chartApi = new ConfluenceChart();
// Deliberate debug handle. Chart geometry bugs are invisible from the
// outside — this is how the trendline slope was measured rather than
@ -571,12 +628,14 @@ createApp({
chartApi.setToolCompleteHandler(handleToolComplete);
chartApi.setLineChangeHandler(updateLineGeometry);
chartApi.setLineEndHandler(endLineHere);
chartApi.setLineDuplicateHandler(duplicateLine);
chartApi.setLineDeleteHandler(deleteLine);
chartApi.setSnap(snap.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
refreshComments();
window.addEventListener('keydown', handleKeydown);
refreshStatus();
refreshDeployment();
await migrateLegacyToken();
await refreshStatus();
await refreshComments();
connect();
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
});
@ -587,6 +646,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, dataAge, dataReceiptVerb, deployMeta, 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 };
return { status, price, barAge, dataUpdatedAt, 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

@ -16,14 +16,22 @@ class ConfluenceChart {
this.clickListener = null;
this.selectedLineId = null;
this.anchorHandles = [];
this.lineHitTarget = null;
this.draggingLine = null;
this.lineMoveListener = null;
this.lineUpListener = null;
this.draggingAnchor = null;
this.onLineChange = null;
this.anchorMoveListener = null;
this.anchorUpListener = null;
this.contextMenu = null;
this.contextCutoff = null;
this.contextLineId = null;
this.contextEndButton = null;
this.contextListener = null;
this.onLineEnd = null;
this.onLineDuplicate = null;
this.onLineDelete = null;
// Tool arming: the sidebar decides what the next chart gesture creates.
this.armedTool = null;
this.gesture = null;
@ -191,6 +199,11 @@ class ConfluenceChart {
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
handles.classList.add('chart-handles');
handles.setAttribute('aria-hidden', 'true');
this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line');
this.lineHitTarget.classList.add('chart-line-hit');
this.lineHitTarget.setAttribute('hidden', '');
this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event));
handles.appendChild(this.lineHitTarget);
for (const anchor of ['start', 'end']) {
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
handle.classList.add('chart-anchor');
@ -213,8 +226,29 @@ class ConfluenceChart {
event.stopPropagation();
this.endSelectedLineHere();
});
this.contextEndButton = endHere;
const duplicate = document.createElement('button');
duplicate.type = 'button';
duplicate.dataset.action = 'duplicate';
duplicate.textContent = 'Duplicate 10 bars right';
duplicate.addEventListener('click', event => {
event.stopPropagation();
const level = this.levels.find(value => value.id === this.contextLineId);
this.hideContextMenu();
if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level });
});
const deleteLine = document.createElement('button');
deleteLine.type = 'button';
deleteLine.dataset.action = 'delete';
deleteLine.textContent = 'Delete';
deleteLine.addEventListener('click', event => {
event.stopPropagation();
const id = this.contextLineId;
this.hideContextMenu();
if (id && this.onLineDelete) this.onLineDelete(id);
});
this.contextMenu.addEventListener('click', event => event.stopPropagation());
this.contextMenu.appendChild(endHere);
this.contextMenu.append(endHere, duplicate, deleteLine);
this.overlayLayer.appendChild(this.contextMenu);
this.priceTag = document.createElement('div');
this.priceTag.className = 'chart-price-tag';
@ -254,6 +288,10 @@ class ConfluenceChart {
this.anchorUpListener = event => this.finishAnchorDrag(event);
window.addEventListener('pointermove', this.anchorMoveListener);
window.addEventListener('pointerup', this.anchorUpListener);
this.lineMoveListener = event => this.moveLine(event);
this.lineUpListener = event => this.finishLineDrag(event);
window.addEventListener('pointermove', this.lineMoveListener);
window.addEventListener('pointerup', this.lineUpListener);
this.contextListener = event => this.showContextMenu(event);
el.addEventListener('contextmenu', this.contextListener);
this.clickListener = event => {
@ -1017,6 +1055,10 @@ class ConfluenceChart {
setLineEndHandler(handler) { this.onLineEnd = handler; }
setLineDuplicateHandler(handler) { this.onLineDuplicate = handler; }
setLineDeleteHandler(handler) { this.onLineDelete = handler; }
setSelectedLine(id) {
this.selectedLineId = id;
this.hideContextMenu();
@ -1102,6 +1144,19 @@ class ConfluenceChart {
return lo + (span ? (time - bars[lo].t) / span : 0);
}
timeAtIndex(index) {
const bars = this.bars;
if (bars.length < 2) return bars[0]?.t ?? 0;
if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t));
const last = bars.length - 1;
if (index >= last) {
return Math.round(bars[last].t + (index - last) * (bars[last].t - bars[last - 1].t));
}
const lower = Math.floor(index);
const fraction = index - lower;
return Math.round(bars[lower].t + fraction * (bars[lower + 1].t - bars[lower].t));
}
lineData(level) {
// Interpolated across bars, matching what the server scores the level at.
// Doing it per second instead kicked the line upward at every session gap,
@ -1132,9 +1187,24 @@ class ConfluenceChart {
);
if (!level || !this.anchorHandles.length) {
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
this.lineHitTarget?.setAttribute('hidden', '');
return;
}
const points = this.lineData(level).slice(0, 2);
const data = this.lineData(level);
const points = data.slice(0, 2);
const hitPoints = [data[0], data[data.length - 1]].map(point => ({
x: this.chart.timeScale().timeToCoordinate(point.time),
y: this.candles.priceToCoordinate(point.value),
}));
if (hitPoints.some(point => point.x == null || point.y == null)) {
this.lineHitTarget.setAttribute('hidden', '');
} else {
this.lineHitTarget.removeAttribute('hidden');
this.lineHitTarget.setAttribute('x1', hitPoints[0].x);
this.lineHitTarget.setAttribute('y1', hitPoints[0].y);
this.lineHitTarget.setAttribute('x2', hitPoints[1].x);
this.lineHitTarget.setAttribute('y2', hitPoints[1].y);
}
points.forEach((point, index) => {
const x = this.chart.timeScale().timeToCoordinate(point.time);
const y = this.candles.priceToCoordinate(point.value);
@ -1150,6 +1220,57 @@ class ConfluenceChart {
});
}
startLineDrag(event) {
if (event.button !== 0) return;
const level = this.levels.find(value => value.id === this.selectedLineId);
const point = this.eventPoint(event);
if (!level || !point || point.t == null) return;
event.preventDefault();
event.stopPropagation();
this.draggingLine = {
id: level.id,
start: point,
original: { ...level },
moved: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
}
moveLine(event) {
if (!this.draggingLine) return;
const point = this.eventPoint(event);
const level = this.levels.find(value => value.id === this.draggingLine.id);
if (!point || point.t == null || !this.withinPlot(point) || !level) return;
event.preventDefault();
const { original, start } = this.draggingLine;
const indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t));
const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
const lastT = this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
const endPrice = original.anchor_p
+ original.slope * (original.last_t - original.anchor_t) + priceShift;
level.anchor_t = anchorT;
level.last_t = lastT;
level.anchor_p = original.anchor_p + priceShift;
level.slope = (endPrice - level.anchor_p) / (lastT - anchorT);
if (original.cutoff_t != null) {
level.cutoff_t = this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
}
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.lineData(level));
this.renderAnchorHandles();
}
finishLineDrag(event) {
if (!this.draggingLine) return;
event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingLine.id);
const moved = this.draggingLine.moved;
this.draggingLine = null;
if (moved && level && this.onLineChange) this.onLineChange({ ...level });
}
startAnchorDrag(event, anchor) {
event.preventDefault();
event.stopPropagation();
@ -1208,20 +1329,19 @@ class ConfluenceChart {
const cutoff = this.bars.reduce((nearest, bar) =>
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
).t;
if (cutoff <= level.last_t) {
this.hideContextMenu();
return;
}
this.contextCutoff = cutoff;
this.contextLineId = level.id;
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
this.contextEndButton.hidden = this.contextCutoff == null;
this.contextMenu.hidden = false;
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`;
this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`;
this.contextMenu.style.top = `${Math.max(4, Math.min(point.y, plot.height - this.contextMenu.offsetHeight - 4))}px`;
}
hideContextMenu() {
if (this.contextMenu) this.contextMenu.hidden = true;
this.contextCutoff = null;
this.contextLineId = null;
}
endSelectedLineHere() {
@ -1255,6 +1375,8 @@ class ConfluenceChart {
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
if (this.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener);
if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener);
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener);
if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener);

View file

@ -32,8 +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="data-freshness">{{ dataReceiptVerb }} <b>{{ dataAge }}</b></span>
<span class="app-meta" :title="deploymentTitle">DEPLOY <b>{{ deployMeta }}</b></span>
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></span>
</div>
</section>
<aside>

View file

@ -13,9 +13,9 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.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],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action: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; }.data-freshness { margin-left:auto; }.data-freshness,.app-meta { white-space:nowrap; }
.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; }.data-freshness { 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; }
@ -80,5 +80,5 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[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-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-context-menu { pointer-events:auto; }.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

@ -6,10 +6,71 @@
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, chartState, selectTimeframe, assertNoPageErrors } from './helpers.mjs';
import {
withChart, launch, openChart, chartState, selectTimeframe, assertNoPageErrors,
} from './helpers.mjs';
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
test('a first-time browser exchanges the friendly password for a session',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let statusRequests = 0;
let submitted = null;
try {
page.on('dialog', async dialog => {
assert.equal(dialog.message(), 'Password for this chart');
await dialog.accept('friendly passphrase');
});
await page.route('**/api/status', async route => {
statusRequests += 1;
if (statusRequests === 1) await route.fulfill({ status: 401, body: '{}' });
else await route.continue();
});
await page.route('**/api/login', async route => {
submitted = route.request().postDataJSON();
await route.fulfill({
status: 204,
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
});
});
await openChart(page);
assert.deepEqual(submitted, { password: 'friendly passphrase' });
assert.ok(statusRequests >= 2, 'status was not retried after login');
const unexpected = page.__errors.filter(error =>
!error.includes('server responded with a status of 401'));
assert.deepEqual(unexpected, [], `page errors: ${unexpected.join('; ')}`);
} finally {
await browser.close();
}
});
test('an existing browser token is migrated once and removed from local storage',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let presentedToken = null;
try {
await page.addInitScript(() => localStorage.setItem('chart-token', 'legacy-token'));
await page.route('**/api/login', async route => {
presentedToken = route.request().headers()['x-chart-token'];
await route.fulfill({
status: 204,
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
});
});
await openChart(page);
assert.equal(presentedToken, 'legacy-token');
assert.equal(await page.evaluate(() => localStorage.getItem('chart-token')), null);
assertNoPageErrors(page, assert);
} finally {
await browser.close();
}
});
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
await withChart(async page => {
const state = await chartState(page);
@ -25,28 +86,23 @@ test('the viewport opens on the live edge, not in the past', { timeout: 180000 }
});
});
test('data freshness and deployment metadata share the existing status row',
test('the status row shows the exact local time data last arrived',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.waitForFunction(() => {
const freshness = document.querySelector('.data-freshness b');
const deployment = document.querySelector('.app-meta b');
return freshness?.textContent.trim() !== 'WAITING'
&& deployment?.textContent.trim() !== '—';
return freshness?.textContent.trim() !== '—';
});
const state = await page.evaluate(() => {
const freshness = document.querySelector('.data-freshness');
const deployment = document.querySelector('.app-meta');
return {
freshness: freshness.textContent.trim(),
deployment: deployment.textContent.trim(),
title: deployment.title,
deployMetadata: document.querySelector('.app-meta'),
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
};
});
assert.match(state.freshness, /^(LOADED|UPDATED)\s+(JUST NOW|\d+[smh] AGO)$/);
assert.match(state.deployment, /^DEPLOY\s+(dev|[0-9a-f]{8}) · \S/);
assert.match(state.title, /^Running since .+ · (dev|[0-9a-f]{7,40})$/);
assert.match(state.freshness, /^UPDATED\s+\d{1,2}:\d{2}:\d{2}\s*(AM|PM)?$/i);
assert.equal(state.deployMetadata, null);
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
assertNoPageErrors(page, assert);
});

View file

@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { launch, openChart, withChart } from './helpers.mjs';
async function createLine(page, note) {
return page.evaluate(async label => {
const c = window.__chart;
const first = c.bars[c.bars.length - 80];
const second = c.bars[c.bars.length - 60];
const response = await fetch('/api/lines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: '1m', side: 'support',
anchor_t: first.t, anchor_p: first.l,
end_t: second.t, end_p: second.l,
note: label,
}),
});
return response.json();
}, note);
}
test('test cleanup never deletes a drawing made by another browser',
{ timeout: 180000 }, async () => {
const { browser, page } = await launch();
let external = null;
let owned = null;
try {
await openChart(page);
await withChart(async testPage => {
external = await createLine(page, 'external during test');
owned = await createLine(testPage, 'owned by test');
});
const ids = await page.evaluate(async () => {
const response = await fetch('/api/drawings');
return (await response.json()).drawings.map(drawing => drawing.id);
});
assert.ok(ids.includes(external.id), 'cleanup deleted another browser\'s drawing');
assert.ok(!ids.includes(owned.id), 'cleanup left its own drawing behind');
} finally {
if (external?.id) {
await page.evaluate(id => fetch(`/api/lines/${encodeURIComponent(id)}`, {
method: 'DELETE',
}), external.id);
}
await browser.close();
}
});

View file

@ -60,16 +60,33 @@ export async function openChart(page) {
*/
export async function withChart(body) {
const { browser, page } = await launch();
let before = [];
const created = new Set();
const pendingTracking = new Set();
const trackCreatedDrawing = async response => {
const request = response.request();
const path = new URL(response.url()).pathname;
if (request.method() !== 'POST'
|| !['/api/lines', '/api/lines/price', '/api/comments'].includes(path)
|| !response.ok()) return;
try {
const drawing = await response.json();
if (drawing.id) created.add(drawing.id);
} catch { /* a successful drawing response should be JSON; cleanup stays best-effort */ }
};
const responseListener = response => {
const pending = trackCreatedDrawing(response)
.finally(() => pendingTracking.delete(pending));
pendingTracking.add(pending);
};
page.on('response', responseListener);
try {
await openChart(page);
before = (await drawingIds(page));
await body(page);
} finally {
page.off('response', responseListener);
try {
const after = await drawingIds(page);
const created = after.filter(id => !before.includes(id));
if (created.length) await deleteDrawings(page, created);
await Promise.all(pendingTracking);
if (created.size) await deleteDrawings(page, [...created]);
} catch { /* the page may already be gone; nothing to clean */ }
await browser.close();
}

View file

@ -132,6 +132,7 @@ test('dragging an anchor uses the same bar-extreme snapping as placement',
const bar = c.bars[c.bars.length - 35];
const plot = c.plotCanvas().getBoundingClientRect();
return {
id: c.selectedLineId,
t: bar.t,
p: bar.l,
x: plot.left + c.chart.timeScale().timeToCoordinate(bar.t),
@ -146,14 +147,13 @@ test('dragging an anchor uses the same bar-extreme snapping as placement',
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];
const moved = await page.evaluate(id => {
const line = window.__chart.levels.find(level => level.id === id);
return line && {
t: line.last_t,
p: line.anchor_p + line.slope * (line.last_t - line.anchor_t),
};
});
}, target.id);
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}`);
@ -161,6 +161,127 @@ test('dragging an anchor uses the same bar-extreme snapping as placement',
});
});
test('the line context menu duplicates by ten bars and deletes through the normal flow',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.35, 0.55);
const second = at(box, 0.55, 0.35);
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
await page.waitForTimeout(1200);
const original = await page.evaluate(() => {
const lines = window.__chart.levels.filter(level =>
level.kind === 'manual' && level.slope !== 0);
return lines.sort((a, b) => b.number - a.number)[0];
});
assert.ok(original, 'no trendline was available to duplicate');
await page.mouse.click(box.x + box.w * 0.60, box.y + box.h * 0.45,
{ button: 'right' });
await page.locator('.chart-context-menu [data-action="duplicate"]').click();
await page.waitForFunction(number => {
const lines = window.__chart.levels.filter(level =>
level.kind === 'manual' && level.slope !== 0);
return lines.some(level => level.number > number);
}, original.number);
const duplicated = await page.evaluate(originalLine => {
const c = window.__chart;
const line = c.levels.filter(level =>
level.kind === 'manual' && level.number > originalLine.number)
.sort((a, b) => b.number - a.number)[0];
const original = c.levels.find(level => level.id === originalLine.id);
const nearestIndex = time => c.bars.reduce((nearest, bar, index) =>
Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0);
return {
line,
anchorShift: nearestIndex(line.anchor_t) - nearestIndex(original.anchor_t),
endShift: nearestIndex(line.last_t) - nearestIndex(original.last_t),
};
}, original);
assert.equal(duplicated.anchorShift, 10);
assert.equal(duplicated.endShift, 10);
const expectedName = original.side === 'support' ? 'up' : 'down';
assert.equal(original.label, expectedName);
assert.equal(duplicated.line.label, `${expectedName} 2`);
assert.equal(duplicated.line.color, original.color);
assert.equal(duplicated.line.line_width, original.line_width);
await page.mouse.click(box.x + box.w * 0.65, box.y + box.h * 0.45,
{ button: 'right' });
await page.locator('.chart-context-menu [data-action="delete"]').click();
await page.waitForFunction(id =>
!window.__chart.levels.some(level => level.id === id), duplicated.line.id);
assertNoPageErrors(page, assert);
});
});
test('dragging the selected line body translates both endpoints together',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.35, 0.55);
const second = at(box, 0.55, 0.35);
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
await page.waitForTimeout(1200);
const original = await page.evaluate(() => {
const c = window.__chart;
const line = c.levels.find(level => level.id === c.selectedLineId);
const hit = c.lineHitTarget;
const plot = c.plotCanvas().getBoundingClientRect();
return {
line: { ...line },
endP: line.anchor_p + line.slope * (line.last_t - line.anchor_t),
anchorIndex: c.indexAt(line.anchor_t),
endIndex: c.indexAt(line.last_t),
drag: {
x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2,
y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2,
},
};
});
await page.mouse.move(original.drag.x, original.drag.y);
await page.mouse.down();
await page.mouse.move(original.drag.x + 55, original.drag.y + 35, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(900);
const moved = await page.evaluate(originalLine => {
const c = window.__chart;
const line = c.levels.find(level => level.id === originalLine.id);
return {
line,
endP: line.anchor_p + line.slope * (line.last_t - line.anchor_t),
anchorIndex: c.indexAt(line.anchor_t),
endIndex: c.indexAt(line.last_t),
};
}, original.line);
const anchorShift = Math.round(moved.anchorIndex - original.anchorIndex);
const endShift = Math.round(moved.endIndex - original.endIndex);
const anchorPriceShift = moved.line.anchor_p - original.line.anchor_p;
const endPriceShift = moved.endP - original.endP;
assert.notEqual(anchorShift, 0, 'the line did not move horizontally');
assert.equal(endShift, anchorShift, 'the endpoints moved by different bar counts');
assert.ok(Math.abs(anchorPriceShift) >= 0.25, 'the line did not move vertically');
assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6,
'the endpoints moved by different price amounts');
await page.waitForFunction(async expected => {
const response = await fetch('/api/drawings');
const drawing = (await response.json()).drawings.find(item => item.id === expected.id);
return drawing?.anchor_t === expected.anchor_t && drawing?.anchor_p === expected.anchor_p;
}, { id: moved.line.id, anchor_t: moved.line.anchor_t, anchor_p: moved.line.anchor_p });
assertNoPageErrors(page, assert);
});
});
test('hovering shows where the anchor will land', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);

View file

@ -1,9 +1,11 @@
import pytest
import jwt
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.api.meta import router as meta_router
from app.api.deps import create_session, session_matches, session_secret
from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
from app.api.ws import router as ws_router
@ -13,9 +15,10 @@ from app.runtime import Runtime
@pytest.fixture
def client(tmp_path):
def build(token: str) -> TestClient:
def build(token: str, password: str = "") -> TestClient:
settings = Settings(
chart_auth_token=token,
chart_password=password,
manual_lines_path=tmp_path / "manual_lines.json",
)
app = FastAPI()
@ -74,6 +77,87 @@ def test_accepts_query_token(client):
assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200
def test_password_login_uses_an_httponly_session_instead_of_exposing_the_token(client):
probe = client("opaque-api-token", "friendly passphrase")
response = probe.post("/api/login", json={"password": "friendly passphrase"})
assert response.status_code == 204
cookie = response.headers["set-cookie"]
assert "chart-session=" in cookie
assert "HttpOnly" in cookie
assert "SameSite=strict" in cookie
assert "opaque-api-token" not in cookie
assert probe.get("/api/bars").status_code == 200
def test_wrong_password_cannot_create_a_session(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "wrong"}).status_code == 401
assert probe.get("/api/bars").status_code == 401
def test_existing_browser_token_is_exchanged_for_a_session(client):
probe = client("opaque-api-token", "friendly passphrase")
response = probe.post(
"/api/login",
json={"password": ""},
headers={"X-Chart-Token": "opaque-api-token"},
)
assert response.status_code == 204
assert probe.get("/api/bars").status_code == 200
def test_logout_invalidates_the_browser_session(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
assert probe.post("/api/logout").status_code == 204
assert probe.get("/api/bars").status_code == 401
def test_session_signature_and_expiry_are_enforced(client):
probe = client("opaque-api-token", "friendly passphrase")
session = create_session(probe.app)
expired = jwt.encode(
{"sub": "shared", "iat": 1, "exp": 2},
session_secret(probe.app),
algorithm="HS256",
)
assert session_matches(probe.app, session)
assert not session_matches(probe.app, f"{session}tampered")
assert not session_matches(probe.app, expired)
def test_unicode_passwords_do_not_crash_login(client):
probe = client("opaque-api-token", "correct horse ünicode")
assert probe.post("/api/login", json={"password": "wrong pässword"}).status_code == 401
assert probe.post("/api/login", json={"password": "correct horse ünicode"}).status_code == 204
def test_https_login_marks_the_session_cookie_secure(client):
response = client("opaque-api-token", "friendly passphrase").post(
"/api/login",
json={"password": "friendly passphrase"},
headers={"X-Forwarded-Proto": "https"},
)
assert "Secure" in response.headers["set-cookie"]
def test_password_alone_enables_auth(client):
probe = client("", "friendly passphrase")
assert probe.get("/api/bars").status_code == 401
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
assert probe.get("/api/bars").status_code == 200
def test_writes_are_protected(client):
payload = {
"tf": "1m",
@ -102,3 +186,23 @@ def test_websocket_rejects_missing_token(client):
def test_websocket_accepts_query_token(client):
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
assert socket.receive_json()["type"] == "snapshot"
def test_websocket_accepts_the_password_session_cookie(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
with probe.websocket_connect("/ws", headers={"origin": "http://testserver"}) as socket:
assert socket.receive_json()["type"] == "snapshot"
def test_websocket_rejects_a_session_cookie_from_another_origin(client):
probe = client("opaque-api-token", "friendly passphrase")
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
with pytest.raises(WebSocketDisconnect) as excinfo:
with probe.websocket_connect(
"/ws", headers={"origin": "https://other.example.com"}
):
pass
assert excinfo.value.code == 1008

View file

@ -31,6 +31,16 @@ def test_hourly_line_uses_absolute_time_on_one_minute_chart():
assert level.weight == 4
def test_unnamed_trendlines_are_named_for_their_direction():
resistance = sample_line()
support = ManualLine(
"ml_support", Timeframe.M1, Side.SUPPORT, 100, 5000, 0.01, 200, 300
)
assert resistance.default_label() == "down"
assert support.default_label() == "up"
def test_manual_line_raises_existing_ma_cluster_score():
ma = Level(
"ma", LevelKind.MA, Timeframe.D1, Side.RESISTANCE, 12, 1, "1d SMA20",