Compare commits
No commits in common. "05e8aef40be7180f13e6258690a9a0ed1ed59973" and "0e2d5fa2cbd8fb417c34b1352771d611c4b3bd99" have entirely different histories.
05e8aef40b
...
0e2d5fa2cb
13 changed files with 24 additions and 211 deletions
|
|
@ -158,26 +158,13 @@ class AlertEngine:
|
||||||
for member in cluster.members
|
for member in cluster.members
|
||||||
if member.kind is LevelKind.MANUAL and member.armed
|
if member.kind is LevelKind.MANUAL and member.armed
|
||||||
]
|
]
|
||||||
early_drawn = [
|
|
||||||
member for member in drawn
|
|
||||||
if member.alert_early_points is not None
|
|
||||||
and (
|
|
||||||
-tolerance
|
|
||||||
<= (
|
|
||||||
member.anchor_p - current_price
|
|
||||||
if member.anchor_p >= current_price
|
|
||||||
else current_price - member.anchor_p
|
|
||||||
)
|
|
||||||
<= member.alert_early_points
|
|
||||||
)
|
|
||||||
]
|
|
||||||
# A drawn line bypasses the score threshold entirely. Weights run
|
# A drawn line bypasses the score threshold entirely. Weights run
|
||||||
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
|
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
|
||||||
# score would mean a line you deliberately drew could never alert.
|
# score would mean a line you deliberately drew could never alert.
|
||||||
# A disarmed one has already had its say and no longer qualifies.
|
# A disarmed one has already had its say and no longer qualifies.
|
||||||
if not drawn and cluster.score < self.min_score:
|
if not drawn and cluster.score < self.min_score:
|
||||||
continue
|
continue
|
||||||
if abs(cluster.center - current_price) > tolerance and not early_drawn:
|
if abs(cluster.center - current_price) > tolerance:
|
||||||
continue
|
continue
|
||||||
# Deliberately not matched on side. A level sitting at price flips
|
# Deliberately not matched on side. A level sitting at price flips
|
||||||
# between support and resistance every time price ticks across it,
|
# between support and resistance every time price ticks across it,
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,6 @@ class Level:
|
||||||
# runtime fills this in where the bar series is available; price_at() is the
|
# runtime fills this in where the bar series is available; price_at() is the
|
||||||
# fallback for levels that are already flat or have no series to measure.
|
# fallback for levels that are already flat or have no series to measure.
|
||||||
current_p: float | None = None
|
current_p: float | None = None
|
||||||
# Optional fixed distance for a hand-placed level. None keeps the global
|
|
||||||
# ATR-based trigger; a value alerts that many points before the level.
|
|
||||||
alert_early_points: float | None = None
|
|
||||||
|
|
||||||
def price_at(self, t: int) -> float:
|
def price_at(self, t: int) -> float:
|
||||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ class ManualLine:
|
||||||
y: float = 0.12
|
y: float = 0.12
|
||||||
collapsed: bool = False
|
collapsed: bool = False
|
||||||
icon: str = ""
|
icon: str = ""
|
||||||
alert_early_points: float | None = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def drawing_kind(self) -> str:
|
def drawing_kind(self) -> str:
|
||||||
|
|
@ -85,7 +84,6 @@ class ManualLine:
|
||||||
number=self.number,
|
number=self.number,
|
||||||
cutoff_t=self.cutoff_t,
|
cutoff_t=self.cutoff_t,
|
||||||
armed=self.armed,
|
armed=self.armed,
|
||||||
alert_early_points=self.alert_early_points,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
|
|
@ -118,10 +116,6 @@ class ManualLine:
|
||||||
y=float(value.get("y", 0.12)),
|
y=float(value.get("y", 0.12)),
|
||||||
collapsed=bool(value.get("collapsed", False)),
|
collapsed=bool(value.get("collapsed", False)),
|
||||||
icon=str(value.get("icon", "")),
|
icon=str(value.get("icon", "")),
|
||||||
alert_early_points=(
|
|
||||||
float(value["alert_early_points"])
|
|
||||||
if value.get("alert_early_points") is not None else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ class PriceAlertCreate(BaseModel):
|
||||||
tf: Timeframe = Timeframe.D1
|
tf: Timeframe = Timeframe.D1
|
||||||
color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
line_width: int = Field(2, ge=1, le=9)
|
line_width: int = Field(2, ge=1, le=9)
|
||||||
alert_early_points: float | None = Field(None, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class LinePatch(BaseModel):
|
class LinePatch(BaseModel):
|
||||||
|
|
@ -106,7 +105,6 @@ class LinePatch(BaseModel):
|
||||||
x: float | None = Field(None, ge=0.0, le=1.0)
|
x: float | None = Field(None, ge=0.0, le=1.0)
|
||||||
y: 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
|
collapsed: bool | None = None
|
||||||
alert_early_points: float | None = Field(None, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|
@ -206,7 +204,6 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
color=payload.color,
|
color=payload.color,
|
||||||
line_width=payload.line_width,
|
line_width=payload.line_width,
|
||||||
alert_early_points=payload.alert_early_points,
|
|
||||||
)
|
)
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
runtime.rebuild_levels()
|
runtime.rebuild_levels()
|
||||||
|
|
@ -336,9 +333,6 @@ def drawings(request: Request):
|
||||||
@router.patch("/lines/{line_id}")
|
@router.patch("/lines/{line_id}")
|
||||||
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
||||||
changes = payload.model_dump(exclude_none=True)
|
changes = payload.model_dump(exclude_none=True)
|
||||||
# None deliberately clears a per-level override and restores ATR behavior.
|
|
||||||
if "alert_early_points" in payload.model_fields_set:
|
|
||||||
changes["alert_early_points"] = payload.alert_early_points
|
|
||||||
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
|
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
|
||||||
raise HTTPException(400, "Line endpoints must have different times")
|
raise HTTPException(400, "Line endpoints must have different times")
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -770,32 +770,10 @@ requires exact timestamp equality, so a legitimate new bar makes the assertion
|
||||||
compare two different instants. It needs one stable snapshot or a tolerance
|
compare two different instants. It needs one stable snapshot or a tolerance
|
||||||
that still catches the original ten-hour regression before it is sound.
|
that still catches the original ten-hour regression before it is sound.
|
||||||
|
|
||||||
The drawing rename case was also quarantined after timing out while waiting for
|
|
||||||
the shared live levels broadcast to replace its optimistic line. Its keyboard
|
|
||||||
assertions had not started yet; the failure was the same mutable-stack ordering
|
|
||||||
dependency seen in the other drawing cases, not evidence about rename behavior.
|
|
||||||
|
|
||||||
These are explicit `node:test` skips with reasons, not deleted coverage. Re-enable
|
These are explicit `node:test` skips with reasons, not deleted coverage. Re-enable
|
||||||
each case only after its stated external dependency is removed and repeated full
|
each case only after its stated external dependency is removed and repeated full
|
||||||
suite runs remain green.
|
suite runs remain green.
|
||||||
|
|
||||||
### 2026-08-13 — price-level geometry and alert timing separated
|
|
||||||
|
|
||||||
Typed price levels had selection and style controls but no geometry editor.
|
|
||||||
They now expose one chart handle: dragging with Snap enabled uses the same bar
|
|
||||||
high/low rule as trendline anchors, while Arrow Up/Down moves the selected level
|
|
||||||
one ES tick beyond that snapped price. The Drawings row also accepts an exact
|
|
||||||
price, which covers adjustments that should not depend on the visible bars.
|
|
||||||
|
|
||||||
Moving the line to get an earlier notification was deliberately not made the
|
|
||||||
alert control. Alert proximity was already `0.5 * ATR14(15m)`, globally and
|
|
||||||
symmetrically, so shifting a line would make a true chart level lie about its
|
|
||||||
price. A price level can instead persist `alert_early_points`: above-market
|
|
||||||
levels qualify that many points before price rises into them, below-market
|
|
||||||
levels before price falls into them. Blank retains the ATR default. Backend
|
|
||||||
tests pin both approach directions and persistence; the browser test pins list
|
|
||||||
editing, snapped handle movement and a one-tick keyboard nudge.
|
|
||||||
|
|
||||||
### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed
|
### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed
|
||||||
|
|
||||||
Trendlines project into the whitespace right of the last candle, but the axis
|
Trendlines project into the whitespace right of the last candle, but the axis
|
||||||
|
|
|
||||||
|
|
@ -742,10 +742,7 @@ ARMED ──price within alertTol of cluster──► FIRED ──► COOLDOWN
|
||||||
└────── price moves > 2*alertTol away AND cooldown elapsed ◄────┘
|
└────── price moves > 2*alertTol away AND cooldown elapsed ◄────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `alertTol = 0.5 * ATR14(15m)` by default. A hand-placed price level may set
|
- `alertTol = 0.5 * ATR14(15m)`
|
||||||
`alert_early_points`; resistance then qualifies that many points below the
|
|
||||||
level and support that many points above it. This changes notification timing,
|
|
||||||
not the level's chart geometry.
|
|
||||||
- `cooldown = 15 minutes`
|
- `cooldown = 15 minutes`
|
||||||
- Minimum score threshold to fire: **configurable, starting value 6 — but this
|
- Minimum score threshold to fire: **configurable, starting value 6 — but this
|
||||||
certainly needs recalibrating in M4.** With the daily MA set as the primary levels,
|
certainly needs recalibrating in M4.** With the daily MA set as the primary levels,
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,6 @@ createApp({
|
||||||
const alertNote = ref('');
|
const alertNote = ref('');
|
||||||
const levelColor = ref('#e0a34a');
|
const levelColor = ref('#e0a34a');
|
||||||
const levelWidth = ref(2);
|
const levelWidth = ref(2);
|
||||||
const alertEarlyPoints = ref(null);
|
|
||||||
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
||||||
const drawingColors = [
|
const drawingColors = [
|
||||||
'#65b7cf', '#27825c', '#45c39b', '#a8c85d',
|
'#65b7cf', '#27825c', '#45c39b', '#a8c85d',
|
||||||
|
|
@ -584,7 +583,6 @@ createApp({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
price: alertPrice.value, note: alertNote.value,
|
price: alertPrice.value, note: alertNote.value,
|
||||||
color: levelColor.value, line_width: levelWidth.value,
|
color: levelColor.value, line_width: levelWidth.value,
|
||||||
alert_early_points: alertEarlyPoints.value,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
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; }
|
||||||
|
|
@ -592,7 +590,6 @@ createApp({
|
||||||
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
|
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
|
||||||
alertPrice.value = null;
|
alertPrice.value = null;
|
||||||
alertNote.value = '';
|
alertNote.value = '';
|
||||||
alertEarlyPoints.value = null;
|
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -725,13 +722,6 @@ createApp({
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateLevelNumber(line, field, raw) {
|
|
||||||
const value = raw === '' ? null : Number(raw);
|
|
||||||
if (field === 'anchor_p' && !(value > 0)) return;
|
|
||||||
if (field === 'alert_early_points' && value != null && value < 0) return;
|
|
||||||
await updateLineStyle(line, { [field]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateLineGeometry(line) {
|
async function updateLineGeometry(line) {
|
||||||
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' },
|
||||||
|
|
@ -790,13 +780,6 @@ createApp({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isEditing(event.target)) return;
|
if (isEditing(event.target)) return;
|
||||||
if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && chartApi) {
|
|
||||||
const direction = event.key === 'ArrowUp' ? 1 : -1;
|
|
||||||
if (chartApi.nudgeSelectedFlat(direction)) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) {
|
if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
deleteSelected();
|
deleteSelected();
|
||||||
|
|
@ -886,6 +869,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
|
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
|
||||||
},
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|
|
||||||
|
|
@ -173,9 +173,9 @@ class ConfluenceChart {
|
||||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||||
});
|
});
|
||||||
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
||||||
upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true,
|
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
||||||
borderUpColor: '#087f76', borderDownColor: '#b9342d',
|
borderUpColor: '#1d6849', borderDownColor: '#963737',
|
||||||
wickUpColor: '#087f76', wickDownColor: '#b9342d',
|
wickUpColor: '#1d6849', wickDownColor: '#963737',
|
||||||
});
|
});
|
||||||
// Volume as an overlay on its own hidden scale, confined to the bottom
|
// Volume as an overlay on its own hidden scale, confined to the bottom
|
||||||
// fifth. An overlay rather than a pane so it cannot alter the price scale:
|
// fifth. An overlay rather than a pane so it cannot alter the price scale:
|
||||||
|
|
@ -1507,29 +1507,13 @@ class ConfluenceChart {
|
||||||
|
|
||||||
renderAnchorHandles() {
|
renderAnchorHandles() {
|
||||||
const level = this.levels.find(
|
const level = this.levels.find(
|
||||||
value => value.id === this.selectedLineId && value.kind === 'manual',
|
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
|
||||||
);
|
);
|
||||||
if (!level || !this.anchorHandles.length) {
|
if (!level || !this.anchorHandles.length) {
|
||||||
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
||||||
this.lineHitTarget?.setAttribute('hidden', '');
|
this.lineHitTarget?.setAttribute('hidden', '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ConfluenceChart.isFlat(level)) {
|
|
||||||
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
||||||
const y = this.candles.priceToCoordinate(level.anchor_p);
|
|
||||||
const handle = this.anchorHandles[0];
|
|
||||||
this.lineHitTarget.setAttribute('hidden', '');
|
|
||||||
this.anchorHandles[1].setAttribute('hidden', '');
|
|
||||||
if (!plot || y == null) {
|
|
||||||
handle.setAttribute('hidden', '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handle.removeAttribute('hidden');
|
|
||||||
handle.setAttribute('cx', Math.max(20, plot.width - 24));
|
|
||||||
handle.setAttribute('cy', y);
|
|
||||||
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const points = [
|
const points = [
|
||||||
{ time: level.anchor_t, value: level.anchor_p },
|
{ time: level.anchor_t, value: level.anchor_p },
|
||||||
{
|
{
|
||||||
|
|
@ -1623,11 +1607,7 @@ class ConfluenceChart {
|
||||||
startAnchorDrag(event, anchor) {
|
startAnchorDrag(event, anchor) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const level = this.levels.find(value => value.id === this.selectedLineId);
|
this.draggingAnchor = { id: this.selectedLineId, anchor };
|
||||||
this.draggingAnchor = {
|
|
||||||
id: this.selectedLineId,
|
|
||||||
anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor,
|
|
||||||
};
|
|
||||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1640,19 +1620,11 @@ class ConfluenceChart {
|
||||||
this.hideSnapDot();
|
this.hideSnapDot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapped = this.snapToBars
|
const snapped = this.snapPoint(point);
|
||||||
? this.snapPoint(point)
|
|
||||||
: { ...point, p: ConfluenceChart.snapToTick(point.p) };
|
|
||||||
if (snapped.t == null || snapped.p == null) return;
|
if (snapped.t == null || snapped.p == null) return;
|
||||||
const time = snapped.t;
|
const time = snapped.t;
|
||||||
const price = snapped.p;
|
const price = snapped.p;
|
||||||
if (this.snapToBars) this.showSnapDot(snapped, point);
|
if (this.snapToBars) this.showSnapDot(snapped, point);
|
||||||
if (this.draggingAnchor.anchor === 'price') {
|
|
||||||
level.anchor_p = price;
|
|
||||||
this.priceLines.get(level.id)?.line.applyOptions({ price });
|
|
||||||
this.renderAnchorHandles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
||||||
if (this.draggingAnchor.anchor === 'start') {
|
if (this.draggingAnchor.anchor === 'start') {
|
||||||
if (time >= level.last_t) return;
|
if (time >= level.last_t) return;
|
||||||
|
|
@ -1680,17 +1652,6 @@ class ConfluenceChart {
|
||||||
if (level && this.onLineChange) this.onLineChange({ ...level });
|
if (level && this.onLineChange) this.onLineChange({ ...level });
|
||||||
}
|
}
|
||||||
|
|
||||||
nudgeSelectedFlat(direction) {
|
|
||||||
const level = this.levels.find(value =>
|
|
||||||
value.id === this.selectedLineId && value.kind === 'manual' && ConfluenceChart.isFlat(value));
|
|
||||||
if (!level) return false;
|
|
||||||
level.anchor_p = ConfluenceChart.snapToTick(level.anchor_p + direction * ConfluenceChart.TICK);
|
|
||||||
this.priceLines.get(level.id)?.line.applyOptions({ price: level.anchor_p });
|
|
||||||
this.renderAnchorHandles();
|
|
||||||
if (this.onLineChange) this.onLineChange({ ...level });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
showContextMenu(event) {
|
showContextMenu(event) {
|
||||||
const level = this.levels.find(
|
const level = this.levels.find(
|
||||||
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
|
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
|
||||||
|
|
@ -1739,7 +1700,7 @@ class ConfluenceChart {
|
||||||
return {
|
return {
|
||||||
time: bar.t,
|
time: bar.t,
|
||||||
value: bar.v,
|
value: bar.v,
|
||||||
color: bar.c >= bar.o ? 'rgba(32,184,166,.38)' : 'rgba(239,91,63,.38)',
|
color: bar.c >= bar.o ? 'rgba(39,130,92,.38)' : 'rgba(189,69,69,.38)',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,6 @@
|
||||||
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
||||||
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
||||||
</div>
|
</div>
|
||||||
<label>Alert early (pts)<input type="number" min="0" step="0.25" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
|
|
||||||
<form class="row price-row" @submit.prevent="addPriceAlert">
|
<form class="row price-row" @submit.prevent="addPriceAlert">
|
||||||
<label>Price<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
|
<label>Price<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
|
||||||
<button type="submit" :disabled="!alertPrice">Add</button>
|
<button type="submit" :disabled="!alertPrice">Add</button>
|
||||||
|
|
@ -198,16 +197,7 @@
|
||||||
<div class="drawing-secondary">
|
<div class="drawing-secondary">
|
||||||
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
|
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
|
||||||
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
|
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
|
||||||
<span v-else-if="item.kind === 'level'" class="level-editors">
|
<span v-else-if="item.kind === 'level'">#{{ item.number }} · L {{ item.line.anchor_p.toFixed(2) }}</span>
|
||||||
<label>Price<input type="number" min="0.25" step="0.25" :value="item.line.anchor_p"
|
|
||||||
aria-label="Level price in drawing list"
|
|
||||||
@keydown.enter="$event.target.blur()"
|
|
||||||
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
|
|
||||||
<label>Early<input type="number" min="0" step="0.25" :value="item.line.alert_early_points ?? ''"
|
|
||||||
placeholder="ATR" aria-label="Level alert early points"
|
|
||||||
@keydown.enter="$event.target.blur()"
|
|
||||||
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
|
|
||||||
</span>
|
|
||||||
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
|
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
|
||||||
|
|
||||||
<div class="drawing-controls" @click.stop v-if="item.line">
|
<div class="drawing-controls" @click.stop v-if="item.line">
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
||||||
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
|
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
.drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); }
|
.drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); }
|
||||||
.drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:91px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover>.palette-close { grid-column:4; background:var(--chart-bg); color:var(--muted); }.color-popover>.palette-close:hover { color:var(--fg); }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
|
.drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:91px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover>.palette-close { grid-column:4; background:var(--chart-bg); color:var(--muted); }.color-popover>.palette-close:hover { color:var(--fg); }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
|
||||||
.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); }.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; }
|
.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; }
|
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ import {
|
||||||
} from './helpers.mjs';
|
} from './helpers.mjs';
|
||||||
|
|
||||||
test('editing a drawing name with Backspace or Delete cannot delete the drawing',
|
test('editing a drawing name with Backspace or Delete cannot delete the drawing',
|
||||||
{
|
{ timeout: 180000 }, async () => {
|
||||||
timeout: 180000,
|
|
||||||
skip: 'quarantined: optimistic creation races the shared live levels broadcast',
|
|
||||||
}, async () => {
|
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const label = `e2e rename ${Date.now()}`;
|
const label = `e2e rename ${Date.now()}`;
|
||||||
const box = await chartBox(page);
|
const box = await chartBox(page);
|
||||||
|
|
@ -97,8 +94,7 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a price level can be edited from the list and adjusted on the chart',
|
test('a price level selects and focuses in both directions', { timeout: 180000 }, async () => {
|
||||||
{ timeout: 180000 }, async () => {
|
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const label = `e2e level focus ${Date.now()}`;
|
const label = `e2e level focus ${Date.now()}`;
|
||||||
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
|
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
|
||||||
|
|
@ -114,17 +110,6 @@ test('a price level can be edited from the list and adjusted on the chart',
|
||||||
const id = await page.evaluate(name =>
|
const id = await page.evaluate(name =>
|
||||||
window.__chart.levels.find(level => level.label === name)?.id || null, label);
|
window.__chart.levels.find(level => level.label === name)?.id || null, label);
|
||||||
assert.ok(id, 'the price level was not created');
|
assert.ok(id, 'the price level was not created');
|
||||||
const priceInput = row.locator('input[aria-label="Level price in drawing list"]');
|
|
||||||
const earlyInput = row.locator('input[aria-label="Level alert early points"]');
|
|
||||||
await priceInput.fill(String(price + 1));
|
|
||||||
await priceInput.press('Enter');
|
|
||||||
await page.waitForFunction(([levelId, expectedPrice]) =>
|
|
||||||
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expectedPrice,
|
|
||||||
[id, price + 1]);
|
|
||||||
await earlyInput.fill('1.5');
|
|
||||||
await earlyInput.press('Enter');
|
|
||||||
await page.waitForFunction(levelId =>
|
|
||||||
window.__chart.levels.find(level => level.id === levelId)?.alert_early_points === 1.5, id);
|
|
||||||
const number = await page.evaluate(levelId =>
|
const number = await page.evaluate(levelId =>
|
||||||
window.__chart.levels.find(level => level.id === levelId).number, id);
|
window.__chart.levels.find(level => level.id === levelId).number, id);
|
||||||
assert.equal(await page.evaluate(levelId =>
|
assert.equal(await page.evaluate(levelId =>
|
||||||
|
|
@ -143,34 +128,17 @@ test('a price level can be edited from the list and adjusted on the chart',
|
||||||
assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'),
|
assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'),
|
||||||
'the price-level highlight was not horizontal');
|
'the price-level highlight was not horizontal');
|
||||||
|
|
||||||
const target = await page.evaluate(() => {
|
await page.keyboard.press('Escape');
|
||||||
|
const point = await page.evaluate(levelId => {
|
||||||
const chart = window.__chart;
|
const chart = window.__chart;
|
||||||
const bar = chart.bars[chart.bars.length - 30];
|
const level = chart.levels.find(value => value.id === levelId);
|
||||||
const plot = chart.plotCanvas().getBoundingClientRect();
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
return {
|
return { x: plot.left + plot.width / 2, y: plot.top + chart.candles.priceToCoordinate(level.anchor_p) };
|
||||||
price: bar.h,
|
}, id);
|
||||||
x: plot.left + chart.chart.timeScale().timeToCoordinate(bar.t),
|
await page.mouse.click(point.x, point.y);
|
||||||
y: plot.top + chart.candles.priceToCoordinate(bar.h),
|
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
|
||||||
};
|
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
|
||||||
});
|
'clicking the chart price level did not select its drawing row');
|
||||||
const handle = page.locator('.chart-anchor[data-anchor="start"]:not([hidden])');
|
|
||||||
const handleBox = await handle.boundingBox();
|
|
||||||
assert.ok(handleBox, 'a selected price level has no grab handle');
|
|
||||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(target.x, target.y, { steps: 8 });
|
|
||||||
await page.mouse.up();
|
|
||||||
await page.waitForFunction(([levelId, expected]) =>
|
|
||||||
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected,
|
|
||||||
[id, target.price]);
|
|
||||||
|
|
||||||
await page.keyboard.press('ArrowUp');
|
|
||||||
await page.waitForFunction(([levelId, expected]) =>
|
|
||||||
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected,
|
|
||||||
[id, target.price + 0.25]);
|
|
||||||
assert.equal(Number(await priceInput.inputValue()), target.price + 0.25,
|
|
||||||
'the drawing-list price did not follow the chart nudge');
|
|
||||||
|
|
||||||
assertNoPageErrors(page, assert);
|
assertNoPageErrors(page, assert);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,6 @@ def test_json_persistence_round_trip(tmp_path):
|
||||||
assert ManualLineStore(path).lines == {}
|
assert ManualLineStore(path).lines == {}
|
||||||
|
|
||||||
|
|
||||||
def test_price_level_alert_offset_survives_json_round_trip(tmp_path):
|
|
||||||
path = tmp_path / "manual_lines.json"
|
|
||||||
line = sample_line()
|
|
||||||
line.slope = 0.0
|
|
||||||
line.alert_early_points = 1.25
|
|
||||||
ManualLineStore(path).add(line)
|
|
||||||
|
|
||||||
loaded = ManualLineStore(path).lines["ml_test"]
|
|
||||||
assert loaded.alert_early_points == 1.25
|
|
||||||
assert loaded.to_level().alert_early_points == 1.25
|
|
||||||
|
|
||||||
|
|
||||||
def test_hourly_line_uses_absolute_time_on_one_minute_chart():
|
def test_hourly_line_uses_absolute_time_on_one_minute_chart():
|
||||||
level = sample_line().to_level()
|
level = sample_line().to_level()
|
||||||
instant = 160
|
instant = 160
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ from app.analysis.manual_lines import ManualLine
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
||||||
def price_alert(price: float, note: str = "", **changes) -> ManualLine:
|
def price_alert(price: float, note: str = "") -> ManualLine:
|
||||||
return ManualLine(
|
return ManualLine(
|
||||||
"ml_price", Timeframe.D1, Side.RESISTANCE, 1000, price, 0.0, 4600, 1000,
|
"ml_price", Timeframe.D1, Side.RESISTANCE, 1000, price, 0.0, 4600, 1000, note=note
|
||||||
note=note, **changes,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,28 +33,6 @@ def test_typed_level_alerts_regardless_of_confluence_score():
|
||||||
assert "gap fill" in alerts[0].message
|
assert "gap fill" in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
def test_resistance_level_can_alert_a_fixed_distance_early():
|
|
||||||
engine = AlertEngine(min_score=28)
|
|
||||||
level = price_alert(100, alert_early_points=2).to_level()
|
|
||||||
|
|
||||||
too_early = cluster_levels([level], 5000, 97.75, 1)
|
|
||||||
in_window = cluster_levels([level], 5000, 98, 1)
|
|
||||||
assert engine.evaluate(too_early, 97.75, 1, 5000, "/ES") == []
|
|
||||||
assert len(engine.evaluate(in_window, 98, 1, 5001, "/ES")) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_support_level_can_alert_a_fixed_distance_early():
|
|
||||||
engine = AlertEngine(min_score=28)
|
|
||||||
line = price_alert(100, alert_early_points=2)
|
|
||||||
line.side = Side.SUPPORT
|
|
||||||
level = line.to_level()
|
|
||||||
|
|
||||||
too_early = cluster_levels([level], 5000, 102.25, 1)
|
|
||||||
in_window = cluster_levels([level], 5000, 102, 1)
|
|
||||||
assert engine.evaluate(too_early, 102.25, 1, 5000, "/ES") == []
|
|
||||||
assert len(engine.evaluate(in_window, 102, 1, 5001, "/ES")) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_price_alerts_survive_the_json_round_trip(tmp_path):
|
def test_price_alerts_survive_the_json_round_trip(tmp_path):
|
||||||
from app.analysis.manual_lines import ManualLineStore
|
from app.analysis.manual_lines import ManualLineStore
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue