Compare commits

..

No commits in common. "a921db4610ccebd05db1af2dd9a139ef75e6f393" and "952a3bb7f369d8d490782900f73fb7e5520a89cc" have entirely different histories.

24 changed files with 106 additions and 1120 deletions

View file

@ -22,8 +22,6 @@ 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,9 +381,3 @@ 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,9 +9,6 @@ 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, next_bucket_start
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."""
@ -42,62 +39,3 @@ 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:
if not (allow_future and lower == len(times) - 1):
return None
current = times[lower]
index = float(lower)
for _ in range(10000):
following = next_bucket_start(current, tf)
if t < following:
active = bucket_duration(current, tf)
return index + (t - current) / active if t <= current + active else None
index += 1
current = following
if t == current:
return index
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,9 +41,7 @@ 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 level.geometry_resolved if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
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,10 +51,6 @@ 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,9 +34,6 @@ 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:
@ -125,7 +122,6 @@ 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,10 +23,6 @@ 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:
@ -111,7 +107,6 @@ 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")
@ -124,9 +119,6 @@ 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.
@ -191,7 +183,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 positioned_level(runtime, line.id).to_dict() return line.to_level().to_dict()
@router.post("/lines/price", status_code=201) @router.post("/lines/price", status_code=201)
@ -218,7 +210,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 positioned_level(runtime, line.id).to_dict() return line.to_level().to_dict()
class CommentCreate(BaseModel): class CommentCreate(BaseModel):
@ -236,7 +228,6 @@ 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)
@ -261,7 +252,6 @@ 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,
@ -358,9 +348,7 @@ 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 positioned_level( return line.to_dict() if line.is_comment else line.to_level().to_dict()
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,9 +4,7 @@ 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, future_bucket_starts
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
router = APIRouter() router = APIRouter()
@ -53,35 +51,6 @@ 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):
series[tf.value] = trendline_series(runtime, tf)
return {"mode": "source_tf", "series": series}
def trendline_series(runtime, tf: Timeframe) -> dict:
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
future = future_bucket_starts(times[-1], tf) if times else []
value["future_times"] = future
value["future_durations"] = [bucket_duration(t, tf) for t in future]
return value
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",
@ -93,7 +62,6 @@ 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),
} }
@ -116,7 +84,6 @@ 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
@ -137,10 +104,6 @@ async def websocket_endpoint(websocket: WebSocket):
"clusters": [cluster.to_dict() for cluster in clusters], "clusters": [cluster.to_dict() for cluster in clusters],
} }
) )
elif message.get("type") == "trendline_geometry":
await websocket.send_json(
{"type": "trendline_geometry", "geometry": trendline_geometry(runtime)}
)
except WebSocketDisconnect: except WebSocketDisconnect:
queue.put_nowait({"type": "disconnect"}) queue.put_nowait({"type": "disconnect"})
@ -150,60 +113,18 @@ 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": if event["type"] == "bar" and event["bar"].tf is tf:
bar = event["bar"] await websocket.send_json(
source_tfs = trendline_timeframes(runtime) {
source_bars = runtime.store.get(bar.tf) "type": "bar", "tf": tf.value,
first_source_t = source_bars[0].t if source_bars else None "bar": event["bar"].to_dict(),
source_index = next( "session_open": session_open(runtime),
(index for index, value in enumerate(source_bars) if value.t == bar.t), None, }
) )
previous_source_t = (
source_bars[source_index - 1].t
if source_index is not None and source_index > 0 else None
)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
if bar.tf is tf:
await websocket.send_json(
{
"type": "bar", "tf": tf.value,
"bar": bar.to_dict(),
"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
),
"trendline_previous_t": previous_source_t,
"trendline_future_times": source_geometry.get("future_times", []),
"trendline_future_durations": source_geometry.get("future_durations", []),
}
)
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,
"previous_t": previous_source_t,
"future_times": source_geometry.get("future_times", []),
"future_durations": source_geometry.get("future_durations", []),
}
)
elif event["type"] == "levels": elif event["type"] == "levels":
message = { await websocket.send_json(
"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,7 +6,6 @@ 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:
@ -23,37 +22,3 @@ 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
def next_bucket_start(t: int, tf: Timeframe) -> int:
"""Next projected source-bar open, with the daily weekend skipped."""
if tf is not Timeframe.D1:
return t + tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
candidate = current.date() + timedelta(days=1)
# Daily futures sessions open Sunday through Thursday.
while candidate.weekday() not in {6, 0, 1, 2, 3}:
candidate += timedelta(days=1)
return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp())
def future_bucket_starts(t: int, tf: Timeframe, count: int = 64) -> list[int]:
values = []
for _ in range(count):
t = next_bucket_start(t, tf)
values.append(t)
return values

View file

@ -30,9 +30,6 @@ 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, price_in_timeframe_space from app.analysis.bar_space import price_in_bar_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,7 +200,8 @@ class Runtime:
self.broadcast_level_delta() self.broadcast_level_delta()
self.rebuild_clusters() self.rebuild_clusters()
def position_manual_levels(self, levels: list[Level], bars: list[Bar]) -> None: @staticmethod
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.
@ -209,17 +210,11 @@ class Runtime:
""" """
if not bars: if not bars:
return return
minute_times = [bar.t for bar in bars] times = [bar.t for bar in bars]
now = minute_times[-1] now = times[-1]
for level in levels: for level in levels:
if not level.slope: if level.slope:
continue level.current_p = price_in_bar_space(level, times, now)
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

@ -1,88 +0,0 @@
# /ES futures-options quote finder
**Status:** future feature; no quote-finder UI, API route, or futures-options
subscription is implemented yet.
## Purpose
Find a small, user-selected set of `/ES` futures options more quickly than the
thinkorswim option-chain UI, show their live tradable quotes, and produce an
unambiguous leg or spread description that the user can copy into thinkorswim.
The app assists research and pricing only. The user reviews and enters every
order in thinkorswim; it must not submit, simulate, or automate orders.
The first useful workflow is deliberately narrow:
1. Select a futures-option expiration and call/put side.
2. Select one or more strikes, including the two legs of a vertical spread.
3. Fetch a current snapshot of bid, ask, mark, last, volume, and open interest
for that small set of exact contracts.
4. Copy a thinkorswim-tested contract or spread description for manual entry.
Do not subscribe to or poll the whole option universe. Discovery should narrow
the candidates first, then one REST request obtains the selected quotes. A user
may explicitly refresh a stale snapshot; continuous streaming is out of scope.
## Schwab API status
Checked against the configured live Schwab credentials on 2026-08-14. These
were read-only requests; no account or order endpoint was called.
| Capability | Result | Implication |
|---|---|---|
| OAuth token and Schwab client | Available | The existing app already authenticates and streams `/ES` futures. |
| `get_quotes(["/ES"])` | Works: HTTP 200, resolving to active future `/ESU26` with `assetMainType: FUTURE` | Use the plural quote endpoint. `get_quote("/ES")` puts the slash in a URL path and can return the `ES` equity instead. |
| `get_option_expiration_chain("/ES")` | Works: HTTP 200; it returned four `/ES` expiry entries and option root `ES` | Use it to enumerate available expiration dates and roots. |
| `get_option_chain("/ES")` | Fails: HTTP 400 | It is not a usable `/ES` futures-options chain/discovery endpoint. |
| `get_option_chain("/ESU26")` | Fails: HTTP 400 | Resolving the active underlying contract does not make the chain endpoint work. |
| `get_quotes()` for the supplied thinkorswim text `./E3AQ26P7780:XCME` | Fails semantically: HTTP 200 with `errors.invalidSymbols` | A thinkorswim identifier is not automatically a valid Schwab REST symbol. Resolve the API equivalent explicitly. |
| `get_quotes(["./E3AQ26P7780"])` | Works: `assetMainType: FUTURE_OPTION`; its reference description is `./E3AQ26P7780:XCME` | For this verified contract, the API form is the thinkorswim text with the `:XCME` exchange suffix removed. The snapshot included bid, ask, mark, last, volume, and open interest. |
| `LEVEL_ONE_FUTURES_OPTIONS` for `./E3AQ26P7780:XCME` | The subscription command was accepted, but no quote handler message arrived during a 15-second premarket probe | Not needed for this feature: it uses snapshots, not continuous updates. This result is retained only as future reference. |
| Historical futures-options prices | Not available | Schwab price history is not available for futures or options; the finder is a current-quote tool, not a historical-pricing system. |
| Futures-options order entry | Not available for this workflow | Keep execution in thinkorswim. Schwab Trader API support for equities and standard options must not be mistaken for `/ES` futures-options routing. |
The expiration endpoint narrows the date/root, but it does **not** return every
strike or a quoteable option symbol. Exact futures-option symbol resolution is
therefore the feature's critical discovery problem, not a formatting detail.
## Symbol and thinkorswim contract
Do not invent a futures-option symbol from a guessed `ES`, month, strike, and
call/put pattern. The supplied thinkorswim string `./E3AQ26P7780:XCME` maps to
the verified API symbol `./E3AQ26P7780`: removing `:XCME` returned the intended
`FUTURE_OPTION`, whose description returned the original thinkorswim form. This
is one tested mapping, not yet a general rule for every exchange, product,
expiration, or option root. Before building the finder, prove the following for
representative current `/ES` options:
1. Schwab `get_quotes()` returns the intended futures option, including a valid
bid and ask rather than an equity or an error.
2. The finder output can be pasted or searched in thinkorswim to select the
same leg. A two-leg spread must preserve buy/sell direction and quantity as
well as strike, expiration, and call/put side.
Store separately any Schwab API symbol and the thinkorswim copy text. They may
be identical, but that is an acceptance criterion to prove, not an assumption.
## Implementation constraints
- Keep future-options code isolated with the existing broker integration in
`app/market/`; no analysis, bars, or UI code should call Schwab directly.
- Request REST snapshots only after a user has selected exact contracts or
explicitly asked to refresh. Batch all displayed contracts into one
`get_quotes()` request; do not open a futures-options WebSocket subscription.
- Show quote freshness and whether data is delayed. A stale, wide, or missing
market is more important than a calculated spread mark.
- Treat a spread mark as a display calculation from the two current legs, not
an executable price. Preserve both bid/ask combinations so the UI can show
realistic debit/credit bounds.
- Do not persist credentials, account details, or order state in this feature.
## First implementation gate
Add a small read-only probe using representative current `/ES` options copied
from thinkorswim and their mapped Schwab API identifiers. Capture both forms,
the REST response type and quote fields, snapshot timestamp or delay status,
and the expiration/strike shown by each system. Add a regression fixture only
after that probe establishes a stable real payload and mapping rule. Until
then, a full-chain UI or automatic symbol construction would be speculative.

View file

@ -922,115 +922,3 @@ 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. Each delta also names its predecessor; a gap caused by
queue pressure or a sleeping tab makes the browser request a full replacement
instead of silently losing one logical bar. 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. Snapshots also
carry future source slots so editing and duplication use the same timestamps on
both sides; daily slots stay at 18:00 ET and skip the weekend. Intraday future
slots retain the separate, pre-existing limitation that the future axis is not
session-aware.
The first browser implementation rebuilt `historical + future` timestamps for
every sampled point. The math test passed, but the next trendline run slowed its
first case from about 4 seconds to 16 and then timed out unrelated placement
tests. Caching the combined array once per source update restored the run to its
normal duration. This was allocation pressure, not a geometry or feed failure.
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: 150 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.
**Selected-line black triangle on a coarser timeframe.** The source-geometry
change made the invisible whole-line drag target a sampled SVG `polyline`
instead of a two-point `line`. Its stroke was transparent, but SVG polylines
default to a black fill. A selected 30m line viewed on 1h therefore closed its
hit path into a giant black polygon; one edge looked like a horizontal line from
the left side of the pane to a point at the price axis. `.chart-line-hit` now
sets `fill:none`. Browser coverage checks the computed fill because the visual
artifact only exists in SVG rendering, not in trendline arithmetic.
The black fill hid a second defect in the same report: after removing it, every
half-hour 30m anchor still had a near-horizontal segment from the left edge when
viewed on 1h. `coordinateAtTime` trusted any non-null `timeToCoordinate` answer.
Lightweight Charts uses a shared union of every series timestamp, so it could
answer for the 30m `:30` anchor even though that instant was not a displayed 1h
candle; in the reproduced case it answered `x=0`. The real first 1h sample was
at `x=1082`, producing the long segment. The first fallback still failed because
`logicalToCoordinate` clamps a far shared logical slot to zero. Coordinates now
interpolate between the surrounding displayed candles' own pixel coordinates,
which puts `:30` halfway between the hourly candles and remains correct even
when another series has inserted foreign timestamps into the shared scale.

View file

@ -596,18 +596,10 @@ 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 persisted as **absolute epoch seconds and price** — never **Geometry.** Anchors are stored in **absolute epoch seconds and price** — never bar
bar indices — so changing the algorithm does not rewrite drawings. A sloped line is indices. This is why a line drawn on the 4h chart renders correctly on the 1m chart
evaluated in the logical bar space of its attributed timeframe, however: charts with no conversion: both are the same `(time, price)` plane. The existing
compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49 `Level.price_at(t)` already handles it.
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
@ -624,10 +616,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 | Sample source-timeframe geometry onto displayed candles; extend right in an SVG overlay | | Render | `LineSeries` with 2 points, extended right (same as §9 auto lines) |
| Select | Click within ~6px of the canonical price at that displayed bar | | Select | Click within ~6px of a line — hit-test in price space via `price_at(t)` |
| Delete | `Delete`/`Backspace` on selection, plus a button | | Delete | `Delete`/`Backspace` on selection, plus a button |
| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars | | Edit | **Delete and redraw.** Endpoint dragging is real work — do not build it in M5 |
**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
@ -1023,7 +1015,6 @@ 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,8 +123,6 @@ 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.
@ -288,7 +286,6 @@ 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) {
@ -309,32 +306,10 @@ 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();
if (message.trendline_first_t != null) {
const geometryCurrent = chartApi.updateTrendlineTime(
message.bar.tf, message.bar.t, null, message.trendline_first_t,
message.trendline_previous_t,
message.trendline_future_times, message.trendline_future_durations,
);
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
}
}
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') {
const geometryCurrent = chartApi.updateTrendlineTime(
message.tf, message.t, message.duration, message.first_t, message.previous_t,
message.future_times, message.future_durations,
);
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
}
syncVisibleLevels();
} else if (message.type === 'trendline_geometry') {
chartApi.setTrendlineGeometry(message.geometry);
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.
@ -342,7 +317,6 @@ 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;
@ -545,7 +519,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, scale: symbolScale.value, pinned: true, text: choice.name, icon, color: symbolColor.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,
}), }),
}); });
@ -675,8 +649,18 @@ createApp({
await deleteLines([id]); await deleteLines([id]);
} }
function shiftTimeByBars(line, time, count = 10) { function shiftTimeByBars(time, count = 10) {
return chartApi?.shiftLineTime(line, time, count) ?? time; const bars = chartApi?.bars || [];
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) {
@ -689,9 +673,8 @@ createApp({
} }
async function duplicateLine(line) { async function duplicateLine(line) {
if (line.geometry_resolved === false) return; const anchorT = shiftTimeByBars(line.anchor_t);
const anchorT = shiftTimeByBars(line, line.anchor_t); const endT = shiftTimeByBars(line.last_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' },
@ -701,7 +684,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, line.cutoff_t), cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line.cutoff_t),
armed: line.armed, armed: line.armed,
}), }),
}); });
@ -840,11 +823,10 @@ 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) {
if (line.geometry_resolved === false) continue; line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal);
line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal); line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_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.shiftLineTime(line, line.cutoff_t, horizontal); line.cutoff_t = chartApi.timeAtIndex(chartApi.indexAt(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);
@ -1031,6 +1013,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, 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 }; 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 };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -15,8 +15,6 @@ 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;
@ -280,7 +278,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', 'polyline'); this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line');
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));
@ -440,67 +438,6 @@ 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]) => {
const times = (value.times || []).map(Number);
const futureTimes = (value.future_times || []).map(Number);
return [tf, {
times,
duration: value.duration == null ? null : Number(value.duration),
durations: (value.durations || []).map(Number),
futureTimes,
futureDurations: (value.future_durations || []).map(Number),
allTimes: [...times, ...futureTimes],
}];
}),
);
}
updateTrendlineTime(
tf, time, duration = null, firstTime = null, previousTime = null,
futureTimes = [], futureDurations = [],
) {
if (this.trendlineGeometryMode !== 'source_tf') return true;
let source = this.trendlineSourceSeries.get(tf);
if (!source) {
source = {
times: [], duration: duration == null ? null : Number(duration), durations: [],
futureTimes: [], futureDurations: [],
allTimes: [],
};
this.trendlineSourceSeries.set(tf, source);
}
const t = Number(time);
const lastBeforeUpdate = source.times[source.times.length - 1];
if (lastBeforeUpdate != null && t > lastBeforeUpdate
&& previousTime != null && lastBeforeUpdate !== Number(previousTime)) {
return false;
}
if (lastBeforeUpdate == null && previousTime != null) return false;
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 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);
}
source.futureTimes = futureTimes.map(Number);
source.futureDurations = futureDurations.map(Number);
source.allTimes = [...source.times, ...source.futureTimes];
return true;
}
/** /**
* 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.
* *
@ -725,10 +662,8 @@ 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');
@ -750,19 +685,9 @@ 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 boxW = node.root.offsetWidth; const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x;
const boxH = node.root.offsetHeight; node.root.style.left = `${Math.round(clampedX)}px`;
if (iconClass) { node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
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.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)) {
@ -1195,21 +1120,19 @@ class ConfluenceChart {
coordinateAtIndex(index) { coordinateAtIndex(index) {
if (!this.bars.length) return null; if (!this.bars.length) return null;
const scale = this.chart.timeScale(); const scale = this.chart.timeScale();
if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t); const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t);
const last = this.bars.length - 1; const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index); if (lastLogical == null) return null;
const upper = lower + 1; return scale.logicalToCoordinate(Number(lastLogical) + index - (this.bars.length - 1));
const x1 = scale.timeToCoordinate(this.bars[lower].t);
const x2 = scale.timeToCoordinate(this.bars[upper].t);
if (x1 == null || x2 == null) return null;
return x1 + (x2 - x1) * (index - lower);
} }
coordinateAtTime(time) { coordinateAtTime(time) {
if (!this.bars.length) return null; const last = this.bars[this.bars.length - 1];
// Interpolate between the displayed candles' own pixel coordinates. The if (!last) return null;
// shared scale may contain foreign timestamps from other series, and its if (time <= last.t) {
// logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h. const direct = this.chart.timeScale().timeToCoordinate(time);
if (direct != null) return direct;
}
return this.coordinateAtIndex(this.indexAt(time)); return this.coordinateAtIndex(this.indexAt(time));
} }
@ -1620,11 +1543,18 @@ 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);
const pointIndex = this.indexAtCoordinate(param.point.x); if (endIndex <= startIndex) continue;
if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue; const start = {
const linePrice = this.linePriceAtIndex(level, pointIndex); x: this.coordinateAtIndex(startIndex),
const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice); y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)),
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 };
} }
@ -1658,85 +1588,6 @@ 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)
: [],
futureTimes: [],
futureDurations: [],
allTimes: this.bars.map(bar => bar.t),
};
}
static sourceDuration(source, index) {
if (source.duration != null) return source.duration;
if (index < source.times.length) {
return source.durations[index] ?? source.durations[source.durations.length - 1] ?? null;
}
const futureIndex = index - source.times.length;
return source.futureDurations[futureIndex]
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
}
static sourceTimes(source) {
return source?.allTimes || source?.times || [];
}
static timeframeIndexAt(source, time, allowFuture = true) {
const times = ConfluenceChart.sourceTimes(source);
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 = ConfluenceChart.sourceTimes(source);
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;
@ -1759,9 +1610,6 @@ 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);
@ -1772,13 +1620,6 @@ 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);
@ -1799,22 +1640,6 @@ 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 segment = (level, t1, p1, t2, p2) => {
const x1 = this.coordinateAtTime(t1);
const x2 = this.coordinateAtTime(t2);
const y1 = p1 == null ? null : this.candles.priceToCoordinate(p1);
const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2);
if ([x1, x2, y1, y2].some(value => value == null)) return null;
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
node.dataset.lineId = level.id;
node.setAttribute('x1', x1);
node.setAttribute('y1', y1);
node.setAttribute('x2', x2);
node.setAttribute('y2', y2);
node.setAttribute('stroke', ConfluenceChart.levelColor(level));
node.setAttribute('stroke-width', level.line_width || 2);
return node;
};
const lines = this.levels const lines = this.levels
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)) .filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
.map(level => { .map(level => {
@ -1822,50 +1647,22 @@ class ConfluenceChart {
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 end = Math.min(naturalEnd, visible.to); const end = Math.min(naturalEnd, visible.to);
if (end <= start) return null; if (end <= start) return null;
const t1 = this.timeAtIndex(start); const x1 = this.coordinateAtIndex(start);
const t2 = this.timeAtIndex(end); const x2 = this.coordinateAtIndex(end);
const p1 = this.linePriceAtIndex(level, start); const y1 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, start));
const p2 = this.linePriceAtIndex(level, end); const y2 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, end));
return segment(level, t1, p1, t2, p2); if ([x1, x2, y1, y2].some(value => value == null)) return null;
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
node.dataset.lineId = level.id;
node.setAttribute('x1', x1);
node.setAttribute('y1', y1);
node.setAttribute('x2', x2);
node.setAttribute('y2', y2);
node.setAttribute('stroke', ConfluenceChart.levelColor(level));
node.setAttribute('stroke-width', level.line_width || 2);
return node;
}) })
.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);
} }
@ -1926,36 +1723,20 @@ class ConfluenceChart {
}, },
]; ];
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
const naturalHitEnd = 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 hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd); 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.linePriceAtIndex(level, index), y: index == null ? null : this.candles.priceToCoordinate(this.linePriceAtIndex(level, index)),
})).map(point => ({
x: point.x,
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
})); }));
if (hitPoints.length < 2 || hitPoints.some(point => point.x == null || point.y == null)) { if (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');
const first = hitPoints[0]; this.lineHitTarget.setAttribute('x1', hitPoints[0].x);
const last = hitPoints[hitPoints.length - 1]; this.lineHitTarget.setAttribute('y1', hitPoints[0].y);
this.lineHitTarget.setAttribute('points', hitPoints.map(point => `${point.x},${point.y}`).join(' ')); this.lineHitTarget.setAttribute('x2', hitPoints[1].x);
// Kept for diagnostics and the existing drag test, though the polyline's this.lineHitTarget.setAttribute('y2', hitPoints[1].y);
// 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);
@ -1976,7 +1757,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 || level.geometry_resolved === false || !point || point.t == null) return; if (!level || !point || point.t == null) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.draggingLine = { this.draggingLine = {
@ -1995,21 +1776,10 @@ 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 source = this.sourceSeries(original); const indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t));
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 = source const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
? this.shiftLineTime(original, original.anchor_t, indexShift) const lastT = this.timeAtIndex(this.indexAt(original.last_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;
@ -2017,9 +1787,7 @@ 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 = source level.cutoff_t = this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
? 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);
@ -2184,10 +1952,6 @@ 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,9 +99,6 @@
<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'}">
@ -264,11 +261,6 @@
</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

@ -13,7 +13,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } } .current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } }
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } } @media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } }
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } } @keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
@ -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; }.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; } .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; }
.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: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.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-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,91 +24,6 @@ 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,
};
const coordinateProbe = {
bars: [{ t: 0 }, { t: halfHour * 2 }],
chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) },
indexAt: () => 0.5,
coordinateAtIndex: () => 123,
};
const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call(
coordinateProbe, halfHour,
);
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;
const gapAccepted = c.updateTrendlineTime(
'30m', sourceTimes[5] + halfHour, halfHour, sourceTimes[2], sourceTimes[3],
);
const timesAfterGap = 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),
gapAccepted,
timesAfterGap,
fractionalCoordinate,
};
});
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');
assert.equal(result.gapAccepted, false,
'a dropped source timestamp was accepted without requesting a full replacement');
assert.deepEqual(result.timesAfterGap, result.expectedTrimmedTimes,
'a lossy source delta mutated geometry before resynchronization');
assert.equal(result.fractionalCoordinate, 123,
'a non-candle source anchor trusted a foreign shared-scale coordinate');
assert.equal(await page.locator('.chart-line-hit').evaluate(node => getComputedStyle(node).fill),
'none', 'the invisible polyline hit region filled the chart as a black polygon');
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, price_in_timeframe_space from app.analysis.bar_space import index_at, price_in_bar_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,70 +43,3 @@ 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,29 +100,6 @@ 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_duration, bucket_start, next_bucket_start from app.bars.session import bucket_start
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@ -56,16 +56,3 @@ 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
def test_daily_future_geometry_skips_the_weekend_and_opens_at_1800_eastern():
thursday = epoch("2026-08-13T18:00:00", ET)
sunday = epoch("2026-08-16T18:00:00", ET)
assert next_bucket_start(thursday, Timeframe.D1) == sunday

View file

@ -1,87 +0,0 @@
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,7 +3,6 @@ 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
@ -60,56 +59,3 @@ 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"
source = geometry["series"]["30m"]
assert source["times"] == [1000, 2800]
assert source["duration"] == 1800
assert source["future_times"][:2] == [4600, 6400]
assert source["future_durations"][:2] == [1800, 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": {},
}
def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path):
app = FastAPI()
app.include_router(ws_router)
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
trendline_source_geometry=True,
))
app.state.runtime = runtime
with TestClient(app).websocket_connect("/ws") as websocket:
websocket.receive_json()
websocket.send_json({"type": "trendline_geometry"})
assert websocket.receive_json() == {
"type": "trendline_geometry",
"geometry": {"mode": "source_tf", "series": {}},
}