Threaded fast trigger + incremental ingest + naked-stop guard

Three fixes from the first live session:

1) Naked-stop bug (critical): the entry-timeout guard cancelled an entry it
   thought was unfilled AFTER it had really filled (our order_filled events lag
   the 60s sync). Cancelling a bracket parent cancels its stop/TP legs, so the
   position went NAKED and bled 22% with no floor. Guard now checks the broker's
   LIVE positions (get_positions) before cancelling and never cancels a filled
   name. Regression test added.

2) Speed: ingest re-fetched a 240-min window for ~80 symbols every minute (~46s
   measured), and the single-threaded loop froze the 4s trigger that whole time
   -> breakouts caught once a minute, not in real time. Ingest is now
   incremental (heavy first pass covers the session so the opening range is
   present; then ~20-min refreshes, closed bars never change). Loop passes
   dropped from ~70s to ~2-5s.

3) Threaded trigger: the fast breakout detector/fire loop now runs on its own
   thread, fully decoupled from the slow scheduler. EventStore, the execution
   service, and the trigger book are made thread-safe (locks) so the thread and
   main loop can't race the shared psycopg2 connection or in-memory state.
   Verified live: fired EZGO/NEOV, all positions retain active stops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-17 12:39:50 -04:00
1 parent 7b039ca288
commit d5d79cccf7
5 files changed
+224 -54

No files matched your search

+33 -26
View File
@@ -12,6 +12,7 @@ from .db import get_connection
from uuid import uuid4
import json
import logging
import threading
logger = logging.getLogger(__name__)
@@ -22,6 +23,9 @@ class EventStore:
def __init__(self, db_path: str = "./data/momentum_events.duckdb"):
"""Initialize event store with DuckDB connection."""
self.con = get_connection(db_path)
# serialize DB access: the fast trigger thread and the main loop share
# this one psycopg2 connection, which is not safe for concurrent use.
self._lock = threading.RLock()
self._ensure_schema()
def _ensure_schema(self):
@@ -64,26 +68,27 @@ class EventStore:
event_id = str(uuid4())
payload_json = json.dumps(event.model_dump(mode="json", exclude_unset=True))
cursor = self.con.cursor()
cursor.execute(
"""
INSERT INTO events
(id, timestamp, mode, event_type, correlation_id, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, current_timestamp)
""",
[
event_id,
event.timestamp,
event.mode.value,
event.event_type.value
if isinstance(event.event_type, EventType)
else str(event.event_type),
event.correlation_id,
event.message,
payload_json,
],
)
self.con.commit()
with self._lock:
cursor = self.con.cursor()
cursor.execute(
"""
INSERT INTO events
(id, timestamp, mode, event_type, correlation_id, message, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, current_timestamp)
""",
[
event_id,
event.timestamp,
event.mode.value,
event.event_type.value
if isinstance(event.event_type, EventType)
else str(event.event_type),
event.correlation_id,
event.message,
payload_json,
],
)
self.con.commit()
logger.debug(
f"Emitted event {event_id}: {event.event_type.value} - {event.message}"
@@ -152,9 +157,10 @@ class EventStore:
{limit_clause}
"""
cursor = self.con.cursor()
cursor.execute(query, params)
rows = cursor.fetchall()
with self._lock:
cursor = self.con.cursor()
cursor.execute(query, params)
rows = cursor.fetchall()
results = []
for row in rows:
@@ -175,9 +181,10 @@ class EventStore:
def count_events(self) -> int:
"""Total number of events. Cheap change-detection signal for SSE."""
cursor = self.con.cursor()
cursor.execute("SELECT COUNT(*) FROM events")
row = cursor.fetchone()
with self._lock:
cursor = self.con.cursor()
cursor.execute("SELECT COUNT(*) FROM events")
row = cursor.fetchone()
return int(row[0]) if row else 0
def get_symbol_timeline(self, symbol: str) -> list[dict]: