Compare commits
No commits in common. "a921db4610ccebd05db1af2dd9a139ef75e6f393" and "952a3bb7f369d8d490782900f73fb7e5520a89cc" have entirely different histories.
a921db4610
...
952a3bb7f3
24 changed files with 106 additions and 1120 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
23
docs/plan.md
23
docs/plan.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
342
static/chart.js
342
static/chart.js
|
|
@ -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' };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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": {}},
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue