Compare commits

..

2 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
9 changed files with 172 additions and 7 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

@ -1526,3 +1526,22 @@ 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

@ -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);

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