- 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
|
# 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.
|
# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws.
|
||||||
CHART_AUTH_TOKEN=
|
CHART_AUTH_TOKEN=
|
||||||
|
CHART_PASSWORD=
|
||||||
REPLAY_FILE=
|
REPLAY_FILE=
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt requirements-dev.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
COPY . .
|
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
|
# 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
|
`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
|
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`
|
prompts. Scripts can supply it as the `X-Chart-Token` header or a `?token=`
|
||||||
header or a `?token=` query parameter (WebSocket handshakes can't carry
|
query parameter.
|
||||||
headers, hence the second form).
|
|
||||||
|
|
||||||
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
|
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
|
built container. Coolify re-injects its env vars into every container it
|
||||||
builds, so the token survives redeploys and reboots.
|
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`
|
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
|
||||||
polls the latter from whatever machine you pushed from, and neither reveals
|
polls the latter from whatever machine you pushed from, and neither reveals
|
||||||
anything about the market data or the configuration.
|
anything about the market data or the configuration.
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class ManualLine:
|
||||||
def default_label(self) -> str:
|
def default_label(self) -> str:
|
||||||
if self.horizontal:
|
if self.horizontal:
|
||||||
return f"@ {self.anchor_p:.2f}"
|
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:
|
def to_level(self) -> Level:
|
||||||
return Level(
|
return Level(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,28 @@
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
import jwt
|
||||||
from fastapi import HTTPException, Request, status
|
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:
|
def configured_token(app) -> str:
|
||||||
runtime = getattr(app.state, "runtime", None)
|
settings = configured_settings(app)
|
||||||
return runtime.settings.chart_auth_token if runtime else ""
|
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:
|
def token_matches(app, presented: str) -> bool:
|
||||||
|
|
@ -16,15 +33,68 @@ def token_matches(app, presented: str) -> bool:
|
||||||
"""
|
"""
|
||||||
want = configured_token(app)
|
want = configured_token(app)
|
||||||
if not want:
|
if not want:
|
||||||
return True
|
return not configured_password(app)
|
||||||
return secrets.compare_digest(presented or "", want)
|
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", "")
|
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(
|
raise HTTPException(
|
||||||
status.HTTP_401_UNAUTHORIZED,
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
"Missing or invalid chart token",
|
"Authentication required",
|
||||||
headers={"WWW-Authenticate": "X-Chart-Token"},
|
headers={"WWW-Authenticate": "Session, X-Chart-Token"},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
"""Endpoints that stay reachable without a token.
|
"""Endpoints that stay reachable without a token.
|
||||||
|
|
||||||
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, so
|
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, and
|
||||||
requiring the token here would mean carrying it around just to answer "is my
|
the browser needs /api/login before it has a session, so these routes stay
|
||||||
commit live yet". Neither endpoint exposes anything about the market data or
|
outside the protected API router.
|
||||||
the configuration.
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
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")
|
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()
|
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "service": "chart"}
|
return {"status": "ok", "service": "chart"}
|
||||||
|
|
@ -25,3 +38,28 @@ def health():
|
||||||
@router.get("/version")
|
@router.get("/version")
|
||||||
def version():
|
def version():
|
||||||
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
|
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
|
hidden: bool = False
|
||||||
color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
line_width: int = Field(2, ge=1, le=4)
|
line_width: int = Field(2, ge=1, le=4)
|
||||||
|
cutoff_t: int | None = None
|
||||||
|
armed: bool = True
|
||||||
|
|
||||||
|
|
||||||
class PriceAlertCreate(BaseModel):
|
class PriceAlertCreate(BaseModel):
|
||||||
|
|
@ -125,6 +127,8 @@ def create_line(request: Request, payload: LineCreate):
|
||||||
hidden=payload.hidden,
|
hidden=payload.hidden,
|
||||||
color=payload.color,
|
color=payload.color,
|
||||||
line_width=payload.line_width,
|
line_width=payload.line_width,
|
||||||
|
cutoff_t=payload.cutoff_t,
|
||||||
|
armed=payload.armed,
|
||||||
)
|
)
|
||||||
runtime = request.app.state.runtime
|
runtime = request.app.state.runtime
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
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.bars.models import Timeframe
|
||||||
from app.analysis.confluence import cluster_levels
|
from app.analysis.confluence import cluster_levels
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
def level_enabled(level, enabled: dict) -> bool:
|
||||||
kind = level.kind.value
|
kind = level.kind.value
|
||||||
if kind == "ma":
|
if kind == "ma":
|
||||||
|
|
@ -54,9 +61,14 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
|
|
||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
# Browsers cannot set headers on a WebSocket handshake, so the token comes
|
# Browsers automatically include the HttpOnly session cookie in the
|
||||||
# in as a query parameter here. 1008 = policy violation.
|
# handshake. Query-token support remains for non-browser clients and for
|
||||||
if not token_matches(websocket.app, websocket.query_params.get("token", "")):
|
# 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")
|
await websocket.close(code=1008, reason="Missing or invalid chart token")
|
||||||
return
|
return
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class Settings(BaseSettings):
|
||||||
ntfy_topic: str = ""
|
ntfy_topic: str = ""
|
||||||
ntfy_server: str = "https://ntfy.sh"
|
ntfy_server: str = "https://ntfy.sh"
|
||||||
chart_auth_token: str = ""
|
chart_auth_token: str = ""
|
||||||
|
chart_password: str = ""
|
||||||
replay_file: Path | None = None
|
replay_file: Path | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@ fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
httpx
|
httpx
|
||||||
pydantic-settings
|
pydantic-settings
|
||||||
|
PyJWT
|
||||||
# Live futures stream; imported only when LIVE_SOURCE=schwab.
|
# Live futures stream; imported only when LIVE_SOURCE=schwab.
|
||||||
schwab-py
|
schwab-py
|
||||||
|
|
|
||||||
149
static/app.js
149
static/app.js
|
|
@ -1,25 +1,58 @@
|
||||||
const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
|
const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
|
||||||
|
|
||||||
// Shared access token. Blank when the server runs without CHART_AUTH_TOKEN,
|
// Keep an existing token long enough to migrate an already-open browser to the
|
||||||
// which is the normal local-development case — nothing prompts.
|
// password session. Successful login removes it; new browsers never store the
|
||||||
|
// API token at all.
|
||||||
const TOKEN_KEY = 'chart-token';
|
const TOKEN_KEY = 'chart-token';
|
||||||
let authToken = localStorage.getItem(TOKEN_KEY) || '';
|
let authToken = localStorage.getItem(TOKEN_KEY) || '';
|
||||||
|
|
||||||
// Reconnects and the status poll both hit 401s, so without this a visitor
|
// Reconnects and the status poll both hit 401s, so without this a visitor
|
||||||
// who cancels gets asked again every couple of seconds.
|
// who cancels gets asked again every couple of seconds.
|
||||||
let promptDeclined = false;
|
let promptDeclined = false;
|
||||||
|
let loginPromise = null;
|
||||||
|
|
||||||
function promptForToken() {
|
async function migrateLegacyToken() {
|
||||||
if (promptDeclined) return false;
|
if (!authToken) return;
|
||||||
const entered = window.prompt('Access token for this chart', '');
|
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) {
|
if (entered === null) {
|
||||||
promptDeclined = true;
|
promptDeclined = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
authToken = entered.trim();
|
const response = await fetch('/api/login', {
|
||||||
localStorage.setItem(TOKEN_KEY, authToken);
|
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 true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithPassword() {
|
||||||
|
if (promptDeclined) return false;
|
||||||
|
if (!loginPromise) {
|
||||||
|
loginPromise = runPasswordLogin().finally(() => { loginPromise = null; });
|
||||||
|
}
|
||||||
|
return loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async function apiFetch(url, options = {}) {
|
async function apiFetch(url, options = {}) {
|
||||||
const send = () => fetch(url, {
|
const send = () => fetch(url, {
|
||||||
|
|
@ -30,7 +63,7 @@ async function apiFetch(url, options = {}) {
|
||||||
});
|
});
|
||||||
const response = await send();
|
const response = await send();
|
||||||
// A 401 means nothing was written, so retrying the same request is safe.
|
// 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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,9 +104,7 @@ function loadPrefs() {
|
||||||
createApp({
|
createApp({
|
||||||
setup() {
|
setup() {
|
||||||
const status = ref({ stream: 'disconnected', bars_held: {} });
|
const status = ref({ stream: 'disconnected', bars_held: {} });
|
||||||
const deployment = ref(null);
|
|
||||||
const dataReceivedAt = ref(null);
|
const dataReceivedAt = ref(null);
|
||||||
const dataReceiptVerb = ref('LOADED');
|
|
||||||
const price = ref(null);
|
const price = ref(null);
|
||||||
const prefs = ref(loadPrefs());
|
const prefs = ref(loadPrefs());
|
||||||
const timeframe = ref(prefs.value.base_tf || '1m');
|
const timeframe = ref(prefs.value.base_tf || '1m');
|
||||||
|
|
@ -113,28 +144,11 @@ createApp({
|
||||||
const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t));
|
const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t));
|
||||||
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
|
||||||
});
|
});
|
||||||
const deployedAt = computed(() => {
|
const dataUpdatedAt = computed(() => {
|
||||||
if (!deployment.value?.started_at) return '—';
|
if (dataReceivedAt.value == null) return '—';
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
hour: 'numeric', minute: '2-digit', second: '2-digit',
|
||||||
}).format(new Date(deployment.value.started_at));
|
}).format(new Date(dataReceivedAt.value));
|
||||||
});
|
|
||||||
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`;
|
|
||||||
});
|
});
|
||||||
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
|
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
|
||||||
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
|
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
|
||||||
|
|
@ -176,11 +190,6 @@ createApp({
|
||||||
if (response.ok) status.value = await response.json();
|
if (response.ok) status.value = await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDeployment() {
|
|
||||||
const response = await apiFetch('/api/version');
|
|
||||||
if (response.ok) deployment.value = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
||||||
|
|
@ -194,7 +203,6 @@ createApp({
|
||||||
const message = JSON.parse(data);
|
const message = JSON.parse(data);
|
||||||
if (message.type === 'snapshot') {
|
if (message.type === 'snapshot') {
|
||||||
dataReceivedAt.value = Date.now();
|
dataReceivedAt.value = Date.now();
|
||||||
dataReceiptVerb.value = 'LOADED';
|
|
||||||
chartApi.setBars(message.bars);
|
chartApi.setBars(message.bars);
|
||||||
levels.value = message.levels || [];
|
levels.value = message.levels || [];
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
|
|
@ -207,7 +215,6 @@ createApp({
|
||||||
// outright. The event carries its timeframe, so honour it.
|
// outright. The event carries its timeframe, so honour it.
|
||||||
if (message.tf && message.tf !== timeframe.value) return;
|
if (message.tf && message.tf !== timeframe.value) return;
|
||||||
dataReceivedAt.value = Date.now();
|
dataReceivedAt.value = Date.now();
|
||||||
dataReceiptVerb.value = 'UPDATED';
|
|
||||||
chartApi.updateBar(message.bar);
|
chartApi.updateBar(message.bar);
|
||||||
price.value = message.bar.c;
|
price.value = message.bar.c;
|
||||||
status.value.last_bar_t = message.bar.t;
|
status.value.last_bar_t = message.bar.t;
|
||||||
|
|
@ -359,7 +366,7 @@ createApp({
|
||||||
const temporaryId = `tmp_${Date.now()}`;
|
const temporaryId = `tmp_${Date.now()}`;
|
||||||
const optimistic = {
|
const optimistic = {
|
||||||
id: temporaryId, kind: 'manual', tf: timeframe.value, side,
|
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),
|
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,
|
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
|
||||||
color: drawColor.value, line_width: drawWidth.value,
|
color: drawColor.value, line_width: drawWidth.value,
|
||||||
|
|
@ -410,6 +417,56 @@ createApp({
|
||||||
await deleteLines([id]);
|
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) {
|
async function deleteLines(ids) {
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
const deleting = new Set(ids);
|
const deleting = new Set(ids);
|
||||||
|
|
@ -474,7 +531,7 @@ createApp({
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
anchor_t: line.anchor_t, anchor_p: line.anchor_p,
|
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; }
|
if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; }
|
||||||
|
|
@ -560,7 +617,7 @@ createApp({
|
||||||
if (chartApi) chartApi.setSnap(value);
|
if (chartApi) chartApi.setSnap(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
chartApi = new ConfluenceChart();
|
chartApi = new ConfluenceChart();
|
||||||
// Deliberate debug handle. Chart geometry bugs are invisible from the
|
// Deliberate debug handle. Chart geometry bugs are invisible from the
|
||||||
// outside — this is how the trendline slope was measured rather than
|
// outside — this is how the trendline slope was measured rather than
|
||||||
|
|
@ -571,12 +628,14 @@ createApp({
|
||||||
chartApi.setToolCompleteHandler(handleToolComplete);
|
chartApi.setToolCompleteHandler(handleToolComplete);
|
||||||
chartApi.setLineChangeHandler(updateLineGeometry);
|
chartApi.setLineChangeHandler(updateLineGeometry);
|
||||||
chartApi.setLineEndHandler(endLineHere);
|
chartApi.setLineEndHandler(endLineHere);
|
||||||
|
chartApi.setLineDuplicateHandler(duplicateLine);
|
||||||
|
chartApi.setLineDeleteHandler(deleteLine);
|
||||||
chartApi.setSnap(snap.value);
|
chartApi.setSnap(snap.value);
|
||||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
|
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
|
||||||
refreshComments();
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
refreshStatus();
|
await migrateLegacyToken();
|
||||||
refreshDeployment();
|
await refreshStatus();
|
||||||
|
await refreshComments();
|
||||||
connect();
|
connect();
|
||||||
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
|
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
|
||||||
});
|
});
|
||||||
|
|
@ -587,6 +646,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
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');
|
}).mount('#app');
|
||||||
|
|
|
||||||
140
static/chart.js
140
static/chart.js
|
|
@ -16,14 +16,22 @@ class ConfluenceChart {
|
||||||
this.clickListener = null;
|
this.clickListener = null;
|
||||||
this.selectedLineId = null;
|
this.selectedLineId = null;
|
||||||
this.anchorHandles = [];
|
this.anchorHandles = [];
|
||||||
|
this.lineHitTarget = null;
|
||||||
|
this.draggingLine = null;
|
||||||
|
this.lineMoveListener = null;
|
||||||
|
this.lineUpListener = null;
|
||||||
this.draggingAnchor = null;
|
this.draggingAnchor = null;
|
||||||
this.onLineChange = null;
|
this.onLineChange = null;
|
||||||
this.anchorMoveListener = null;
|
this.anchorMoveListener = null;
|
||||||
this.anchorUpListener = null;
|
this.anchorUpListener = null;
|
||||||
this.contextMenu = null;
|
this.contextMenu = null;
|
||||||
this.contextCutoff = null;
|
this.contextCutoff = null;
|
||||||
|
this.contextLineId = null;
|
||||||
|
this.contextEndButton = null;
|
||||||
this.contextListener = null;
|
this.contextListener = null;
|
||||||
this.onLineEnd = null;
|
this.onLineEnd = null;
|
||||||
|
this.onLineDuplicate = null;
|
||||||
|
this.onLineDelete = null;
|
||||||
// Tool arming: the sidebar decides what the next chart gesture creates.
|
// Tool arming: the sidebar decides what the next chart gesture creates.
|
||||||
this.armedTool = null;
|
this.armedTool = null;
|
||||||
this.gesture = null;
|
this.gesture = null;
|
||||||
|
|
@ -191,6 +199,11 @@ class ConfluenceChart {
|
||||||
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
handles.classList.add('chart-handles');
|
handles.classList.add('chart-handles');
|
||||||
handles.setAttribute('aria-hidden', 'true');
|
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']) {
|
for (const anchor of ['start', 'end']) {
|
||||||
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
handle.classList.add('chart-anchor');
|
handle.classList.add('chart-anchor');
|
||||||
|
|
@ -213,8 +226,29 @@ class ConfluenceChart {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.endSelectedLineHere();
|
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.addEventListener('click', event => event.stopPropagation());
|
||||||
this.contextMenu.appendChild(endHere);
|
this.contextMenu.append(endHere, duplicate, deleteLine);
|
||||||
this.overlayLayer.appendChild(this.contextMenu);
|
this.overlayLayer.appendChild(this.contextMenu);
|
||||||
this.priceTag = document.createElement('div');
|
this.priceTag = document.createElement('div');
|
||||||
this.priceTag.className = 'chart-price-tag';
|
this.priceTag.className = 'chart-price-tag';
|
||||||
|
|
@ -254,6 +288,10 @@ class ConfluenceChart {
|
||||||
this.anchorUpListener = event => this.finishAnchorDrag(event);
|
this.anchorUpListener = event => this.finishAnchorDrag(event);
|
||||||
window.addEventListener('pointermove', this.anchorMoveListener);
|
window.addEventListener('pointermove', this.anchorMoveListener);
|
||||||
window.addEventListener('pointerup', this.anchorUpListener);
|
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);
|
this.contextListener = event => this.showContextMenu(event);
|
||||||
el.addEventListener('contextmenu', this.contextListener);
|
el.addEventListener('contextmenu', this.contextListener);
|
||||||
this.clickListener = event => {
|
this.clickListener = event => {
|
||||||
|
|
@ -1017,6 +1055,10 @@ class ConfluenceChart {
|
||||||
|
|
||||||
setLineEndHandler(handler) { this.onLineEnd = handler; }
|
setLineEndHandler(handler) { this.onLineEnd = handler; }
|
||||||
|
|
||||||
|
setLineDuplicateHandler(handler) { this.onLineDuplicate = handler; }
|
||||||
|
|
||||||
|
setLineDeleteHandler(handler) { this.onLineDelete = handler; }
|
||||||
|
|
||||||
setSelectedLine(id) {
|
setSelectedLine(id) {
|
||||||
this.selectedLineId = id;
|
this.selectedLineId = id;
|
||||||
this.hideContextMenu();
|
this.hideContextMenu();
|
||||||
|
|
@ -1102,6 +1144,19 @@ class ConfluenceChart {
|
||||||
return lo + (span ? (time - bars[lo].t) / span : 0);
|
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) {
|
lineData(level) {
|
||||||
// Interpolated across bars, matching what the server scores the level at.
|
// Interpolated across bars, matching what the server scores the level at.
|
||||||
// Doing it per second instead kicked the line upward at every session gap,
|
// Doing it per second instead kicked the line upward at every session gap,
|
||||||
|
|
@ -1132,9 +1187,24 @@ class ConfluenceChart {
|
||||||
);
|
);
|
||||||
if (!level || !this.anchorHandles.length) {
|
if (!level || !this.anchorHandles.length) {
|
||||||
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
||||||
|
this.lineHitTarget?.setAttribute('hidden', '');
|
||||||
return;
|
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) => {
|
points.forEach((point, index) => {
|
||||||
const x = this.chart.timeScale().timeToCoordinate(point.time);
|
const x = this.chart.timeScale().timeToCoordinate(point.time);
|
||||||
const y = this.candles.priceToCoordinate(point.value);
|
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) {
|
startAnchorDrag(event, anchor) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
@ -1208,20 +1329,19 @@ class ConfluenceChart {
|
||||||
const cutoff = this.bars.reduce((nearest, bar) =>
|
const cutoff = this.bars.reduce((nearest, bar) =>
|
||||||
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
||||||
).t;
|
).t;
|
||||||
if (cutoff <= level.last_t) {
|
this.contextLineId = level.id;
|
||||||
this.hideContextMenu();
|
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
|
||||||
return;
|
this.contextEndButton.hidden = this.contextCutoff == null;
|
||||||
}
|
|
||||||
this.contextCutoff = cutoff;
|
|
||||||
this.contextMenu.hidden = false;
|
this.contextMenu.hidden = false;
|
||||||
const plot = this.plotCanvas().getBoundingClientRect();
|
const plot = this.plotCanvas().getBoundingClientRect();
|
||||||
this.contextMenu.style.left = `${Math.min(point.x, plot.width - 170)}px`;
|
this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`;
|
||||||
this.contextMenu.style.top = `${Math.min(point.y, plot.height - 40)}px`;
|
this.contextMenu.style.top = `${Math.max(4, Math.min(point.y, plot.height - this.contextMenu.offsetHeight - 4))}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
hideContextMenu() {
|
hideContextMenu() {
|
||||||
if (this.contextMenu) this.contextMenu.hidden = true;
|
if (this.contextMenu) this.contextMenu.hidden = true;
|
||||||
this.contextCutoff = null;
|
this.contextCutoff = null;
|
||||||
|
this.contextLineId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
endSelectedLineHere() {
|
endSelectedLineHere() {
|
||||||
|
|
@ -1255,6 +1375,8 @@ class ConfluenceChart {
|
||||||
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
|
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
|
||||||
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
||||||
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
|
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.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
|
||||||
if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener);
|
if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener);
|
||||||
if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener);
|
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-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
|
||||||
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
|
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
|
||||||
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
|
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
|
||||||
<span class="data-freshness">{{ dataReceiptVerb }} <b>{{ dataAge }}</b></span>
|
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></span>
|
||||||
<span class="app-meta" :title="deploymentTitle">DEPLOY <b>{{ deployMeta }}</b></span>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside>
|
<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); }
|
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; }
|
.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); }
|
.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; }
|
.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; }
|
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
|
||||||
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
||||||
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
|
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
|
||||||
|
|
@ -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-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-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; }
|
||||||
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
|
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
|
||||||
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
|
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-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); }
|
.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 { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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'];
|
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 () => {
|
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const state = await chartState(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 () => {
|
{ timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const freshness = document.querySelector('.data-freshness b');
|
const freshness = document.querySelector('.data-freshness b');
|
||||||
const deployment = document.querySelector('.app-meta b');
|
return freshness?.textContent.trim() !== '—';
|
||||||
return freshness?.textContent.trim() !== 'WAITING'
|
|
||||||
&& deployment?.textContent.trim() !== '—';
|
|
||||||
});
|
});
|
||||||
const state = await page.evaluate(() => {
|
const state = await page.evaluate(() => {
|
||||||
const freshness = document.querySelector('.data-freshness');
|
const freshness = document.querySelector('.data-freshness');
|
||||||
const deployment = document.querySelector('.app-meta');
|
|
||||||
return {
|
return {
|
||||||
freshness: freshness.textContent.trim(),
|
freshness: freshness.textContent.trim(),
|
||||||
deployment: deployment.textContent.trim(),
|
deployMetadata: document.querySelector('.app-meta'),
|
||||||
title: deployment.title,
|
|
||||||
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
|
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.match(state.freshness, /^(LOADED|UPDATED)\s+(JUST NOW|\d+[smh] AGO)$/);
|
assert.match(state.freshness, /^UPDATED\s+\d{1,2}:\d{2}:\d{2}\s*(AM|PM)?$/i);
|
||||||
assert.match(state.deployment, /^DEPLOY\s+(dev|[0-9a-f]{8}) · \S/);
|
assert.equal(state.deployMetadata, null);
|
||||||
assert.match(state.title, /^Running since .+ · (dev|[0-9a-f]{7,40})$/);
|
|
||||||
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
|
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
|
||||||
assertNoPageErrors(page, assert);
|
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) {
|
export async function withChart(body) {
|
||||||
const { browser, page } = await launch();
|
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 {
|
try {
|
||||||
await openChart(page);
|
await openChart(page);
|
||||||
before = (await drawingIds(page));
|
|
||||||
await body(page);
|
await body(page);
|
||||||
} finally {
|
} finally {
|
||||||
|
page.off('response', responseListener);
|
||||||
try {
|
try {
|
||||||
const after = await drawingIds(page);
|
await Promise.all(pendingTracking);
|
||||||
const created = after.filter(id => !before.includes(id));
|
if (created.size) await deleteDrawings(page, [...created]);
|
||||||
if (created.length) await deleteDrawings(page, created);
|
|
||||||
} catch { /* the page may already be gone; nothing to clean */ }
|
} catch { /* the page may already be gone; nothing to clean */ }
|
||||||
await browser.close();
|
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 bar = c.bars[c.bars.length - 35];
|
||||||
const plot = c.plotCanvas().getBoundingClientRect();
|
const plot = c.plotCanvas().getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
|
id: c.selectedLineId,
|
||||||
t: bar.t,
|
t: bar.t,
|
||||||
p: bar.l,
|
p: bar.l,
|
||||||
x: plot.left + c.chart.timeScale().timeToCoordinate(bar.t),
|
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.mouse.up();
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
const moved = await page.evaluate(() => {
|
const moved = await page.evaluate(id => {
|
||||||
const lines = window.__chart.levels.filter(level => level.kind === 'manual' && level.slope !== 0);
|
const line = window.__chart.levels.find(level => level.id === id);
|
||||||
const line = lines.sort((a, b) => b.number - a.number)[0];
|
|
||||||
return line && {
|
return line && {
|
||||||
t: line.last_t,
|
t: line.last_t,
|
||||||
p: line.anchor_p + line.slope * (line.last_t - line.anchor_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.equal(moved.t, target.t, 'the handle moved to a different bar');
|
||||||
assert.ok(Math.abs(moved.p - target.p) < 1e-6,
|
assert.ok(Math.abs(moved.p - target.p) < 1e-6,
|
||||||
`the handle landed at ${moved.p}, not the bar low ${target.p}`);
|
`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 () => {
|
test('hovering shows where the anchor will land', { timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const box = await chartBox(page);
|
const box = await chartBox(page);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
import jwt
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.meta import router as meta_router
|
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.routes import router as api_router
|
||||||
from app.api.schwab_auth import router as schwab_auth_router
|
from app.api.schwab_auth import router as schwab_auth_router
|
||||||
from app.api.ws import router as ws_router
|
from app.api.ws import router as ws_router
|
||||||
|
|
@ -13,9 +15,10 @@ from app.runtime import Runtime
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path):
|
||||||
def build(token: str) -> TestClient:
|
def build(token: str, password: str = "") -> TestClient:
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
chart_auth_token=token,
|
chart_auth_token=token,
|
||||||
|
chart_password=password,
|
||||||
manual_lines_path=tmp_path / "manual_lines.json",
|
manual_lines_path=tmp_path / "manual_lines.json",
|
||||||
)
|
)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
@ -74,6 +77,87 @@ def test_accepts_query_token(client):
|
||||||
assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200
|
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):
|
def test_writes_are_protected(client):
|
||||||
payload = {
|
payload = {
|
||||||
"tf": "1m",
|
"tf": "1m",
|
||||||
|
|
@ -102,3 +186,23 @@ def test_websocket_rejects_missing_token(client):
|
||||||
def test_websocket_accepts_query_token(client):
|
def test_websocket_accepts_query_token(client):
|
||||||
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
|
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
|
||||||
assert socket.receive_json()["type"] == "snapshot"
|
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
|
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():
|
def test_manual_line_raises_existing_ma_cluster_score():
|
||||||
ma = Level(
|
ma = Level(
|
||||||
"ma", LevelKind.MA, Timeframe.D1, Side.RESISTANCE, 12, 1, "1d SMA20",
|
"ma", LevelKind.MA, Timeframe.D1, Side.RESISTANCE, 12, 1, "1d SMA20",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue