This commit is contained in:
Chris Amow 2026-08-21 03:10:31 +00:00
parent 6e69b7337a
commit dae60fdf67
7 changed files with 343 additions and 33 deletions

View file

@ -101,6 +101,39 @@ class PriceAlertCreate(BaseModel):
alert_early_points: float | None = Field(None, ge=0) alert_early_points: float | None = Field(None, ge=0)
class LineRestore(BaseModel):
"""Re-insert a deleted drawing with the same id and number.
Create always mints a new id. Undo of delete needs the original identity
back, including drawing number, or clusters and the sidebar # label drift.
"""
id: str
tf: Timeframe
side: Side
anchor_t: int
anchor_p: float
slope: float
last_t: int
created_at: int | None = None
note: str = ""
label: str = ""
hidden: bool = False
color: str | None = None
line_width: int | None = None
number: int = 0
cutoff_t: int | None = None
armed: bool = True
kind: str = ""
pinned: bool = True
x: float = Field(0.72, ge=0.0, le=1.0)
y: float = Field(0.12, ge=0.0, le=1.0)
collapsed: bool = False
icon: str = ""
alert_early_points: float | None = None
scale: float = Field(1.0, ge=0.5, le=3)
class LinePatch(BaseModel): class LinePatch(BaseModel):
side: Side | None = None side: Side | None = None
note: str | None = None note: str | None = None
@ -243,6 +276,45 @@ def create_line(request: Request, payload: LineCreate):
return positioned_level(runtime, line.id).to_dict() return positioned_level(runtime, line.id).to_dict()
@router.post("/lines/restore", status_code=201)
def restore_line(request: Request, payload: LineRestore):
runtime = request.app.state.runtime
if payload.id in runtime.manual_lines.lines:
raise HTTPException(409, "Line already exists")
kind = "" if payload.kind in {"", "manual", "trendline", "level"} else payload.kind
color = payload.color if payload.color and len(payload.color) == 7 and payload.color.startswith("#") else "#65b7cf"
line = ManualLine(
id=payload.id,
tf=payload.tf,
side=payload.side,
anchor_t=payload.anchor_t,
anchor_p=payload.anchor_p,
slope=payload.slope,
last_t=payload.last_t,
created_at=payload.created_at or int(time.time()),
note=payload.note or payload.label,
hidden=payload.hidden,
color=color,
line_width=payload.line_width or 2,
number=payload.number,
cutoff_t=payload.cutoff_t,
armed=payload.armed,
kind=kind,
pinned=payload.pinned,
x=payload.x,
y=payload.y,
collapsed=payload.collapsed,
icon=payload.icon,
alert_early_points=payload.alert_early_points,
scale=payload.scale,
)
line = runtime.manual_lines.add(line)
runtime.rebuild_levels()
if line.is_overlay:
return {**line.to_dict(), "kind": line.drawing_kind}
return positioned_level(runtime, line.id).to_dict()
@router.post("/lines/price", status_code=201) @router.post("/lines/price", status_code=201)
def create_price_alert(request: Request, payload: PriceAlertCreate): def create_price_alert(request: Request, payload: PriceAlertCreate):
runtime = request.app.state.runtime runtime = request.app.state.runtime

View file

@ -1,6 +1,9 @@
# Undo and Redo # Undo and Redo
Status: proposed. Status: proposed. An in-session client stack (Ctrl/Cmd+Z and an undo
button) plus `POST /api/lines/restore` shipped as an interim so delete
undo keeps the same id and number. Redo and the command journal are still
unbuilt.
## Goal ## Goal

View file

@ -141,6 +141,8 @@ createApp({
const selectedDrawing = ref(null); const selectedDrawing = ref(null);
const selectedDrawings = ref([]); const selectedDrawings = ref([]);
const drawingList = ref(null); const drawingList = ref(null);
const undoStack = ref([]);
let applyingUndo = false;
const alertPrice = ref(null); const alertPrice = ref(null);
const alertNote = ref(''); const alertNote = ref('');
const levelColor = ref('#e0a34a'); const levelColor = ref('#e0a34a');
@ -731,6 +733,92 @@ createApp({
chartApi.setComments(visible); chartApi.setComments(visible);
} }
const canUndo = computed(() => undoStack.value.length > 0);
const undoTitle = computed(() => {
const last = undoStack.value.at(-1);
return last ? `Undo ${last.label}` : 'Undo';
});
function remember(label, invert) {
if (applyingUndo) return;
undoStack.value = [...undoStack.value.slice(-49), { label, invert }];
}
function overlayDrawing(item) {
return Boolean(
item.comment
|| ['comment', 'symbol', 'fibonacci'].includes(item.kind)
|| ['comment', 'symbol', 'fibonacci'].includes(item.line?.kind),
);
}
function drawingSnapshot(item) {
const drawing = item.comment || item.line;
if (!drawing) return null;
const kind = item.kind === 'fibonacci' || drawing.kind === 'fibonacci' ? 'fibonacci'
: item.kind === 'symbol' || drawing.kind === 'symbol' ? 'symbol'
: item.kind === 'comment' || drawing.kind === 'comment' ? 'comment'
: '';
return {
id: drawing.id,
tf: drawing.tf,
side: drawing.side || 'support',
anchor_t: drawing.anchor_t,
anchor_p: drawing.anchor_p,
slope: drawing.slope ?? 0,
last_t: drawing.last_t ?? drawing.anchor_t,
created_at: drawing.created_at || Math.floor(Date.now() / 1000),
note: drawing.note || drawing.label || '',
hidden: !!drawing.hidden,
color: drawing.color || '#65b7cf',
line_width: drawing.line_width || 2,
number: drawing.number || 0,
cutoff_t: drawing.cutoff_t ?? null,
armed: drawing.armed !== false,
kind,
pinned: drawing.pinned !== false,
x: drawing.x ?? 0.72,
y: drawing.y ?? 0.12,
collapsed: !!drawing.collapsed,
icon: drawing.icon || '',
alert_early_points: drawing.alert_early_points ?? null,
scale: drawing.scale || 1,
};
}
async function restoreDrawings(snapshots) {
for (const snapshot of snapshots) {
const response = await apiFetch('/api/lines/restore', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snapshot),
});
if (!response.ok) {
console.error(`Unable to restore drawing: HTTP ${response.status}`);
continue;
}
const saved = await response.json();
if (['comment', 'symbol', 'fibonacci'].includes(snapshot.kind)) continue;
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
}
if (snapshots.some(item => ['comment', 'symbol', 'fibonacci'].includes(item.kind))) {
await refreshComments();
}
syncVisibleLevels();
}
async function undo() {
if (chartApi?.clearPendingAnchor()) return;
const last = undoStack.value.at(-1);
if (!last || applyingUndo) return;
applyingUndo = true;
undoStack.value = undoStack.value.slice(0, -1);
try {
await last.invert();
} finally {
applyingUndo = false;
}
}
async function createComment(result) { async function createComment(result) {
const text = commentText.value.trim(); const text = commentText.value.trim();
if (!text) return; if (!text) return;
@ -748,8 +836,10 @@ createApp({
console.error('Unable to save comment', response.status); console.error('Unable to save comment', response.status);
return; return;
} }
const saved = await response.json();
commentText.value = ''; commentText.value = '';
await refreshComments(); await refreshComments();
remember('comment', () => deleteDrawing({ id: saved.id, kind: 'comment' }));
} }
async function createSymbol(result, icon = selectedSymbol.value) { async function createSymbol(result, icon = selectedSymbol.value) {
@ -766,7 +856,9 @@ createApp({
console.error('Unable to save symbol', response.status); console.error('Unable to save symbol', response.status);
return; return;
} }
const saved = await response.json();
await refreshComments(); await refreshComments();
remember('mark', () => deleteDrawing({ id: saved.id, kind: 'symbol' }));
} }
function chooseSymbol(symbol) { function chooseSymbol(symbol) {
@ -808,20 +900,36 @@ createApp({
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes), body: JSON.stringify(changes),
}); });
if (response.ok) await refreshComments(); if (!response.ok) return false;
await refreshComments();
return true;
} }
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed }); const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
const moveComment = (comment, changes) => patchComment(comment, changes); async function moveComment(comment, changes) {
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned }); const previous = {};
for (const key of Object.keys(changes)) previous[key] = comment[key];
if (Object.keys(changes).every(key => changes[key] === previous[key])) return;
if (await patchComment(comment, changes)) {
remember('move', () => patchComment(comment, previous));
}
}
async function togglePinned(comment) {
const previous = comment.pinned;
if (await patchComment(comment, { pinned: !comment.pinned })) {
remember('pin', () => patchComment(comment, { pinned: previous }));
}
}
async function deleteDrawing(item) { async function deleteDrawing(item) {
const snapshot = applyingUndo ? null : drawingSnapshot(item);
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' }); await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.comment) await refreshComments(); if (overlayDrawing(item)) await refreshComments();
else levels.value = levels.value.filter(level => level.id !== item.id); else levels.value = levels.value.filter(level => level.id !== item.id);
if (selectedDrawing.value === item.id) selectedDrawing.value = null; if (selectedDrawing.value === item.id) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id); selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id);
syncVisibleLevels(); syncVisibleLevels();
if (snapshot) remember('delete', () => restoreDrawings([snapshot]));
} }
async function createFibonacci(start, end) { async function createFibonacci(start, end) {
@ -839,10 +947,11 @@ createApp({
console.error(`Unable to save fibonacci: HTTP ${response.status}`); console.error(`Unable to save fibonacci: HTTP ${response.status}`);
return; return;
} }
await refreshComments();
const saved = await response.json(); const saved = await response.json();
await refreshComments();
selectedDrawing.value = saved.id; selectedDrawing.value = saved.id;
selectedDrawings.value = [saved.id]; selectedDrawings.value = [saved.id];
remember('fibonacci', () => deleteDrawing({ id: saved.id, kind: 'fibonacci' }));
} }
async function createTrendline(first, second) { async function createTrendline(first, second) {
@ -870,6 +979,7 @@ createApp({
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved); if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
selectedDrawing.value = saved.id; selectedDrawing.value = saved.id;
selectedDrawings.value = [saved.id]; selectedDrawings.value = [saved.id];
remember('trendline', () => deleteDrawing({ id: saved.id }));
} catch (error) { } catch (error) {
levels.value = levels.value.filter(level => level.id !== temporaryId); levels.value = levels.value.filter(level => level.id !== temporaryId);
console.error('Unable to save line', error); console.error('Unable to save line', error);
@ -890,6 +1000,7 @@ createApp({
if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; } if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; }
const saved = await response.json(); const saved = await response.json();
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
remember('level', () => deleteDrawing({ id: saved.id }));
alertPrice.value = null; alertPrice.value = null;
alertNote.value = ''; alertNote.value = '';
alertEarlyPoints.value = null; alertEarlyPoints.value = null;
@ -899,10 +1010,22 @@ createApp({
async function deleteSelected() { async function deleteSelected() {
const ids = selectedDrawings.value.length const ids = selectedDrawings.value.length
? selectedDrawings.value : [selectedDrawing.value].filter(Boolean); ? selectedDrawings.value : [selectedDrawing.value].filter(Boolean);
const snapshots = ids
.map(id => drawings.value.find(drawing => drawing.id === id))
.map(item => item && drawingSnapshot(item))
.filter(Boolean);
applyingUndo = true;
try {
for (const id of ids) { for (const id of ids) {
const item = drawings.value.find(drawing => drawing.id === id); const item = drawings.value.find(drawing => drawing.id === id);
if (item) await deleteDrawing(item); if (item) await deleteDrawing(item);
} }
} finally {
applyingUndo = false;
}
if (snapshots.length) {
remember(snapshots.length > 1 ? 'delete drawings' : 'delete', () => restoreDrawings(snapshots));
}
} }
async function deleteLine(id) { async function deleteLine(id) {
@ -948,10 +1071,15 @@ createApp({
selectedDrawing.value = saved.id; selectedDrawing.value = saved.id;
selectedDrawings.value = [saved.id]; selectedDrawings.value = [saved.id];
syncVisibleLevels(); syncVisibleLevels();
remember('duplicate', () => deleteDrawing({ id: saved.id }));
} }
async function deleteLines(ids) { async function deleteLines(ids) {
if (!ids.length) return; if (!ids.length) return;
const snapshots = ids
.map(id => drawings.value.find(drawing => drawing.id === id))
.map(item => item && drawingSnapshot(item))
.filter(Boolean);
const deleting = new Set(ids); const deleting = new Set(ids);
levels.value = levels.value.filter(level => !deleting.has(level.id)); levels.value = levels.value.filter(level => !deleting.has(level.id));
if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null; if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null;
@ -964,6 +1092,9 @@ createApp({
responses.forEach(response => { responses.forEach(response => {
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
}); });
if (snapshots.length) {
remember(snapshots.length > 1 ? 'delete drawings' : 'delete', () => restoreDrawings(snapshots));
}
} }
function selectComment(comment) { function selectComment(comment) {
@ -993,19 +1124,31 @@ createApp({
async function toggleSelectedVisibility() { async function toggleSelectedVisibility() {
const hidden = !selectedAreHidden.value; const hidden = !selectedAreHidden.value;
for (const id of [...selectedDrawings.value]) { const previous = [...selectedDrawings.value].map(id => {
const item = drawings.value.find(drawing => drawing.id === id); const item = drawings.value.find(drawing => drawing.id === id);
const drawing = item?.line || item?.comment; const drawing = item?.line || item?.comment;
if (drawing) await updateLineStyle(drawing, { hidden }); return drawing ? { drawing, hidden: drawing.hidden } : null;
}).filter(Boolean);
for (const entry of previous) await updateLineStyle(entry.drawing, { hidden });
if (previous.length) {
remember(hidden ? 'hide' : 'show', async () => {
for (const entry of previous) await updateLineStyle(entry.drawing, { hidden: entry.hidden });
});
} }
} }
async function setArmed(line, armed) { async function setArmed(line, armed) {
await updateLineStyle(line, { armed }); const previous = line.armed;
if (await updateLineStyle(line, { armed })) {
remember(armed ? 'arm' : 'disarm', () => updateLineStyle(line, { armed: previous }));
}
} }
async function renameLine(line, name) { async function renameLine(line, name) {
await updateLineStyle(line, { note: name.trim() }); const previous = line.note ?? line.label ?? '';
if (await updateLineStyle(line, { note: name.trim() })) {
remember('rename', () => updateLineStyle(line, { note: previous }));
}
} }
async function updateLineStyle(line, changes) { async function updateLineStyle(line, changes) {
@ -1013,23 +1156,27 @@ createApp({
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes), body: JSON.stringify(changes),
}); });
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; } if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return false; }
const saved = await response.json(); const saved = await response.json();
// A comment is not a level and must not be pushed into the level list, // A comment is not a level and must not be pushed into the level list,
// where it would be drawn as a line. // where it would be drawn as a line.
if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return; } if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return true; }
levels.value = levels.value.map(level => level.id === line.id ? saved : level); levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels(); syncVisibleLevels();
return true;
} }
async function updateLevelNumber(line, field, raw) { async function updateLevelNumber(line, field, raw) {
const value = raw === '' ? null : Number(raw); const value = raw === '' ? null : Number(raw);
if (field === 'anchor_p' && !(value > 0)) return; if (field === 'anchor_p' && !(value > 0)) return;
if (field === 'alert_early_points' && value != null && value < 0) return; if (field === 'alert_early_points' && value != null && value < 0) return;
await updateLineStyle(line, { [field]: value }); const previous = line[field];
if (await updateLineStyle(line, { [field]: value })) {
remember('edit', () => updateLineStyle(line, { [field]: previous }));
}
} }
async function updateLineGeometry(line) { async function updateLineGeometry(line, previous = null) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -1039,12 +1186,19 @@ createApp({
}); });
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; }
const saved = await response.json(); const saved = await response.json();
if (line.kind === 'fibonacci') { await refreshComments(); return; } if (line.kind === 'fibonacci') await refreshComments();
else {
levels.value = levels.value.map(level => level.id === line.id ? saved : level); levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels(); syncVisibleLevels();
} }
if (previous) {
remember('move', () => updateLineGeometry(previous));
}
}
async function updateCommentGeometry(comment, changes) { async function updateCommentGeometry(comment, changes) {
const previous = {};
for (const key of Object.keys(changes)) previous[key] = comment[key];
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes), body: JSON.stringify(changes),
@ -1053,6 +1207,7 @@ createApp({
const saved = await response.json(); const saved = await response.json();
comments.value = comments.value.map(value => value.id === comment.id ? saved : value); comments.value = comments.value.map(value => value.id === comment.id ? saved : value);
syncComments(); syncComments();
remember('move', () => updateCommentGeometry(comment, previous));
} }
async function nudgeSelectedDrawings(key, multiplier) { async function nudgeSelectedDrawings(key, multiplier) {
@ -1066,9 +1221,10 @@ createApp({
if (!item) continue; if (!item) continue;
if (item.line) { if (item.line) {
const line = { ...item.line }; const line = { ...item.line };
const previous = { ...item.line };
if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK); if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK);
if (line.slope === 0) { if (line.slope === 0) {
if (vertical) await updateLineGeometry(line); if (vertical) await updateLineGeometry(line, previous);
continue; continue;
} }
const endPrice = item.line.anchor_p const endPrice = item.line.anchor_p
@ -1083,7 +1239,7 @@ createApp({
} }
} }
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
await updateLineGeometry(line); await updateLineGeometry(line, previous);
continue; continue;
} }
const comment = item.comment; const comment = item.comment;
@ -1114,7 +1270,7 @@ createApp({
} }
} }
async function endLineHere(line) { async function endLineHere(line, previousCutoff) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cutoff_t: line.cutoff_t }), body: JSON.stringify({ cutoff_t: line.cutoff_t }),
@ -1123,6 +1279,9 @@ createApp({
const saved = await response.json(); const saved = await response.json();
levels.value = levels.value.map(level => level.id === line.id ? saved : level); levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels(); syncVisibleLevels();
if (previousCutoff !== undefined) {
remember('end line', () => updateLineStyle(line, { cutoff_t: previousCutoff }));
}
} }
// Backspace is a normal editing key inside a field. Without this guard, // Backspace is a normal editing key inside a field. Without this guard,
@ -1158,6 +1317,13 @@ createApp({
} }
} }
if (isEditing(event.target)) return; if (isEditing(event.target)) return;
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === 'z') {
if (chartApi?.pendingAnchor || canUndo.value) {
event.preventDefault();
undo();
}
return;
}
if (event.key.startsWith('Arrow') && hasDrawingSelection.value) { if (event.key.startsWith('Arrow') && hasDrawingSelection.value) {
event.preventDefault(); event.preventDefault();
const multiplier = event.shiftKey ? 4 : 1; const multiplier = event.shiftKey ? 4 : 1;
@ -1318,8 +1484,8 @@ createApp({
chartApi.setRthEnabled(prefs.value.enabled.rth); chartApi.setRthEnabled(prefs.value.enabled.rth);
chartApi.setClickHandler(handleChartClick); chartApi.setClickHandler(handleChartClick);
chartApi.setToolCompleteHandler(handleToolComplete); chartApi.setToolCompleteHandler(handleToolComplete);
chartApi.setLineChangeHandler(line => { chartApi.setLineChangeHandler((line, previous) => {
nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line)); nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line, previous));
}); });
chartApi.setDrawingColorDescriptor(drawingColorDescription); chartApi.setDrawingColorDescriptor(drawingColorDescription);
chartApi.setLineEndHandler(endLineHere); chartApi.setLineEndHandler(endLineHere);
@ -1351,6 +1517,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -1520,6 +1520,13 @@ class ConfluenceChart {
else this.detachPointers('tool'); else this.detachPointers('tool');
} }
clearPendingAnchor() {
if (!this.pendingAnchor) return false;
this.pendingAnchor = null;
this.clearLinePreview();
return true;
}
/** /**
* Pixels between the element's left edge and the plot area. * Pixels between the element's left edge and the plot area.
* *
@ -2738,9 +2745,10 @@ class ConfluenceChart {
event.preventDefault(); event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingLine.id); const level = this.levels.find(value => value.id === this.draggingLine.id);
const moved = this.draggingLine.moved; const moved = this.draggingLine.moved;
const original = this.draggingLine.original;
this.draggingLine = null; this.draggingLine = null;
this.detachPointers('line'); this.detachPointers('line');
if (moved && level && this.onLineChange) this.onLineChange({ ...level }); if (moved && level && this.onLineChange) this.onLineChange({ ...level }, { ...original });
} }
startAnchorDrag(event, anchor) { startAnchorDrag(event, anchor) {
@ -2750,6 +2758,7 @@ class ConfluenceChart {
this.draggingAnchor = { this.draggingAnchor = {
id: this.selectedLineId, id: this.selectedLineId,
anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor, anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor,
original: level ? { ...level } : null,
}; };
event.currentTarget.setPointerCapture?.(event.pointerId); event.currentTarget.setPointerCapture?.(event.pointerId);
this.attachPointers('anchor'); this.attachPointers('anchor');
@ -2802,11 +2811,19 @@ class ConfluenceChart {
finishAnchorDrag(event) { finishAnchorDrag(event) {
if (!this.draggingAnchor) return; if (!this.draggingAnchor) return;
event.preventDefault(); event.preventDefault();
const original = this.draggingAnchor.original;
const level = this.handleLevel(this.draggingAnchor.id); const level = this.handleLevel(this.draggingAnchor.id);
this.draggingAnchor = null; this.draggingAnchor = null;
this.detachPointers('anchor'); this.detachPointers('anchor');
this.hideSnapDot(); this.hideSnapDot();
if (level && this.onLineChange) this.onLineChange({ ...level }); if (level && original && this.onLineChange && (
level.anchor_t !== original.anchor_t
|| level.anchor_p !== original.anchor_p
|| level.slope !== original.slope
|| level.last_t !== original.last_t
)) {
this.onLineChange({ ...level }, { ...original });
}
} }
showContextMenu(event) { showContextMenu(event) {
@ -2845,23 +2862,25 @@ class ConfluenceChart {
endSelectedLineHere() { endSelectedLineHere() {
const level = this.levels.find(value => value.id === this.selectedLineId); const level = this.levels.find(value => value.id === this.selectedLineId);
if (!level || this.contextCutoff == null) return; if (!level || this.contextCutoff == null) return;
const previousCutoff = level.cutoff_t;
level.cutoff_t = this.contextCutoff; level.cutoff_t = this.contextCutoff;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderLineBridges(); this.renderLineBridges();
this.hideContextMenu(); this.hideContextMenu();
if (this.onLineEnd) this.onLineEnd({ ...level }); if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff);
} }
extendSelectedLine() { extendSelectedLine() {
const level = this.levels.find(value => value.id === this.selectedLineId); const level = this.levels.find(value => value.id === this.selectedLineId);
if (!level || level.cutoff_t == null) return; if (!level || level.cutoff_t == null) return;
const previousCutoff = level.cutoff_t;
level.cutoff_t = null; level.cutoff_t = null;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderLineBridges(); this.renderLineBridges();
this.hideContextMenu(); this.hideContextMenu();
if (this.onLineEnd) this.onLineEnd({ ...level }); if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff);
} }
// Tinted by the bar's own direction, muted so the candles stay the subject. // Tinted by the bar's own direction, muted so the candles stay the subject.

View file

@ -22,8 +22,11 @@
<section class="chart-shell"> <section class="chart-shell">
<div class="chart-head"> <div class="chart-head">
<div class="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></span></div> <div class="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></span></div>
<div class="chart-head-tools">
<button type="button" class="undo-btn" :disabled="!canUndo" :title="undoTitle" aria-label="Undo" @click="undo"><i class="fa-solid fa-rotate-left"></i></button>
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div> <div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div> </div>
</div>
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div> <div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
<div class="statusbar"> <div class="statusbar">
<span v-if="extraDetail">FEED <b>{{ status.stream }}</b></span> <span v-if="extraDetail">FEED <b>{{ status.stream }}</b></span>
@ -44,6 +47,7 @@
<summary>Layers</summary> <summary>Layers</summary>
<details class="layer-group ma-fold" open> <details class="layer-group ma-fold" open>
<summary> <summary>
<i class="fa-solid fa-chevron-down ma-fold-caret" aria-hidden="true"></i>
<label class="master" @click.stop><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label> <label class="master" @click.stop><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
</summary> </summary>
<div class="ma-rows"> <div class="ma-rows">

View file

@ -11,7 +11,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); } .chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
.quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); } .quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); }
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; } .chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.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; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; 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; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; 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; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } } .current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } }
@ -36,7 +36,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.color-popover { grid-template-columns:1fr; width:178px; gap:3px; } .color-popover { grid-template-columns:1fr; width:178px; gap:3px; }
.color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); } .color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); }
.level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; } .level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }
.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); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 4px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.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; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); } .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); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-fold-caret { width:10px; color:var(--muted); font-size:9px; transition:transform .15s; }.ma-fold[open] > summary .ma-fold-caret { transform:rotate(180deg); }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 22px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.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; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); }
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
/* Tool palette: the head arms the tool, the body configures what it creates. */ /* Tool palette: the head arms the tool, the body configures what it creates. */

View file

@ -162,3 +162,49 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path):
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json() cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
assert cleared["cutoff_t"] is None assert cleared["cutoff_t"] is None
def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
client = TestClient(app)
created = client.post("/api/lines", json={
"tf": "1m", "side": "support",
"anchor_t": 100, "anchor_p": 1.0,
"end_t": 200, "end_p": 2.0,
"note": "keep me",
}).json()
line_id, number = created["id"], created["number"]
assert client.delete(f"/api/lines/{line_id}").status_code == 204
restored = client.post("/api/lines/restore", json={
"id": line_id,
"tf": "1m",
"side": "support",
"anchor_t": 100,
"anchor_p": 1.0,
"slope": 0.01,
"last_t": 200,
"note": "keep me",
"number": number,
}).json()
assert restored["id"] == line_id
assert restored["number"] == number
assert client.post("/api/lines/restore", json={
"id": line_id,
"tf": "1m",
"side": "support",
"anchor_t": 100,
"anchor_p": 1.0,
"slope": 0.01,
"last_t": 200,
"number": number,
}).status_code == 409