timeframe trendline interactions

This commit is contained in:
Chris Amow 2026-08-14 04:58:08 -05:00
parent 952a3bb7f3
commit 8ab054cf67
23 changed files with 824 additions and 95 deletions

View file

@ -22,6 +22,8 @@ SCHWAB_SYMBOL=/ES
TIMEFRAMES=1m,5m,15m,30m,1h,1d TIMEFRAMES=1m,5m,15m,30m,1h,1d
BASE_TIMEFRAMES=1m,30m,1d BASE_TIMEFRAMES=1m,30m,1d
MAX_BARS_PER_TF=5000 MAX_BARS_PER_TF=5000
# Emergency rollback: false restores the previous displayed/1m trendline geometry.
TRENDLINE_SOURCE_GEOMETRY=true
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
DAILY_ANCHOR_ET=18:00 DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json MANUAL_LINES_PATH=./data/manual_lines.json

View file

@ -381,3 +381,9 @@ Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`
In production `/app/data` is a **Coolify persistent volume** — without it the In production `/app/data` is a **Coolify persistent volume** — without it the
container filesystem is ephemeral and every deploy would silently wipe every container filesystem is ephemeral and every deploy would silently wipe every
line you've drawn. line you've drawn.
Sloped lines are priced in the bar space of the timeframe on which they were
drawn, then sampled onto the displayed candles. This keeps a 30m line in the
same place on 30m and 1m and makes the chart agree with alert pricing. Set
`TRENDLINE_SOURCE_GEOMETRY=false` and restart for an immediate rollback to the
previous displayed/1m-grid behavior; drawing data is unchanged either way.

View file

@ -9,6 +9,9 @@ disagree — measured at 147 points across a weekend on a real /ES chart.
""" """
from bisect import bisect_right from bisect import bisect_right
from app.bars.models import Timeframe
from app.bars.session import bucket_duration
def index_at(times: list[int], t: int) -> float: def index_at(times: list[int], t: int) -> float:
"""Fractional index of a timestamp within an ascending bar-time series.""" """Fractional index of a timestamp within an ascending bar-time series."""
@ -39,3 +42,49 @@ def price_in_bar_space(level, times: list[int], t: int) -> float:
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t) end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
ratio = (index_at(times, t) - start_index) / (end_index - start_index) ratio = (index_at(times, t) - start_index) / (end_index - start_index)
return level.anchor_p + (end_price - level.anchor_p) * ratio return level.anchor_p + (end_price - level.anchor_p) * ratio
def timeframe_index_at(
times: list[int], t: int, tf: Timeframe, *, allow_future: bool = False,
) -> float | None:
"""Position `t` in a timeframe's own logical bar space.
Unlike ``index_at``, this never extrapolates backward from a truncated
window. Within a real source bucket it advances by that bucket's normal
duration, so the final minutes before a weekend do not get divided by the
entire weekend gap.
"""
if not times:
return None
upper = bisect_right(times, t)
if upper and times[upper - 1] == t:
return float(upper - 1)
lower = upper - 1
if lower < 0:
return None
duration = bucket_duration(times[lower], tf)
elapsed = t - times[lower]
if duration <= 0 or elapsed < 0:
return None
if elapsed > duration and not (allow_future and lower == len(times) - 1):
return None
return lower + elapsed / duration
def price_in_timeframe_space(
level, times: list[int], tf: Timeframe, t: int,
) -> float | None:
"""Price a line in the bar space of the timeframe it belongs to."""
# Endpoints may deliberately sit in the projection area. They use the same
# repeated-source-bucket approximation as the browser; the live evaluation
# instant itself must still belong to held source history.
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
target_index = timeframe_index_at(times, t, tf)
if start_index is None or end_index is None or target_index is None:
return None
if end_index == start_index:
return level.anchor_p
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
ratio = (target_index - start_index) / (end_index - start_index)
return level.anchor_p + (end_price - level.anchor_p) * ratio

View file

@ -41,7 +41,9 @@ def cluster_levels(
positioned = [ positioned = [
(level.current_p if level.current_p is not None else 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 for level in levels
if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t) if level.geometry_resolved
and not level.hidden
and (level.cutoff_t is None or current_t <= level.cutoff_t)
] ]
for positional_side in (Side.SUPPORT, Side.RESISTANCE): for positional_side in (Side.SUPPORT, Side.RESISTANCE):
side_levels = sorted( side_levels = sorted(

View file

@ -51,6 +51,10 @@ class Level:
# Optional fixed distance for a hand-placed level. None keeps the global # Optional fixed distance for a hand-placed level. None keeps the global
# ATR-based trigger; a value alerts that many points before the level. # ATR-based trigger; a value alerts that many points before the level.
alert_early_points: float | None = None alert_early_points: float | None = None
# False means the attributed timeframe does not hold enough history to
# price this line safely. Such a line remains visible but cannot cluster or
# alert using the absolute-time fallback.
geometry_resolved: bool = True
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)

View file

@ -34,6 +34,9 @@ class ManualLine:
collapsed: bool = False collapsed: bool = False
icon: str = "" icon: str = ""
alert_early_points: float | None = None alert_early_points: float | None = None
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
# on (anchor_t, anchor_p) regardless of this value.
scale: float = 1.0
@property @property
def drawing_kind(self) -> str: def drawing_kind(self) -> str:
@ -122,6 +125,7 @@ class ManualLine:
float(value["alert_early_points"]) float(value["alert_early_points"])
if value.get("alert_early_points") is not None else None if value.get("alert_early_points") is not None else None
), ),
scale=float(value.get("scale", 1.0) or 1.0),
) )

View file

@ -23,6 +23,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
def positioned_level(runtime, line_id: str):
return next(level for level in runtime.levels if level.id == line_id)
@router.post("/debug/captures", status_code=201) @router.post("/debug/captures", status_code=201)
async def create_debug_capture(request: Request): async def create_debug_capture(request: Request):
try: try:
@ -107,6 +111,7 @@ class LinePatch(BaseModel):
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) alert_early_points: float | None = Field(None, ge=0)
scale: float | None = Field(None, ge=0.5, le=3)
@router.get("/status") @router.get("/status")
@ -119,6 +124,9 @@ def status(request: Request):
"symbol": runtime.stream.symbol, "symbol": runtime.stream.symbol,
"last_bar_t": runtime.stream.last_bar_t, "last_bar_t": runtime.stream.last_bar_t,
"bars_held": runtime.store.counts(), "bars_held": runtime.store.counts(),
"trendline_geometry": (
"source_tf" if runtime.settings.trendline_source_geometry else "legacy"
),
"warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe}, "warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe},
# How late the event loop is running. Rising numbers mean something is # How late the event loop is running. Rising numbers mean something is
# blocking it — see docs/async_refactor.md. # blocking it — see docs/async_refactor.md.
@ -183,7 +191,7 @@ def create_line(request: Request, payload: LineCreate):
runtime = request.app.state.runtime runtime = request.app.state.runtime
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
runtime.rebuild_levels() runtime.rebuild_levels()
return line.to_level().to_dict() return positioned_level(runtime, line.id).to_dict()
@router.post("/lines/price", status_code=201) @router.post("/lines/price", status_code=201)
@ -210,7 +218,7 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
) )
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
runtime.rebuild_levels() runtime.rebuild_levels()
return line.to_level().to_dict() return positioned_level(runtime, line.id).to_dict()
class CommentCreate(BaseModel): class CommentCreate(BaseModel):
@ -228,6 +236,7 @@ class CommentCreate(BaseModel):
"skull", "face-laugh-squint", "champagne-glasses", "skull", "face-laugh-squint", "champagne-glasses",
"arrow-left", "arrow-right", "hand", "right-to-bracket", "play", "arrow-left", "arrow-right", "hand", "right-to-bracket", "play",
] | None = None ] | None = None
scale: float = Field(1.0, ge=0.5, le=3)
@router.post("/comments", status_code=201) @router.post("/comments", status_code=201)
@ -252,6 +261,7 @@ def create_comment(request: Request, payload: CommentCreate):
color=payload.color, color=payload.color,
kind="symbol" if payload.icon else "comment", kind="symbol" if payload.icon else "comment",
icon=payload.icon or "", icon=payload.icon or "",
scale=payload.scale,
pinned=payload.pinned, pinned=payload.pinned,
x=payload.x, x=payload.x,
y=payload.y, y=payload.y,
@ -348,7 +358,9 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
request.app.state.runtime.rebuild_levels() request.app.state.runtime.rebuild_levels()
# A comment has no level form — returning one would hand the caller a shape # A comment has no level form — returning one would hand the caller a shape
# that looks like something the confluence engine tracks. # that looks like something the confluence engine tracks.
return line.to_dict() if line.is_comment else line.to_level().to_dict() return line.to_dict() if line.is_comment else positioned_level(
request.app.state.runtime, line.id,
).to_dict()
@router.delete("/lines/{line_id}", status_code=204) @router.delete("/lines/{line_id}", status_code=204)

View file

@ -4,7 +4,9 @@ from urllib.parse import urlsplit
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.api.deps import SESSION_COOKIE, session_matches, token_matches
from app.analysis.levels import LevelKind
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_duration
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
router = APIRouter() router = APIRouter()
@ -51,6 +53,28 @@ def session_open(runtime) -> float | None:
return bars[-1].o if bars else None return bars[-1].o if bars else None
def trendline_timeframes(runtime) -> set[Timeframe]:
return {
level.tf for level in runtime.levels
if level.kind is LevelKind.MANUAL and level.slope
}
def trendline_geometry(runtime) -> dict:
if not runtime.settings.trendline_source_geometry:
return {"mode": "legacy", "series": {}}
series = {}
for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value):
times = [bar.t for bar in runtime.store.get(tf)]
value = {"times": times}
if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times]
else:
value["duration"] = tf.seconds
series[tf.value] = value
return {"mode": "source_tf", "series": series}
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
return { return {
"type": "snapshot", "type": "snapshot",
@ -62,6 +86,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
if runtime.store.get(Timeframe.M1, 1) if runtime.store.get(Timeframe.M1, 1)
else None, else None,
"session_open": session_open(runtime), "session_open": session_open(runtime),
"trendline_geometry": trendline_geometry(runtime),
} }
@ -84,6 +109,7 @@ async def websocket_endpoint(websocket: WebSocket):
tf = Timeframe.M1 tf = Timeframe.M1
prefs = None prefs = None
await websocket.send_json(snapshot(runtime, tf, prefs)) await websocket.send_json(snapshot(runtime, tf, prefs))
geometry_tfs = trendline_timeframes(runtime)
async def receive(): async def receive():
nonlocal tf, prefs nonlocal tf, prefs
@ -113,18 +139,46 @@ async def websocket_endpoint(websocket: WebSocket):
event = await queue.get() event = await queue.get()
if event["type"] == "disconnect": if event["type"] == "disconnect":
break break
if event["type"] == "bar" and event["bar"].tf is tf: if event["type"] == "bar":
bar = event["bar"]
source_tfs = trendline_timeframes(runtime)
source_bars = runtime.store.get(bar.tf)
first_source_t = source_bars[0].t if source_bars else None
if bar.tf is tf:
await websocket.send_json( await websocket.send_json(
{ {
"type": "bar", "tf": tf.value, "type": "bar", "tf": tf.value,
"bar": event["bar"].to_dict(), "bar": bar.to_dict(),
"session_open": session_open(runtime), "session_open": session_open(runtime),
"trendline_first_t": (
first_source_t
if runtime.settings.trendline_source_geometry
and bar.tf in source_tfs else None
),
}
)
elif (
runtime.settings.trendline_source_geometry
and bar.tf in source_tfs
):
await websocket.send_json(
{
"type": "trendline_bar",
"tf": bar.tf.value,
"t": bar.t,
"duration": bucket_duration(bar.t, bar.tf),
"first_t": first_source_t,
} }
) )
elif event["type"] == "levels": elif event["type"] == "levels":
await websocket.send_json( message = {
{"type": "levels", "changed": event["changed"], "removed": event["removed"]} "type": "levels", "changed": event["changed"], "removed": event["removed"],
) }
current_tfs = trendline_timeframes(runtime)
if current_tfs != geometry_tfs:
message["trendline_geometry"] = trendline_geometry(runtime)
geometry_tfs = current_tfs
await websocket.send_json(message)
elif event["type"] == "clusters": elif event["type"] == "clusters":
clusters = connection_clusters(runtime, prefs) clusters = connection_clusters(runtime, prefs)
await websocket.send_json( await websocket.send_json(

View file

@ -6,6 +6,7 @@ from app.bars.models import Timeframe
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
SESSION_OPEN = time(18, 0) SESSION_OPEN = time(18, 0)
SESSION_CLOSE = time(17, 0)
def _session_open_local(current: datetime) -> datetime: def _session_open_local(current: datetime) -> datetime:
@ -22,3 +23,17 @@ def bucket_start(t: int, tf: Timeframe) -> int:
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
return int(_session_open_local(current).timestamp()) return int(_session_open_local(current).timestamp())
def bucket_duration(t: int, tf: Timeframe) -> int:
"""Wall-clock span of one logical bar bucket.
Intraday buckets are fixed. A daily line advances only through the active
18:00-17:00 ET session, not the settlement halt; constructing the close in
Eastern keeps DST transitions correct without shifting stored UTC times.
"""
if tf is not Timeframe.D1:
return tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN)
return int(next_close.timestamp()) - t

View file

@ -30,6 +30,9 @@ class Settings(BaseSettings):
timeframes: str = "1m,5m,15m,30m,1h,1d" timeframes: str = "1m,5m,15m,30m,1h,1d"
base_timeframes: str = "1m,30m,1d" base_timeframes: str = "1m,30m,1d"
max_bars_per_tf: int = 5000 max_bars_per_tf: int = 5000
# Rollback gate for source-timeframe trendline geometry. When false, both
# server pricing and browser rendering use the previous 1m/displayed grid.
trendline_source_geometry: bool = True
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
daily_anchor_et: str = "18:00" daily_anchor_et: str = "18:00"
manual_lines_path: Path = Path("./data/manual_lines.json") manual_lines_path: Path = Path("./data/manual_lines.json")

View file

@ -8,7 +8,7 @@ from app.analysis.alerts import Alert, AlertEngine
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.bars.aggregator import Aggregator from app.bars.aggregator import Aggregator
from app.bars.session import bucket_start from app.bars.session import bucket_start
from app.analysis.bar_space import price_in_bar_space from app.analysis.bar_space import price_in_bar_space, price_in_timeframe_space
from app.analysis.horizontals import build_prior_day_levels from app.analysis.horizontals import build_prior_day_levels
from app.analysis.levels import Level from app.analysis.levels import Level
from app.analysis.moving_averages import build_ma_levels from app.analysis.moving_averages import build_ma_levels
@ -200,8 +200,7 @@ class Runtime:
self.broadcast_level_delta() self.broadcast_level_delta()
self.rebuild_clusters() self.rebuild_clusters()
@staticmethod def position_manual_levels(self, levels: list[Level], bars: list[Bar]) -> None:
def position_manual_levels(levels: list[Level], bars: list[Bar]) -> None:
"""Price sloped lines across bars rather than seconds. """Price sloped lines across bars rather than seconds.
The chart spaces bars evenly, so the line a person drew advances per bar. The chart spaces bars evenly, so the line a person drew advances per bar.
@ -210,11 +209,17 @@ class Runtime:
""" """
if not bars: if not bars:
return return
times = [bar.t for bar in bars] minute_times = [bar.t for bar in bars]
now = times[-1] now = minute_times[-1]
for level in levels: for level in levels:
if level.slope: if not level.slope:
level.current_p = price_in_bar_space(level, times, now) continue
if not self.settings.trendline_source_geometry:
level.current_p = price_in_bar_space(level, minute_times, now)
continue
source_times = [bar.t for bar in self.store.get(level.tf)]
level.current_p = price_in_timeframe_space(level, source_times, level.tf, now)
level.geometry_resolved = level.current_p is not None
def broadcast_level_delta(self) -> None: def broadcast_level_delta(self) -> None:
"""Send only levels whose serialised form actually changed. """Send only levels whose serialised form actually changed.

View file

@ -922,3 +922,84 @@ consecutive bars, all aligned, all sixty seconds apart.
Worth generalising: a measurement that looks absurd is worth chasing rather than Worth generalising: a measurement that looks absurd is worth chasing rather than
clamping. The absurd number here was "67 bars of padding", and the bug behind it clamping. The absurd number here was "67 bars of padding", and the bug behind it
had nothing to do with padding. had nothing to do with padding.
### 2026-08-14 — trendlineproblem: high-TF lines used the wrong bar grid
**Confirmed and fixed behind `TRENDLINE_SOURCE_GEOMETRY`.** No drawing records
were migrated or rewritten.
The original diagnosis mixed one real defect with one representational detail.
A 30m candle being pinned at its bucket-open timestamp is normal: that timestamp
identifies the aggregate candle, and the exact 1m that printed its extreme is
unavailable for native 30m history older than the minute seed. It remains a
separate product choice, not part of this fix.
The real defect was that the line's coordinate system depended on its consumer.
The browser used the displayed `this.bars`, limited to the 1,000 bars in its
WebSocket snapshot. The server used every stored 1m bar, up to 5,000. Neither
used the line's attributed timeframe. Complete nested 30m/1m windows often hide
this because their indexes differ by a constant factor. Once anchors predate
the shorter window, edge extrapolation counts closed-market clock time as
fictional 1m bars and changes the projected slope.
Measured against the five persisted 30m drawings before changing code, the
source-30m price minus the browser's 1m-window price at the live edge was:
```
#9 +175.5 points
#10 -6.5 points
#12 -1.1 points
#13 +1.2 points
#14 -11.1 points
```
This was also an alert mismatch: `Runtime.position_manual_levels` priced every
manual line in the server's 1m store, so the chart, the drawn timeframe and the
phone could each be using different geometry.
**Canonical geometry now belongs to the line's `tf`.** Persisted
`anchor_t`/`anchor_p` and `last_t` still identify the endpoints; `slope`
recovers the second endpoint price. `timeframe_index_at` resolves those times in
the source bar sequence and advances fractionally through a real source bucket.
It refuses to invent history before the first source bar. Daily fractional
progress uses the active 18:00-17:00 ET session, so the settlement halt adds no
slope; the boundary is constructed in Eastern time for DST correctness.
WebSocket snapshots carry timestamp-only source series for the timeframes used
by sloped manual lines, plus live timestamp updates. A levels delta sends a full
replacement when another browser introduces a new source timeframe, and each
bar carries the server ring buffer's first timestamp so long-lived tabs trim
evicted history too. The browser evaluates the same source-space line and
samples it only onto displayed candle timestamps, so
manual lines cannot add points to Lightweight Charts' shared scale. Historical
series, future projection, hit testing and the selected-line hit polyline all
call that price function. Whole-line dragging, horizontal keyboard nudging and
duplication shift timestamps in source bars rather than whatever timeframe is
currently displayed. Endpoint dragging can still choose a finer-timeframe
instant; it becomes a fractional coordinate in the source bar. Future endpoints
use the same repeated-source-bucket approximation on both sides; the separate,
pre-existing limitation that future axis times are not session-aware remains.
If source history no longer reaches an anchor, `geometry_resolved` is false.
The drawing remains available, but confluence and alerts exclude it instead of
falling back to per-second pricing. That is safer than issuing a push at an
invented price. Source-dependent whole-line movement, nudging and duplication
are also disabled until the line resolves.
**Rollback is one setting.** Set `TRENDLINE_SOURCE_GEOMETRY=false` and restart.
The server restores legacy 1m pricing, the snapshot tells the browser to restore
displayed-grid rendering and movement, and persisted drawings remain untouched.
`GET /api/status` reports `trendline_geometry` as `source_tf` or `legacy`.
Verification: focused backend regressions cover off-window anchors, equivalent
overlapping grids, a partial 30m bar before a weekend, missing source history,
future endpoints and the daily settlement halt. Runtime and snapshot tests cover
source pricing, unresolved lines and the rollback payload. A deterministic
browser test proves a 30m line with both anchors outside the 1m window prices
correctly, samples only displayed timestamps, nudges by a 30m source bar, and
returns to the old wrong result when rollback mode is selected. It also proves
that evicted source timestamps are trimmed.
Full result: 148 backend tests passed; 31 browser
tests passed with 9 previously quarantined tests skipped. Live Chromium then
showed active persisted lines #12, #13 and #14 with a zero-point delta between
their final rendered sample and server `current_p`, and no console errors.

View file

@ -596,10 +596,18 @@ definition* (you drew them). So they validate the confluence engine without the
automatic detector's tuning risk, and they later become the ground truth that M8's automatic detector's tuning risk, and they later become the ground truth that M8's
scoring coefficients get tuned against. scoring coefficients get tuned against.
**Geometry.** Anchors are stored in **absolute epoch seconds and price** — never bar **Geometry.** Anchors are persisted as **absolute epoch seconds and price** — never
indices. This is why a line drawn on the 4h chart renders correctly on the 1m chart bar indices — so changing the algorithm does not rewrite drawings. A sloped line is
with no conversion: both are the same `(time, price)` plane. The existing evaluated in the logical bar space of its attributed timeframe, however: charts
`Level.price_at(t)` already handles it. compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49
hours in which no bars exist. The stored `slope` recovers the second endpoint price;
the source-timeframe bar sequence determines interpolation and projection. The
browser samples that canonical geometry only at displayed candle timestamps, and the
server uses the same source series for clusters and alerts. A source history that no
longer reaches an anchor leaves the drawing visible but unresolved and unable to
cluster or alert rather than silently extrapolating from the edge of a shorter
window. `TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid
behavior without changing persisted data.
**Timeframe attribution.** Tag the line with the timeframe that was *displayed when it **Timeframe attribution.** Tag the line with the timeframe that was *displayed when it
was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the
@ -616,10 +624,10 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`.
| Place endpoints | `chart.subscribeClick(handler)` → two clicks | | Place endpoints | `chart.subscribeClick(handler)` → two clicks |
| Pixel → price | `series.coordinateToPrice(param.point.y)` | | Pixel → price | `series.coordinateToPrice(param.point.y)` |
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` | | Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` |
| Render | `LineSeries` with 2 points, extended right (same as §9 auto lines) | | Render | Sample source-timeframe geometry onto displayed candles; extend right in an SVG overlay |
| Select | Click within ~6px of a line — hit-test in price space via `price_at(t)` | | Select | Click within ~6px of the canonical price at that displayed bar |
| Delete | `Delete`/`Backspace` on selection, plus a button | | Delete | `Delete`/`Backspace` on selection, plus a button |
| Edit | **Delete and redraw.** Endpoint dragging is real work — do not build it in M5 | | Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars |
**Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px. **Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px.
Cheap to implement and it is the difference between a usable drawing tool and a Cheap to implement and it is the difference between a usable drawing tool and a
@ -1015,6 +1023,7 @@ SCHWAB_SYMBOL=/ES
TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
MAX_BARS_PER_TF=5000 # in-memory ring buffer bound MAX_BARS_PER_TF=5000 # in-memory ring buffer bound
TRENDLINE_SOURCE_GEOMETRY=true # false = rollback to displayed/1m-grid pricing
# Daily MA set is the primary requirement; others ship disabled. See §7.4 # Daily MA set is the primary requirement; others ship disabled. See §7.4
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200

View file

@ -123,6 +123,8 @@ createApp({
const commentFloat = ref(false); const commentFloat = ref(false);
const selectedSymbol = ref('arrow-up'); const selectedSymbol = ref('arrow-up');
const symbolColor = ref('#65b7cf'); const symbolColor = ref('#65b7cf');
const symbolScale = ref(1);
const symbolScales = [0.5, 1, 1.5, 2, 3];
const symbolPanelOpen = ref(false); const symbolPanelOpen = ref(false);
// Comments are not levels, so they never arrive over the levels socket and // Comments are not levels, so they never arrive over the levels socket and
// are fetched and refreshed explicitly. // are fetched and refreshed explicitly.
@ -286,6 +288,7 @@ createApp({
const message = JSON.parse(data); const message = JSON.parse(data);
if (message.type === 'snapshot') { if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
chartApi.setTrendlineGeometry(message.trendline_geometry);
chartApi.setBars(message.bars); chartApi.setBars(message.bars);
levels.value = message.levels || []; levels.value = message.levels || [];
if (!drawColorInitialized) { if (!drawColorInitialized) {
@ -306,10 +309,16 @@ createApp({
// outright. The event carries its timeframe, so honour it. // outright. The event carries its timeframe, so honour it.
if (message.tf && message.tf !== timeframe.value) return; if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
chartApi.updateTrendlineTime(
message.bar.tf, message.bar.t, null, message.trendline_first_t,
);
chartApi.updateBar(message.bar); chartApi.updateBar(message.bar);
price.value = message.bar.c; price.value = message.bar.c;
sessionOpen.value = message.session_open ?? sessionOpen.value; sessionOpen.value = message.session_open ?? sessionOpen.value;
status.value.last_bar_t = message.bar.t; status.value.last_bar_t = message.bar.t;
} else if (message.type === 'trendline_bar') {
chartApi.updateTrendlineTime(message.tf, message.t, message.duration, message.first_t);
syncVisibleLevels();
} else if (message.type === 'levels') { } else if (message.type === 'levels') {
// A delta, not a replacement: VWAP changes every minute while the // A delta, not a replacement: VWAP changes every minute while the
// daily averages carry hundreds of points and change once a session. // daily averages carry hundreds of points and change once a session.
@ -317,6 +326,7 @@ createApp({
const byId = new Map(levels.value.filter(level => !removed.has(level.id)).map(level => [level.id, level])); const byId = new Map(levels.value.filter(level => !removed.has(level.id)).map(level => [level.id, level]));
for (const level of message.changed || []) byId.set(level.id, level); for (const level of message.changed || []) byId.set(level.id, level);
levels.value = [...byId.values()]; levels.value = [...byId.values()];
if (message.trendline_geometry) chartApi.setTrendlineGeometry(message.trendline_geometry);
syncVisibleLevels(); syncVisibleLevels();
} else if (message.type === 'clusters') { } else if (message.type === 'clusters') {
clusters.value = message.clusters; clusters.value = message.clusters;
@ -519,7 +529,7 @@ createApp({
const response = await apiFetch('/api/comments', { const response = await apiFetch('/api/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
text: choice.name, icon, color: symbolColor.value, pinned: true, text: choice.name, icon, color: symbolColor.value, scale: symbolScale.value, pinned: true,
anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y, anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y,
}), }),
}); });
@ -649,18 +659,8 @@ createApp({
await deleteLines([id]); await deleteLines([id]);
} }
function shiftTimeByBars(time, count = 10) { function shiftTimeByBars(line, time, count = 10) {
const bars = chartApi?.bars || []; return chartApi?.shiftLineTime(line, time, count) ?? time;
if (bars.length < 2) return time;
let nearest = 0;
for (let index = 1; index < bars.length; index += 1) {
if (Math.abs(bars[index].t - time) < Math.abs(bars[nearest].t - time)) nearest = index;
}
const target = nearest + count;
if (target < bars.length) return bars[target].t;
const last = bars.length - 1;
const step = bars[last].t - bars[last - 1].t;
return bars[last].t + (target - last) * step;
} }
function duplicateName(line) { function duplicateName(line) {
@ -673,8 +673,9 @@ createApp({
} }
async function duplicateLine(line) { async function duplicateLine(line) {
const anchorT = shiftTimeByBars(line.anchor_t); if (line.geometry_resolved === false) return;
const endT = shiftTimeByBars(line.last_t); const anchorT = shiftTimeByBars(line, line.anchor_t);
const endT = shiftTimeByBars(line, line.last_t);
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t); const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
const response = await apiFetch('/api/lines', { const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -684,7 +685,7 @@ createApp({
end_t: endT, end_p: endP, end_t: endT, end_p: endP,
note: duplicateName(line), hidden: line.hidden, note: duplicateName(line), hidden: line.hidden,
color: line.color || '#65b7cf', line_width: line.line_width || 2, color: line.color || '#65b7cf', line_width: line.line_width || 2,
cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line.cutoff_t), cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t),
armed: line.armed, armed: line.armed,
}), }),
}); });
@ -823,10 +824,11 @@ createApp({
+ item.line.slope * (item.line.last_t - item.line.anchor_t) + item.line.slope * (item.line.last_t - item.line.anchor_t)
+ vertical * ConfluenceChart.TICK; + vertical * ConfluenceChart.TICK;
if (horizontal) { if (horizontal) {
line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal); if (line.geometry_resolved === false) continue;
line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_t) + horizontal); line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal);
if (line.cutoff_t != null) { if (line.cutoff_t != null) {
line.cutoff_t = chartApi.timeAtIndex(chartApi.indexAt(line.cutoff_t) + horizontal); line.cutoff_t = chartApi.shiftLineTime(line, line.cutoff_t, horizontal);
} }
} }
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
@ -1013,6 +1015,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -15,6 +15,8 @@ class ConfluenceChart {
this.projectionLayer = null; this.projectionLayer = null;
this.bars = []; this.bars = [];
this.levels = []; this.levels = [];
this.trendlineGeometryMode = 'legacy';
this.trendlineSourceSeries = new Map();
this.onChartClick = null; this.onChartClick = null;
this.tooltip = null; this.tooltip = null;
this.chartEl = null; this.chartEl = null;
@ -278,7 +280,7 @@ class ConfluenceChart {
this.lineFocus.classList.add('chart-line-focus'); this.lineFocus.classList.add('chart-line-focus');
this.lineFocus.setAttribute('hidden', ''); this.lineFocus.setAttribute('hidden', '');
handles.appendChild(this.lineFocus); handles.appendChild(this.lineFocus);
this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
this.lineHitTarget.classList.add('chart-line-hit'); this.lineHitTarget.classList.add('chart-line-hit');
this.lineHitTarget.setAttribute('hidden', ''); this.lineHitTarget.setAttribute('hidden', '');
this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event)); this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event));
@ -438,6 +440,43 @@ class ConfluenceChart {
this.renderTrendlineProjections(); this.renderTrendlineProjections();
} }
setTrendlineGeometry(geometry) {
this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy';
this.trendlineSourceSeries = new Map(
Object.entries(geometry?.series || {}).map(([tf, value]) => [tf, {
times: (value.times || []).map(Number),
duration: value.duration == null ? null : Number(value.duration),
durations: (value.durations || []).map(Number),
}]),
);
}
updateTrendlineTime(tf, time, duration = null, firstTime = null) {
if (this.trendlineGeometryMode !== 'source_tf') return;
let source = this.trendlineSourceSeries.get(tf);
if (!source) {
source = { times: [], duration: duration == null ? null : Number(duration), durations: [] };
this.trendlineSourceSeries.set(tf, source);
}
if (firstTime != null) {
const first = Number(firstTime);
const remove = source.times.findIndex(value => value >= first);
const count = remove < 0 ? source.times.length : remove;
if (count > 0) {
source.times.splice(0, count);
if (source.duration == null) source.durations.splice(0, count);
}
}
const t = Number(time);
const last = source.times.length - 1;
if (last < 0 || t > source.times[last]) {
source.times.push(t);
if (source.duration == null) source.durations.push(Number(duration));
} else if (t === source.times[last] && source.duration == null && duration != null) {
source.durations[last] = Number(duration);
}
}
/** /**
* Empty slots past the last bar, so the axis can label the projection area. * Empty slots past the last bar, so the axis can label the projection area.
* *
@ -662,8 +701,10 @@ class ConfluenceChart {
if (iconClass) { if (iconClass) {
node.icon.className = `fa-solid ${iconClass}`; node.icon.className = `fa-solid ${iconClass}`;
node.label.replaceChildren(node.icon); node.label.replaceChildren(node.icon);
node.root.style.setProperty('--mark-scale', String(comment.scale || 1));
} else { } else {
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note; node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
node.root.style.removeProperty('--mark-scale');
} }
node.root.style.borderColor = comment.color; node.root.style.borderColor = comment.color;
node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f'); node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f');
@ -685,10 +726,20 @@ class ConfluenceChart {
const offRight = placed.after || (x !== null && x > width); const offRight = placed.after || (x !== null && x > width);
node.root.classList.toggle('off-left', offLeft && !offRight); node.root.classList.toggle('off-left', offLeft && !offRight);
node.root.classList.toggle('off-right', offRight); node.root.classList.toggle('off-right', offRight);
const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x; const boxW = node.root.offsetWidth;
const boxH = node.root.offsetHeight;
if (iconClass) {
const rawX = x == null ? 4 : x - boxW / 2;
const rawY = y == null ? 24 : y - boxH / 2;
const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : rawX;
node.root.style.left = `${Math.round(clampedX)}px`;
node.root.style.top = `${Math.round(Math.min(Math.max(rawY, 4), height - boxH - 4))}px`;
} else {
const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : x;
node.root.style.left = `${Math.round(clampedX)}px`; node.root.style.left = `${Math.round(clampedX)}px`;
node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`; node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
} }
}
for (const [id, node] of this.commentNodes) { for (const [id, node] of this.commentNodes) {
if (!seen.has(id)) { if (!seen.has(id)) {
clearTimeout(node.focusTimer); clearTimeout(node.focusTimer);
@ -1543,18 +1594,11 @@ class ConfluenceChart {
const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
const endIndex = Math.min(naturalEnd, visible.to); const endIndex = Math.min(naturalEnd, visible.to);
if (endIndex <= startIndex) continue; const pointIndex = this.indexAtCoordinate(param.point.x);
const start = { if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue;
x: this.coordinateAtIndex(startIndex), const linePrice = this.linePriceAtIndex(level, pointIndex);
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)), const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice);
}; if (lineY == null) continue;
const end = {
x: this.coordinateAtIndex(endIndex),
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, endIndex)),
};
if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue;
if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue;
const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x);
const distance = Math.abs(lineY - param.point.y); const distance = Math.abs(lineY - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
} }
@ -1588,6 +1632,72 @@ class ConfluenceChart {
return lo + (span ? (time - bars[lo].t) / span : 0); return lo + (span ? (time - bars[lo].t) / span : 0);
} }
sourceSeries(level) {
if (this.trendlineGeometryMode !== 'source_tf') return null;
const source = this.trendlineSourceSeries.get(level.tf);
if (source) return source;
if (this.bars[0]?.tf !== level.tf) return null;
const duration = ConfluenceChart.tfSeconds[level.tf] || null;
return {
times: this.bars.map(bar => bar.t),
duration,
durations: duration == null
? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400)
: [],
};
}
static sourceDuration(source, index) {
return source.duration ?? source.durations[index] ?? source.durations[source.durations.length - 1] ?? null;
}
static timeframeIndexAt(source, time, allowFuture = true) {
const times = source?.times || [];
if (!times.length) return null;
let lo = 0;
let hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (times[mid] <= time) lo = mid + 1; else hi = mid;
}
const lower = lo - 1;
if (lower >= 0 && times[lower] === time) return lower;
if (lower < 0) return null;
const duration = ConfluenceChart.sourceDuration(source, lower);
const elapsed = time - times[lower];
if (!(duration > 0) || elapsed < 0) return null;
if (elapsed > duration && !(allowFuture && lower === times.length - 1)) return null;
return lower + elapsed / duration;
}
static timeframeTimeAt(source, index) {
const times = source?.times || [];
if (!times.length || index < 0) return null;
const lower = Math.min(Math.floor(index), times.length - 1);
const duration = ConfluenceChart.sourceDuration(source, lower);
if (!(duration > 0)) return null;
return Math.round(times[lower] + (index - lower) * duration);
}
sourceLinePriceAt(level, time) {
const source = this.sourceSeries(level);
if (!source) return null;
const start = ConfluenceChart.timeframeIndexAt(source, level.anchor_t);
const end = ConfluenceChart.timeframeIndexAt(source, level.last_t);
const target = ConfluenceChart.timeframeIndexAt(source, time);
if (start == null || end == null || target == null) return null;
if (start === end) return level.anchor_p;
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start);
}
shiftLineTime(level, time, bars) {
const source = this.sourceSeries(level);
if (!source) return this.timeAtIndex(this.indexAt(time) + bars);
const index = ConfluenceChart.timeframeIndexAt(source, time);
return index == null ? time : ConfluenceChart.timeframeTimeAt(source, index + bars) ?? time;
}
timeAtIndex(index) { timeAtIndex(index) {
const bars = this.bars; const bars = this.bars;
if (bars.length < 2) return bars[0]?.t ?? 0; if (bars.length < 2) return bars[0]?.t ?? 0;
@ -1610,6 +1720,9 @@ class ConfluenceChart {
} }
linePriceAtIndex(level, index) { linePriceAtIndex(level, index) {
if (this.trendlineGeometryMode === 'source_tf') {
return this.sourceLinePriceAt(level, this.timeAtIndex(index));
}
const start = this.indexAt(level.anchor_t); const start = this.indexAt(level.anchor_t);
const end = this.indexAt(level.last_t); const end = this.indexAt(level.last_t);
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
@ -1620,6 +1733,13 @@ class ConfluenceChart {
seriesLineData(level) { seriesLineData(level) {
if (!this.bars.length) return []; if (!this.bars.length) return [];
if (this.trendlineGeometryMode === 'source_tf') {
const cutoff = level.cutoff_t ?? Infinity;
return this.bars
.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff)
.map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) }))
.filter(point => point.value != null);
}
const start = this.indexAt(level.anchor_t); const start = this.indexAt(level.anchor_t);
const lastBar = this.bars.length - 1; const lastBar = this.bars.length - 1;
const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t); const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t);
@ -1640,17 +1760,11 @@ class ConfluenceChart {
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
if (!visible) return; if (!visible) return;
const lastBar = this.bars.length - 1; const lastBar = this.bars.length - 1;
const lines = this.levels const segment = (level, t1, p1, t2, p2) => {
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)) const x1 = this.coordinateAtTime(t1);
.map(level => { const x2 = this.coordinateAtTime(t2);
const start = Math.max(lastBar, this.indexAt(level.anchor_t), visible.from); const y1 = p1 == null ? null : this.candles.priceToCoordinate(p1);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2);
const end = Math.min(naturalEnd, visible.to);
if (end <= start) return null;
const x1 = this.coordinateAtIndex(start);
const x2 = this.coordinateAtIndex(end);
const y1 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, start));
const y2 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, end));
if ([x1, x2, y1, y2].some(value => value == null)) return null; if ([x1, x2, y1, y2].some(value => value == null)) return null;
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line'); const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
node.dataset.lineId = level.id; node.dataset.lineId = level.id;
@ -1661,8 +1775,58 @@ class ConfluenceChart {
node.setAttribute('stroke', ConfluenceChart.levelColor(level)); node.setAttribute('stroke', ConfluenceChart.levelColor(level));
node.setAttribute('stroke-width', level.line_width || 2); node.setAttribute('stroke-width', level.line_width || 2);
return node; return node;
};
const lines = this.levels
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
.map(level => {
const start = Math.max(lastBar, this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
const end = Math.min(naturalEnd, visible.to);
if (end <= start) return null;
const t1 = this.timeAtIndex(start);
const t2 = this.timeAtIndex(end);
const p1 = this.linePriceAtIndex(level, start);
const p2 = this.linePriceAtIndex(level, end);
return segment(level, t1, p1, t2, p2);
}) })
.filter(Boolean); .filter(Boolean);
if (this.trendlineGeometryMode === 'source_tf') {
const firstDisplayed = this.bars[0].t;
const lastDisplayed = this.bars[lastBar].t;
for (const level of this.levels.filter(value =>
value.kind === 'manual' && !value.hidden && !ConfluenceChart.isFlat(value))) {
const cutoff = level.cutoff_t ?? Infinity;
const sampled = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff
&& this.sourceLinePriceAt(level, bar.t) != null);
if (!sampled.length) {
if (Number.isFinite(cutoff)
&& level.anchor_t >= firstDisplayed && cutoff <= lastDisplayed) {
const node = segment(
level, level.anchor_t, level.anchor_p,
cutoff, this.sourceLinePriceAt(level, cutoff),
);
if (node) lines.push(node);
}
continue;
}
const first = sampled[0];
if (first.t > level.anchor_t && level.anchor_t >= firstDisplayed) {
const node = segment(
level, level.anchor_t, level.anchor_p,
first.t, this.sourceLinePriceAt(level, first.t),
);
if (node) lines.push(node);
}
const last = sampled[sampled.length - 1];
if (Number.isFinite(cutoff) && last.t < cutoff && cutoff <= lastDisplayed) {
const node = segment(
level, last.t, this.sourceLinePriceAt(level, last.t),
cutoff, this.sourceLinePriceAt(level, cutoff),
);
if (node) lines.push(node);
}
}
}
this.projectionLayer.replaceChildren(...lines); this.projectionLayer.replaceChildren(...lines);
} }
@ -1724,19 +1888,34 @@ class ConfluenceChart {
]; ];
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
const hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t); const hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
const hitIndexes = [this.indexAt(level.anchor_t), hitEnd]; const hitStart = this.indexAt(level.anchor_t);
const hitIndexes = [];
if (hitEnd != null) {
for (let index = Math.max(hitStart, visible?.from ?? hitStart); index <= hitEnd; index += 1) {
hitIndexes.push(index);
}
if (!hitIndexes.length || hitIndexes[hitIndexes.length - 1] < hitEnd) hitIndexes.push(hitEnd);
}
const hitPoints = hitIndexes.map(index => ({ const hitPoints = hitIndexes.map(index => ({
x: index == null ? null : this.coordinateAtIndex(index), x: index == null ? null : this.coordinateAtIndex(index),
y: index == null ? null : this.candles.priceToCoordinate(this.linePriceAtIndex(level, index)), y: index == null ? null : this.linePriceAtIndex(level, index),
})).map(point => ({
x: point.x,
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
})); }));
if (hitPoints.some(point => point.x == null || point.y == null)) { if (hitPoints.length < 2 || hitPoints.some(point => point.x == null || point.y == null)) {
this.lineHitTarget.setAttribute('hidden', ''); this.lineHitTarget.setAttribute('hidden', '');
} else { } else {
this.lineHitTarget.removeAttribute('hidden'); this.lineHitTarget.removeAttribute('hidden');
this.lineHitTarget.setAttribute('x1', hitPoints[0].x); const first = hitPoints[0];
this.lineHitTarget.setAttribute('y1', hitPoints[0].y); const last = hitPoints[hitPoints.length - 1];
this.lineHitTarget.setAttribute('x2', hitPoints[1].x); this.lineHitTarget.setAttribute('points', hitPoints.map(point => `${point.x},${point.y}`).join(' '));
this.lineHitTarget.setAttribute('y2', hitPoints[1].y); // Kept for diagnostics and the existing drag test, though the polyline's
// actual hit region follows every sampled point.
this.lineHitTarget.setAttribute('x1', first.x);
this.lineHitTarget.setAttribute('y1', first.y);
this.lineHitTarget.setAttribute('x2', last.x);
this.lineHitTarget.setAttribute('y2', last.y);
} }
points.forEach((point, index) => { points.forEach((point, index) => {
const x = this.coordinateAtTime(point.time); const x = this.coordinateAtTime(point.time);
@ -1757,7 +1936,7 @@ class ConfluenceChart {
if (event.button !== 0) return; if (event.button !== 0) return;
const level = this.levels.find(value => value.id === this.selectedLineId); const level = this.levels.find(value => value.id === this.selectedLineId);
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!level || !point || point.t == null) return; if (!level || level.geometry_resolved === false || !point || point.t == null) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.draggingLine = { this.draggingLine = {
@ -1776,10 +1955,21 @@ class ConfluenceChart {
if (!point || point.t == null || !this.withinPlot(point) || !level) return; if (!point || point.t == null || !this.withinPlot(point) || !level) return;
event.preventDefault(); event.preventDefault();
const { original, start } = this.draggingLine; const { original, start } = this.draggingLine;
const indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t)); const source = this.sourceSeries(original);
const sourcePoint = source && ConfluenceChart.timeframeIndexAt(source, point.t);
const sourceStart = source && ConfluenceChart.timeframeIndexAt(source, start.t);
const indexShift = Math.round(
sourcePoint != null && sourceStart != null
? sourcePoint - sourceStart
: this.indexAt(point.t) - this.indexAt(start.t),
);
const priceShift = ConfluenceChart.snapToTick(point.p - start.p); const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); const anchorT = source
const lastT = this.timeAtIndex(this.indexAt(original.last_t) + indexShift); ? this.shiftLineTime(original, original.anchor_t, indexShift)
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
const lastT = source
? this.shiftLineTime(original, original.last_t, indexShift)
: this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
const endPrice = original.anchor_p const endPrice = original.anchor_p
+ original.slope * (original.last_t - original.anchor_t) + priceShift; + original.slope * (original.last_t - original.anchor_t) + priceShift;
level.anchor_t = anchorT; level.anchor_t = anchorT;
@ -1787,7 +1977,9 @@ class ConfluenceChart {
level.anchor_p = original.anchor_p + priceShift; level.anchor_p = original.anchor_p + priceShift;
level.slope = (endPrice - level.anchor_p) / (lastT - anchorT); level.slope = (endPrice - level.anchor_p) / (lastT - anchorT);
if (original.cutoff_t != null) { if (original.cutoff_t != null) {
level.cutoff_t = this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift); level.cutoff_t = source
? this.shiftLineTime(original, original.cutoff_t, indexShift)
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
} }
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
@ -1952,6 +2144,10 @@ ConfluenceChart.tfColors = {
'30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073', '30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073',
}; };
ConfluenceChart.tfSeconds = {
'1m': 60, '2m': 120, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600,
};
// VWAP and the prior-day levels are both stamped 1d, so without their own // VWAP and the prior-day levels are both stamped 1d, so without their own
// colours they would be indistinguishable from the daily moving averages. // colours they would be indistinguishable from the daily moving averages.
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' }; ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };

View file

@ -99,6 +99,9 @@
<i :class="`fa-solid fa-${symbol.icon}`"></i> <i :class="`fa-solid fa-${symbol.icon}`"></i>
</button> </button>
</div> </div>
<label>Size<select v-model.number="symbolScale" aria-label="Mark size">
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
</select></label>
</div> </div>
</div> </div>
<div class="tool tool-level" :class="{armed: armedTool === 'level'}"> <div class="tool tool-level" :class="{armed: armedTool === 'level'}">
@ -261,6 +264,11 @@
</details> </details>
</div> </div>
<div class="drawing-controls" @click.stop v-else> <div class="drawing-controls" @click.stop v-else>
<select v-if="item.kind === 'symbol'" class="mark-scale" :value="item.comment.scale || 1"
aria-label="Mark size"
@change="updateLineStyle(item.comment, {scale: Number($event.target.value)})">
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
</select>
<button v-if="item.kind === 'comment'" class="collapse-toggle" @click.stop="toggleComment(item.comment)" <button v-if="item.kind === 'comment'" class="collapse-toggle" @click.stop="toggleComment(item.comment)"
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'"> :title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i> <i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>

View file

@ -25,7 +25,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; } .trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
.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:95px; 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; }.drawing-controls select.mark-scale { width:38px; }.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:95px; 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; }
.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; }
@ -76,7 +76,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
font-size:9px; white-space:nowrap; } font-size:9px; white-space:nowrap; }
.chart-comment.floating { border-style:dashed; } .chart-comment.floating { border-style:dashed; }
.chart-comment:not(.symbol) { padding-left:19px; }.chart-comment-collapse { position:absolute; inset:0 auto 0 0; display:grid; place-items:center; width:14px; padding:0; border:0; border-right:1px solid var(--line); color:var(--muted); font-size:7px; }.chart-comment-collapse[hidden],.chart-comment-grab[hidden] { display:none; }.chart-comment-grab { position:absolute; right:-9px; top:-9px; z-index:2; display:grid; place-items:center; width:18px; height:18px; padding:0; border:1px solid var(--line); border-radius:50%; background:var(--panel); color:var(--muted); font-size:8px; cursor:grab; opacity:.7; }.chart-comment-grab:hover { opacity:1; color:var(--fg); }.chart-comment-grab:active { cursor:grabbing; } .chart-comment:not(.symbol) { padding-left:19px; }.chart-comment-collapse { position:absolute; inset:0 auto 0 0; display:grid; place-items:center; width:14px; padding:0; border:0; border-right:1px solid var(--line); color:var(--muted); font-size:7px; }.chart-comment-collapse[hidden],.chart-comment-grab[hidden] { display:none; }.chart-comment-grab { position:absolute; right:-9px; top:-9px; z-index:2; display:grid; place-items:center; width:18px; height:18px; padding:0; border:1px solid var(--line); border-radius:50%; background:var(--panel); color:var(--muted); font-size:8px; cursor:grab; opacity:.7; }.chart-comment-grab:hover { opacity:1; color:var(--fg); }.chart-comment-grab:active { cursor:grabbing; }
.chart-comment.floating { cursor:pointer; }.chart-comment.symbol { display:grid; place-items:center; width:30px; height:30px; padding:0; border-radius:50%; color:var(--comment-focus-color); font-size:18px; cursor:pointer; }.chart-comment.symbol .chart-comment-text { display:grid; place-items:center; } .chart-comment.floating { cursor:pointer; }.chart-comment.symbol { display:grid; place-items:center; width:calc(30px * var(--mark-scale, 1)); height:calc(30px * var(--mark-scale, 1)); padding:0; border-radius:50%; color:var(--comment-focus-color); font-size:calc(18px * var(--mark-scale, 1)); cursor:pointer; }.chart-comment.symbol .chart-comment-text { display:grid; place-items:center; }
.chart-comment-focus { position:absolute; inset:-1px; border:1px solid var(--comment-focus-color); border-radius:inherit; opacity:0; pointer-events:none; } .chart-comment-focus { position:absolute; inset:-1px; border:1px solid var(--comment-focus-color); border-radius:inherit; opacity:0; pointer-events:none; }
.chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; } .chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; }
@keyframes comment-focus { @keyframes comment-focus {

View file

@ -24,6 +24,67 @@ const timeAt = (page, box, point) =>
return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX())); return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX()));
}, point.x - box.x); }, point.x - box.x);
test('a 30m line keeps source geometry when the 1m window starts after its anchors',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const halfHour = 30 * 60;
const weekend = 49 * 3600;
const sourceTimes = [
0, halfHour,
halfHour + weekend, 2 * halfHour + weekend,
3 * halfHour + weekend, 4 * halfHour + weekend,
];
const displayed = [];
for (let t = sourceTimes[4]; t <= sourceTimes[5]; t += 60) {
displayed.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
}
const line = {
id: 'synthetic_30m', kind: 'manual', tf: '30m',
anchor_t: sourceTimes[0], anchor_p: 100,
last_t: sourceTimes[3],
slope: 3 / (sourceTimes[3] - sourceTimes[0]),
cutoff_t: null,
};
c.bars = displayed;
c.setTrendlineGeometry({
mode: 'source_tf',
series: { '30m': { times: sourceTimes, duration: halfHour } },
});
const sourcePrice = c.linePriceAtIndex(line, displayed.length - 1);
const sampled = c.seriesLineData(line);
const shifted = c.shiftLineTime(line, line.anchor_t, 1);
c.updateTrendlineTime('30m', sourceTimes[5], halfHour, sourceTimes[2]);
const trimmedTimes = c.trendlineSourceSeries.get('30m').times;
c.setTrendlineGeometry({ mode: 'legacy', series: {} });
const legacyPrice = c.linePriceAtIndex(line, displayed.length - 1);
return {
sourcePrice,
legacyPrice,
shifted,
sourceSecondBar: sourceTimes[1],
sampledTimes: sampled.map(point => point.time),
displayedTimes: displayed.map(bar => bar.t),
trimmedTimes,
expectedTrimmedTimes: sourceTimes.slice(2),
};
});
assert.equal(result.sourcePrice, 105,
'the 30m line was repriced in the truncated 1m window');
assert.notEqual(result.legacyPrice, result.sourcePrice,
'the rollback path no longer restores displayed-grid geometry');
assert.equal(result.shifted, result.sourceSecondBar,
'a source-timeframe nudge did not move by one 30m bar');
assert.deepEqual(result.sampledTimes, result.displayedTimes,
'manual line samples introduced timestamps outside the displayed candle grid');
assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
'source timestamps evicted by the server remained in the browser');
assertNoPageErrors(page, assert);
});
});
test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => { test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
const box = await chartBox(page); const box = await chartBox(page);

View file

@ -1,4 +1,4 @@
from app.analysis.bar_space import index_at, price_in_bar_space from app.analysis.bar_space import index_at, price_in_bar_space, price_in_timeframe_space
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe from app.bars.models import Timeframe
@ -43,3 +43,70 @@ def test_flat_anchors_return_the_anchor_price():
times = [i * MINUTE for i in range(5)] times = [i * MINUTE for i in range(5)]
level = sloped(0, 100.0, MINUTE, 100.0) level = sloped(0, 100.0, MINUTE, 100.0)
assert price_in_bar_space(level, times, times[4]) == 100.0 assert price_in_bar_space(level, times, times[4]) == 100.0
def test_a_30m_line_does_not_change_slope_when_1m_history_starts_after_its_anchors():
half_hour = 30 * MINUTE
weekend = 49 * 3600
source_times = [
0, half_hour,
half_hour + weekend, 2 * half_hour + weekend,
3 * half_hour + weekend, 4 * half_hour + weekend,
]
line = sloped(source_times[0], 100.0, source_times[3], 103.0)
line.tf = Timeframe.M30
# This is the browser's failure mode: its 1m window starts after both
# anchors, so legacy edge extrapolation invents a different coordinate
# system. Source geometry still advances one point per 30m bar.
target = source_times[5]
truncated_minutes = list(range(source_times[4], target + MINUTE, MINUTE))
assert price_in_timeframe_space(line, source_times, Timeframe.M30, target) == 105.0
assert price_in_bar_space(line, truncated_minutes, target) != 105.0
def test_complete_nested_30m_and_1m_grids_agree():
half_hour = 30 * MINUTE
source_times = [i * half_hour for i in range(12)]
minute_times = list(range(0, source_times[-1] + MINUTE, MINUTE))
line = sloped(source_times[1], 100.0, source_times[5], 104.0)
line.tf = Timeframe.M30
target = source_times[9]
assert price_in_timeframe_space(line, source_times, Timeframe.M30, target) == 108.0
assert price_in_bar_space(line, minute_times, target) == 108.0
def test_a_partial_30m_bar_advances_normally_before_a_weekend():
half_hour = 30 * MINUTE
weekend = 49 * 3600
source_times = [0, half_hour, half_hour + weekend]
line = sloped(0, 100.0, half_hour, 101.0)
line.tf = Timeframe.M30
assert price_in_timeframe_space(
line, source_times, Timeframe.M30, half_hour - MINUTE,
) == 100.0 + 29 / 30
assert price_in_timeframe_space(
line, source_times, Timeframe.M30, half_hour + weekend,
) == 102.0
def test_source_geometry_refuses_to_invent_history_before_its_first_bar():
half_hour = 30 * MINUTE
source_times = [half_hour, 2 * half_hour, 3 * half_hour]
line = sloped(0, 100.0, half_hour, 101.0)
line.tf = Timeframe.M30
assert price_in_timeframe_space(line, source_times, Timeframe.M30, 3 * half_hour) is None
def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser():
half_hour = 30 * MINUTE
source_times = [0, half_hour, 2 * half_hour]
line = sloped(0, 100.0, 10 * half_hour, 110.0)
line.tf = Timeframe.M30
assert price_in_timeframe_space(
line, source_times, Timeframe.M30, 2 * half_hour,
) == 102.0

View file

@ -100,6 +100,29 @@ def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
assert sloped.drawing_kind == "trendline" assert sloped.drawing_kind == "trendline"
def test_a_mark_scale_survives_json_and_does_not_move_its_anchor(tmp_path):
path = tmp_path / "lines.json"
store = ManualLineStore(path)
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
store.add(ManualLine(id="ml_mark", slope=0.0, kind="symbol",
icon="skull", note="Skull", scale=2.0, **common))
raw = ManualLine(
id="ml_old", slope=0.0, kind="symbol", icon="play", note="Go", **common,
).to_dict()
del raw["scale"]
store.add(ManualLine.from_dict(raw))
loaded = ManualLineStore(path)
mark = loaded.lines["ml_mark"]
old = loaded.lines["ml_old"]
assert mark.scale == 2.0
assert (mark.anchor_t, mark.anchor_p) == (1000, 100.0)
assert old.scale == 1.0
assert (old.anchor_t, old.anchor_p) == (1000, 100.0)
assert [level.id for level in loaded.levels()] == []
def test_every_drawing_gets_a_number_including_comments(tmp_path): def test_every_drawing_gets_a_number_including_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side from app.analysis.levels import Side

View file

@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_start from app.bars.session import bucket_duration, bucket_start
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@ -56,3 +56,9 @@ def test_sunday_open_across_dst(local_value, expected_local):
) )
def test_daily_open_tracks_eastern_dst_in_utc(value_utc, expected_open_utc): def test_daily_open_tracks_eastern_dst_in_utc(value_utc, expected_open_utc):
assert bucket_start(epoch(value_utc), Timeframe.D1) == epoch(expected_open_utc) assert bucket_start(epoch(value_utc), Timeframe.D1) == epoch(expected_open_utc)
def test_daily_geometry_excludes_the_settlement_halt():
start = epoch("2026-08-10T18:00:00", ET)
assert bucket_duration(start, Timeframe.D1) == 23 * 3600

View file

@ -0,0 +1,87 @@
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.bars.models import Bar, Timeframe
from app.config import Settings
from app.runtime import Runtime
MINUTE = 60
def bar(tf: Timeframe, t: int) -> Bar:
return Bar(tf, t, 100, 101, 99, 100, 1, True, "/ES", "test")
def add_weekend_line(runtime: Runtime) -> tuple[ManualLine, list[int], int]:
half_hour = 30 * MINUTE
weekend = 49 * 3600
source_times = [
0, half_hour,
half_hour + weekend, 2 * half_hour + weekend,
3 * half_hour + weekend, 4 * half_hour + weekend,
]
line = runtime.manual_lines.add(ManualLine(
id="ml_30m", tf=Timeframe.M30, side=Side.SUPPORT,
anchor_t=source_times[0], anchor_p=100,
slope=3 / (source_times[3] - source_times[0]),
last_t=source_times[3], created_at=source_times[3],
))
for t in source_times:
runtime.store.put(bar(Timeframe.M30, t))
target = source_times[-1]
for t in range(source_times[-2], target + MINUTE, MINUTE):
runtime.store.put(bar(Timeframe.M1, t))
return line, source_times, target
def test_runtime_prices_a_30m_line_in_30m_space_when_1m_anchors_are_off_window(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "lines.json",
trendline_source_geometry=True,
))
add_weekend_line(runtime)
runtime.rebuild_levels()
level = next(value for value in runtime.levels if value.id == "ml_30m")
assert level.current_p == 105.0
assert level.geometry_resolved is True
def test_rollback_gate_restores_legacy_1m_pricing(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "lines.json",
trendline_source_geometry=False,
))
add_weekend_line(runtime)
runtime.rebuild_levels()
level = next(value for value in runtime.levels if value.id == "ml_30m")
assert level.current_p != 105.0
assert level.geometry_resolved is True
def test_a_line_without_its_source_anchor_history_cannot_cluster_or_alert(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "lines.json",
trendline_source_geometry=True,
))
line = runtime.manual_lines.add(ManualLine(
id="ml_old", tf=Timeframe.M30, side=Side.SUPPORT,
anchor_t=0, anchor_p=100, slope=1 / 1800,
last_t=1800, created_at=1800,
))
runtime.store.put(bar(Timeframe.M30, 3600))
runtime.store.put(bar(Timeframe.M30, 5400))
runtime.store.put(bar(Timeframe.M1, 5400))
runtime.price = 102
runtime.atr15 = 10
runtime.rebuild_levels()
level = next(value for value in runtime.levels if value.id == line.id)
assert level.current_p is None
assert level.geometry_resolved is False
assert all(level.id not in {member.id for member in cluster.members}
for cluster in runtime.clusters)

View file

@ -3,6 +3,7 @@ from fastapi.testclient import TestClient
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.analysis.manual_lines import ManualLine
from app.api.ws import router as ws_router, snapshot from app.api.ws import router as ws_router, snapshot
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.config import Settings from app.config import Settings
@ -59,3 +60,35 @@ def test_quote_change_always_uses_the_daily_session_open(tmp_path):
message = snapshot(runtime, Timeframe.H1) message = snapshot(runtime, Timeframe.H1)
assert message["session_open"] == 6123.25 assert message["session_open"] == 6123.25
def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
trendline_source_geometry=True,
))
runtime.manual_lines.add(ManualLine(
"ml_30m", Timeframe.M30, Side.SUPPORT,
1000, 100, 1 / 1800, 2800, 2800,
))
runtime.store.put(Bar(Timeframe.M30, 1000, 100, 101, 99, 100, 1, True, "/ES", "test"))
runtime.store.put(Bar(Timeframe.M30, 2800, 100, 101, 99, 100, 1, True, "/ES", "test"))
runtime.rebuild_levels()
geometry = snapshot(runtime, Timeframe.M1)["trendline_geometry"]
assert geometry == {
"mode": "source_tf",
"series": {"30m": {"times": [1000, 2800], "duration": 1800}},
}
def test_snapshot_rollback_gate_omits_source_geometry(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
trendline_source_geometry=False,
))
assert snapshot(runtime, Timeframe.M1)["trendline_geometry"] == {
"mode": "legacy", "series": {},
}