@ -22,11 +22,15 @@ class EventDetector:
self ,
self ,
db_dsn : str ,
db_dsn : str ,
oracle_url : str ,
oracle_url : str ,
bars_cache : dict [ str , dict [ dt . date , dict ] ] | None = None ,
) - > None :
) - > None :
self . _db_dsn = db_dsn
self . _db_dsn = db_dsn
self . _oracle_url = oracle_url
self . _oracle_url = oracle_url
self . _bars_cache = bars_cache # pre-fetched bars from backtest_sim
self . _db_unavailable : bool = False # circuit breaker: skip after first failure
self . _db_unavailable : bool = False # circuit breaker: skip after first failure
self . _screener_unavailable : bool = False # circuit breaker for screener API
self . _screener_unavailable : bool = False # circuit breaker for screener API
self . _company_cache : dict [ str , dict [ str , Any ] ] = { } # symbol -> {sector, market_cap}
self . _screener_cache : dict [ str , float | None ] | None = None # cached screener results
@staticmethod
@staticmethod
def _compute_score ( row : dict [ str , Any ] , config : BacktestConfig ) - > float :
def _compute_score ( row : dict [ str , Any ] , config : BacktestConfig ) - > float :
@ -211,10 +215,14 @@ class EventDetector:
)
)
continue
continue
# Compute score using the SAME scoring model as the backtester.
# Compute score if missing from DB.
# Always recompute to ensure consistency with backtest results,
# Use compute_entry_score (ranking-only, no hard gates).
# since DB-stored scores may have been computed with a different model.
# Trade filtering is handled by engine gates in select_candidates,
enriched [ " score " ] = self . _compute_score ( enriched , config )
# not by the scoring function. Using v5/v8/v9 scoring here would
# reject too many candidates that engine gates should evaluate.
if " score " not in enriched or enriched . get ( " score " ) is None :
from libs . backtest . scoring import compute_entry_score
enriched [ " score " ] = compute_entry_score ( enriched )
enriched_rows . append ( enriched )
enriched_rows . append ( enriched )
@ -347,6 +355,10 @@ class EventDetector:
if not symbols :
if not symbols :
return { } , { } , { }
return { } , { } , { }
# Fast path: use pre-fetched bars_cache (backtest mode)
if self . _bars_cache is not None :
return self . _enrichment_from_cache ( symbols , start , end )
try :
try :
from libs . oracle_client import OracleClient , PriceService
from libs . oracle_client import OracleClient , PriceService
@ -386,7 +398,7 @@ class EventDetector:
bars_by_symbol [ sym ] = date_map
bars_by_symbol [ sym ] = date_map
last_20 = dollar_vols [ - 20 : ]
last_20 = dollar_vols [ - 20 : ]
avg_dvol [ sym ] = sum ( last_20 ) / len ( last_20 ) if last_20 else 0.0
avg_dvol [ sym ] = sum ( last_20 ) / len ( last_20 ) if last_20 else 0.0
atr_14_map [ sym ] = _compute_atr14 ( resp . bars )
atr_14_map [ sym ] = _compute_atr14 _from_dicts( list ( date_map . values ( ) ) )
except Exception as sym_exc :
except Exception as sym_exc :
err_msg = f " { type ( sym_exc ) . __name__ } : { sym_exc } " if str ( sym_exc ) else type ( sym_exc ) . __name__
err_msg = f " { type ( sym_exc ) . __name__ } : { sym_exc } " if str ( sym_exc ) else type ( sym_exc ) . __name__
# "Not found" is expected for delisted/renamed symbols — debug only
# "Not found" is expected for delisted/renamed symbols — debug only
@ -407,6 +419,40 @@ class EventDetector:
logger . warning ( " event_detector_oracle_failed " , error = err_msg )
logger . warning ( " event_detector_oracle_failed " , error = err_msg )
return { } , { } , { }
return { } , { } , { }
def _enrichment_from_cache (
self ,
symbols : list [ str ] ,
start : dt . date ,
end : dt . date ,
) - > tuple [
dict [ str , dict [ dt . date , dict [ str , Any ] ] ] ,
dict [ str , float ] ,
dict [ str , float | None ] ,
] :
""" Compute enrichment data from pre-fetched bars_cache (no Oracle calls). """
bars_by_symbol : dict [ str , dict [ dt . date , dict [ str , Any ] ] ] = { }
avg_dvol : dict [ str , float ] = { }
atr_14_map : dict [ str , float | None ] = { }
for sym in symbols :
all_bars = self . _bars_cache . get ( sym , { } )
# Filter to requested date range
date_map = { d : b for d , b in all_bars . items ( ) if start < = d < = end }
bars_by_symbol [ sym ] = date_map
if not date_map :
avg_dvol [ sym ] = 0.0
atr_14_map [ sym ] = None
continue
sorted_bars = [ date_map [ d ] for d in sorted ( date_map . keys ( ) ) ]
dollar_vols = [ b [ " close " ] * b [ " volume " ] for b in sorted_bars ]
last_20 = dollar_vols [ - 20 : ]
avg_dvol [ sym ] = sum ( last_20 ) / len ( last_20 ) if last_20 else 0.0
atr_14_map [ sym ] = _compute_atr14_from_dicts ( sorted_bars )
return bars_by_symbol , avg_dvol , atr_14_map
async def _fetch_screener_market_caps (
async def _fetch_screener_market_caps (
self ,
self ,
symbols : list [ str ] ,
symbols : list [ str ] ,
@ -419,6 +465,11 @@ class EventDetector:
if not symbols or self . _screener_unavailable :
if not symbols or self . _screener_unavailable :
return { }
return { }
# Return from cache if available (screener data is static across days)
if self . _screener_cache is not None :
symbol_set = { s . upper ( ) for s in symbols }
return { s : self . _screener_cache . get ( s ) for s in symbol_set }
symbol_set = { s . upper ( ) for s in symbols }
symbol_set = { s . upper ( ) for s in symbols }
result : dict [ str , float | None ] = { s : None for s in symbol_set }
result : dict [ str , float | None ] = { s : None for s in symbol_set }
@ -435,8 +486,11 @@ class EventDetector:
exclude_types = " ETF,FUND,ADR,SPAC " ,
exclude_types = " ETF,FUND,ADR,SPAC " ,
)
)
# Cache ALL screener results for future calls
self . _screener_cache = { }
for stock in stocks :
for stock in stocks :
sym = ( stock . symbol or " " ) . upper ( )
sym = ( stock . symbol or " " ) . upper ( )
self . _screener_cache [ sym ] = stock . market_cap
if sym in symbol_set :
if sym in symbol_set :
result [ sym ] = stock . market_cap
result [ sym ] = stock . market_cap
@ -452,13 +506,15 @@ class EventDetector:
symbols : list [ str ] ,
symbols : list [ str ] ,
concurrency : int = 16 ,
concurrency : int = 16 ,
) - > dict [ str , dict [ str , Any ] ] :
) - > dict [ str , dict [ str , Any ] ] :
""" Fetch sector and market_cap for each symbol from Oracle ."""
""" Fetch sector and market_cap for each symbol from Oracle (cached) ."""
if not symbols :
if not symbols :
return { }
return { }
result : dict [ str , dict [ str , Any ] ] = { }
# Only fetch symbols not yet in cache
semaphore = asyncio . Semaphore ( concurrency )
uncached = [ s for s in symbols if s not in self . _company_cache ]
if uncached :
semaphore = asyncio . Semaphore ( concurrency )
try :
try :
from libs . oracle_client import CompanyService , OracleClient
from libs . oracle_client import CompanyService , OracleClient
@ -476,16 +532,17 @@ class EventDetector:
except Exception :
except Exception :
return sym , { " sector " : " UNKNOWN " , " market_cap " : None }
return sym , { " sector " : " UNKNOWN " , " market_cap " : None }
fetched = await asyncio . gather ( * ( _fetch_one ( sym ) for sym in symbols ) )
fetched = await asyncio . gather ( * ( _fetch_one ( sym ) for sym in uncached ) )
for sym , info in fetched :
for sym , info in fetched :
result [ sym ] = info
self . _company_cache [ sym ] = info
except Exception as exc :
except Exception as exc :
err_msg = f " { type ( exc ) . __name__ } : { exc } " if str ( exc ) else type ( exc ) . __name__
err_msg = f " { type ( exc ) . __name__ } : { exc } " if str ( exc ) else type ( exc ) . __name__
logger . warning ( " event_detector_company_fetch_failed " , error = err_msg )
logger . warning ( " event_detector_company_fetch_failed " , error = err_msg )
result : dict [ str , dict [ str , Any ] ] = { }
for sym in symbols :
for sym in symbols :
result . setdefaul t( sym , { " sector " : " UNKNOWN " , " market_cap " : None } )
result [ sym ] = self . _company_cache . ge t( sym , { " sector " : " UNKNOWN " , " market_cap " : None } )
return result
return result
@ -494,7 +551,7 @@ class EventDetector:
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
def _compute_atr14 ( bars : list [ Any ] ) - > float | None :
def _compute_atr14 ( bars : list [ Any ] ) - > float | None :
""" Compute 14-day ATR from a list of bar objects ."""
""" Compute 14-day ATR from a list of bar objects (API response) ."""
if len ( bars ) < 2 :
if len ( bars ) < 2 :
return None
return None
@ -517,6 +574,30 @@ def _compute_atr14(bars: list[Any]) -> float | None:
return sum ( last_14 ) / len ( last_14 )
return sum ( last_14 ) / len ( last_14 )
def _compute_atr14_from_dicts ( bars : list [ dict ] ) - > float | None :
""" Compute 14-day ATR from a list of bar dicts (cache format). """
if len ( bars ) < 2 :
return None
true_ranges : list [ float ] = [ ]
for i in range ( 1 , len ( bars ) ) :
prev_close = float ( bars [ i - 1 ] [ " close " ] )
high = float ( bars [ i ] [ " high " ] )
low = float ( bars [ i ] [ " low " ] )
tr = max (
high - low ,
abs ( high - prev_close ) ,
abs ( low - prev_close ) ,
)
true_ranges . append ( tr )
if not true_ranges :
return None
last_14 = true_ranges [ - 14 : ] if len ( true_ranges ) > = 14 else true_ranges
return sum ( last_14 ) / len ( last_14 )
def _parse_date ( raw : Any ) - > dt . date | None :
def _parse_date ( raw : Any ) - > dt . date | None :
if isinstance ( raw , dt . datetime ) :
if isinstance ( raw , dt . datetime ) :
return raw . date ( )
return raw . date ( )