fix(scanners): rank by signal quality before limiting in 3 more scanners

Same issue as options_flow: early exit on candidate count discards strong
signals that happen to be later in iteration order.

insider_buying: Dict iteration order matched OpenInsider HTML scrape order,
not signal quality. Now scores by cluster buys + C-suite + dollar value,
then takes top N.

technical_breakout: Stopped at limit*2 in file order despite data already
being batch-downloaded (zero API cost to check all). Removed early exit,
scan full universe, sort by volume_multiple.

sector_rotation: Checked laggards in arbitrary dict order, spending API
calls on random tickers. Now sorts by most-negative 5d return first so
the strongest laggard candidates are checked before hitting the budget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Youssef Aitousarrah 2026-02-21 14:14:39 -08:00
parent 136fa47645
commit 719a2d3f4e
3 changed files with 19 additions and 12 deletions

View File

@ -90,6 +90,9 @@ class InsiderBuyingScanner(BaseScanner):
else: else:
context = f"{title} {insider_name} purchased {value_str} of {ticker}" context = f"{title} {insider_name} purchased {value_str} of {ticker}"
# Scoring: cluster buys > C-suite > dollar value
insider_score = value + (num_insiders * 500_000) + (1_000_000 if is_c_suite else 0)
candidates.append( candidates.append(
{ {
"ticker": ticker, "ticker": ticker,
@ -101,11 +104,13 @@ class InsiderBuyingScanner(BaseScanner):
"insider_title": title, "insider_title": title,
"transaction_value": value, "transaction_value": value,
"num_insiders_buying": num_insiders, "num_insiders_buying": num_insiders,
"insider_score": insider_score,
} }
) )
if len(candidates) >= self.limit: # Sort by signal quality, then limit
break candidates.sort(key=lambda c: c.get("insider_score", 0), reverse=True)
candidates = candidates[: self.limit]
logger.info(f"Insider buying: {len(candidates)} candidates") logger.info(f"Insider buying: {len(candidates)} candidates")
return candidates return candidates

View File

@ -123,18 +123,20 @@ class SectorRotationScanner(BaseScanner):
continue continue
# Step 3: Only call get_ticker_info() for laggard tickers (< 2% 5d move). # Step 3: Only call get_ticker_info() for laggard tickers (< 2% 5d move).
# This dramatically reduces API calls from max_tickers down to ~20-30%. # Sort by most-negative return first — best laggards checked before limit.
candidates = [] laggards = [(t, r) for t, r in ticker_returns.items() if r <= 2.0]
for ticker, ret_5d in ticker_returns.items(): laggards.sort(key=lambda x: x[1])
if ret_5d > 2.0:
continue # Already moved — not a laggard
if len(candidates) >= self.limit: candidates = []
api_calls = 0
max_api_calls = self.limit * 3 # budget for get_ticker_info calls
for ticker, ret_5d in laggards:
if len(candidates) >= self.limit or api_calls >= max_api_calls:
break break
api_calls += 1
result = self._check_sector_laggard(ticker, accelerating_sectors, get_ticker_info) result = self._check_sector_laggard(ticker, accelerating_sectors, get_ticker_info)
if result: if result:
# Overwrite ret_5d with the value we already computed
result["stock_5d_return"] = round(ret_5d, 2) result["stock_5d_return"] = round(ret_5d, 2)
candidates.append(result) candidates.append(result)

View File

@ -85,12 +85,12 @@ class TechnicalBreakoutScanner(BaseScanner):
result = self._check_breakout(ticker, data) result = self._check_breakout(ticker, data)
if result: if result:
candidates.append(result) candidates.append(result)
if len(candidates) >= self.limit * 2:
break
# Sort by strongest breakout signal, then limit
candidates.sort(key=lambda c: c.get("volume_multiple", 0), reverse=True) candidates.sort(key=lambda c: c.get("volume_multiple", 0), reverse=True)
candidates = candidates[: self.limit]
logger.info(f"Technical breakouts: {len(candidates)} candidates") logger.info(f"Technical breakouts: {len(candidates)} candidates")
return candidates[: self.limit] return candidates
def _check_breakout(self, ticker: str, data: pd.DataFrame) -> Optional[Dict[str, Any]]: def _check_breakout(self, ticker: str, data: pd.DataFrame) -> Optional[Dict[str, Any]]:
"""Check if ticker has a volume-confirmed breakout.""" """Check if ticker has a volume-confirmed breakout."""