Compare commits

..

No commits in common. "0e32c0bbab1a5a2b62c5bba828badf0e94d8fa2b" and "9008e9cd8e2a442613c9bc29efaeac8980545bc6" have entirely different histories.

10 changed files with 17 additions and 249 deletions

View file

@ -1,70 +0,0 @@
# 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.

View file

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

View file

@ -16,34 +16,12 @@ 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 not bars or bar.t > bars[-1].t: if bars and bars[-1].t == bar.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])

View file

@ -56,12 +56,6 @@ 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

View file

@ -1499,49 +1499,3 @@ 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.

View file

@ -33,7 +33,6 @@ 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;
@ -43,10 +42,6 @@ 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;
} }
@ -171,10 +166,6 @@ 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);
@ -536,18 +527,7 @@ class ConfluenceChart {
*/ */
armTool(tool) { armTool(tool) {
this.armedTool = tool; this.armedTool = tool;
// Magnet snaps the crosshair to the bar's close, so hovering by a bar's low this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
// 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();
@ -578,7 +558,6 @@ 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();
@ -588,22 +567,6 @@ 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);
} }
@ -642,23 +605,21 @@ 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.
// Deliberate drag or click? At 5px a twitch while pressing the second click // A placed anchor wins over this click's own drag. Recomputing `dragged`
// counted as a drag, which threw away the anchor placed by the first click // here meant a few pixels of movement while pressing the second click was
// and restarted the line under the cursor. Making a pending anchor always // read as a fresh press-drag-release, which threw the anchor away and
// win fixed that and broke the other direction: a real press-drag begun // started the line at the second click instead — the preview had been
// after an abandoned click was hijacked by that stale anchor, starting the // rubber-banding from the real anchor all along, so the finished line
// line far from where the drag did. So the threshold decides, and it is // jumped away from where it had just been drawn.
// wide enough that only a deliberate drag clears the anchor. const dragged = !this.pendingAnchor
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD; && Math.hypot(end.x - start.x, end.y - start.y) >= 5;
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;

View file

@ -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>/ESsence</title> <title>/ES Confluence</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>
<h1><strong>/ES</strong>sence</h1> <div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></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> <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>

View file

@ -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:44px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; } header { height:64px; 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:40px; }.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:54px; }.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,4 +77,3 @@ 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; }

View file

@ -279,17 +279,3 @@ 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}]}) == []

View file

@ -15,36 +15,3 @@ 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