Compare commits
2 commits
0b3244b725
...
0e32c0bbab
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e32c0bbab | |||
| 039e91b4e4 |
9 changed files with 172 additions and 7 deletions
70
AGENTS.md
Normal file
70
AGENTS.md
Normal 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
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}]}) == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue