end trendline here possible
This commit is contained in:
parent
512e94237a
commit
9777188a43
8 changed files with 103 additions and 6 deletions
|
|
@ -30,7 +30,11 @@ def cluster_levels(
|
|||
if tolerance <= 0:
|
||||
return []
|
||||
groups: list[list[tuple[float, Level]]] = []
|
||||
positioned = [(level.price_at(current_t), level) for level in levels if not level.hidden]
|
||||
positioned = [
|
||||
(level.price_at(current_t), level)
|
||||
for level in levels
|
||||
if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
|
||||
]
|
||||
for positional_side in (Side.SUPPORT, Side.RESISTANCE):
|
||||
side_levels = sorted(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class Level:
|
|||
color: str | None = None
|
||||
line_width: int | None = None
|
||||
number: int | None = None
|
||||
cutoff_t: int | None = None
|
||||
|
||||
def price_at(self, t: int) -> float:
|
||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ManualLine:
|
|||
color: str = "#65b7cf"
|
||||
line_width: int = 2
|
||||
number: int = 0
|
||||
cutoff_t: int | None = None
|
||||
|
||||
def to_level(self) -> Level:
|
||||
return Level(
|
||||
|
|
@ -45,6 +46,7 @@ class ManualLine:
|
|||
color=self.color,
|
||||
line_width=self.line_width,
|
||||
number=self.number,
|
||||
cutoff_t=self.cutoff_t,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
|
|
@ -69,6 +71,7 @@ class ManualLine:
|
|||
color=str(value.get("color", "#65b7cf")),
|
||||
line_width=int(value.get("line_width", 2)),
|
||||
number=int(value.get("number", 0)),
|
||||
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class LinePatch(BaseModel):
|
|||
anchor_p: float | None = None
|
||||
slope: float | None = None
|
||||
last_t: int | None = None
|
||||
cutoff_t: int | None = None
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
|
|
|
|||
|
|
@ -223,6 +223,17 @@ createApp({
|
|||
syncVisibleLevels();
|
||||
}
|
||||
|
||||
async function endLineHere(line) {
|
||||
const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cutoff_t: line.cutoff_t }),
|
||||
});
|
||||
if (!response.ok) { console.error(`Unable to end line: HTTP ${response.status}`); return; }
|
||||
const saved = await response.json();
|
||||
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||
syncVisibleLevels();
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) {
|
||||
event.preventDefault();
|
||||
|
|
@ -279,6 +290,7 @@ createApp({
|
|||
chartApi.setClickHandler(handleChartClick);
|
||||
chartApi.setMoveHandler(handleChartMove);
|
||||
chartApi.setLineChangeHandler(updateLineGeometry);
|
||||
chartApi.setLineEndHandler(endLineHere);
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
refreshStatus();
|
||||
connect();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ class ConfluenceChart {
|
|||
this.onLineChange = null;
|
||||
this.anchorMoveListener = null;
|
||||
this.anchorUpListener = null;
|
||||
this.contextMenu = null;
|
||||
this.contextCutoff = null;
|
||||
this.contextListener = null;
|
||||
this.onLineEnd = null;
|
||||
}
|
||||
|
||||
create(el) {
|
||||
|
|
@ -70,11 +74,27 @@ class ConfluenceChart {
|
|||
this.anchorHandles.push(handle);
|
||||
}
|
||||
el.appendChild(handles);
|
||||
this.contextMenu = document.createElement('div');
|
||||
this.contextMenu.className = 'chart-context-menu';
|
||||
this.contextMenu.hidden = true;
|
||||
const endHere = document.createElement('button');
|
||||
endHere.type = 'button';
|
||||
endHere.textContent = 'End trendline here';
|
||||
endHere.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
this.endSelectedLineHere();
|
||||
});
|
||||
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
||||
this.contextMenu.appendChild(endHere);
|
||||
el.appendChild(this.contextMenu);
|
||||
this.anchorMoveListener = event => this.moveAnchor(event);
|
||||
this.anchorUpListener = event => this.finishAnchorDrag(event);
|
||||
window.addEventListener('pointermove', this.anchorMoveListener);
|
||||
window.addEventListener('pointerup', this.anchorUpListener);
|
||||
this.contextListener = event => this.showContextMenu(event);
|
||||
el.addEventListener('contextmenu', this.contextListener);
|
||||
this.clickListener = event => {
|
||||
this.hideContextMenu();
|
||||
if (!this.onChartClick) return;
|
||||
const bounds = el.getBoundingClientRect();
|
||||
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
|
||||
|
|
@ -129,7 +149,7 @@ class ConfluenceChart {
|
|||
lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple,
|
||||
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
lastValueVisible: level.cutoff_t == null,
|
||||
title: level.label,
|
||||
// Overlays outside the visible price range must not flatten the candles.
|
||||
autoscaleInfoProvider: () => null,
|
||||
|
|
@ -143,6 +163,11 @@ class ConfluenceChart {
|
|||
let data;
|
||||
if (isMa) {
|
||||
data = (level.points || []).map(([time, value]) => ({ time, value }));
|
||||
const latestTime = this.bars[this.bars.length - 1]?.t;
|
||||
const latestValue = data[data.length - 1]?.value;
|
||||
if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) {
|
||||
data.push({ time: latestTime, value: latestValue });
|
||||
}
|
||||
} else {
|
||||
data = this.lineData(level);
|
||||
}
|
||||
|
|
@ -157,8 +182,11 @@ class ConfluenceChart {
|
|||
|
||||
setLineChangeHandler(handler) { this.onLineChange = handler; }
|
||||
|
||||
setLineEndHandler(handler) { this.onLineEnd = handler; }
|
||||
|
||||
setSelectedLine(id) {
|
||||
this.selectedLineId = id;
|
||||
this.hideContextMenu();
|
||||
this.renderAnchorHandles();
|
||||
}
|
||||
|
||||
|
|
@ -236,11 +264,14 @@ class ConfluenceChart {
|
|||
];
|
||||
const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t);
|
||||
const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t);
|
||||
const lastIndex = this.bars.length - 1;
|
||||
if (anchorIndex >= 0 && secondIndex > anchorIndex && lastIndex > secondIndex) {
|
||||
const cutoffIndex = level.cutoff_t == null
|
||||
? -1
|
||||
: this.bars.findIndex(bar => bar.t >= level.cutoff_t);
|
||||
const targetIndex = cutoffIndex >= 0 ? cutoffIndex : this.bars.length - 1;
|
||||
if (anchorIndex >= 0 && secondIndex > anchorIndex && targetIndex > secondIndex) {
|
||||
const value = level.anchor_p
|
||||
+ (secondPrice - level.anchor_p) * (lastIndex - anchorIndex) / (secondIndex - anchorIndex);
|
||||
points.push({ time: this.bars[lastIndex].t, value });
|
||||
+ (secondPrice - level.anchor_p) * (targetIndex - anchorIndex) / (secondIndex - anchorIndex);
|
||||
points.push({ time: this.bars[targetIndex].t, value });
|
||||
}
|
||||
return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time);
|
||||
}
|
||||
|
|
@ -294,6 +325,7 @@ class ConfluenceChart {
|
|||
level.slope = (secondPrice - price) / (level.last_t - time);
|
||||
} else {
|
||||
if (time <= level.anchor_t) return;
|
||||
if (level.cutoff_t != null && time >= level.cutoff_t) return;
|
||||
level.last_t = time;
|
||||
level.slope = (price - level.anchor_p) / (time - level.anchor_t);
|
||||
}
|
||||
|
|
@ -310,6 +342,42 @@ class ConfluenceChart {
|
|||
if (level && this.onLineChange) this.onLineChange({ ...level });
|
||||
}
|
||||
|
||||
showContextMenu(event) {
|
||||
const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual');
|
||||
if (!level || !this.bars.length) return;
|
||||
event.preventDefault();
|
||||
const bounds = this.chartEl.getBoundingClientRect();
|
||||
const x = event.clientX - bounds.left;
|
||||
const rawTime = this.chart.timeScale().coordinateToTime(x);
|
||||
if (rawTime == null) return;
|
||||
const cutoff = this.bars.reduce((nearest, bar) =>
|
||||
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest
|
||||
).t;
|
||||
if (cutoff <= level.last_t) {
|
||||
this.hideContextMenu();
|
||||
return;
|
||||
}
|
||||
this.contextCutoff = cutoff;
|
||||
this.contextMenu.hidden = false;
|
||||
this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`;
|
||||
this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`;
|
||||
}
|
||||
|
||||
hideContextMenu() {
|
||||
if (this.contextMenu) this.contextMenu.hidden = true;
|
||||
this.contextCutoff = null;
|
||||
}
|
||||
|
||||
endSelectedLineHere() {
|
||||
const level = this.levels.find(value => value.id === this.selectedLineId);
|
||||
if (!level || this.contextCutoff == null) return;
|
||||
level.cutoff_t = this.contextCutoff;
|
||||
const entry = this.levelSeries.get(level.id);
|
||||
if (entry) entry.series.setData(this.lineData(level));
|
||||
this.hideContextMenu();
|
||||
if (this.onLineEnd) this.onLineEnd({ ...level });
|
||||
}
|
||||
|
||||
toCandle(bar) {
|
||||
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
||||
}
|
||||
|
|
@ -317,6 +385,7 @@ class ConfluenceChart {
|
|||
destroy() {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
|
||||
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
|
||||
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
||||
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
|
||||
if (this.chart) this.chart.remove();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
|
|||
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
||||
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
||||
.chart-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; }
|
||||
|
|
|
|||
|
|
@ -31,3 +31,9 @@ def test_lone_daily_level_is_emitted():
|
|||
def test_levels_on_opposite_sides_of_price_do_not_cluster():
|
||||
clusters = cluster_levels([level("below", 99.9, 2), level("above", 100.1, 2)], 200, 100, 1)
|
||||
assert clusters == []
|
||||
|
||||
|
||||
def test_level_ended_before_current_time_is_excluded():
|
||||
ended = level("ended", 98, 12, Timeframe.D1)
|
||||
ended.cutoff_t = 150
|
||||
assert cluster_levels([ended], 200, 100, 1) == []
|
||||
|
|
|
|||
Loading…
Reference in a new issue