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:
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(
{
"ticker": ticker,
@ -101,11 +104,13 @@ class InsiderBuyingScanner(BaseScanner):
"insider_title": title,
"transaction_value": value,
"num_insiders_buying": num_insiders,
"insider_score": insider_score,
}
)
if len(candidates) >= self.limit:
break
# Sort by signal quality, then limit
candidates.sort(key=lambda c: c.get("insider_score", 0), reverse=True)
candidates = candidates[: self.limit]
logger.info(f"Insider buying: {len(candidates)} candidates")
return candidates

View File

@ -123,18 +123,20 @@ class SectorRotationScanner(BaseScanner):
continue
# 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%.
candidates = []
for ticker, ret_5d in ticker_returns.items():
if ret_5d > 2.0:
continue # Already moved — not a laggard
# Sort by most-negative return first — best laggards checked before limit.
laggards = [(t, r) for t, r in ticker_returns.items() if r <= 2.0]
laggards.sort(key=lambda x: x[1])
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
api_calls += 1
result = self._check_sector_laggard(ticker, accelerating_sectors, get_ticker_info)
if result:
# Overwrite ret_5d with the value we already computed
result["stock_5d_return"] = round(ret_5d, 2)
candidates.append(result)

View File

@ -85,12 +85,12 @@ class TechnicalBreakoutScanner(BaseScanner):
result = self._check_breakout(ticker, data)
if 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 = candidates[: self.limit]
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]]:
"""Check if ticker has a volume-confirmed breakout."""