trader.discovery.filters module¶
The ordered gate a discovered symbol must clear.
Cheapest first, and the expensive work is not here at all: run_once does the 400-day fetch and the backtest, and only survivors of this chain ever reach it. An analyst lookup is 0.05-0.23s; the alternative is a 400-day fetch.
There is no trend filter. An earlier version combined a standalone net-negative-analyst rejection with a conjunction of downtrend AND net-negative analysts; the standalone rule always returned first, so the conjunction could never fire — dead code, found by mutation testing (replacing it with if False left the whole suite passing) and confirmed against a real trace. trend_lookback_days and _is_falling were removed rather than kept as dead config and dead code. See the design doc’s amended item 7 for the full account. The standalone analyst filter already serves the operator’s cost concern (“no reason to backtest something down 30 days AND analysts say sell”), and does so more aggressively, since it doesn’t require the downtrend half at all.
- trader.discovery.filters.DEFAULT_STRONG_BUY_MEAN_THRESHOLD = 2.0¶
Yahoo’s consensus scale is 1 (strong buy) .. 5 (strong sell); <= 2.0 mirrors the sibling stock-screener repo’s validated <= 1.5 for “strong buy”, loosened slightly since this is a discovery signal feeding a model that reasons further, not a final accept/reject (issue #27, design doc decision #6). A tuning parameter, not an architectural one.
- class trader.discovery.filters.FilterOutcome(accepted, reason, code=None)[source]¶
Bases:
objectWhether a candidate survived, and the reason either way.
code is None on an accepted outcome — there is nothing to name — and always one of the REASON_* constants above on a rejection. It is what screen_for_entry hands run_once so a blocked fixed_tickers entry’s decisions row can carry a structured rejection_reason instead of only the free-text reason.
- Parameters:
accepted (bool)
reason (str)
code (str | None)
- accepted: bool¶
- reason: str¶
- code: str | None¶
- trader.discovery.filters.apply_filters(candidate, *, asset, opinion, bars, settings)[source]¶
Run the chain, stopping at the first rejection.
Pure and unchanged from the caller’s point of view: it still takes every input already gathered and decides in one call. The laziness that skips gathering an input a candidate was always going to be rejected before reaching lives in discovery.scan._evaluate, which calls listing_gate, asset_gate and opinion_gate — the exact same functions this composes — before it ever fetches the asset, the opinion or the bars this needs.
- Parameters:
candidate (Candidate)
asset (TradableAsset | None)
opinion (AnalystOpinion | None)
bars (Sequence[Bar])
settings (DiscoverySettings)
- Return type:
- trader.discovery.filters.asset_gate(candidate, asset)[source]¶
The tradability check alone. None means it passed.
Exposed (not _-private) rather than inlined only in apply_filters, so that discovery.scan._evaluate can run this exact check the moment it has an asset — before it ever fetches an analyst opinion or a bar history — without duplicating the rule or reaching into a private name. This stays the one place tradability is decided; apply_filters below composes it with the other gates rather than re-deriving it.
- Parameters:
candidate (Candidate)
asset (TradableAsset | None)
- Return type:
FilterOutcome | None
- trader.discovery.filters.is_strong_buy_consensus(opinion, *, mean_threshold=2.0)[source]¶
Whether analysts favor this symbol strongly enough to propose it.
Additive, not a replacement for opinion_gate: this decides whether an index-membership symbol becomes a Candidate at all (issue #27’s origin=”analyst_scan”); opinion_gate’s net-negative reject still runs on every candidate afterward, unchanged, as the safety floor.
A named, tested predicate rather than arithmetic inlined at the call site, per the design doc’s decision #6 — so the two conditions this checks (a low mean, or strong-buy outnumbering everything else combined) stay a single, greppable definition of “strong buy consensus” instead of each caller re-deriving it slightly differently.
- Parameters:
opinion (AnalystOpinion)
mean_threshold (float)
- Return type:
bool
- trader.discovery.filters.listing_gate(candidate)[source]¶
Reject a listing whose symbol says it is not common stock.
The cheapest gate there is — a string check, no network — so scan runs it before get_asset, and it is the reason a theme scan that turns up a warrant costs nothing to reject it.
It exists because the broker cannot answer this question. Measured 2026-08-04: Alpaca reports HQWWW and NUCLW as tradable=True, asset_class=’us_equity’, exactly like the common shares they derive from, so asset_gate passes them. Both came out of a real theme scan. A warrant is a different instrument — it expires, it is leveraged, and an 8% trailing stop on one is not the risk that percentage implies on a share.
Neither is it the same rule as the volume floor. That floor happened to reject those two, but a liquid warrant on a hot name would clear it and nothing else in the chain would notice it was not a share.
Scope, deliberately: NASDAQ’s positional fifth-letter convention only. NYSE’s .WS-style suffixes are a separate rule and are not covered — a symbol this gate passes is not thereby certified to be common stock.
- Parameters:
candidate (Candidate)
- Return type:
FilterOutcome | None
- trader.discovery.filters.opinion_gate(candidate, opinion)[source]¶
The analyst-consensus check alone. None means it passed.
Absent coverage is not disapproval — ETFs have no analysts at all — so a None opinion always passes here. Exposed for the same reason as asset_gate: it lets _evaluate stop before ever fetching 30 days of bars for a symbol analysts already dislike.
- Parameters:
candidate (Candidate)
opinion (AnalystOpinion | None)
- Return type:
FilterOutcome | None