Compare commits

...

4 commits

Author SHA1 Message Date
9b6cff853b Keep a pinned comment in place across a timeframe switch
A comment placed on a 30m bar slid to the far left the moment the chart switched
to 15m. timeToCoordinate answers only for times that are data points on the
current series, so a 30m bucket start returns null on another timeframe — and
the render was reading null as "off the left edge", which parked every such
comment against the left of the pane.

Anchors resolve to the bar that contains them instead, found by binary search
over the current bars, which is timeframe-independent: an 09:30 note sits on the
09:30 bar at 15m and on the 09:00 bar at 1h. Times genuinely before the first
bar or after the last are reported separately, so real off-screen comments still
park on the edge they left rather than being confused with unresolved ones.
setBars re-renders comments too, since a timeframe switch replaces the bar grid
underneath every pinned one.

Verified in a browser across 30m to 15m to 1h and back: the anchor stays 08:30
throughout, resolving to the 08:30 bar at 15m and the 08:00 bar at 1h, never
edge-parked, and returning to its original coordinate. Edge-parking still fires
where it should — scrolled 400 bars away a comment parks right, and comes back
when the view returns to live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:10:51 -05:00
9ab68f6cff Add chart comments and turn Lines & levels into Drawings
A comment is a ManualLine with kind="comment", so it inherits persistence, the
shared drawing-number sequence, the list, filtering and deletion rather than
needing a parallel set of endpoints. The rule that must never bend is that
ManualLineStore.levels() excludes them: a comment reaching the level list would
join a confluence cluster and push a phone notification about a piece of text.
It is created with armed=False, and PATCH returns to_dict() rather than
to_level() for a comment, so nothing is ever handed a level-shaped comment.
Three tests cover the exclusion, the shared numbering and the kind derived for
drawings saved before comments existed.

Pinned comments carry anchor_t and anchor_p and travel with the chart; floating
ones carry x and y as fractions of the pane, hold their place through any zoom
and can be dragged. They render as DOM rather than canvas because they hold
arbitrary text, collapse to a numbered dot, and a floating one must ignore the
time scale entirely. A pinned comment scrolled out of view parks on the edge it
left, pointing back toward itself, so it never simply disappears.

Lines & levels becomes Drawings, filtered by type and by text. The text match
covers the label, the kind and the #number, so "comment", "cpi" and "7" all
narrow the list, and Delete acts on whatever the filter shows — which is what
makes deleting by type or by string one button.

Verified in a browser: placing a comment renders it at the click, clicking it
collapses it, scrolling away parks it on the edge, a floating one holds its
pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type,
1 by text and 0 for a miss, with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:58:21 -05:00
078bc8f42c Fix trendline anchors jumping away from where they were drawn
Two faults, both of which made a finished line disagree with its own preview.

A placed anchor was being discarded by a twitch. finishToolGesture recomputed
`dragged` from the second click's own pointerdown/up, so a few pixels of
movement while pressing was read as a fresh press-drag-release: the anchor from
the first click was thrown away and the line began at the second click instead,
to the right of where it was meant to start. The preview had been rubber-banding
from the real anchor the whole time, which is why the result jumped on commit. A
pending anchor now wins over the current click's drag.

Snapping ignored where you pointed. snapPoint took the nearer of the bar's high
and low but only within 8px, so a cursor between the two snapped to neither and
returned a raw mid-bar price — and the side quietly fell back to the dropdown.
The gate is gone: with snapping on, an anchor always lands on the nearer extreme
of the nearest bar, and that choice *is* the side, a high being resistance and a
low support. app.js already preferred snappedSide over the dropdown, so the
inference was written and simply never fired.

snapPoint also guards against being handed an already-snapped point, which
carries no cursor y and previously compared against NaN.

Verified by driving the gesture in a browser: click, move, then a second click
with 4px of movement while pressed now yields anchor_t equal to the first
click's time and anchor_p exactly equal to the bar's high.

Also lands the groundwork for chart comments, inert until the UI is wired: a
`kind` on ManualLine with `pinned`, `x`, `y` and `collapsed`, a POST /comments
endpoint, GET /drawings, and the chart's DOM comment layer. Comments are stored
with the lines so they share numbering, filtering and deletion, and
ManualLineStore.levels() excludes them — a comment reaching the level list would
join a confluence cluster and push a notification about a piece of text. Tests
cover the exclusion, the derived kind for lines saved before comments existed,
and that numbering is shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:18:40 -05:00
0465519348 Reclaim the sidebar's vertical space
The right column ran past the viewport with nothing selected. Five changes, none
of which remove functionality.

The five daily MA periods now share one line. 10px gaps and a 22px indent had
pushed 200 onto a row by itself; nowrap, 7px gaps and 12px boxes fit all five
with room to spare. Auto trendlines becomes a parenthetical on the Manual lines
row instead of owning one — it is disabled until M8, so a full row overstated
it. The alert log becomes a collapsible section like Confluence zones, closed by
default with its count in the summary, so activity stays visible while it is
shut and the banner, sound and phone push are untouched.

Tools becomes a collapsible section as well, open by default, and each tool's
panel is now bound to armedTool: the label, colour, width and side controls
appear only for the tool actually armed. armTool already toggled and allowed one
armed tool at a time, so the panels follow it with no new state.

Layers moves above Tools and starts collapsed. The zeroed top margin moves from
a Tools-specific class to .sidebar-section:first-of-type, so reordering again
cannot reintroduce a gap above the first section.

Measured with nothing armed: 1110px of content down to 900px, inside the
viewport rather than past it. Verified in a browser — arming each tool reveals
that tool's panel and no other, disarming hides both, Tools opens by default,
Layers starts closed and still lays its periods out on one line when opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:02:06 -05:00
8 changed files with 606 additions and 46 deletions

View file

@ -25,6 +25,29 @@ class ManualLine:
number: int = 0
cutoff_t: int | None = None
armed: bool = True
# "" means a line predating drawing kinds; `kind` derives it. Comments carry
# their text in `note`, like every other drawing carries its label.
kind: str = ""
pinned: bool = True
x: float = 0.72
y: float = 0.12
collapsed: bool = False
@property
def drawing_kind(self) -> str:
"""What this drawing is, for grouping and filtering.
Derived when absent so drawings saved before comments existed keep
working: a zero slope was always a typed price level, anything else a
drawn trendline.
"""
if self.kind:
return self.kind
return "level" if self.horizontal else "trendline"
@property
def is_comment(self) -> bool:
return self.drawing_kind == "comment"
@property
def horizontal(self) -> bool:
@ -85,6 +108,11 @@ class ManualLine:
number=int(value.get("number", 0)),
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
armed=bool(value.get("armed", True)),
kind=str(value.get("kind", "")),
pinned=bool(value.get("pinned", True)),
x=float(value.get("x", 0.72)),
y=float(value.get("y", 0.12)),
collapsed=bool(value.get("collapsed", False)),
)
@ -146,4 +174,15 @@ class ManualLineStore:
self.save()
def levels(self) -> list[Level]:
return [line.to_level() for line in self.lines.values()]
"""Only the drawings that are levels.
Comments are stored alongside lines so they share numbering, filtering
and deletion, but they are annotations. Letting one through here would
put it in a confluence cluster and fire a push notification about a
piece of text.
"""
return [line.to_level() for line in self.lines.values() if not line.is_comment]
def drawings(self) -> list[ManualLine]:
"""Everything drawn, comments included, newest number last."""
return sorted(self.lines.values(), key=lambda line: line.number)

View file

@ -54,6 +54,10 @@ class LinePatch(BaseModel):
last_t: int | None = None
cutoff_t: int | None = None
armed: bool | None = None
pinned: bool | None = None
x: float | None = Field(None, ge=0.0, le=1.0)
y: float | None = Field(None, ge=0.0, le=1.0)
collapsed: bool | None = None
@router.get("/status")
@ -151,6 +155,58 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
return line.to_level().to_dict()
class CommentCreate(BaseModel):
text: str = Field(min_length=1, max_length=2000)
# Pinned to a moment on the chart, or floating and always on screen.
pinned: bool = True
anchor_t: int | None = None
anchor_p: float | None = None
x: float = Field(0.72, ge=0.0, le=1.0)
y: float = Field(0.12, ge=0.0, le=1.0)
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
tf: Timeframe = Timeframe.M1
@router.post("/comments", status_code=201)
def create_comment(request: Request, payload: CommentCreate):
"""A note on the chart. Stored with the lines so it shares their numbering,
filtering and deletion, but it is never a level — see ManualLineStore.levels.
"""
runtime = request.app.state.runtime
now = int(time.time())
anchored = payload.anchor_t if payload.anchor_t is not None else now
line = ManualLine(
id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf,
# Side is meaningless for a comment; clustering never sees it.
side=Side.SUPPORT,
anchor_t=anchored,
anchor_p=payload.anchor_p if payload.anchor_p is not None else (runtime.price or 0.0),
slope=0.0,
last_t=anchored,
created_at=now,
note=payload.text,
color=payload.color,
kind="comment",
pinned=payload.pinned,
x=payload.x,
y=payload.y,
# A comment must never alert, whatever else changes around it.
armed=False,
)
line = runtime.manual_lines.add(line)
return line.to_dict()
@router.get("/drawings")
def drawings(request: Request):
"""Every drawing, comments included, for the sidebar list."""
store = request.app.state.runtime.manual_lines
return {"drawings": [
{**line.to_dict(), "kind": line.drawing_kind} for line in store.drawings()
]}
@router.patch("/lines/{line_id}")
def patch_line(request: Request, line_id: str, payload: LinePatch):
changes = payload.model_dump(exclude_none=True)
@ -161,7 +217,9 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
except KeyError as exc:
raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels()
return line.to_level().to_dict()
# A comment has no level form — returning one would hand the caller a shape
# that looks like something the confluence engine tracks.
return line.to_dict() if line.is_comment else line.to_level().to_dict()
@router.delete("/lines/{line_id}", status_code=204)

View file

@ -1425,3 +1425,67 @@ owning a row, which suits a control that is disabled until M8. The alert log
becomes a `<details>` like Confluence zones, closed by default with its count in
the summary — collapsed by default is the point, since leaving it open would
save nothing, and the count means activity is still visible while closed.
**Sidebar vertical space.** The right column was taller than the viewport with
nothing selected. Five changes, no functionality removed:
- The five daily MA periods fit one line (`flex-wrap:nowrap`, tighter gaps, 12px
boxes); 10px gaps and a 22px indent had pushed 200 onto a row of its own.
- Auto trendlines becomes a parenthetical `(auto)` on the Manual lines row
rather than owning one, which suits a control disabled until M8.
- Alert log becomes a `<details>` like Confluence zones, closed by default with
its count in the summary so activity still shows while shut.
- Tools becomes a `<details>` too, open by default, and each tool's panel is
bound to `armedTool` — only the armed tool shows its label, colour, width and
side controls. `armTool` already toggles and permits one armed tool at a time,
so the panels follow it exactly.
- Order is Layers, Tools, then the rest, with Layers collapsed by default.
Measured with nothing armed: 1110px of content down to 900px, which is inside
the viewport rather than past it. `.sidebar-section:first-of-type` carries the
zeroed top margin so reordering cannot reintroduce a gap at the top.
### 2026-08-10 (evening) — chart comments, and Drawings
**Comments are drawings, not levels.** A comment is stored as a `ManualLine`
with `kind="comment"`, so it inherits persistence, the shared drawing-number
sequence, the sidebar list, filtering and deletion without a parallel set of
endpoints. The one rule that must never bend: `ManualLineStore.levels()` filters
comments out. A comment reaching the level list would join a confluence cluster
and push a phone notification about a piece of text. It is also created with
`armed=False`, and `PATCH /lines/{id}` returns `to_dict()` rather than
`to_level()` for one, so no caller is ever handed a level-shaped comment.
`kind` is derived when absent — zero slope was always a typed level, anything
else a drawn trendline — so drawings saved before comments existed keep working.
**Pinned or floating.** Pinned comments carry `anchor_t`/`anchor_p` and move with
the chart; floating ones carry `x`/`y` as fractions of the pane, hold their place
through any zoom, and can be dragged. Comments render as DOM rather than canvas:
they hold arbitrary text, collapse to a numbered dot, and a floating one has to
ignore the time scale entirely. A pinned comment scrolled out of view parks on
the edge it left, pointing back the way it went, so it is never simply lost.
**"Lines & levels" becomes "Drawings"**, filtered by type and by text — the text
match covers the label, the kind and the `#number`, so `comment`, `cpi` and `7`
all narrow the list. Delete acts on what the filter shows, which is what makes
deleting by type or by string a single button.
One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured
the comment row's icon span and dragged it into the text column. Scoped to
`span:not(.drawing-icon)`.
**A comment lost its place when the timeframe changed.** Placed on a 30m bar,
then switched to 15m, it slid to the far left. `timeToCoordinate` answers only
for times that are data points on the current series, so a 30m bucket start
returned `null` on another timeframe — and `null` was being read as "off the
left edge". Anchors are now resolved to the bar that *contains* them, which is
timeframe-independent: an 09:30 note sits on the 09:30 bar at 15m and on the
09:00 bar at 1h. `setBars` also re-renders comments, since a timeframe switch
replaces the grid underneath every pinned one.
Verified across 30m → 15m → 1h → 30m: the anchor stays 08:30 throughout,
resolving to the 08:30 bar on 15m and the 08:00 bar on 1h, never edge-parked,
and returning to its original x on the way back. Edge-parking still works where
it should — a comment scrolled 400 bars out parks right and comes back on
return to live.

View file

@ -84,6 +84,13 @@ createApp({
const drawColor = ref('#65b7cf');
const drawWidth = ref(2);
const drawSide = ref('support');
const commentText = ref('');
const commentFloat = ref(false);
// Comments are not levels, so they never arrive over the levels socket and
// are fetched and refreshed explicitly.
const comments = ref([]);
const drawingFilter = ref('');
const drawingKind = ref('all');
const snap = ref(true);
const selectedLine = ref(null);
const selectedLines = ref([]);
@ -107,6 +114,37 @@ createApp({
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
// One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint.
const drawings = computed(() => {
const lines = manualLines.value.map(line => ({
id: line.id,
number: line.number,
kind: line.slope === 0 ? 'level' : 'trendline',
label: line.label || '',
line,
}));
const notes = comments.value.map(comment => ({
id: comment.id,
number: comment.number,
kind: 'comment',
label: comment.note || '',
comment,
}));
return [...lines, ...notes].sort((a, b) => a.number - b.number);
});
const filteredDrawings = computed(() => {
const needle = drawingFilter.value.trim().toLowerCase();
return drawings.value.filter(item => {
if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false;
if (!needle) return true;
// Matched against what is on screen — the label or comment text, the
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle);
});
});
async function refreshStatus() {
const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json();
@ -210,6 +248,10 @@ createApp({
// One placement per arming, so a tool cannot keep firing on stray drags.
armedTool.value = null;
chartApi.armTool(null);
if (result.tool === 'comment') {
await createComment(result);
return;
}
if (result.tool === 'level') {
alertPrice.value = result.price;
await addPriceAlert();
@ -218,6 +260,64 @@ createApp({
await createTrendline(result.start, result.end);
}
async function refreshComments() {
const response = await apiFetch('/api/drawings');
if (!response.ok) return;
const payload = await response.json();
comments.value = (payload.drawings || []).filter(item => item.kind === 'comment');
syncComments();
}
function syncComments() {
if (chartApi) chartApi.setComments(comments.value.filter(item => !item.hidden));
}
async function createComment(result) {
const text = commentText.value.trim();
if (!text) return;
const response = await apiFetch('/api/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
pinned: !commentFloat.value,
anchor_t: result.time ?? null,
anchor_p: result.price ?? null,
x: result.x, y: result.y,
}),
});
if (!response.ok) {
console.error('Unable to save comment', response.status);
return;
}
commentText.value = '';
await refreshComments();
}
async function patchComment(comment, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
if (response.ok) await refreshComments();
}
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
const moveComment = (comment, x, y) => patchComment(comment, { x, y });
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
async function deleteDrawing(item) {
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.kind === 'comment') await refreshComments();
else levels.value = levels.value.filter(level => level.id !== item.id);
syncVisibleLevels();
}
async function deleteFilteredDrawings() {
const doomed = [...filteredDrawings.value];
if (!doomed.length) return;
for (const item of doomed) await deleteDrawing(item);
}
async function createTrendline(first, second) {
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
const side = start.snappedSide || end.snappedSide || drawSide.value;
@ -327,6 +427,9 @@ createApp({
});
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
const saved = await response.json();
// A comment is not a level and must not be pushed into the level list,
// where it would be drawn as a line.
if (line.kind === 'comment') { await refreshComments(); return; }
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels();
}
@ -434,6 +537,8 @@ createApp({
chartApi.setLineChangeHandler(updateLineGeometry);
chartApi.setLineEndHandler(endLineHere);
chartApi.setSnap(snap.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
refreshComments();
window.addEventListener('keydown', handleKeydown);
refreshStatus();
connect();
@ -446,6 +551,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed };
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
},
}).mount('#app');

View file

@ -33,6 +33,11 @@ class ConfluenceChart {
this.toolMoveListener = null;
this.toolUpListener = null;
this.pendingView = null;
this.comments = [];
this.commentNodes = new Map();
this.commentLayer = null;
this.onCommentToggle = null;
this.onCommentMove = null;
}
static TICK = 0.25;
@ -161,6 +166,9 @@ class ConfluenceChart {
this.priceTag.className = 'chart-price-tag';
this.priceTag.hidden = true;
el.appendChild(this.priceTag);
this.commentLayer = document.createElement('div');
this.commentLayer.className = 'chart-comments';
el.appendChild(this.commentLayer);
this.toolDownListener = event => this.startToolGesture(event);
this.toolMoveListener = event => this.moveToolGesture(event);
this.toolUpListener = event => this.finishToolGesture(event);
@ -192,7 +200,10 @@ class ConfluenceChart {
}
this.updateLineTooltip(param);
});
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles();
this.renderComments();
});
}
setBars(bars) {
@ -218,6 +229,8 @@ class ConfluenceChart {
this.chart.timeScale().setVisibleRange(this.pendingView);
}
requestAnimationFrame(() => this.renderAnchorHandles());
// The bar grid just changed underneath every pinned comment.
this.renderComments();
}
updateBar(bar) {
@ -235,6 +248,130 @@ class ConfluenceChart {
this.renderAnchorHandles();
}
setComments(comments) {
this.comments = comments;
this.renderComments();
}
setCommentHandlers({ onToggle, onMove }) {
this.onCommentToggle = onToggle;
this.onCommentMove = onMove;
}
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
// and a floating one must ignore the time scale entirely.
renderComments() {
if (!this.commentLayer) return;
const width = this.chartEl.clientWidth;
const height = this.chartEl.clientHeight;
const seen = new Set();
for (const comment of this.comments || []) {
seen.add(comment.id);
let node = this.commentNodes.get(comment.id);
if (!node) {
node = this.buildCommentNode(comment);
this.commentNodes.set(comment.id, node);
this.commentLayer.appendChild(node.root);
}
node.comment = comment;
node.root.classList.toggle('collapsed', !!comment.collapsed);
node.root.classList.toggle('floating', !comment.pinned);
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
node.root.style.borderColor = comment.color;
node.root.title = comment.note;
if (!comment.pinned) {
// A fraction of the pane, so it holds its place through any zoom.
node.root.style.left = `${Math.round(comment.x * width)}px`;
node.root.style.top = `${Math.round(comment.y * height)}px`;
node.root.classList.remove('off-left', 'off-right');
continue;
}
const placed = this.anchorCoordinate(comment.anchor_t);
const x = placed.x;
const y = this.candles.priceToCoordinate(comment.anchor_p);
// Scrolled past: park it on the edge it went off, pointing that way,
// rather than letting it vanish and be hard to find again.
const offLeft = placed.before || (x !== null && x < 0);
const offRight = placed.after || (x !== null && x > width);
node.root.classList.toggle('off-left', offLeft && !offRight);
node.root.classList.toggle('off-right', offRight);
const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x;
node.root.style.left = `${Math.round(clampedX)}px`;
node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
}
for (const [id, node] of this.commentNodes) {
if (!seen.has(id)) {
node.root.remove();
this.commentNodes.delete(id);
}
}
}
/**
* Where an anchor time sits on the current timeframe.
*
* timeToCoordinate answers only for times that are data points on the series,
* so a comment placed on a 30m bar returned null the moment the chart
* switched to 15m or 1h — and null was being read as "off the left edge",
* which slid every such comment to the far left. The anchor is resolved to
* the bar that *contains* it instead, which is timeframe-independent: a 09:30
* note lands on the 09:30 bar at 15m and on the 09:00 bar at 1h.
*/
anchorCoordinate(anchorT) {
if (!this.bars.length) return { x: null, before: false, after: false };
if (anchorT < this.bars[0].t) return { x: null, before: true, after: false };
const lastBar = this.bars[this.bars.length - 1];
if (anchorT > lastBar.t) {
// Past the newest bar is not off the chart — the right edge carries
// whitespace, so ask the scale directly and only park it if it answers.
const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t);
return { x: beyond, before: false, after: beyond === null };
}
let lo = 0;
let hi = this.bars.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.bars[mid].t <= anchorT) lo = mid;
else hi = mid - 1;
}
return { x: this.chart.timeScale().timeToCoordinate(this.bars[lo].t), before: false, after: false };
}
buildCommentNode(comment) {
const root = document.createElement('div');
root.className = 'chart-comment';
const label = document.createElement('span');
label.className = 'chart-comment-text';
root.appendChild(label);
const node = { root, label, comment };
root.addEventListener('click', event => {
event.stopPropagation();
if (this.onCommentToggle) this.onCommentToggle(node.comment);
});
// Floating comments are dragged; pinned ones belong to their bar.
root.addEventListener('pointerdown', event => {
if (node.comment.pinned) return;
event.stopPropagation();
const bounds = this.chartEl.getBoundingClientRect();
const move = moveEvent => {
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - 8)}px`;
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - 8)}px`;
};
const up = upEvent => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
const x = (upEvent.clientX - bounds.left - 8) / bounds.width;
const y = (upEvent.clientY - bounds.top - 8) / bounds.height;
const clamp = value => Math.min(Math.max(value, 0), 1);
if (this.onCommentMove) this.onCommentMove(node.comment, clamp(x), clamp(y));
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
return node;
}
// A typed price alert is a manual line with zero slope. Treating it as flat
// here is what makes it render as a level rather than a stubby segment.
static isFlat(level) {
@ -441,6 +578,21 @@ class ConfluenceChart {
this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId);
if (tool === 'comment') {
// Both placements come from one gesture: the time and price under the
// cursor for a pinned comment, and the same spot as a fraction of the
// pane for a floating one, so the tool decides which without a second
// interaction.
this.onToolComplete?.({
tool,
time: end.t,
price: ConfluenceChart.snapToTick(end.p),
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
});
return;
}
if (tool === 'level') {
// A click with no drag is a valid placement; the drag is only there to
// let you fine-tune the price before committing.
@ -453,8 +605,15 @@ class ConfluenceChart {
// it. Rejecting short gestures outright left the tool armed and silent, so
// the next click began a whole new line — which read as the line suddenly
// continuing at a different slope.
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5;
const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end));
// A placed anchor wins over this click's own drag. Recomputing `dragged`
// here meant a few pixels of movement while pressing the second click was
// read as a fresh press-drag-release, which threw the anchor away and
// started the line at the second click instead — the preview had been
// rubber-banding from the real anchor all along, so the finished line
// jumped away from where it had just been drawn.
const dragged = !this.pendingAnchor
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5;
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
const b = this.snapPoint(end);
if (!dragged && !this.pendingAnchor) {
this.pendingAnchor = b;
@ -467,11 +626,23 @@ class ConfluenceChart {
this.onToolComplete?.({ tool, start: a, end: b });
}
/** Trendline anchors snap to bar extremes; levels snap to the tick grid. */
/**
* Trendline anchors snap to bar extremes; levels snap to the tick grid.
*
* With snapping on, an anchor always lands on the nearer extreme of the
* nearest bar — there is no proximity gate. An 8px one used to let the
* cursor fall between the high and the low and snap to neither, so the
* preview showed a raw mid-bar price and the side quietly fell back to the
* dropdown. Which extreme you land on *is* the side: a high is resistance, a
* low is support, so the choice is made by where you point rather than by a
* control you have to remember to set. An already-snapped point carries no
* cursor y, so it is returned untouched rather than re-snapped against NaN.
*/
snapPoint(point) {
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
const base = { t: fallbackT, p: point.p, snappedSide: null };
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
if (point.y == null) return base;
const nearest = this.bars.reduce(
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
);
@ -482,8 +653,7 @@ class ConfluenceChart {
const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return base;
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
}
renderGesture() {

View file

@ -35,13 +35,30 @@
</div>
</section>
<aside>
<h2>Tools</h2>
<details class="sidebar-section">
<summary>Layers</summary>
<div class="layer-group">
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
</div>
<div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
</div>
<div class="layer-group layer-inline">
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label>
</div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
</details>
<details class="sidebar-section tools-section" open>
<summary>Tools</summary>
<div class="tool" :class="{armed: armedTool === 'trendline'}">
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
</button>
<div class="tool-body">
<div class="tool-body" v-show="armedTool === 'trendline'">
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
<div class="row">
<label>Colour<input type="color" v-model="drawColor" aria-label="Trendline colour"></label>
@ -58,7 +75,7 @@
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body">
<div class="tool-body" v-show="armedTool === 'level'">
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
<div class="row">
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
@ -71,42 +88,76 @@
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
</div>
</div>
<details class="sidebar-section" open>
<summary>Layers</summary>
<div class="layer-group">
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
<div class="tool" :class="{armed: armedTool === 'comment'}">
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'comment'">
<label>Text<textarea v-model.trim="commentText" rows="2" placeholder="What happened here?" aria-label="Comment text"></textarea></label>
<label class="check"><input type="checkbox" v-model="commentFloat">Float (stay on screen)</label>
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
</div>
<div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
</div>
<div class="layer-group layer-inline">
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label>
</div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
</details>
<details class="sidebar-section" open>
<summary>Lines &amp; levels ({{ manualLines.length }})</summary>
<div class="trendline-actions" v-if="manualLines.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
<div class="drawing-filters">
<select v-model="drawingKind" aria-label="Filter drawings by type">
<option value="all">All types</option>
<option value="trendline">Trendlines</option>
<option value="level">Levels</option>
<option value="comment">Comments</option>
</select>
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div>
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div>
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
<span v-if="line.slope === 0">#{{ line.number }} · level {{ line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
<label class="armed-toggle" :class="{off: !line.armed}" @click.stop :title="line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
<input type="checkbox" :checked="line.armed" @change="setArmed(line, $event.target.checked)">
{{ line.armed ? 'armed' : 'tripped' }}
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
:title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
Delete shown ({{ filteredDrawings.length }})
</button>
</div>
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedLines.includes(item.id)}"
@click="item.kind !== 'comment' && toggleLineSelection(item.id)">
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
:checked="selectedLines.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleLineSelection(item.id)">
<span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span>
<input v-if="item.kind !== 'comment'" :value="item.line.label" aria-label="Drawing name"
@click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)">
<span v-if="item.kind === 'comment'">#{{ item.number }} · comment · {{ item.comment.pinned ? 'pinned' : 'floating' }}</span>
<span v-else-if="item.kind === 'level'">#{{ item.number }} · level {{ item.line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side }}</span>
<label v-if="item.kind === 'comment'" class="armed-toggle" @click.stop
:title="item.comment.pinned ? 'Pinned to its bar — scrolls with the chart' : 'Floating — always on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
{{ item.comment.pinned ? 'pinned' : 'float' }}
</label>
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
<div class="line-style-controls" @click.stop>
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
<label v-else class="armed-toggle" :class="{off: !item.line.armed}" @click.stop
:title="item.line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
{{ item.line.armed ? 'armed' : 'tripped' }}
</label>
<button @click.stop="deleteDrawing(item)" aria-label="Delete drawing">Delete</button>
<div class="line-style-controls" @click.stop v-if="item.kind !== 'comment'">
<input type="color" :value="item.line.color || '#65b7cf'" aria-label="Drawing color" @change="updateLineStyle(item.line, { color: $event.target.value })">
<select :value="item.line.line_width || 2" aria-label="Drawing width" @change="updateLineStyle(item.line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
</div>
<div class="line-style-controls" @click.stop v-else>
<input type="color" :value="item.comment.color || '#c8992f'" aria-label="Comment colour" @change="updateLineStyle(item.comment, { color: $event.target.value })">
<button class="collapse-toggle" @click.stop="toggleComment(item.comment)">{{ item.comment.collapsed ? 'expand' : 'collapse' }}</button>
</div>
</div>
</details>

View file

@ -17,8 +17,8 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.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); }.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; }
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 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 { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.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; }
.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; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
@ -52,3 +52,27 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted);
border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg);
font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap;
box-shadow:0 1px 3px rgba(0,0,0,.16); }
.chart-comment.collapsed { max-width:none; width:18px; height:18px; padding:0; display:flex;
align-items:center; justify-content:center; border-radius:50%; border-left-width:1px;
font-size:9px; white-space:nowrap; }
.chart-comment.floating { border-style:dashed; cursor:grab; }
/* Parked on the edge it scrolled off, pointing the way back to it. */
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
.chart-comment.off-left::before { content:'◀ '; }
.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; }
.chart-comment.off-left, .chart-comment.off-right { opacity:.72; }
/* --- drawings filter --------------------------------------------------- */
.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; }
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }
.collapse-toggle { font:inherit; font-size:9px; padding:2px 6px; border:1px solid var(--line);
border-radius:4px; background:transparent; color:var(--muted); cursor:pointer; }
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }

View file

@ -40,3 +40,52 @@ def test_manual_line_raises_existing_ma_cluster_score():
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
assert before.score == 12
assert after.score == 16
def test_a_comment_is_never_a_level(tmp_path):
# Comments live with the lines so they share numbering, filtering and
# deletion — but a comment reaching levels() would join a confluence
# cluster and fire a push notification about a piece of text.
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
store.add(ManualLine(id="ml_level", slope=0.0, **common))
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
note="watch this", **common))
assert [level.id for level in store.levels()] == ["ml_level"]
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note"]
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
flat = store.add(ManualLine(id="ml_flat", slope=0.0, **common))
sloped = store.add(ManualLine(id="ml_sloped", slope=0.5, **common))
assert flat.drawing_kind == "level"
assert sloped.drawing_kind == "trendline"
def test_every_drawing_gets_a_number_including_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
first = store.add(ManualLine(id="ml_a", slope=0.0, **common))
note = store.add(ManualLine(id="ml_b", slope=0.0, kind="comment", **common))
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
assert [first.number, note.number, third.number] == [1, 2, 3]