169 lines
4.9 KiB
Python
169 lines
4.9 KiB
Python
|
|
"""
|
||
|
|
T19 Event Types — typed events for the UV clock host.
|
||
|
|
|
||
|
|
Per Fable's T19 spec (ANNEX B-CORRECTION):
|
||
|
|
"iceoryx2 = the wires; ASEx = the state cells; UV clock = the conductor."
|
||
|
|
|
||
|
|
Events are published via iceoryx2/zinc topics (transport).
|
||
|
|
ASEx guards the state cells that handlers mutate.
|
||
|
|
|
||
|
|
Staleness Law (ANNEX A):
|
||
|
|
1. BarFire is EDGE-TRIGGERED on scan_number monotonic advance — NEVER timer-synthesized.
|
||
|
|
2. Every event carries provenance: scan_number, scan_ts, ingest_ts, AGE at dispatch.
|
||
|
|
3. Freshness watchdog TimerEvent emits StaleInput when stale.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import time
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from enum import Enum
|
||
|
|
from typing import Any, Optional
|
||
|
|
|
||
|
|
|
||
|
|
class EventType(str, Enum):
|
||
|
|
SCAN = "SCAN"
|
||
|
|
TICK = "TICK"
|
||
|
|
TIMER = "TIMER"
|
||
|
|
BAR_FIRE = "BAR_FIRE"
|
||
|
|
STALE_INPUT = "STALE_INPUT"
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class EventProvenance:
|
||
|
|
"""
|
||
|
|
First-class freshness payload. Handlers CANNOT NOT know this.
|
||
|
|
|
||
|
|
Every event carries provenance per T19 Staleness Law §2.
|
||
|
|
"""
|
||
|
|
scan_number: int
|
||
|
|
scan_ts: int # ns, original scan timestamp
|
||
|
|
ingest_ts: int # ns, when we received it
|
||
|
|
source: str # "live" | "replay"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def age_ns(self) -> int:
|
||
|
|
"""Age at construction time (not dispatch — dispatch adds overhead)."""
|
||
|
|
return time.time_ns() - self.ingest_ts
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_fresh(self) -> bool:
|
||
|
|
"""Fresh if age < 1.5x cadence (default 5.85s * 1.5 = 8.775s)."""
|
||
|
|
return self.age_ns < 8_775_000_000 # 8.775s in ns
|
||
|
|
|
||
|
|
def stale_threshold_ns(self, cadence_ns: int = 5_850_000_000) -> int:
|
||
|
|
return int(cadence_ns * 1.5)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class ScanEvent:
|
||
|
|
"""
|
||
|
|
NG7 scan from HZ (live) or recorded source (replay).
|
||
|
|
~5.85s cadence live. Edge-triggered on scan_number advance.
|
||
|
|
"""
|
||
|
|
event_type: EventType = EventType.SCAN
|
||
|
|
scan_number: int = 0
|
||
|
|
symbol: str = ""
|
||
|
|
data: Any = None # scan payload (dict, bytes, etc.)
|
||
|
|
provenance: EventProvenance = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.provenance is None:
|
||
|
|
object.__setattr__(self, 'provenance', EventProvenance(
|
||
|
|
scan_number=self.scan_number,
|
||
|
|
scan_ts=time.time_ns(),
|
||
|
|
ingest_ts=time.time_ns(),
|
||
|
|
source="live",
|
||
|
|
))
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class TickEvent:
|
||
|
|
"""
|
||
|
|
OBF price tick. ~0.18s cadence live, per-symbol.
|
||
|
|
TP/SL handlers subscribe to this.
|
||
|
|
"""
|
||
|
|
event_type: EventType = EventType.TICK
|
||
|
|
symbol: str = ""
|
||
|
|
price: float = 0.0
|
||
|
|
bid: float = 0.0
|
||
|
|
ask: float = 0.0
|
||
|
|
volume: float = 0.0
|
||
|
|
provenance: EventProvenance = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.provenance is None:
|
||
|
|
object.__setattr__(self, 'provenance', EventProvenance(
|
||
|
|
scan_number=0,
|
||
|
|
scan_ts=time.time_ns(),
|
||
|
|
ingest_ts=time.time_ns(),
|
||
|
|
source="live",
|
||
|
|
))
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class TimerEvent:
|
||
|
|
"""
|
||
|
|
Scheduled wakeup: TTL expiries, watchdogs, cadence ticks.
|
||
|
|
"""
|
||
|
|
event_type: EventType = EventType.TIMER
|
||
|
|
timer_id: str = ""
|
||
|
|
payload: Any = None
|
||
|
|
provenance: EventProvenance = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.provenance is None:
|
||
|
|
object.__setattr__(self, 'provenance', EventProvenance(
|
||
|
|
scan_number=0,
|
||
|
|
scan_ts=time.time_ns(),
|
||
|
|
ingest_ts=time.time_ns(),
|
||
|
|
source="live",
|
||
|
|
))
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class BarFire:
|
||
|
|
"""
|
||
|
|
Brain dispatch event. EDGE-TRIGGERED on scan_number monotonic advance.
|
||
|
|
Never timer-synthesized. No new scan = no BarFire.
|
||
|
|
"""
|
||
|
|
event_type: EventType = EventType.BAR_FIRE
|
||
|
|
scan_number: int = 0
|
||
|
|
symbol: str = ""
|
||
|
|
data: Any = None
|
||
|
|
provenance: EventProvenance = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.provenance is None:
|
||
|
|
object.__setattr__(self, 'provenance', EventProvenance(
|
||
|
|
scan_number=self.scan_number,
|
||
|
|
scan_ts=time.time_ns(),
|
||
|
|
ingest_ts=time.time_ns(),
|
||
|
|
source="live",
|
||
|
|
))
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class StaleInput:
|
||
|
|
"""
|
||
|
|
Freshness watchdog alarm. Emitted when:
|
||
|
|
now - last_scan_ts > 1.5x cadence
|
||
|
|
|
||
|
|
This is the June-22 scan-freeze / silent-scanner-death detector, native.
|
||
|
|
Fixes BLUE's oldest observability hole as a side effect.
|
||
|
|
"""
|
||
|
|
event_type: EventType = EventType.STALE_INPUT
|
||
|
|
source: str = ""
|
||
|
|
last_scan_ts: int = 0
|
||
|
|
current_ts: int = 0
|
||
|
|
stale_duration_ns: int = 0
|
||
|
|
provenance: EventProvenance = None
|
||
|
|
|
||
|
|
def __post_init__(self):
|
||
|
|
if self.provenance is None:
|
||
|
|
object.__setattr__(self, 'provenance', EventProvenance(
|
||
|
|
scan_number=0,
|
||
|
|
scan_ts=self.last_scan_ts,
|
||
|
|
ingest_ts=time.time_ns(),
|
||
|
|
source="live",
|
||
|
|
))
|