Compare commits
3 commits
9008e9cd8e
...
0e32c0bbab
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e32c0bbab | |||
| 039e91b4e4 | |||
| 0b3244b725 |
10 changed files with 249 additions and 17 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)
|
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:
|
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]
|
bars = self._bars[bar.tf]
|
||||||
if bars and bars[-1].t == bar.t:
|
if not bars or bar.t > bars[-1].t:
|
||||||
bars[-1] = bar
|
|
||||||
elif not bars or bar.t > bars[-1].t:
|
|
||||||
bars.append(bar)
|
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]:
|
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
|
||||||
bars = list(self._bars[tf])
|
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]] = []
|
ticks: list[tuple[int, float | None, int]] = []
|
||||||
for content in message.get("content") or []:
|
for content in message.get("content") or []:
|
||||||
price = content.get(FIELD_LAST_PRICE)
|
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)
|
size = content.get(FIELD_LAST_SIZE)
|
||||||
traded_at = content.get(FIELD_TRADE_TIME)
|
traded_at = content.get(FIELD_TRADE_TIME)
|
||||||
# A trade stamp alongside a moved cumulative volume is a trade even when
|
# A trade stamp alongside a moved cumulative volume is a trade even when
|
||||||
|
|
|
||||||
|
|
@ -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
|
`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.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class ConfluenceChart {
|
||||||
this.toolMoveListener = null;
|
this.toolMoveListener = null;
|
||||||
this.toolUpListener = null;
|
this.toolUpListener = null;
|
||||||
this.pendingView = null;
|
this.pendingView = null;
|
||||||
|
this.snapDot = null;
|
||||||
this.comments = [];
|
this.comments = [];
|
||||||
this.commentNodes = new Map();
|
this.commentNodes = new Map();
|
||||||
this.commentLayer = null;
|
this.commentLayer = null;
|
||||||
|
|
@ -42,6 +43,10 @@ class ConfluenceChart {
|
||||||
|
|
||||||
static TICK = 0.25;
|
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) {
|
static snapToTick(price) {
|
||||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +171,10 @@ class ConfluenceChart {
|
||||||
this.priceTag.className = 'chart-price-tag';
|
this.priceTag.className = 'chart-price-tag';
|
||||||
this.priceTag.hidden = true;
|
this.priceTag.hidden = true;
|
||||||
el.appendChild(this.priceTag);
|
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 = document.createElement('div');
|
||||||
this.commentLayer.className = 'chart-comments';
|
this.commentLayer.className = 'chart-comments';
|
||||||
el.appendChild(this.commentLayer);
|
el.appendChild(this.commentLayer);
|
||||||
|
|
@ -527,7 +536,18 @@ class ConfluenceChart {
|
||||||
*/
|
*/
|
||||||
armTool(tool) {
|
armTool(tool) {
|
||||||
this.armedTool = 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.chartEl.classList.toggle('armed', Boolean(tool));
|
||||||
this.pendingAnchor = null;
|
this.pendingAnchor = null;
|
||||||
this.clearGesture();
|
this.clearGesture();
|
||||||
|
|
@ -558,6 +578,7 @@ class ConfluenceChart {
|
||||||
if (!this.armedTool) return;
|
if (!this.armedTool) return;
|
||||||
const point = this.eventPoint(event);
|
const point = this.eventPoint(event);
|
||||||
if (!point) return;
|
if (!point) return;
|
||||||
|
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point));
|
||||||
if (this.gesture) {
|
if (this.gesture) {
|
||||||
this.gesture.end = point;
|
this.gesture.end = point;
|
||||||
this.renderGesture();
|
this.renderGesture();
|
||||||
|
|
@ -567,6 +588,22 @@ class ConfluenceChart {
|
||||||
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
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) {
|
renderPending(a, b) {
|
||||||
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
|
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
|
// 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
|
// the next click began a whole new line — which read as the line suddenly
|
||||||
// continuing at a different slope.
|
// continuing at a different slope.
|
||||||
// A placed anchor wins over this click's own drag. Recomputing `dragged`
|
// Deliberate drag or click? At 5px a twitch while pressing the second click
|
||||||
// here meant a few pixels of movement while pressing the second click was
|
// counted as a drag, which threw away the anchor placed by the first click
|
||||||
// read as a fresh press-drag-release, which threw the anchor away and
|
// and restarted the line under the cursor. Making a pending anchor always
|
||||||
// started the line at the second click instead — the preview had been
|
// win fixed that and broke the other direction: a real press-drag begun
|
||||||
// rubber-banding from the real anchor all along, so the finished line
|
// after an abandoned click was hijacked by that stale anchor, starting the
|
||||||
// jumped away from where it had just been drawn.
|
// line far from where the drag did. So the threshold decides, and it is
|
||||||
const dragged = !this.pendingAnchor
|
// wide enough that only a deliberate drag clears the anchor.
|
||||||
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD;
|
||||||
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
|
|
||||||
const b = this.snapPoint(end);
|
const b = this.snapPoint(end);
|
||||||
if (!dragged && !this.pendingAnchor) {
|
if (!dragged && !this.pendingAnchor) {
|
||||||
this.pendingAnchor = b;
|
this.pendingAnchor = b;
|
||||||
this.renderPending(b, b);
|
this.renderPending(b, b);
|
||||||
return;
|
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.pendingAnchor = null;
|
||||||
this.clearLinePreview();
|
this.clearLinePreview();
|
||||||
if (a.t == null || b.t == null || a.t === b.t) return;
|
if (a.t == null || b.t == null || a.t === b.t) return;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>/ES Confluence</title>
|
<title>/ESsence</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
|
<!-- 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
|
Pinned deliberately: an unpinned icon set is a silent redesign on someone
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<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>
|
<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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; }
|
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; }
|
#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; }
|
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; }
|
.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); }
|
.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 { 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; }
|
.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; }
|
.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 ---------------------------------------------------- */
|
||||||
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
.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);
|
.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;
|
.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); }
|
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; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -279,3 +279,17 @@ def test_a_trade_known_only_by_its_volume_still_counts():
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
assert parse_level_one(volume_only) == [(1786356932000, None, 0)]
|
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 [value.t for value in store.get(Timeframe.M1)] == [120, 180]
|
||||||
assert store.get(Timeframe.M1, 1)[0].t == 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