- 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:
parent
4488c7d4b9
commit
3b3c06a1f3
20 changed files with 784 additions and 115 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 . .
|
||||
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ fastapi
|
|||
uvicorn[standard]
|
||||
httpx
|
||||
pydantic-settings
|
||||
PyJWT
|
||||
# Live futures stream; imported only when LIVE_SOURCE=schwab.
|
||||
schwab-py
|
||||
|
|
|
|||
149
static/app.js
149
static/app.js
|
|
@ -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');
|
||||
|
|
|
|||
140
static/chart.js
140
static/chart.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
50
tests/e2e/harness.test.mjs
Normal file
50
tests/e2e/harness.test.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue