Skip to content

Commit 6c3f9ad

Browse files
committed
Merge branch 'feature/dispatch-loop-started-event' into develop
2 parents 34454ce + e4c3f69 commit 6c3f9ad

8 files changed

Lines changed: 89 additions & 4 deletions

File tree

.github/copilot-instructions.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ Key data flows: Bar events → Strategies → Trading signals → Position manag
1313
## Development Workflow
1414
- **Dependencies**: Use Poetry (`poetry install --all-extras`). Python 3.10+ required.
1515
- **Linting**: `invoke lint` runs mypy and ruff (line length 120, excludes `pocs/`)
16-
- **Testing**: `invoke test` runs pytest with 100% coverage requirement. Use `--html_report` for coverage HTML.
16+
- **Testing**: `invoke test` runs lint + pytest with 100% line coverage required.
17+
- **Single test**: `poetry run pytest tests/test_file.py::test_name -vv --no-cov`
1718
- **Cleaning**: `invoke clean` removes caches and build artifacts
1819
- **Documentation**: `invoke build_docs` generates Sphinx docs in `docs/_build/html/`
1920

@@ -43,13 +44,17 @@ Key data flows: Bar events → Strategies → Trading signals → Position manag
4344
- **Live Trading**: Requires API keys. Use `realtime_dispatcher()` instead of `backtesting_dispatcher()`.
4445
- **Precision Handling**: Use `round_decimal()`/`truncate_decimal()` for display/calculation precision.
4546

47+
## Testing Conventions
48+
- `asyncio_mode = auto` (setup.cfg) — async test functions run without `@pytest.mark.asyncio`.
49+
- Pytest fixtures `backtesting_dispatcher` and `realtime_dispatcher` are available globally via `tests/conftest.py`.
50+
- Shared test pairs/pair-infos live in `tests/common.py` (e.g. `btc_pair`, `btc_pair_info`).
51+
- 100% line coverage is required; use `# pragma: no cover` only for unreachable/platform-specific branches.
52+
4653
## Common Pitfalls
4754
- Naive datetimes cause issues - always use timezone-aware (`dt.utc_now()`).
4855
- Concurrent position modifications require locks (`asyncio.Lock` per pair).
4956
- WebSocket reconnections handled automatically, but monitor for gaps.
5057
- Borrowing disabled by default in samples - set `borrowing_disabled=False` to enable shorts.</content>
5158

5259
## Permissions
53-
- Do not run git commit, git add, are create any branches. Let me stage and/or commit manually.
54-
- Always ask before installing new packages and never do it unless its inside a virtual environment.
55-
60+
- Always ask before installing new packages and do it inside a virtual environment.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
* `fees.Percentage` now supports charging fees in base currency.
8+
* Both `BacktestingDispatcher` and `RealtimeDispatcher` now support `subscribe_event_loop_started` to register handlers that are called once when the dispatch loop starts.
89

910
## 1.10.2
1011

basana/core/dispatcher/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .base import (
2020
EventDispatcher,
2121
EventHandler,
22+
LoopStartedHandler,
2223
SchedulerJob,
2324
)
2425

basana/core/dispatcher/backtesting.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ def _set_now(self, now: datetime.datetime):
5353
self._last_dt = now
5454

5555
async def _dispatch_loop(self):
56+
self._last_dt = self._event_mux.peek_next_event_dt()
57+
await self._notify_loop_started()
58+
5659
while not self.stopped:
5760
# Time is driven by event timestamps, so "now" advances only when events are processed.
5861
next_dt = self._event_mux.peek_next_event_dt()

basana/core/dispatcher/base.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
logger = logging.getLogger(__name__)
3535
EventHandler = Callable[[core_event.Event], Awaitable[Any]]
36+
LoopStartedHandler = Callable[[], Awaitable[Any]]
3637

3738

3839
# Indices for prefetched event tuples stored in the EventMultiplexer heap.
@@ -114,6 +115,7 @@ def __init__(self, max_concurrent: int):
114115
self._event_handlers: Dict[core_event.EventSource, List[EventHandler]] = defaultdict(list)
115116
self._sniffers_pre: List[EventHandler] = []
116117
self._sniffers_post: List[EventHandler] = []
118+
self._loop_started_handlers: List[LoopStartedHandler] = []
117119
self._producers: Set[core_event.Producer] = set()
118120
# Task group for core tasks like producers and dispatch loop.
119121
self._core_tasks: Optional[helpers.TaskGroup] = None
@@ -179,6 +181,17 @@ def subscribe_all(self, event_handler: EventHandler, front_run: bool = False):
179181
if event_handler not in sniffers:
180182
sniffers.append(event_handler)
181183

184+
def subscribe_event_loop_started(self, handler: LoopStartedHandler):
185+
"""Registers an async callable that will be called once when the dispatch loop starts.
186+
187+
:param handler: An async callable that receives no arguments.
188+
"""
189+
190+
assert not self._running, "Subscribing once we're running is not currently supported."
191+
192+
if handler not in self._loop_started_handlers:
193+
self._loop_started_handlers.append(handler)
194+
182195
def schedule(self, when: datetime.datetime, job: SchedulerJob):
183196
"""Schedules a function to be executed at a given time.
184197
@@ -248,6 +261,10 @@ async def _core_task_group(self):
248261
finally:
249262
self._core_tasks = None
250263

264+
async def _notify_loop_started(self):
265+
if self._loop_started_handlers:
266+
await gather_no_raise(*[handler() for handler in self._loop_started_handlers])
267+
251268
async def _dispatch_event(self, event: core_event.Event, handlers: List[EventHandler]):
252269
if self._debug_enabled:
253270
logger.debug(logs.StructuredMessage(

basana/core/dispatcher/realtime.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def subscribe_idle(self, idle_handler: IdleHandler):
5555
self._idle_handlers.append(idle_handler)
5656

5757
async def _dispatch_loop(self):
58+
await self._notify_loop_started()
5859
while not self.stopped:
5960
now = dt.utc_now()
6061
# Feed the task pool with scheduled jobs and events that are ready for processing.

tests/test_backtesting_dispatcher.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,45 @@ def test_now_fails_if_no_events_were_processed(backtesting_dispatcher):
219219
backtesting_dispatcher.now()
220220

221221

222+
async def test_event_loop_started(backtesting_dispatcher):
223+
started = []
224+
225+
async def on_started():
226+
assert backtesting_dispatcher.now_available
227+
started.append(True)
228+
229+
src = event.FifoQueueEventSource(events=[event.Event(dt.utc_now())])
230+
231+
async def on_event(_event):
232+
pass
233+
234+
backtesting_dispatcher.subscribe(src, on_event)
235+
backtesting_dispatcher.subscribe_event_loop_started(on_started)
236+
await backtesting_dispatcher.run()
237+
238+
assert len(started) == 1
239+
240+
241+
async def test_duplicate_loop_started_subscription_is_ignored(backtesting_dispatcher):
242+
started = []
243+
244+
async def on_started():
245+
assert backtesting_dispatcher.now_available
246+
started.append(True)
247+
248+
src = event.FifoQueueEventSource(events=[event.Event(dt.utc_now())])
249+
250+
async def on_event(_event):
251+
pass
252+
253+
backtesting_dispatcher.subscribe(src, on_event)
254+
backtesting_dispatcher.subscribe_event_loop_started(on_started)
255+
backtesting_dispatcher.subscribe_event_loop_started(on_started)
256+
await backtesting_dispatcher.run()
257+
258+
assert len(started) == 1
259+
260+
222261
async def test_recursive_schedule_bug(backtesting_dispatcher):
223262
jobs_processed = 0
224263

tests/test_realtime_dispatcher.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,24 @@ async def on_idle():
218218
assert handler_calls == 2
219219

220220

221+
async def test_event_loop_started(realtime_dispatcher):
222+
started = []
223+
224+
async def on_started():
225+
started.append(True)
226+
227+
async def on_idle():
228+
realtime_dispatcher.stop()
229+
230+
async def test_main():
231+
realtime_dispatcher.subscribe_event_loop_started(on_started)
232+
realtime_dispatcher.subscribe_idle(on_idle)
233+
await realtime_dispatcher.run()
234+
assert len(started) == 1
235+
236+
await asyncio.wait_for(test_main(), 2)
237+
238+
221239
async def test_cancelation_is_forwarded(realtime_dispatcher):
222240
async def test_main():
223241
with pytest.raises(asyncio.CancelledError):

0 commit comments

Comments
 (0)