Compare commits

..

3 commits

Author SHA1 Message Date
0e32c0bbab Rename to /ESsence and write down the house rules
The header spent a whole line on a "CME FUTURES" eyebrow that told you nothing
the chart didn't — it only ever shows /ES. Dropping it takes the header from
64px to 44px and hands the space to the chart. The title becomes /ESsence.

AGENTS.md records the rules worth keeping, chief among them that a bug should
prompt the question of whether a unit test could reasonably have caught it —
written when the answer is yes, skipped when it is a rendering or data-source
quirk, and named after the failure rather than the function.

It is AGENTS.md rather than CLAUDE.md deliberately: opencode's instruction
loader walks up looking for AGENTS.md only and never reads CLAUDE.md, and the
ask-opencode skill asks the calling agent to distil house rules by hand rather
than forwarding a file. CLAUDE.md is a symlink to it so both tools resolve to
one source of truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:10:24 -05:00
039e91b4e4 Stop a zero-price tick and a late exchange bar corrupting the store
Two faults, both introduced by the tick feed, both visible as a huge bar that
flattened the price scale.

A LEVEL_ONE_FUTURES update arrived carrying LAST_PRICE: 0. The parser rejected
None, but 0 is not None, so a minute opened at zero — o=0.0 h=7777.25 l=0.0 —
and provisional_higher carried that low into 5m, 15m, 30m, 1h and the daily bar.
Non-positive prices are treated as absent now, so the last real price carries
forward and the update still counts as the trade it is.

Separately, store.put replaced a bar only when it matched the tail. That was
sufficient while one closed bar arrived per minute, but ticks open the next
minute before CHART_FUTURES delivers the previous one, so the exchange's own bar
stopped matching the tail and was silently dropped — leaving the tick-built
approximation, with its partial volume, in place permanently. put now searches
back a bounded number of buckets for the one it belongs to, and refuses to let a
provisional bar overwrite a settled one.

Tests cover all three invariants: a zero price parses as a trade with no price,
a late closed bar replaces its bucket and keeps the exchange's volume, and a
tick cannot overwrite a settled bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:10:24 -05:00
0b3244b725 Make trendline placement match what the cursor shows
Two faults, one of them a regression that reached production.

Making a pending anchor always win fixed a twitch on the second click stealing
the start point, and broke the opposite case: a genuine press-drag begun after
an abandoned click was hijacked by that stale anchor, so the line started far
from where the drag did. A single threshold decides now — 12px of travel between
press and release makes a gesture a drag, wide enough to survive a twitch on a
deliberate click and unambiguous for a real one. A drag abandons any half-placed
anchor instead of adopting it.

The crosshair was also lying. Lightweight Charts defaults to CrosshairMode.Magnet,
which snaps the crosshair to the bar's close, so hovering beside a bar's low drew
it mid-bar and a correctly-placed anchor looked wrong. Measured: aiming 4px above
a bar low anchors at the low, 7773, not the close, 7773.25 — the placement was
right and only the feedback was wrong. Arming a tool switches the crosshair to
Normal, and a snap dot now marks the exact point the anchor will use, coloured by
the side that extreme implies.

Verified in a browser across all four paths: two clicks with a twitch on the
second, an abandoned click then a real drag, a plain press-drag, and hovering.
Each starts where it should and lands on a bar extreme, and the stale anchor is
no longer adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:29:00 -05:00
10 changed files with 249 additions and 17 deletions

70
AGENTS.md Normal file
View file

@ -0,0 +1,70 @@
# Working on this repo
## Tests earn their place by catching a real bug
When a bug is found, ask whether a unit test could reasonably have caught it. If
yes, write that test with the fix. If no — a rendering artefact, a browser
quirk, a data-source oddity — say so and don't add one.
The bar is "would this have failed before the fix, and would it fail again if
someone reintroduced it". Tests that restate the implementation, assert
constructor defaults, or exercise paths nothing depends on are noise; they make
the suite slow to run and expensive to change, which is how a suite stops being
trusted.
What has actually paid off here: bar aggregation and bucket boundaries, the
store's replace-vs-append rules, level and alert arithmetic, parsing real
market-data payloads (fixtures are trimmed real responses, not invented), and
the invariants that would otherwise be silent — a comment must never become a
level, a tick must never overwrite a settled bar, volume must be counted once.
Name the test after the failure, not the function: `test_a_tick_cannot_overwrite
_a_settled_bar` beats `test_put`.
## Verify UI in a real browser
Chart bugs are invisible from the outside — the API, the socket and the
frontend source can each be correct while the screen is wrong. Drive the
Playwright container against the dev stack:
```
docker exec -i chart-playwright-1 node - <<'EOF'
const { chromium } = require('/usr/lib/node_modules/playwright');
// launch with args:['--lang=en-US'] — see below
EOF
```
**Always launch Chromium with `args: ['--lang=en-US']`.** The container has no
usable locale, so Chromium reports `en-US@posix`, `Intl` throws, and the chart
renders as a blank canvas that looks exactly like a broken app.
`window.__chart` is a deliberate debug handle. Querying it separates "the data
is missing" from "the data is off-screen" — which is how a viewport bug that
three passing API checks had missed was finally found.
## Running tests
```
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
```
pytest + pytest-asyncio, declared in `requirements-dev.txt`. Tests live in
`tests/`, import from `app.*`, and use `tmp_path` for anything that persists.
Async paths are driven with `asyncio.run(...)` directly rather than async test
markers.
## Things that will cost you an hour
- **Never write scratch `.py` files into the repo root.** It is bind-mounted, so
`--reload` restarts the app, and startup takes ~82 seconds. Pipe throwaway
scripts over stdin instead: `docker exec -i chart-api-1 python - <<'EOF'`.
Screenshots into `artifacts/` are safe; only `.py` triggers the reloader.
- **Dev and production keep separate drawing stores.** Dev writes
`data/manual_lines.json`; production has its own Coolify volume. A fix that
"didn't land" is often the other store.
- **Rebuild the image after touching `requirements.txt`.** The bind mount makes
source edits look live while an added dependency is simply absent.
- **A deploy resets alert cooldowns**, so production may re-alert on whatever
price is sitting on. There is no durable state yet.
- Times are epoch seconds, UTC, everywhere. Only the display is localised —
never shift the stored values.

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -16,12 +16,34 @@ class InMemoryBarStore:
lambda: deque(maxlen=max_bars_per_tf)
)
# How far back from the tail a late bar may still land. A closed minute bar
# arrives a beat after the ticks that opened the next minute, so it is
# rarely more than a bucket or two behind.
LATE_BAR_LOOKBACK = 8
def put(self, bar: Bar) -> None:
"""Store a bar, replacing the bucket it belongs to.
Matching only the tail was enough while one closed bar arrived per
minute. With tick-built bars a minute's authoritative bar shows up
*after* ticks have already opened the next one, so the exchange's own
figures were being dropped and the approximation left in place forever.
"""
bars = self._bars[bar.tf]
if bars and bars[-1].t == bar.t:
bars[-1] = bar
elif not bars or bar.t > bars[-1].t:
if not bars or bar.t > bars[-1].t:
bars.append(bar)
return
for index in range(len(bars) - 1, max(-1, len(bars) - self.LATE_BAR_LOOKBACK - 1), -1):
if bars[index].t == bar.t:
# A provisional bar must never overwrite a settled one: ticks
# keep arriving for a minute the exchange has already closed.
if bars[index].closed and not bar.closed:
return
bars[index] = bar
return
if bars[index].t < bar.t:
# Buckets are ordered, so nothing further back can match.
return
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
bars = list(self._bars[tf])

View file

@ -56,6 +56,12 @@ def parse_level_one(message: dict) -> list[tuple[int, float | None, int]]:
ticks: list[tuple[int, float | None, int]] = []
for content in message.get("content") or []:
price = content.get(FIELD_LAST_PRICE)
# Zero is not a price. The field arrives as 0 on some updates, and
# because 0 is not None it opened a bar at zero, which dragged the low
# of that minute — and every timeframe aggregating it — to the bottom of
# the chart. Treated as absent, so the last real price carries forward.
if price is not None and float(price) <= 0:
price = None
size = content.get(FIELD_LAST_SIZE)
traded_at = content.get(FIELD_TRADE_TIME)
# A trade stamp alongside a moved cumulative volume is a trade even when

View file

@ -1499,3 +1499,49 @@ snap on shows the note and no dropdown, snap off shows the dropdown.
`created_at` (epoch seconds) is already stored on every drawing and returned by
`GET /api/drawings`, so filtering by age needs UI only, not a migration.
**Trendline placement, third pass — and a regression I shipped.** Making a
pending anchor always win (previous entry) fixed the twitch case and broke the
opposite one: a genuine press-drag begun after an abandoned click was hijacked
by that stale anchor, so the line started far from the drag. That reached
production. The rule is now a single threshold — 12px of travel between press
and release makes it a drag, which is wide enough to survive a twitch on a
deliberate click and unambiguous for a real drag. A drag clears any half-placed
anchor rather than silently adopting it.
**The crosshair was lying about the anchor.** Lightweight Charts defaults to
`CrosshairMode.Magnet`, which snaps the crosshair to the bar's *close*. Hovering
by a bar's low therefore drew the crosshair mid-bar, and a correctly-snapped
anchor looked wrong — measured: aiming 4px above a bar low placed the anchor at
the low (7773) and not the close (7773.25), while the crosshair sat at the
close. Arming a tool now switches the crosshair to `Normal`, and a snap dot
marks the exact point the anchor will use, coloured by the side it implies.
Four gesture paths are verified in a browser: two clicks with a twitch on the
second, an abandoned click followed by a real drag, a plain press-drag, and
hovering. All start where they should and land on a bar extreme.
Worth recording for diagnosis: a reported "line ended up high off the bar"
turned out to render exactly on its bar — zero pixels off at 1h, 30m and 15m —
because the anchor had snapped to the *drawn* timeframe extreme (the 09:00 1h
low, 7744.25) while being checked against 1m bars, where it matches neither
extreme. Always compare an anchor against the timeframe it was drawn on.
**A zero price wrecked every timeframe's scale.** A LEVEL_ONE_FUTURES update
arrived with `LAST_PRICE: 0`. The parser rejected `None` but `0` is not `None`,
so a minute opened at zero — `o=0.0 h=7777.25 l=0.0` — and `provisional_higher`
carried that low into 5m, 15m, 30m, 1h and the daily bar, flattening the price
scale everywhere. Non-positive prices are now treated as absent, so the last
real price carries forward, and the tick still counts as a trade.
**The exchange's own bars were being dropped.** `store.put` replaced a bar only
when it matched the *tail*. That held while one closed bar arrived per minute,
but ticks open the next minute before CHART_FUTURES delivers the previous one —
so the authoritative bar no longer matched the tail and was discarded, leaving
the tick approximation and its partial volume in place permanently. `put` now
searches back a bounded number of buckets, and refuses to let a provisional bar
overwrite a settled one.
Both were introduced by the tick feature and both are covered by tests: a zero
price parses as a trade with no price, a late closed bar replaces its bucket and
keeps the exchange's volume, and a tick cannot overwrite a settled bar.

View file

@ -33,6 +33,7 @@ class ConfluenceChart {
this.toolMoveListener = null;
this.toolUpListener = null;
this.pendingView = null;
this.snapDot = null;
this.comments = [];
this.commentNodes = new Map();
this.commentLayer = null;
@ -42,6 +43,10 @@ class ConfluenceChart {
static TICK = 0.25;
// Pixels of travel between press and release that make a gesture a drag
// rather than a click. Wide enough to survive a twitch on a deliberate click.
static DRAG_THRESHOLD = 12;
static snapToTick(price) {
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
}
@ -166,6 +171,10 @@ class ConfluenceChart {
this.priceTag.className = 'chart-price-tag';
this.priceTag.hidden = true;
el.appendChild(this.priceTag);
this.snapDot = document.createElement('div');
this.snapDot.className = 'chart-snap-dot';
this.snapDot.hidden = true;
el.appendChild(this.snapDot);
this.commentLayer = document.createElement('div');
this.commentLayer.className = 'chart-comments';
el.appendChild(this.commentLayer);
@ -527,7 +536,18 @@ class ConfluenceChart {
*/
armTool(tool) {
this.armedTool = tool;
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
// Magnet snaps the crosshair to the bar's close, so hovering by a bar's low
// drew it mid-bar and made a correctly-placed anchor look wrong. While a
// tool is armed the crosshair tracks the cursor and the snap dot shows
// where the anchor will actually land.
this.chart.applyOptions({
handleScroll: !tool,
handleScale: !tool,
crosshair: {
mode: tool ? LightweightCharts.CrosshairMode.Normal : LightweightCharts.CrosshairMode.Magnet,
},
});
this.hideSnapDot();
this.chartEl.classList.toggle('armed', Boolean(tool));
this.pendingAnchor = null;
this.clearGesture();
@ -558,6 +578,7 @@ class ConfluenceChart {
if (!this.armedTool) return;
const point = this.eventPoint(event);
if (!point) return;
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point));
if (this.gesture) {
this.gesture.end = point;
this.renderGesture();
@ -567,6 +588,22 @@ class ConfluenceChart {
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
}
showSnapDot(snapped) {
if (snapped.t == null) return this.hideSnapDot();
const x = this.chart.timeScale().timeToCoordinate(snapped.t);
const y = this.candles.priceToCoordinate(snapped.p);
if (x == null || y == null) return this.hideSnapDot();
this.snapDot.hidden = false;
this.snapDot.style.left = `${Math.round(x)}px`;
this.snapDot.style.top = `${Math.round(y)}px`;
// Says which it is before you commit, since the extreme decides the side.
this.snapDot.dataset.side = snapped.snappedSide || '';
}
hideSnapDot() {
if (this.snapDot) this.snapDot.hidden = true;
}
renderPending(a, b) {
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
}
@ -605,21 +642,23 @@ class ConfluenceChart {
// it. Rejecting short gestures outright left the tool armed and silent, so
// the next click began a whole new line — which read as the line suddenly
// continuing at a different slope.
// A placed anchor wins over this click's own drag. Recomputing `dragged`
// here meant a few pixels of movement while pressing the second click was
// read as a fresh press-drag-release, which threw the anchor away and
// started the line at the second click instead — the preview had been
// rubber-banding from the real anchor all along, so the finished line
// jumped away from where it had just been drawn.
const dragged = !this.pendingAnchor
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5;
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
// Deliberate drag or click? At 5px a twitch while pressing the second click
// counted as a drag, which threw away the anchor placed by the first click
// and restarted the line under the cursor. Making a pending anchor always
// win fixed that and broke the other direction: a real press-drag begun
// after an abandoned click was hijacked by that stale anchor, starting the
// line far from where the drag did. So the threshold decides, and it is
// wide enough that only a deliberate drag clears the anchor.
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD;
const b = this.snapPoint(end);
if (!dragged && !this.pendingAnchor) {
this.pendingAnchor = b;
this.renderPending(b, b);
return;
}
// A drag says everything it needs to on its own; anything half-placed
// before it is abandoned rather than silently adopted.
const a = dragged ? this.snapPoint(start) : this.pendingAnchor;
this.pendingAnchor = null;
this.clearLinePreview();
if (a.t == null || b.t == null || a.t === b.t) return;

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>/ES Confluence</title>
<title>/ESsence</title>
<link rel="stylesheet" href="/static/style.css">
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
Pinned deliberately: an unpinned icon set is a silent redesign on someone
@ -15,7 +15,7 @@
<body>
<div id="app">
<header>
<div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></div>
<h1><strong>/ES</strong>sence</h1>
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
</header>
<main>

View file

@ -2,7 +2,7 @@
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; }
#app { min-height:100vh; padding:18px; }
header { height:64px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
header { height:44px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; }
.eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; }
.status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }
@ -51,7 +51,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.armed-toggle { display:flex; align-items:center; gap:4px; font-size:10px; color:var(--accent); cursor:pointer; }
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted);
@ -77,3 +77,4 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; }

View file

@ -279,3 +279,17 @@ def test_a_trade_known_only_by_its_volume_still_counts():
]
}
assert parse_level_one(volume_only) == [(1786356932000, None, 0)]
def test_a_zero_last_price_is_not_a_price():
# Seen live: LAST_PRICE arrived as 0, and because 0 is not None it opened a
# bar at zero whose low dragged every aggregating timeframe to the floor.
zero = {"content": [{"key": "/ES", "LAST_PRICE": 0, "LAST_SIZE": 2,
"TRADE_TIME_MILLIS": 1786356933000}]}
# Still a trade — size and stamp are there — but with no usable price, so
# the caller carries the last real one forward.
assert parse_level_one(zero) == [(1786356933000, None, 2)]
def test_a_zero_price_with_no_trade_markers_is_dropped_entirely():
assert parse_level_one({"content": [{"key": "/ES", "LAST_PRICE": 0}]}) == []

View file

@ -15,3 +15,36 @@ def test_store_replaces_forming_bar_and_bounds_history():
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180]
assert store.get(Timeframe.M1, 1)[0].t == 180
def test_a_closed_bar_replaces_its_bucket_behind_the_tail():
# Ticks open the next minute before the exchange's own bar for the previous
# one arrives. Matching only the tail dropped it, leaving the tick-built
# approximation — with its partial volume — in place permanently.
from app.bars.models import Bar, Timeframe
from app.bars.store import InMemoryBarStore
store = InMemoryBarStore(100)
common = dict(tf=Timeframe.M1, symbol="/ES", source="schwab")
store.put(Bar(t=60, o=1, h=2, l=1, c=2, v=5, closed=False, **common))
store.put(Bar(t=120, o=2, h=3, l=2, c=3, v=1, closed=False, **common))
# The authoritative bar for the first minute, arriving late.
store.put(Bar(t=60, o=1, h=9, l=1, c=4, v=400, closed=True, **common))
held = store.get(Timeframe.M1)
assert [b.t for b in held] == [60, 120]
assert held[0].closed is True
assert held[0].v == 400, "the exchange's volume must win over the tick estimate"
def test_a_tick_cannot_overwrite_a_settled_bar():
from app.bars.models import Bar, Timeframe
from app.bars.store import InMemoryBarStore
store = InMemoryBarStore(100)
common = dict(tf=Timeframe.M1, symbol="/ES", source="schwab")
store.put(Bar(t=60, o=1, h=9, l=1, c=4, v=400, closed=True, **common))
store.put(Bar(t=60, o=1, h=2, l=1, c=2, v=5, closed=False, **common))
held = store.get(Timeframe.M1)[0]
assert held.closed is True and held.v == 400