Implement M2 session-aware aggregation
This commit is contained in:
parent
acc59817c4
commit
f87ca0a153
11 changed files with 226 additions and 16 deletions
57
app/bars/aggregator.py
Normal file
57
app/bars/aggregator.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from dataclasses import replace
|
||||
|
||||
from app.bars.models import Bar, Timeframe
|
||||
from app.bars.session import bucket_start
|
||||
|
||||
|
||||
class Aggregator:
|
||||
def __init__(self, timeframes: list[Timeframe] | None = None):
|
||||
self.timeframes = timeframes or list(Timeframe)
|
||||
self.forming: dict[Timeframe, Bar] = {}
|
||||
|
||||
@staticmethod
|
||||
def _can_aggregate(source: Timeframe, target: Timeframe) -> bool:
|
||||
if source is Timeframe.M1:
|
||||
return True
|
||||
if source is Timeframe.H1:
|
||||
return target in (Timeframe.H1, Timeframe.H4, Timeframe.D1)
|
||||
return source is target
|
||||
|
||||
def update(self, incoming: Bar) -> list[Bar]:
|
||||
emitted: list[Bar] = []
|
||||
for tf in self.timeframes:
|
||||
if not self._can_aggregate(incoming.tf, tf):
|
||||
continue
|
||||
if tf is incoming.tf:
|
||||
emitted.append(replace(incoming))
|
||||
continue
|
||||
|
||||
start = bucket_start(incoming.t, tf)
|
||||
current = self.forming.get(tf)
|
||||
if current is not None and start < current.t:
|
||||
continue
|
||||
if current is None or start > current.t:
|
||||
if current is not None:
|
||||
emitted.append(replace(current, closed=True))
|
||||
current = Bar(
|
||||
tf=tf,
|
||||
t=start,
|
||||
o=incoming.o,
|
||||
h=incoming.h,
|
||||
l=incoming.l,
|
||||
c=incoming.c,
|
||||
v=incoming.v,
|
||||
closed=False,
|
||||
symbol=incoming.symbol,
|
||||
source=incoming.source,
|
||||
)
|
||||
self.forming[tf] = current
|
||||
else:
|
||||
current.h = max(current.h, incoming.h)
|
||||
current.l = min(current.l, incoming.l)
|
||||
current.c = incoming.c
|
||||
current.v += incoming.v
|
||||
current.symbol = incoming.symbol
|
||||
current.source = incoming.source
|
||||
emitted.append(replace(current))
|
||||
return emitted
|
||||
30
app/bars/session.py
Normal file
30
app/bars/session.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from datetime import datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.bars.models import Timeframe
|
||||
|
||||
UTC = ZoneInfo("UTC")
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
SESSION_OPEN = time(18, 0)
|
||||
|
||||
|
||||
def _session_open_local(current: datetime) -> datetime:
|
||||
session_date = current.date() if current.timetz().replace(tzinfo=None) >= SESSION_OPEN else current.date() - timedelta(days=1)
|
||||
return datetime.combine(session_date, SESSION_OPEN, EASTERN)
|
||||
|
||||
|
||||
def bucket_start(t: int, tf: Timeframe) -> int:
|
||||
if tf not in (Timeframe.H4, Timeframe.D1):
|
||||
return (t // tf.seconds) * tf.seconds
|
||||
|
||||
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
|
||||
session_open = _session_open_local(current)
|
||||
if tf is Timeframe.D1:
|
||||
return int(session_open.timestamp())
|
||||
|
||||
# CME's 4h anchors are wall-clock ET anchors. This intentionally makes the
|
||||
# DST-transition bucket three or five elapsed hours instead of shifting it.
|
||||
elapsed_wall = current.replace(tzinfo=None) - session_open.replace(tzinfo=None)
|
||||
bucket_hours = int(elapsed_wall.total_seconds() // 14400) * 4
|
||||
local_start = session_open.replace(tzinfo=None) + timedelta(hours=bucket_hours)
|
||||
return int(local_start.replace(tzinfo=EASTERN).timestamp())
|
||||
|
|
@ -15,6 +15,8 @@ class MarketDataSource(Protocol):
|
|||
tf: Timeframe,
|
||||
start: int | None,
|
||||
end: int | None,
|
||||
*,
|
||||
range_: str | None = None,
|
||||
) -> list[Bar]: ...
|
||||
|
||||
def supports_stream(self) -> bool: ...
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class ReplaySource:
|
|||
tf: Timeframe,
|
||||
start: int | None = None,
|
||||
end: int | None = None,
|
||||
*,
|
||||
range_: str | None = None,
|
||||
) -> list[Bar]:
|
||||
return [
|
||||
bar
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ class StreamService:
|
|||
) -> None:
|
||||
if source is None or not source.supports_history():
|
||||
return
|
||||
history = getattr(source, "history")
|
||||
bars = await history(self.symbol, tf, None, None, range_=range_)
|
||||
bars = await source.history(self.symbol, tf, None, None, range_=range_)
|
||||
for bar in bars:
|
||||
await self._emit(bar)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
from dataclasses import dataclass, field
|
||||
|
||||
from app.bars.models import Bar, Timeframe
|
||||
from app.bars.aggregator import Aggregator
|
||||
from app.bars.store import InMemoryBarStore
|
||||
from app.config import Settings
|
||||
from app.market.factory import live_source, seed_source
|
||||
|
|
@ -14,24 +15,27 @@ class Runtime:
|
|||
store: InMemoryBarStore = field(init=False)
|
||||
stream: StreamService = field(init=False)
|
||||
subscribers: set[asyncio.Queue[Bar]] = field(default_factory=set)
|
||||
aggregator: Aggregator = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
||||
self.aggregator = Aggregator(self.settings.enabled_timeframes)
|
||||
self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol)
|
||||
self.stream.add_handler(self.on_bar)
|
||||
|
||||
async def on_bar(self, bar: Bar) -> None:
|
||||
self.store.put(bar)
|
||||
for aggregated in self.aggregator.update(bar):
|
||||
self.store.put(aggregated)
|
||||
for queue in self.subscribers.copy():
|
||||
if queue.full():
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(bar)
|
||||
queue.put_nowait(aggregated)
|
||||
|
||||
async def start(self) -> asyncio.Task:
|
||||
try:
|
||||
await self.stream.seed(
|
||||
seed_source(self.settings), Timeframe.M1, self.settings.seed_1m_range
|
||||
)
|
||||
source = seed_source(self.settings)
|
||||
await self.stream.seed(source, Timeframe.H1, self.settings.seed_1h_range)
|
||||
await self.stream.seed(source, Timeframe.M1, self.settings.seed_1m_range)
|
||||
except Exception:
|
||||
# A transient seed failure must not prevent the live stream or UI starting.
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ createApp({
|
|||
setup() {
|
||||
const status = ref({ stream: 'disconnected', bars_held: {} });
|
||||
const price = ref(null);
|
||||
const timeframe = ref('1m');
|
||||
const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d'];
|
||||
const now = ref(Date.now());
|
||||
let chartApi = null;
|
||||
let socket = null;
|
||||
|
|
@ -23,7 +25,7 @@ createApp({
|
|||
function connect() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${protocol}://${location.host}/ws`);
|
||||
socket.onopen = () => socket.send(JSON.stringify({ type: 'subscribe', tf: '1m' }));
|
||||
socket.onopen = () => socket.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value }));
|
||||
socket.onmessage = ({ data }) => {
|
||||
const message = JSON.parse(data);
|
||||
if (message.type === 'snapshot') {
|
||||
|
|
@ -41,6 +43,13 @@ createApp({
|
|||
};
|
||||
}
|
||||
|
||||
function selectTimeframe(tf) {
|
||||
timeframe.value = tf;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'subscribe', tf }));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chartApi = new ConfluenceChart();
|
||||
chartApi.create(document.getElementById('chart'));
|
||||
|
|
@ -54,6 +63,6 @@ createApp({
|
|||
if (chartApi) chartApi.destroy();
|
||||
});
|
||||
|
||||
return { status, price, barAge };
|
||||
return { status, price, barAge, timeframe, timeframes, selectTimeframe };
|
||||
},
|
||||
}).mount('#app');
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@
|
|||
<section class="chart-shell">
|
||||
<div class="chart-head">
|
||||
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
|
||||
<div class="timeframes"><button class="active">1m</button></div>
|
||||
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
|
||||
</div>
|
||||
<div id="chart"></div>
|
||||
<div class="statusbar">
|
||||
<span>FEED <b>{{ status.stream }}</b></span>
|
||||
<span>LAST BAR <b>{{ barAge }}</b></span>
|
||||
<span>HELD <b>{{ status.bars_held?.['1m'] || 0 }}</b></span>
|
||||
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
|
||||
</div>
|
||||
</section>
|
||||
<aside>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--ac
|
|||
.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); }
|
||||
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
||||
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
|
||||
.chart-head { height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
|
||||
.symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
|
||||
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
|
||||
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||
#chart { height:calc(100vh - 190px); min-height:420px; }
|
||||
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
|
||||
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
|
||||
@media (max-width:850px) { #app { padding:10px; }main { grid-template-columns:1fr; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; } }
|
||||
@media (max-width:850px) { #app { padding:10px; }main { grid-template-columns:1fr; }#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; } }
|
||||
|
|
|
|||
43
tests/test_aggregator.py
Normal file
43
tests/test_aggregator.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from app.bars.aggregator import Aggregator
|
||||
from app.bars.models import Bar, Timeframe
|
||||
|
||||
|
||||
def minute(t: int, price: float = 100, volume: int = 1) -> Bar:
|
||||
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price + 0.5, volume, True, "ES=F", "replay")
|
||||
|
||||
|
||||
def test_aggregates_all_timeframes_and_closes_on_later_bucket():
|
||||
aggregator = Aggregator()
|
||||
first = aggregator.update(minute(0, 100, 2))
|
||||
second = aggregator.update(minute(60, 102, 3))
|
||||
boundary = aggregator.update(minute(900, 105, 4))
|
||||
|
||||
assert {bar.tf for bar in first} == set(Timeframe)
|
||||
forming_15m = [bar for bar in second if bar.tf is Timeframe.M15][-1]
|
||||
assert (forming_15m.o, forming_15m.h, forming_15m.l, forming_15m.c, forming_15m.v) == (
|
||||
100,
|
||||
103,
|
||||
99,
|
||||
102.5,
|
||||
5,
|
||||
)
|
||||
bars_15m = [bar for bar in boundary if bar.tf is Timeframe.M15]
|
||||
assert [(bar.t, bar.closed) for bar in bars_15m] == [(0, True), (900, False)]
|
||||
|
||||
|
||||
def test_gap_closes_previous_bucket_without_synthesizing_empty_bars():
|
||||
aggregator = Aggregator([Timeframe.M5])
|
||||
aggregator.update(minute(0))
|
||||
output = aggregator.update(minute(1800))
|
||||
|
||||
assert [(bar.t, bar.closed) for bar in output] == [(0, True), (1800, False)]
|
||||
|
||||
|
||||
def test_replay_is_deterministic():
|
||||
tape = [minute(t, 100 + index) for index, t in enumerate(range(0, 3600, 60))]
|
||||
|
||||
def run():
|
||||
aggregator = Aggregator()
|
||||
return [bar.to_dict() for source in tape for bar in aggregator.update(source)]
|
||||
|
||||
assert run() == run()
|
||||
63
tests/test_session.py
Normal file
63
tests/test_session.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from app.bars.models import Timeframe
|
||||
from app.bars.session import bucket_start
|
||||
|
||||
UTC = ZoneInfo("UTC")
|
||||
ET = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def epoch(value: str, zone=UTC) -> int:
|
||||
return int(datetime.fromisoformat(value).replace(tzinfo=zone).timestamp())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "tf", "expected"),
|
||||
[
|
||||
("2026-08-09T22:00:00", Timeframe.D1, "2026-08-09T22:00:00"),
|
||||
("2026-08-10T16:37:00", Timeframe.D1, "2026-08-09T22:00:00"),
|
||||
("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"),
|
||||
("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"),
|
||||
("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"),
|
||||
("2026-08-10T01:59:00", Timeframe.H4, "2026-08-09T22:00:00"),
|
||||
("2026-08-10T02:00:00", Timeframe.H4, "2026-08-10T02:00:00"),
|
||||
("2026-08-10T17:59:00", Timeframe.H4, "2026-08-10T14:00:00"),
|
||||
],
|
||||
)
|
||||
def test_session_boundaries(value, tf, expected):
|
||||
assert bucket_start(epoch(value), tf) == epoch(expected)
|
||||
|
||||
|
||||
def test_intraday_buckets_use_utc_boundaries():
|
||||
assert bucket_start(epoch("2026-08-10T12:37:45"), Timeframe.M15) == epoch(
|
||||
"2026-08-10T12:30:00"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
# Spring forward: the 22:00 ET bucket ends at 02:00 EDT after three real hours.
|
||||
("2026-03-08T06:59:00", "2026-03-08T03:00:00"),
|
||||
("2026-03-08T07:00:00", "2026-03-08T07:00:00"),
|
||||
# Fall back: the 22:00 ET bucket lasts five real hours and ends at 02:00 EST.
|
||||
("2026-11-01T06:59:00", "2026-11-01T02:00:00"),
|
||||
("2026-11-01T07:00:00", "2026-11-01T07:00:00"),
|
||||
],
|
||||
)
|
||||
def test_four_hour_wall_clock_anchor_across_dst(value, expected):
|
||||
assert bucket_start(epoch(value), Timeframe.H4) == epoch(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("local_value", "expected_local"),
|
||||
[
|
||||
("2026-03-08T18:00:00", "2026-03-08T18:00:00"),
|
||||
("2026-11-01T18:00:00", "2026-11-01T18:00:00"),
|
||||
],
|
||||
)
|
||||
def test_sunday_open_across_dst(local_value, expected_local):
|
||||
assert bucket_start(epoch(local_value, ET), Timeframe.D1) == epoch(expected_local, ET)
|
||||
Loading…
Reference in a new issue