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
|
||||
BASE_TIMEFRAMES=1m,30m,1d
|
||||
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
|
||||
DAILY_ANCHOR_ET=18:00
|
||||
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
|
||||
container filesystem is ephemeral and every deploy would silently wipe every
|
||||
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 app.bars.models import Timeframe
|
||||
from app.bars.session import bucket_duration, next_bucket_start
|
||||
|
||||
|
||||
def index_at(times: list[int], t: int) -> float:
|
||||
"""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)
|
||||
ratio = (index_at(times, t) - start_index) / (end_index - start_index)
|
||||
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 = [
|
||||
(level.current_p if level.current_p is not None else level.price_at(current_t), level)
|
||||
for level in levels
|
||||
if level.geometry_resolved
|
||||
and not level.hidden
|
||||
and (level.cutoff_t is None or current_t <= level.cutoff_t)
|
||||
if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
|
||||
]
|
||||
for positional_side in (Side.SUPPORT, Side.RESISTANCE):
|
||||
side_levels = sorted(
|
||||
|
|
|
|||
|
|
@ -51,10 +51,6 @@ class Level:
|
|||
# Optional fixed distance for a hand-placed level. None keeps the global
|
||||
# ATR-based trigger; a value alerts that many points before the level.
|
||||
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:
|
||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ class ManualLine:
|
|||
collapsed: bool = False
|
||||
icon: str = ""
|
||||
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
|
||||
def drawing_kind(self) -> str:
|
||||
|
|
@ -125,7 +122,6 @@ class ManualLine:
|
|||
float(value["alert_early_points"])
|
||||
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)])
|
||||
|
||||
|
||||
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)
|
||||
async def create_debug_capture(request: Request):
|
||||
try:
|
||||
|
|
@ -111,7 +107,6 @@ class LinePatch(BaseModel):
|
|||
y: float | None = Field(None, ge=0.0, le=1.0)
|
||||
collapsed: bool | None = None
|
||||
alert_early_points: float | None = Field(None, ge=0)
|
||||
scale: float | None = Field(None, ge=0.5, le=3)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
|
@ -124,9 +119,6 @@ def status(request: Request):
|
|||
"symbol": runtime.stream.symbol,
|
||||
"last_bar_t": runtime.stream.last_bar_t,
|
||||
"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},
|
||||
# How late the event loop is running. Rising numbers mean something is
|
||||
# blocking it — see docs/async_refactor.md.
|
||||
|
|
@ -191,7 +183,7 @@ def create_line(request: Request, payload: LineCreate):
|
|||
runtime = request.app.state.runtime
|
||||
line = runtime.manual_lines.add(line)
|
||||
runtime.rebuild_levels()
|
||||
return positioned_level(runtime, line.id).to_dict()
|
||||
return line.to_level().to_dict()
|
||||
|
||||
|
||||
@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)
|
||||
runtime.rebuild_levels()
|
||||
return positioned_level(runtime, line.id).to_dict()
|
||||
return line.to_level().to_dict()
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
|
|
@ -236,7 +228,6 @@ class CommentCreate(BaseModel):
|
|||
"skull", "face-laugh-squint", "champagne-glasses",
|
||||
"arrow-left", "arrow-right", "hand", "right-to-bracket", "play",
|
||||
] | None = None
|
||||
scale: float = Field(1.0, ge=0.5, le=3)
|
||||
|
||||
|
||||
@router.post("/comments", status_code=201)
|
||||
|
|
@ -261,7 +252,6 @@ def create_comment(request: Request, payload: CommentCreate):
|
|||
color=payload.color,
|
||||
kind="symbol" if payload.icon else "comment",
|
||||
icon=payload.icon or "",
|
||||
scale=payload.scale,
|
||||
pinned=payload.pinned,
|
||||
x=payload.x,
|
||||
y=payload.y,
|
||||
|
|
@ -358,9 +348,7 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
|
|||
request.app.state.runtime.rebuild_levels()
|
||||
# A comment has no level form — returning one would hand the caller a shape
|
||||
# that looks like something the confluence engine tracks.
|
||||
return line.to_dict() if line.is_comment else positioned_level(
|
||||
request.app.state.runtime, line.id,
|
||||
).to_dict()
|
||||
return line.to_dict() if line.is_comment else line.to_level().to_dict()
|
||||
|
||||
|
||||
@router.delete("/lines/{line_id}", status_code=204)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ from urllib.parse import urlsplit
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
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.session import bucket_duration, future_bucket_starts
|
||||
from app.analysis.confluence import cluster_levels
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -53,35 +51,6 @@ def session_open(runtime) -> float | 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:
|
||||
return {
|
||||
"type": "snapshot",
|
||||
|
|
@ -93,7 +62,6 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
|||
if runtime.store.get(Timeframe.M1, 1)
|
||||
else None,
|
||||
"session_open": session_open(runtime),
|
||||
"trendline_geometry": trendline_geometry(runtime),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -116,7 +84,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
tf = Timeframe.M1
|
||||
prefs = None
|
||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||
geometry_tfs = trendline_timeframes(runtime)
|
||||
|
||||
async def receive():
|
||||
nonlocal tf, prefs
|
||||
|
|
@ -137,10 +104,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
"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:
|
||||
queue.put_nowait({"type": "disconnect"})
|
||||
|
||||
|
|
@ -150,60 +113,18 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
event = await queue.get()
|
||||
if event["type"] == "disconnect":
|
||||
break
|
||||
if event["type"] == "bar":
|
||||
bar = event["bar"]
|
||||
source_tfs = trendline_timeframes(runtime)
|
||||
source_bars = runtime.store.get(bar.tf)
|
||||
first_source_t = source_bars[0].t if source_bars else None
|
||||
source_index = next(
|
||||
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
||||
if event["type"] == "bar" and event["bar"].tf is tf:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "bar", "tf": tf.value,
|
||||
"bar": event["bar"].to_dict(),
|
||||
"session_open": session_open(runtime),
|
||||
}
|
||||
)
|
||||
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":
|
||||
message = {
|
||||
"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)
|
||||
await websocket.send_json(
|
||||
{"type": "levels", "changed": event["changed"], "removed": event["removed"]}
|
||||
)
|
||||
elif event["type"] == "clusters":
|
||||
clusters = connection_clusters(runtime, prefs)
|
||||
await websocket.send_json(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from app.bars.models import Timeframe
|
|||
UTC = ZoneInfo("UTC")
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
SESSION_OPEN = time(18, 0)
|
||||
SESSION_CLOSE = time(17, 0)
|
||||
|
||||
|
||||
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)
|
||||
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"
|
||||
base_timeframes: str = "1m,30m,1d"
|
||||
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"
|
||||
daily_anchor_et: str = "18:00"
|
||||
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.aggregator import Aggregator
|
||||
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.levels import Level
|
||||
from app.analysis.moving_averages import build_ma_levels
|
||||
|
|
@ -200,7 +200,8 @@ class Runtime:
|
|||
self.broadcast_level_delta()
|
||||
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.
|
||||
|
||||
The chart spaces bars evenly, so the line a person drew advances per bar.
|
||||
|
|
@ -209,17 +210,11 @@ class Runtime:
|
|||
"""
|
||||
if not bars:
|
||||
return
|
||||
minute_times = [bar.t for bar in bars]
|
||||
now = minute_times[-1]
|
||||
times = [bar.t for bar in bars]
|
||||
now = times[-1]
|
||||
for level in levels:
|
||||
if not level.slope:
|
||||
continue
|
||||
if not self.settings.trendline_source_geometry:
|
||||
level.current_p = price_in_bar_space(level, minute_times, now)
|
||||
continue
|
||||
source_times = [bar.t for bar in self.store.get(level.tf)]
|
||||
level.current_p = price_in_timeframe_space(level, source_times, level.tf, now)
|
||||
level.geometry_resolved = level.current_p is not None
|
||||
if level.slope:
|
||||
level.current_p = price_in_bar_space(level, times, now)
|
||||
|
||||
def broadcast_level_delta(self) -> None:
|
||||
"""Send only levels whose serialised form actually changed.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
clamping. The absurd number here was "67 bars of padding", and the bug behind it
|
||||
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
|
||||
scoring coefficients get tuned against.
|
||||
|
||||
**Geometry.** Anchors are persisted as **absolute epoch seconds and price** — never
|
||||
bar indices — so changing the algorithm does not rewrite drawings. A sloped line is
|
||||
evaluated in the logical bar space of its attributed timeframe, however: charts
|
||||
compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49
|
||||
hours in which no bars exist. The stored `slope` recovers the second endpoint price;
|
||||
the source-timeframe bar sequence determines interpolation and projection. The
|
||||
browser samples that canonical geometry only at displayed candle timestamps, and the
|
||||
server uses the same source series for clusters and alerts. A source history that no
|
||||
longer reaches an anchor leaves the drawing visible but unresolved and unable to
|
||||
cluster or alert rather than silently extrapolating from the edge of a shorter
|
||||
window. `TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid
|
||||
behavior without changing persisted data.
|
||||
**Geometry.** Anchors are stored in **absolute epoch seconds and price** — never bar
|
||||
indices. This is why a line drawn on the 4h chart renders correctly on the 1m chart
|
||||
with no conversion: both are the same `(time, price)` plane. The existing
|
||||
`Level.price_at(t)` already handles 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
|
||||
|
|
@ -624,10 +616,10 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`.
|
|||
| Place endpoints | `chart.subscribeClick(handler)` → two clicks |
|
||||
| Pixel → price | `series.coordinateToPrice(param.point.y)` |
|
||||
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` |
|
||||
| Render | Sample source-timeframe geometry onto displayed candles; extend right in an SVG overlay |
|
||||
| Select | Click within ~6px of the canonical price at that displayed bar |
|
||||
| Render | `LineSeries` with 2 points, extended right (same as §9 auto lines) |
|
||||
| Select | Click within ~6px of a line — hit-test in price space via `price_at(t)` |
|
||||
| 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.
|
||||
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
|
||||
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
|
||||
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
|
||||
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
|
||||
|
|
|
|||
|
|
@ -123,8 +123,6 @@ createApp({
|
|||
const commentFloat = ref(false);
|
||||
const selectedSymbol = ref('arrow-up');
|
||||
const symbolColor = ref('#65b7cf');
|
||||
const symbolScale = ref(1);
|
||||
const symbolScales = [0.5, 1, 1.5, 2, 3];
|
||||
const symbolPanelOpen = ref(false);
|
||||
// Comments are not levels, so they never arrive over the levels socket and
|
||||
// are fetched and refreshed explicitly.
|
||||
|
|
@ -288,7 +286,6 @@ createApp({
|
|||
const message = JSON.parse(data);
|
||||
if (message.type === 'snapshot') {
|
||||
dataReceivedAt.value = Date.now();
|
||||
chartApi.setTrendlineGeometry(message.trendline_geometry);
|
||||
chartApi.setBars(message.bars);
|
||||
levels.value = message.levels || [];
|
||||
if (!drawColorInitialized) {
|
||||
|
|
@ -309,32 +306,10 @@ createApp({
|
|||
// outright. The event carries its timeframe, so honour it.
|
||||
if (message.tf && message.tf !== timeframe.value) return;
|
||||
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);
|
||||
price.value = message.bar.c;
|
||||
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||
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') {
|
||||
// A delta, not a replacement: VWAP changes every minute while the
|
||||
// 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]));
|
||||
for (const level of message.changed || []) byId.set(level.id, level);
|
||||
levels.value = [...byId.values()];
|
||||
if (message.trendline_geometry) chartApi.setTrendlineGeometry(message.trendline_geometry);
|
||||
syncVisibleLevels();
|
||||
} else if (message.type === 'clusters') {
|
||||
clusters.value = message.clusters;
|
||||
|
|
@ -545,7 +519,7 @@ createApp({
|
|||
const response = await apiFetch('/api/comments', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
|
@ -675,8 +649,18 @@ createApp({
|
|||
await deleteLines([id]);
|
||||
}
|
||||
|
||||
function shiftTimeByBars(line, time, count = 10) {
|
||||
return chartApi?.shiftLineTime(line, time, count) ?? time;
|
||||
function shiftTimeByBars(time, count = 10) {
|
||||
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) {
|
||||
|
|
@ -689,9 +673,8 @@ createApp({
|
|||
}
|
||||
|
||||
async function duplicateLine(line) {
|
||||
if (line.geometry_resolved === false) return;
|
||||
const anchorT = shiftTimeByBars(line, line.anchor_t);
|
||||
const endT = shiftTimeByBars(line, line.last_t);
|
||||
const anchorT = shiftTimeByBars(line.anchor_t);
|
||||
const endT = shiftTimeByBars(line.last_t);
|
||||
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
|
||||
const response = await apiFetch('/api/lines', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -701,7 +684,7 @@ createApp({
|
|||
end_t: endT, end_p: endP,
|
||||
note: duplicateName(line), hidden: line.hidden,
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
|
@ -840,11 +823,10 @@ createApp({
|
|||
+ item.line.slope * (item.line.last_t - item.line.anchor_t)
|
||||
+ vertical * ConfluenceChart.TICK;
|
||||
if (horizontal) {
|
||||
if (line.geometry_resolved === false) continue;
|
||||
line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
|
||||
line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal);
|
||||
line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal);
|
||||
line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_t) + horizontal);
|
||||
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);
|
||||
|
|
@ -1031,6 +1013,6 @@ createApp({
|
|||
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');
|
||||
|
|
|
|||
342
static/chart.js
342
static/chart.js
|
|
@ -15,8 +15,6 @@ class ConfluenceChart {
|
|||
this.projectionLayer = null;
|
||||
this.bars = [];
|
||||
this.levels = [];
|
||||
this.trendlineGeometryMode = 'legacy';
|
||||
this.trendlineSourceSeries = new Map();
|
||||
this.onChartClick = null;
|
||||
this.tooltip = null;
|
||||
this.chartEl = null;
|
||||
|
|
@ -280,7 +278,7 @@ class ConfluenceChart {
|
|||
this.lineFocus.classList.add('chart-line-focus');
|
||||
this.lineFocus.setAttribute('hidden', '');
|
||||
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.setAttribute('hidden', '');
|
||||
this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event));
|
||||
|
|
@ -440,67 +438,6 @@ class ConfluenceChart {
|
|||
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.
|
||||
*
|
||||
|
|
@ -725,10 +662,8 @@ class ConfluenceChart {
|
|||
if (iconClass) {
|
||||
node.icon.className = `fa-solid ${iconClass}`;
|
||||
node.label.replaceChildren(node.icon);
|
||||
node.root.style.setProperty('--mark-scale', String(comment.scale || 1));
|
||||
} else {
|
||||
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.setProperty('--comment-focus-color', comment.color || '#c8992f');
|
||||
|
|
@ -750,19 +685,9 @@ class ConfluenceChart {
|
|||
const offRight = placed.after || (x !== null && x > width);
|
||||
node.root.classList.toggle('off-left', offLeft && !offRight);
|
||||
node.root.classList.toggle('off-right', offRight);
|
||||
const boxW = node.root.offsetWidth;
|
||||
const boxH = node.root.offsetHeight;
|
||||
if (iconClass) {
|
||||
const rawX = x == null ? 4 : x - boxW / 2;
|
||||
const rawY = y == null ? 24 : y - boxH / 2;
|
||||
const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : rawX;
|
||||
node.root.style.left = `${Math.round(clampedX)}px`;
|
||||
node.root.style.top = `${Math.round(Math.min(Math.max(rawY, 4), height - boxH - 4))}px`;
|
||||
} else {
|
||||
const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : x;
|
||||
node.root.style.left = `${Math.round(clampedX)}px`;
|
||||
node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
|
||||
}
|
||||
const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 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) {
|
||||
if (!seen.has(id)) {
|
||||
|
|
@ -1195,21 +1120,19 @@ class ConfluenceChart {
|
|||
coordinateAtIndex(index) {
|
||||
if (!this.bars.length) return null;
|
||||
const scale = this.chart.timeScale();
|
||||
if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t);
|
||||
const last = this.bars.length - 1;
|
||||
const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index);
|
||||
const upper = lower + 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);
|
||||
const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t);
|
||||
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
|
||||
if (lastLogical == null) return null;
|
||||
return scale.logicalToCoordinate(Number(lastLogical) + index - (this.bars.length - 1));
|
||||
}
|
||||
|
||||
coordinateAtTime(time) {
|
||||
if (!this.bars.length) return null;
|
||||
// Interpolate between the displayed candles' own pixel coordinates. The
|
||||
// shared scale may contain foreign timestamps from other series, and its
|
||||
// logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h.
|
||||
const last = this.bars[this.bars.length - 1];
|
||||
if (!last) return null;
|
||||
if (time <= last.t) {
|
||||
const direct = this.chart.timeScale().timeToCoordinate(time);
|
||||
if (direct != null) return direct;
|
||||
}
|
||||
return this.coordinateAtIndex(this.indexAt(time));
|
||||
}
|
||||
|
||||
|
|
@ -1620,11 +1543,18 @@ class ConfluenceChart {
|
|||
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 endIndex = Math.min(naturalEnd, visible.to);
|
||||
const pointIndex = this.indexAtCoordinate(param.point.x);
|
||||
if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue;
|
||||
const linePrice = this.linePriceAtIndex(level, pointIndex);
|
||||
const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice);
|
||||
if (lineY == null) continue;
|
||||
if (endIndex <= startIndex) continue;
|
||||
const start = {
|
||||
x: this.coordinateAtIndex(startIndex),
|
||||
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)),
|
||||
};
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const bars = this.bars;
|
||||
if (bars.length < 2) return bars[0]?.t ?? 0;
|
||||
|
|
@ -1759,9 +1610,6 @@ class ConfluenceChart {
|
|||
}
|
||||
|
||||
linePriceAtIndex(level, index) {
|
||||
if (this.trendlineGeometryMode === 'source_tf') {
|
||||
return this.sourceLinePriceAt(level, this.timeAtIndex(index));
|
||||
}
|
||||
const start = this.indexAt(level.anchor_t);
|
||||
const end = this.indexAt(level.last_t);
|
||||
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
||||
|
|
@ -1772,13 +1620,6 @@ class ConfluenceChart {
|
|||
|
||||
seriesLineData(level) {
|
||||
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 lastBar = this.bars.length - 1;
|
||||
const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t);
|
||||
|
|
@ -1799,22 +1640,6 @@ class ConfluenceChart {
|
|||
const visible = this.visibleBarIndexes();
|
||||
if (!visible) return;
|
||||
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
|
||||
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
|
||||
.map(level => {
|
||||
|
|
@ -1822,50 +1647,22 @@ class ConfluenceChart {
|
|||
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
|
||||
const end = Math.min(naturalEnd, visible.to);
|
||||
if (end <= start) return null;
|
||||
const t1 = this.timeAtIndex(start);
|
||||
const t2 = this.timeAtIndex(end);
|
||||
const p1 = this.linePriceAtIndex(level, start);
|
||||
const p2 = this.linePriceAtIndex(level, end);
|
||||
return segment(level, t1, p1, t2, p2);
|
||||
const x1 = this.coordinateAtIndex(start);
|
||||
const x2 = this.coordinateAtIndex(end);
|
||||
const y1 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, start));
|
||||
const y2 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, end));
|
||||
if ([x1, x2, y1, y2].some(value => value == null)) return null;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -1926,36 +1723,20 @@ class ConfluenceChart {
|
|||
},
|
||||
];
|
||||
const visible = this.visibleBarIndexes();
|
||||
const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
|
||||
const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd);
|
||||
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 hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
|
||||
const hitIndexes = [this.indexAt(level.anchor_t), hitEnd];
|
||||
const hitPoints = hitIndexes.map(index => ({
|
||||
x: index == null ? null : this.coordinateAtIndex(index),
|
||||
y: index == null ? null : this.linePriceAtIndex(level, index),
|
||||
})).map(point => ({
|
||||
x: point.x,
|
||||
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
|
||||
y: index == null ? null : this.candles.priceToCoordinate(this.linePriceAtIndex(level, index)),
|
||||
}));
|
||||
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', '');
|
||||
} else {
|
||||
this.lineHitTarget.removeAttribute('hidden');
|
||||
const first = hitPoints[0];
|
||||
const last = hitPoints[hitPoints.length - 1];
|
||||
this.lineHitTarget.setAttribute('points', hitPoints.map(point => `${point.x},${point.y}`).join(' '));
|
||||
// Kept for diagnostics and the existing drag test, though the polyline's
|
||||
// actual hit region follows every sampled point.
|
||||
this.lineHitTarget.setAttribute('x1', first.x);
|
||||
this.lineHitTarget.setAttribute('y1', first.y);
|
||||
this.lineHitTarget.setAttribute('x2', last.x);
|
||||
this.lineHitTarget.setAttribute('y2', last.y);
|
||||
this.lineHitTarget.setAttribute('x1', hitPoints[0].x);
|
||||
this.lineHitTarget.setAttribute('y1', hitPoints[0].y);
|
||||
this.lineHitTarget.setAttribute('x2', hitPoints[1].x);
|
||||
this.lineHitTarget.setAttribute('y2', hitPoints[1].y);
|
||||
}
|
||||
points.forEach((point, index) => {
|
||||
const x = this.coordinateAtTime(point.time);
|
||||
|
|
@ -1976,7 +1757,7 @@ class ConfluenceChart {
|
|||
if (event.button !== 0) return;
|
||||
const level = this.levels.find(value => value.id === this.selectedLineId);
|
||||
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.stopPropagation();
|
||||
this.draggingLine = {
|
||||
|
|
@ -1995,21 +1776,10 @@ class ConfluenceChart {
|
|||
if (!point || point.t == null || !this.withinPlot(point) || !level) return;
|
||||
event.preventDefault();
|
||||
const { original, start } = this.draggingLine;
|
||||
const source = this.sourceSeries(original);
|
||||
const sourcePoint = source && ConfluenceChart.timeframeIndexAt(source, point.t);
|
||||
const sourceStart = source && ConfluenceChart.timeframeIndexAt(source, start.t);
|
||||
const indexShift = Math.round(
|
||||
sourcePoint != null && sourceStart != null
|
||||
? sourcePoint - sourceStart
|
||||
: this.indexAt(point.t) - this.indexAt(start.t),
|
||||
);
|
||||
const indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t));
|
||||
const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
|
||||
const anchorT = source
|
||||
? this.shiftLineTime(original, original.anchor_t, indexShift)
|
||||
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
|
||||
const lastT = source
|
||||
? this.shiftLineTime(original, original.last_t, indexShift)
|
||||
: this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
|
||||
const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
|
||||
const lastT = this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
|
||||
const endPrice = original.anchor_p
|
||||
+ original.slope * (original.last_t - original.anchor_t) + priceShift;
|
||||
level.anchor_t = anchorT;
|
||||
|
|
@ -2017,9 +1787,7 @@ class ConfluenceChart {
|
|||
level.anchor_p = original.anchor_p + priceShift;
|
||||
level.slope = (endPrice - level.anchor_p) / (lastT - anchorT);
|
||||
if (original.cutoff_t != null) {
|
||||
level.cutoff_t = source
|
||||
? this.shiftLineTime(original, original.cutoff_t, indexShift)
|
||||
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
|
||||
level.cutoff_t = this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
|
||||
}
|
||||
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
|
||||
const entry = this.levelSeries.get(level.id);
|
||||
|
|
@ -2184,10 +1952,6 @@ ConfluenceChart.tfColors = {
|
|||
'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
|
||||
// colours they would be indistinguishable from the daily moving averages.
|
||||
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
|
||||
|
|
|
|||
|
|
@ -99,9 +99,6 @@
|
|||
<i :class="`fa-solid fa-${symbol.icon}`"></i>
|
||||
</button>
|
||||
</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 class="tool tool-level" :class="{armed: armedTool === 'level'}">
|
||||
|
|
@ -264,11 +261,6 @@
|
|||
</details>
|
||||
</div>
|
||||
<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)"
|
||||
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
|
||||
<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); }
|
||||
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
||||
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-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%); } }
|
||||
@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); } }
|
||||
|
|
@ -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; }
|
||||
.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-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-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; }
|
||||
|
|
@ -76,7 +76,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
|||
font-size:9px; white-space:nowrap; }
|
||||
.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.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 .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; }
|
||||
@keyframes comment-focus {
|
||||
|
|
|
|||
|
|
@ -24,91 +24,6 @@ const timeAt = (page, box, point) =>
|
|||
return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX()));
|
||||
}, 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 () => {
|
||||
await withChart(async 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.bars.models import Timeframe
|
||||
|
||||
|
|
@ -43,70 +43,3 @@ def test_flat_anchors_return_the_anchor_price():
|
|||
times = [i * MINUTE for i in range(5)]
|
||||
level = sloped(0, 100.0, MINUTE, 100.0)
|
||||
assert price_in_bar_space(level, times, times[4]) == 100.0
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
||||
from app.analysis.levels import Side
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo
|
|||
import pytest
|
||||
|
||||
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")
|
||||
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):
|
||||
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.levels import Level, LevelKind, Side
|
||||
from app.analysis.manual_lines import ManualLine
|
||||
from app.api.ws import router as ws_router, snapshot
|
||||
from app.bars.models import Bar, Timeframe
|
||||
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)
|
||||
|
||||
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