@ -35,37 +35,24 @@ class EventDetector:
@staticmethod
@staticmethod
def _compute_score ( row : dict [ str , Any ] , config : BacktestConfig ) - > float :
def _compute_score ( row : dict [ str , Any ] , config : BacktestConfig ) - > float :
""" Compute score using the config ' s scoring_model — matches backtester. """
""" Compute score using the config ' s scoring_model — matches backtester. """
import libs . backtest . scoring as _scoring
model = config . signal . scoring_model
model = config . signal . scoring_model
if model == " return_max_long_v5 " :
from libs . backtest . scoring import compute_return_max_long_score_v5
# Derive function name from model name to stay in sync with new models
return compute_return_max_long_score_v5 ( row )
# without requiring manual updates here.
elif model == " return_max_long_v7 " :
# "return_max_long_v13e" → compute_return_max_long_score_v13e
from libs . backtest . scoring import compute_return_max_long_score_v7
# "return_max_long_v1" → compute_return_max_long_score (legacy, no suffix)
return compute_return_max_long_score_v7 ( row )
# "pead" / "patient_drift" / "microstructure" → compute_{model}_score
elif model == " return_max_long_v8 " :
fn : Any = None
from libs . backtest . scoring import compute_return_max_long_score_v8
if model . startswith ( " return_max_long_ " ) :
return compute_return_max_long_score_v8 ( row )
suffix = model [ len ( " return_max_long_ " ) : ]
elif model == " return_max_long_v9 " :
fn_name = " compute_return_max_long_score " if suffix == " v1 " else f " compute_return_max_long_score_ { suffix } "
from libs . backtest . scoring import compute_return_max_long_score_v9
fn = getattr ( _scoring , fn_name , None )
return compute_return_max_long_score_v9 ( row )
if fn is None :
elif model == " return_max_long_v9g " :
fn = getattr ( _scoring , f " compute_ { model } _score " , None )
from libs . backtest . scoring import compute_return_max_long_score_v9g
if fn is None :
return compute_return_max_long_score_v9g ( row )
fn = _scoring . compute_entry_score
elif model == " return_max_long_v10 " :
return fn ( row )
from libs . backtest . scoring import compute_return_max_long_score_v10
return compute_return_max_long_score_v10 ( row )
elif model == " return_max_long_v11 " :
from libs . backtest . scoring import compute_return_max_long_score_v11
return compute_return_max_long_score_v11 ( row )
elif model == " return_max_long_v11g " :
from libs . backtest . scoring import compute_return_max_long_score_v11g
return compute_return_max_long_score_v11g ( row )
elif model == " pead " :
from libs . backtest . scoring import compute_pead_score
return compute_pead_score ( row )
else :
from libs . backtest . scoring import compute_entry_score
return compute_entry_score ( row )
async def get_candidates_for_date (
async def get_candidates_for_date (
self ,
self ,
@ -90,17 +77,78 @@ class EventDetector:
if not raw_rows :
if not raw_rows :
logger . debug ( " event_detector_no_events " , date = execution_date . isoformat ( ) )
logger . debug ( " event_detector_no_events " , date = execution_date . isoformat ( ) )
return [ ]
return [ ]
enriched = await self . _enrich_raw_rows ( raw_rows , bar_end_date = execution_date , config = config )
logger . info (
" event_detector_candidates_ready " ,
date = execution_date . isoformat ( ) ,
count = len ( enriched ) ,
)
return enriched
async def get_candidates_for_lookback (
self ,
today : dt . date ,
start_date : dt . date ,
config : BacktestConfig ,
) - > list [ dict [ str , Any ] ] :
""" Return enriched candidate rows for events with entry_date in [start_date, today).
Used on daemon startup to pick up events from previous trading days that are
still within their max_holding_days window . Each returned row has :
- is_lookback_entry = True
- lookback_days_elapsed = N ( trading days since the original execution_date )
"""
raw_rows = await self . _fetch_events_for_date_range ( start_date , today )
if not raw_rows :
logger . debug (
" event_detector_no_lookback_events " ,
start = start_date . isoformat ( ) ,
end = today . isoformat ( ) ,
)
return [ ]
enriched = await self . _enrich_raw_rows ( raw_rows , bar_end_date = today , config = config )
# Annotate with lookback metadata
from libs . backtest . calendar import get_trading_days
_elapsed_cache : dict [ dt . date , int ] = { }
for row in enriched :
raw_exec = row . get ( " execution_date " ) or row . get ( " entry_date " )
if raw_exec :
exec_date = raw_exec if isinstance ( raw_exec , dt . date ) else dt . date . fromisoformat ( str ( raw_exec ) )
if exec_date not in _elapsed_cache :
tdays = get_trading_days ( exec_date , today )
_elapsed_cache [ exec_date ] = max ( 0 , len ( tdays ) - 1 )
row [ " is_lookback_entry " ] = True
row [ " lookback_days_elapsed " ] = _elapsed_cache [ exec_date ]
logger . info (
" event_detector_lookback_candidates_ready " ,
start = start_date . isoformat ( ) ,
today = today . isoformat ( ) ,
count = len ( enriched ) ,
)
return enriched
async def _enrich_raw_rows (
self ,
raw_rows : list [ dict [ str , Any ] ] ,
bar_end_date : dt . date ,
config : BacktestConfig ,
) - > list [ dict [ str , Any ] ] :
""" Enrich raw DB rows with bars, market features, and scores.
Shared by get_candidates_for_date and get_candidates_for_lookback .
bar_end_date is the upper bound for bar fetches ( usually execution_date or today ) .
"""
unique_symbols = sorted ( {
unique_symbols = sorted ( {
str ( r . get ( " symbol " , " " ) ) . upper ( )
str ( r . get ( " symbol " , " " ) ) . upper ( )
for r in raw_rows
for r in raw_rows
if r . get ( " symbol " )
if r . get ( " symbol " )
} )
} )
# Fetch bars for the last 30 days for ADV + ATR computation
# Fetch 120 days of bars — enough for 60d features (entropy, Hurst, gravitational pull)
bar_start = execution_date - dt . timedelta ( days = 45 )
# which need ~65 trading days (~91 calendar days) before the reaction date.
bar_start = bar_end_date - dt . timedelta ( days = 120 )
bars_by_symbol , avg_dvol , atr_by_symbol = await self . _fetch_enrichment_data (
bars_by_symbol , avg_dvol , atr_by_symbol = await self . _fetch_enrichment_data (
unique_symbols , bar_start , execution_date
unique_symbols , bar_start , bar_end _date
)
)
company_info = await self . _fetch_company_info ( unique_symbols )
company_info = await self . _fetch_company_info ( unique_symbols )
screener_mcaps = await self . _fetch_screener_market_caps ( unique_symbols )
screener_mcaps = await self . _fetch_screener_market_caps ( unique_symbols )
@ -217,6 +265,57 @@ class EventDetector:
)
)
continue
continue
# Compute tier2/tier3 features from bars if missing from DB.
# The DB FeatureSnapshot only stores basic NLP/event features; technical
# features (entropy, Hurst, gravitational pull, etc.) are computed only in
# the Parquet enrichment pipeline. Reproduce them here from Oracle bars.
# Use event_date (pre-event baseline) to match the Parquet pipeline exactly.
# Fall back to reaction_date if event_date is unavailable or not a trading day.
sym_bars_for_features = bars_by_symbol . get ( sym , { } )
_event_date_raw = enriched . get ( " event_date " )
_ed_candidate = _parse_date ( _event_date_raw )
# event_date must be present in bars (i.e., a trading day) to use it
if _ed_candidate and _ed_candidate in sym_bars_for_features :
rd_for_features = _ed_candidate
else :
rd_for_features = _parse_date ( enriched . get ( " reaction_date " ) )
if rd_for_features and sym_bars_for_features :
from libs . features . market_features import (
avg_dollar_volume_20d as _adv20d_fn ,
pre_event_bb_position as _bb_pos_fn ,
pre_event_entropy as _entropy_fn ,
pre_event_gravitational_pull as _grav_pull_fn ,
pre_event_hurst as _hurst_fn ,
pre_event_market_temperature as _mkt_temp_fn ,
)
from libs . oracle_client . models import PriceBar as _OraclePriceBar
_price_bars = [
_OraclePriceBar (
date = d . isoformat ( ) ,
open = float ( b . get ( " open " , 0 ) ) ,
high = float ( b . get ( " high " , 0 ) ) ,
low = float ( b . get ( " low " , 0 ) ) ,
close = float ( b . get ( " close " , 0 ) ) ,
volume = int ( b . get ( " volume " , 0 ) ) ,
)
for d , b in sorted ( sym_bars_for_features . items ( ) )
]
_rd_str = rd_for_features . isoformat ( )
if enriched . get ( " avg_dollar_volume_20d " ) is None :
enriched [ " avg_dollar_volume_20d " ] = _adv20d_fn ( _price_bars , _rd_str )
if enriched . get ( " pre_event_bb_position " ) is None :
enriched [ " pre_event_bb_position " ] = _bb_pos_fn ( _price_bars , _rd_str )
if enriched . get ( " pre_event_hurst_60d " ) is None :
enriched [ " pre_event_hurst_60d " ] = _hurst_fn ( _price_bars , _rd_str )
if enriched . get ( " pre_event_entropy_60d " ) is None :
enriched [ " pre_event_entropy_60d " ] = _entropy_fn ( _price_bars , _rd_str )
if enriched . get ( " pre_event_gravitational_pull " ) is None :
enriched [ " pre_event_gravitational_pull " ] = _grav_pull_fn ( _price_bars , _rd_str )
if enriched . get ( " pre_event_market_temperature " ) is None :
enriched [ " pre_event_market_temperature " ] = _mkt_temp_fn ( _price_bars , _rd_str )
# Compute score using config's scoring model for consistency with
# Compute score using config's scoring model for consistency with
# BacktestRunner. Only use config model when event_v1 features are
# BacktestRunner. Only use config model when event_v1 features are
# present (parse_confidence_overall etc.), otherwise the model's hard
# present (parse_confidence_overall etc.), otherwise the model's hard
@ -229,11 +328,6 @@ class EventDetector:
enriched_rows . append ( enriched )
enriched_rows . append ( enriched )
logger . info (
" event_detector_candidates_ready " ,
date = execution_date . isoformat ( ) ,
count = len ( enriched_rows ) ,
)
return enriched_rows
return enriched_rows
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
@ -319,6 +413,7 @@ class EventDetector:
" event_timestamp " : ts ,
" event_timestamp " : ts ,
" reaction_date " : label . reaction_date ,
" reaction_date " : label . reaction_date ,
" entry_date " : label . entry_date ,
" entry_date " : label . entry_date ,
" entry_convention " : label . entry_convention ,
" execution_date " : execution_date ,
" execution_date " : execution_date ,
" label_status " : label . label_status ,
" label_status " : label . label_status ,
# Merged features from ALL FeatureSnapshots for this event
# Merged features from ALL FeatureSnapshots for this event
@ -340,6 +435,98 @@ class EventDetector:
self . _db_unavailable = True
self . _db_unavailable = True
return [ ]
return [ ]
async def _fetch_events_for_date_range (
self ,
start_date : dt . date ,
end_date : dt . date ,
) - > list [ dict [ str , Any ] ] :
""" Query EventLabel rows where entry_date in [start_date, end_date).
Used for lookback entry : surfaces events that fired before the daemon
started but are still within their max_holding_days window .
"""
if self . _db_unavailable :
return [ ]
try :
from sqlalchemy import select
from sqlalchemy . ext . asyncio import AsyncSession , async_sessionmaker , create_async_engine
from libs . db . models import Event , EventLabel , FeatureSnapshot , SymbolMaster
engine = create_async_engine (
self . _db_dsn , echo = False ,
connect_args = { " timeout " : 5 } ,
)
async_session = async_sessionmaker ( engine , class_ = AsyncSession , expire_on_commit = False )
_UTC = __import__ ( " zoneinfo " ) . ZoneInfo ( " UTC " )
async with async_session ( ) as session :
stmt = (
select ( Event , EventLabel , FeatureSnapshot , SymbolMaster )
. join ( EventLabel , Event . event_id == EventLabel . event_id )
. join ( FeatureSnapshot , Event . event_id == FeatureSnapshot . event_id )
. outerjoin ( SymbolMaster , Event . symbol_id == SymbolMaster . symbol_id )
. where ( EventLabel . entry_date > = start_date )
. where ( EventLabel . entry_date < end_date )
. where ( EventLabel . label_status . in_ ( [ " ok " , " truncated " , " pending " ] ) )
. order_by ( FeatureSnapshot . created_at_utc . asc ( ) )
)
rows = ( await session . execute ( stmt ) ) . all ( )
await engine . dispose ( )
event_meta : dict [ str , tuple ] = { }
event_features : dict [ str , dict ] = { }
for event , label , snapshot , sym in rows :
eid = event . event_id
if sym is None or not sym . ticker :
continue
if eid not in event_meta :
event_meta [ eid ] = ( event , label , sym )
event_features [ eid ] = { }
event_features [ eid ] . update ( snapshot . feature_json or { } )
result : list [ dict [ str , Any ] ] = [ ]
for eid , ( event , label , sym ) in event_meta . items ( ) :
ts = event . filed_at_utc
if ts is None and event . event_date is not None :
ts = dt . datetime . combine (
event . event_date , dt . time ( 21 , 0 ) , tzinfo = _UTC
)
row : dict [ str , Any ] = {
" event_id " : eid ,
" symbol " : sym . ticker ,
" issuer_id " : event . issuer_id ,
" event_type " : event . event_type or " " ,
" event_direction " : event . event_direction or " " ,
" event_date " : event . event_date ,
" event_timestamp " : ts ,
" reaction_date " : label . reaction_date ,
" entry_date " : label . entry_date ,
" entry_convention " : label . entry_convention ,
" execution_date " : label . entry_date , # use entry_date as execution_date
" label_status " : label . label_status ,
* * event_features [ eid ] ,
}
result . append ( row )
logger . debug (
" event_detector_db_range_fetched " ,
start = start_date . isoformat ( ) ,
end = end_date . isoformat ( ) ,
count = len ( result ) ,
)
return result
except Exception as exc :
if not self . _db_unavailable :
err_msg = f " { type ( exc ) . __name__ } : { exc } " if str ( exc ) else type ( exc ) . __name__
logger . warning ( " event_detector_db_range_fetch_failed " , error = err_msg )
self . _db_unavailable = True
return [ ]
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
# Enrichment helpers
# Enrichment helpers
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #