Price trendlines across bars, add one-shot alerts, collapse layers, drop 1h MAs
The trendline bug: a line continued past its second anchor at a different slope. Two conventions were fighting, and both were wrong. Lightweight Charts spaces bars evenly however much time separates them — a weekend is forty-nine hours and one bar wide. The renderer extended the line by interpolating between bar indices, which looked straight but disagreed with the server, since price_at() advances per second. Measured on real bars that reached 147 points: the chart drew a level the alerts did not believe in. Making the renderer match price_at() fixed the disagreement and made the visible kick worse, because now the line really did climb an hour's worth of slope across a one-bar maintenance break. Neither convention is what a person means by drawing a line. A trendline advances per bar, so both sides now evaluate in bar space: a new bar_space module the runtime uses to position sloped levels, mirrored by indexAt() in the chart. The line is straight on screen and the alert fires where it is drawn. Also, from testing against the live chart: - A plain click with the trendline tool armed did nothing and left the tool armed, so the next click began a new line — which is how the slope change was first noticed. Click-click and press-drag-release are both supported now, with the rubber band following the cursor between clicks. - Hand-placed levels are armed, fire once, then disarm themselves, and can be re-armed from the sidebar. Verified end to end: created armed, tripped within thirty seconds, disarmed, re-armed. - Layers is collapsible. - The 1h moving averages are gone; only the daily set remains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d5edb12171
commit
2efcec6a76
14 changed files with 258 additions and 53 deletions
|
|
@ -11,7 +11,6 @@ TIMEFRAMES=1m,5m,15m,30m,1h,1d
|
|||
BASE_TIMEFRAMES=1m,30m,1d
|
||||
MAX_BARS_PER_TF=5000
|
||||
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
|
||||
MA_SETS__1H=
|
||||
DAILY_ANCHOR_ET=18:00
|
||||
MANUAL_LINES_PATH=./data/manual_lines.json
|
||||
CONFLUENCE_MIN_SCORE=28
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ from app.analysis.levels import LevelKind
|
|||
class Alert:
|
||||
cluster: Cluster
|
||||
message: str
|
||||
# Hand-placed levels that caused this alert; the caller disarms them so a
|
||||
# one-shot alert stays one-shot.
|
||||
tripped: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -61,10 +64,15 @@ class AlertEngine:
|
|||
# Strongest first, so when several overlapping zones qualify at once the
|
||||
# one that survives suppression is the most significant.
|
||||
for cluster in sorted(clusters, key=lambda item: item.score, reverse=True):
|
||||
drawn = [member for member in cluster.members if member.kind is LevelKind.MANUAL]
|
||||
drawn = [
|
||||
member
|
||||
for member in cluster.members
|
||||
if member.kind is LevelKind.MANUAL and member.armed
|
||||
]
|
||||
# A drawn line bypasses the score threshold entirely. Weights run
|
||||
# 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.
|
||||
# A disarmed one has already had its say and no longer qualifies.
|
||||
if not drawn and cluster.score < self.min_score:
|
||||
continue
|
||||
if abs(cluster.center - current_price) > tolerance:
|
||||
|
|
@ -93,5 +101,5 @@ class AlertEngine:
|
|||
message = (
|
||||
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
|
||||
)
|
||||
alerts.append(Alert(cluster, message))
|
||||
alerts.append(Alert(cluster, message, tuple(member.id for member in drawn)))
|
||||
return alerts
|
||||
|
|
|
|||
41
app/analysis/bar_space.py
Normal file
41
app/analysis/bar_space.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Positions within a bar series, rather than on a clock.
|
||||
|
||||
A chart spaces bars evenly no matter how much time separates them: a weekend is
|
||||
forty-nine hours but one bar wide. So a line that looks straight is straight in
|
||||
*index* space, and a trendline advances per bar, not per second.
|
||||
|
||||
Evaluating trendlines any other way makes the drawn line and the alerted price
|
||||
disagree — measured at 147 points across a weekend on a real /ES chart.
|
||||
"""
|
||||
from bisect import bisect_right
|
||||
|
||||
|
||||
def index_at(times: list[int], t: int) -> float:
|
||||
"""Fractional index of a timestamp within an ascending bar-time series."""
|
||||
if not times:
|
||||
return 0.0
|
||||
if len(times) == 1:
|
||||
return 0.0
|
||||
# Outside the series there are no bars to measure against, so fall back to
|
||||
# the spacing at the nearest edge.
|
||||
if t <= times[0]:
|
||||
step = times[1] - times[0]
|
||||
return (t - times[0]) / step if step else 0.0
|
||||
if t >= times[-1]:
|
||||
step = times[-1] - times[-2]
|
||||
return (len(times) - 1) + ((t - times[-1]) / step if step else 0.0)
|
||||
|
||||
lower = bisect_right(times, t) - 1
|
||||
span = times[lower + 1] - times[lower]
|
||||
return lower + ((t - times[lower]) / span if span else 0.0)
|
||||
|
||||
|
||||
def price_in_bar_space(level, times: list[int], t: int) -> float:
|
||||
"""A level's price at `t`, interpolated across bars rather than seconds."""
|
||||
start_index = index_at(times, level.anchor_t)
|
||||
end_index = index_at(times, level.last_t)
|
||||
if end_index == start_index:
|
||||
return level.anchor_p
|
||||
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
|
||||
ratio = (index_at(times, t) - start_index) / (end_index - start_index)
|
||||
return level.anchor_p + (end_price - level.anchor_p) * ratio
|
||||
|
|
@ -39,7 +39,7 @@ def cluster_levels(
|
|||
return []
|
||||
groups: list[list[tuple[float, Level]]] = []
|
||||
positioned = [
|
||||
(level.price_at(current_t), level)
|
||||
(level.current_p if level.current_p is not None else 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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ class Level:
|
|||
line_width: int | None = None
|
||||
number: int | None = None
|
||||
cutoff_t: int | None = None
|
||||
# Hand-placed levels fire once and disarm themselves; everything derived
|
||||
# (averages, prior-day, VWAP) is permanently armed.
|
||||
armed: bool = True
|
||||
# Sloped lines are evaluated across bars, not seconds (see bar_space). 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.
|
||||
current_p: float | None = None
|
||||
|
||||
def price_at(self, t: int) -> float:
|
||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class ManualLine:
|
|||
line_width: int = 2
|
||||
number: int = 0
|
||||
cutoff_t: int | None = None
|
||||
armed: bool = True
|
||||
|
||||
@property
|
||||
def horizontal(self) -> bool:
|
||||
|
|
@ -57,6 +58,7 @@ class ManualLine:
|
|||
line_width=self.line_width,
|
||||
number=self.number,
|
||||
cutoff_t=self.cutoff_t,
|
||||
armed=self.armed,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
|
|
@ -82,6 +84,7 @@ class ManualLine:
|
|||
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,
|
||||
armed=bool(value.get("armed", True)),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class LinePatch(BaseModel):
|
|||
slope: float | None = None
|
||||
last_t: int | None = None
|
||||
cutoff_t: int | None = None
|
||||
armed: bool | None = None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ class Settings(BaseSettings):
|
|||
base_timeframes: str = "1m,30m,1d"
|
||||
max_bars_per_tf: int = 5000
|
||||
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
|
||||
ma_sets__1h: str = ""
|
||||
daily_anchor_et: str = "18:00"
|
||||
manual_lines_path: Path = Path("./data/manual_lines.json")
|
||||
confluence_min_score: float = 28
|
||||
|
|
@ -49,10 +48,7 @@ class Settings(BaseSettings):
|
|||
|
||||
@property
|
||||
def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]:
|
||||
configured = {
|
||||
Timeframe.D1: self.ma_sets__1d,
|
||||
Timeframe.H1: self.ma_sets__1h,
|
||||
}
|
||||
configured = {Timeframe.D1: self.ma_sets__1d}
|
||||
result: dict[Timeframe, list[tuple[str, int]]] = {}
|
||||
for tf, value in configured.items():
|
||||
definitions = []
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
|||
from app.analysis.alerts import Alert, AlertEngine
|
||||
from app.bars.models import Bar, Timeframe
|
||||
from app.bars.aggregator import Aggregator
|
||||
from app.analysis.bar_space import price_in_bar_space
|
||||
from app.analysis.horizontals import build_prior_day_levels
|
||||
from app.analysis.levels import Level
|
||||
from app.analysis.moving_averages import build_ma_levels
|
||||
|
|
@ -82,15 +83,33 @@ class Runtime:
|
|||
self.settings.ma_sets,
|
||||
)
|
||||
minute_bars = self.store.get(Timeframe.M1)
|
||||
manual = self.manual_lines.levels()
|
||||
self.position_manual_levels(manual, minute_bars)
|
||||
self.levels = (
|
||||
self.ma_levels
|
||||
+ build_prior_day_levels(self.store.get(Timeframe.D1), self.price)
|
||||
+ build_vwap_level(minute_bars)
|
||||
+ self.manual_lines.levels()
|
||||
+ manual
|
||||
)
|
||||
self.broadcast_level_delta()
|
||||
self.rebuild_clusters()
|
||||
|
||||
@staticmethod
|
||||
def position_manual_levels(levels: list[Level], bars: list[Bar]) -> None:
|
||||
"""Price sloped lines across bars rather than seconds.
|
||||
|
||||
The chart spaces bars evenly, so the line a person drew advances per bar.
|
||||
Pricing it per second instead put the alert somewhere the line visibly
|
||||
was not — 147 points out across a weekend.
|
||||
"""
|
||||
if not bars:
|
||||
return
|
||||
times = [bar.t for bar in bars]
|
||||
now = times[-1]
|
||||
for level in levels:
|
||||
if level.slope:
|
||||
level.current_p = price_in_bar_space(level, times, now)
|
||||
|
||||
def broadcast_level_delta(self) -> None:
|
||||
"""Send only levels whose serialised form actually changed.
|
||||
|
||||
|
|
@ -126,12 +145,31 @@ class Runtime:
|
|||
)
|
||||
|
||||
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
||||
tripped: set[str] = set()
|
||||
for alert in alerts:
|
||||
self.broadcast({"type": "alert", "cluster": alert.cluster, "message": alert.message})
|
||||
task = asyncio.create_task(self.notify(alert.message))
|
||||
# Held so the task is not garbage collected mid-flight.
|
||||
self._notify_tasks.add(task)
|
||||
task.add_done_callback(self._notify_tasks.discard)
|
||||
tripped.update(alert.tripped)
|
||||
if tripped:
|
||||
self.disarm(tripped)
|
||||
|
||||
def disarm(self, ids: set[str]) -> None:
|
||||
"""A hand-placed level fires once, then waits to be re-armed."""
|
||||
changed = False
|
||||
for line_id in ids:
|
||||
try:
|
||||
self.manual_lines.update(line_id, {"armed": False})
|
||||
changed = True
|
||||
except KeyError:
|
||||
continue
|
||||
# Rebuilt after the loop, not inside it: rebuild_levels re-enters
|
||||
# rebuild_clusters, and doing that mid-dispatch would rewrite the very
|
||||
# clusters being iterated.
|
||||
if changed:
|
||||
self.rebuild_levels()
|
||||
|
||||
async def notify(self, message: str) -> None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ async function apiFetch(url, options = {}) {
|
|||
|
||||
const defaultPrefs = {
|
||||
base_tf: '1m',
|
||||
enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false, horizontal: true, vwap: true },
|
||||
enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, manual: true, auto: false, horizontal: true, vwap: true },
|
||||
hidden_levels_score: false,
|
||||
};
|
||||
|
||||
|
|
@ -306,6 +306,10 @@ createApp({
|
|||
await deleteLines([...selectedLines.value]);
|
||||
}
|
||||
|
||||
async function setArmed(line, armed) {
|
||||
await updateLineStyle(line, { armed });
|
||||
}
|
||||
|
||||
async function renameLine(line, name) {
|
||||
await updateLineStyle(line, { note: name.trim() });
|
||||
}
|
||||
|
|
@ -387,13 +391,15 @@ createApp({
|
|||
}
|
||||
}
|
||||
|
||||
const MA_PERIODS = { '1d': [10, 20, 50, 100, 200] };
|
||||
|
||||
function allEnabled(tf) {
|
||||
const available = tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21];
|
||||
const available = MA_PERIODS[tf] || [];
|
||||
return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period));
|
||||
}
|
||||
|
||||
function toggleGroup(tf, checked) {
|
||||
prefs.value.enabled.ma[tf] = checked ? (tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]) : [];
|
||||
prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : [];
|
||||
}
|
||||
|
||||
watch(prefs, () => {
|
||||
|
|
@ -430,6 +436,6 @@ createApp({
|
|||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle };
|
||||
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed };
|
||||
},
|
||||
}).mount('#app');
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class ConfluenceChart {
|
|||
// Tool arming: the sidebar decides what the next chart gesture creates.
|
||||
this.armedTool = null;
|
||||
this.gesture = null;
|
||||
this.pendingAnchor = null;
|
||||
this.onToolComplete = null;
|
||||
this.snapToBars = true;
|
||||
this.priceTag = null;
|
||||
|
|
@ -255,7 +256,9 @@ class ConfluenceChart {
|
|||
this.armedTool = tool;
|
||||
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
|
||||
this.chartEl.classList.toggle('armed', Boolean(tool));
|
||||
this.pendingAnchor = null;
|
||||
this.clearGesture();
|
||||
this.clearLinePreview();
|
||||
}
|
||||
|
||||
eventPoint(event) {
|
||||
|
|
@ -279,11 +282,20 @@ class ConfluenceChart {
|
|||
}
|
||||
|
||||
moveToolGesture(event) {
|
||||
if (!this.armedTool || !this.gesture) return;
|
||||
if (!this.armedTool) return;
|
||||
const point = this.eventPoint(event);
|
||||
if (!point) return;
|
||||
this.gesture.end = point;
|
||||
this.renderGesture();
|
||||
if (this.gesture) {
|
||||
this.gesture.end = point;
|
||||
this.renderGesture();
|
||||
return;
|
||||
}
|
||||
// Waiting on the second click: rubber-band from the placed anchor.
|
||||
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
||||
}
|
||||
|
||||
renderPending(a, b) {
|
||||
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
|
||||
}
|
||||
|
||||
finishToolGesture(event) {
|
||||
|
|
@ -299,10 +311,22 @@ class ConfluenceChart {
|
|||
this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) });
|
||||
return;
|
||||
}
|
||||
// A stray click should not create a zero-length trendline.
|
||||
if (Math.hypot(end.x - start.x, end.y - start.y) < 5) return;
|
||||
const a = this.snapPoint(start);
|
||||
|
||||
// Both idioms are supported. Press-drag-release places a trendline in one
|
||||
// gesture; a plain click sets the first anchor and the next click finishes
|
||||
// it. Rejecting short gestures outright left the tool armed and silent, so
|
||||
// the next click began a whole new line — which read as the line suddenly
|
||||
// continuing at a different slope.
|
||||
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
||||
const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end));
|
||||
const b = this.snapPoint(end);
|
||||
if (!dragged && !this.pendingAnchor) {
|
||||
this.pendingAnchor = b;
|
||||
this.renderPending(b, b);
|
||||
return;
|
||||
}
|
||||
this.pendingAnchor = null;
|
||||
this.clearLinePreview();
|
||||
if (a.t == null || b.t == null || a.t === b.t) return;
|
||||
this.onToolComplete?.({ tool, start: a, end: b });
|
||||
}
|
||||
|
|
@ -354,8 +378,9 @@ class ConfluenceChart {
|
|||
|
||||
clearGesture() {
|
||||
this.gesture = null;
|
||||
this.clearLinePreview();
|
||||
if (this.priceTag) this.priceTag.hidden = true;
|
||||
// The pending anchor's rubber band must survive between the two clicks.
|
||||
if (!this.pendingAnchor) this.clearLinePreview();
|
||||
}
|
||||
|
||||
setClickHandler(handler) { this.onChartClick = handler; }
|
||||
|
|
@ -422,24 +447,55 @@ class ConfluenceChart {
|
|||
return best?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fractional index of a timestamp in the bar series. Mirrors index_at() in
|
||||
* app/analysis/bar_space.py — the chart spaces bars evenly however much time
|
||||
* separates them, so this is the space a straight line is straight in.
|
||||
*/
|
||||
indexAt(time) {
|
||||
const bars = this.bars;
|
||||
if (bars.length < 2) return 0;
|
||||
if (time <= bars[0].t) {
|
||||
const step = bars[1].t - bars[0].t;
|
||||
return step ? (time - bars[0].t) / step : 0;
|
||||
}
|
||||
const last = bars.length - 1;
|
||||
if (time >= bars[last].t) {
|
||||
const step = bars[last].t - bars[last - 1].t;
|
||||
return last + (step ? (time - bars[last].t) / step : 0);
|
||||
}
|
||||
let lo = 0;
|
||||
let hi = last;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (bars[mid].t <= time) lo = mid; else hi = mid;
|
||||
}
|
||||
const span = bars[hi].t - bars[lo].t;
|
||||
return lo + (span ? (time - bars[lo].t) / span : 0);
|
||||
}
|
||||
|
||||
lineData(level) {
|
||||
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
||||
const points = [
|
||||
{ time: level.anchor_t, value: level.anchor_p },
|
||||
{ time: level.last_t, value: secondPrice },
|
||||
];
|
||||
const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t);
|
||||
const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t);
|
||||
// Interpolated across bars, matching what the server scores the level at.
|
||||
// Doing it per second instead kicked the line upward at every session gap,
|
||||
// because a one-hour halt is one bar wide but an hour of slope.
|
||||
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
||||
const times = [level.anchor_t, level.last_t];
|
||||
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) * (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);
|
||||
const target = cutoffIndex >= 0 ? this.bars[cutoffIndex] : this.bars[this.bars.length - 1];
|
||||
if (target && target.t > level.last_t) times.push(target.t);
|
||||
|
||||
const startIndex = this.indexAt(level.anchor_t);
|
||||
const span = this.indexAt(level.last_t) - startIndex;
|
||||
return times
|
||||
.filter((time, index) => index === 0 || time !== times[index - 1])
|
||||
.map(time => ({
|
||||
time,
|
||||
value: span
|
||||
? level.anchor_p + (endPrice - level.anchor_p) * (this.indexAt(time) - startIndex) / span
|
||||
: level.anchor_p,
|
||||
}));
|
||||
}
|
||||
|
||||
renderAnchorHandles() {
|
||||
|
|
|
|||
|
|
@ -67,23 +67,22 @@
|
|||
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Layers</h2>
|
||||
<div class="layer-group">
|
||||
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
||||
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
|
||||
</div>
|
||||
<div class="layer-group optional">
|
||||
<label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label>
|
||||
</div>
|
||||
<div class="layer-group">
|
||||
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
||||
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
||||
</div>
|
||||
<div class="layer-group">
|
||||
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
||||
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
|
||||
</div>
|
||||
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
||||
<details class="sidebar-section" open>
|
||||
<summary>Layers</summary>
|
||||
<div class="layer-group">
|
||||
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
||||
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
|
||||
</div>
|
||||
<div class="layer-group">
|
||||
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
||||
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
||||
</div>
|
||||
<div class="layer-group">
|
||||
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
||||
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
|
||||
</div>
|
||||
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
||||
</details>
|
||||
<details class="sidebar-section" open>
|
||||
<summary>Lines & levels ({{ manualLines.length }})</summary>
|
||||
<div class="trendline-actions" v-if="manualLines.length">
|
||||
|
|
@ -96,6 +95,10 @@
|
|||
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
|
||||
<span v-if="line.slope === 0">#{{ line.number }} · level {{ line.anchor_p.toFixed(2) }}</span>
|
||||
<span v-else>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
|
||||
<label class="armed-toggle" :class="{off: !line.armed}" @click.stop :title="line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
|
||||
<input type="checkbox" :checked="line.armed" @change="setArmed(line, $event.target.checked)">
|
||||
{{ line.armed ? 'armed' : 'tripped' }}
|
||||
</label>
|
||||
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
|
||||
<div class="line-style-controls" @click.stop>
|
||||
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
|||
#chart.armed { cursor:crosshair; }
|
||||
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
|
||||
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
|
||||
.arm-hint { color:var(--accent); }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||
.arm-hint { color:var(--accent); }
|
||||
.armed-toggle { display:flex; align-items:center; gap:4px; font-size:10px; color:var(--accent); cursor:pointer; }
|
||||
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
|
||||
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
||||
|
|
|
|||
45
tests/test_bar_space.py
Normal file
45
tests/test_bar_space.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from app.analysis.bar_space import index_at, price_in_bar_space
|
||||
from app.analysis.levels import Level, LevelKind, Side
|
||||
from app.bars.models import Timeframe
|
||||
|
||||
MINUTE = 60
|
||||
|
||||
|
||||
def sloped(anchor_t: int, anchor_p: float, last_t: int, last_p: float) -> Level:
|
||||
slope = (last_p - anchor_p) / (last_t - anchor_t)
|
||||
return Level(
|
||||
"ml", LevelKind.MANUAL, Timeframe.M1, Side.SUPPORT, 1, 1, "line",
|
||||
anchor_t, anchor_p, slope, None, 0, anchor_t, last_t, False, False,
|
||||
)
|
||||
|
||||
|
||||
def test_index_is_linear_when_bars_are_evenly_spaced():
|
||||
times = [i * MINUTE for i in range(10)]
|
||||
assert index_at(times, 0) == 0
|
||||
assert index_at(times, 5 * MINUTE) == 5
|
||||
assert index_at(times, int(2.5 * MINUTE)) == 2.5
|
||||
|
||||
|
||||
def test_a_gap_costs_one_index_however_long_it_is():
|
||||
# Two bars either side of a weekend are adjacent on screen.
|
||||
times = [0, MINUTE, MINUTE + 49 * 3600, MINUTE + 49 * 3600 + MINUTE]
|
||||
assert index_at(times, MINUTE) == 1
|
||||
assert index_at(times, MINUTE + 49 * 3600) == 2
|
||||
|
||||
|
||||
def test_line_stays_straight_in_bar_space_across_a_gap():
|
||||
# Bars: ten minutes, a 49-hour weekend, then ten more.
|
||||
weekend = 49 * 3600
|
||||
times = [i * MINUTE for i in range(10)] + [10 * MINUTE + weekend + i * MINUTE for i in range(10)]
|
||||
level = sloped(times[0], 100.0, times[9], 109.0) # 1 point per bar
|
||||
|
||||
# Extending nine further bars must add nine more points, gap notwithstanding.
|
||||
assert price_in_bar_space(level, times, times[18]) == 118.0
|
||||
# Clock-based pricing would have run away during the halt.
|
||||
assert level.price_at(times[18]) > 3000
|
||||
|
||||
|
||||
def test_flat_anchors_return_the_anchor_price():
|
||||
times = [i * MINUTE for i in range(5)]
|
||||
level = sloped(0, 100.0, MINUTE, 100.0)
|
||||
assert price_in_bar_space(level, times, times[4]) == 100.0
|
||||
Loading…
Reference in a new issue