Action Engine
How a LightGBM prediction becomes a typed decision row: triggers, inference paths, universe, predicates, and what each consumer reads. Everything here is a source fact at the shas in the footer unless dated otherwise.
What the engine is
The engine writes typed decision rows to one table, action_decisions: one row per
(ticker, horizon) per run, for the 1d, 5d, 20d and 60d horizons the model returns. Each run asks the
Python pipeline for fresh LightGBM predictions, passes each horizon through the deterministic mapper
MapVerdict (internal/services/action_engine/verdict_mapper.go), and inserts the rows.
Same prediction in, same row out.
tradeable_v2, tradeable_mid_large
Model-tier parameters (an analyzer tier and a worker tier, named by vendor aliases) still thread through the
scheduler and the Analyze request, but the code that consumed them was the removed reviewer, so they have no
effect on a decision. The LLM roles that remain around the engine are a risk flagger and a catalyst extractor,
described below; neither is an input to MapVerdict.
action_decisions| Consumer | What it reads |
|---|---|
| Stock feed (Buy / Sell tabs) | The latest 60d row per ticker through the buy_now / sell_now predicates |
| Auto-Pilot paper books | Latest rows for entry ranking and its own exit predicate (see Auto-Pilot) |
| Tripwire monitor | Buy recommendations and their decision-time prices, to watch live trades against them |
| Push notifications | A predicate crossing between the previous and the new primary row |
| Diagnostics | Backtest, filter-preview and risk-flagger statistics endpoints |
Trigger modes
Five trigger types are declared in internal/services/action_decisions_service.go. Only
on_demand uses RunOnDemand; the other four use the worker entry point Run,
which honours a live audit lock. The trigger string is stored on every row.
| Trigger | Fired by | Scope |
|---|---|---|
| scheduled_batch | The refresh sweep, 4× per market day at 09:00, 15:30, 18:30 and 20:30 UTC (weekends and holidays skipped), plus an intraday refresh every 30 minutes while the market is open | Sweep: every ticker in ticker_fundamentals with a market cap of at least $100M. Intraday: a small batch of the stalest latest rows, skipping audit-locked ones |
| on_demand | A user tapping Analyze | One ticker. Served from the 4h cache when fresh; the rows it writes carry a 24h audit lock |
| scanner_signal | The catalyst scanner: each successful dispatch from its sources fans out to the engine | One ticker. Routine dispatches reuse the cache; catalyst-mode dispatches invalidate it first. Never audit-locks |
| catalyst_event | The tripwire monitor, when live price action invalidates a buy thesis (thesis_invalidated) |
One ticker. A separate catalyst dispatcher is constructed at boot but not wired to any producer |
| price_gap_refresh | The price-gap monitor: live price has drifted more than 5% from the price stamped at decision time | One ticker, polled every 60 seconds with a 30-minute per-ticker cooldown and a per-tick cap, largest drift first |
The risk-flagger sweep runs after each of the four scheduled sweeps. It is not a trigger type and writes no new decision rows.
Inference paths
Two paths produce model output. They are separate code, separate tables and separate consumers.
predict_one every engine run- The backend calls the pipeline's
POST /lightgbm/predict_onefor one ticker, passing the live price when it has one. - The pipeline returns a predicted % return for 1d, 5d, 20d and 60d, aggregated across the cohorts that contain the ticker.
- Every call goes through one process-wide inference gate: bounded concurrency plus a circuit breaker that fails fast after consecutive failures and re-closes on a successful probe.
- A refused or failed call means not evaluated. It is never recorded as a bearish result, and no row is written.
- Output lands in
action_decisions. This is what the five triggers above use.
score_batch once per session- The backend requests one batch per session date at 16:45 ET and re-requests it after a retryable failure, an expired lease or a due deferral.
- The pipeline admits the batch only if that session's
dq-dailyrun (due 16:40 ET) reportsready_for_batch; otherwise the job is deferred asdq_pending. - The batch scores the whole universe in one pass and publishes atomically. It never writes
action_decisions. - Its output is a cross-sectional percentile rank from the last complete batch. With no published batch there is no rank; nothing falls back to
action_decisions.
Path A feeds verdicts, the Stock feed predicates, push crossings and the Auto-Pilot paper books. Path B runs beside the legacy sweeps and produces batch-stamped cross-sectional ranks. The requester can be switched off by configuration, so a source sha does not tell you whether a batch was published on a given day.
Universe
Layer 0: cohort filters (pipeline)
Universe eligibility is decided where the models are trained and served, in the pipeline's cohort definitions
(COHORT_DEFINITIONS in src/pipeline.py). Two cohorts are in production; the earlier cohorts were removed on 2026-05-15.
| Cohort | Market cap | Avg volume | History | Symbol exclusions |
|---|---|---|---|---|
| tradeable_v2 | ≥ $300M | — | ≥ 60 bars | Trailing W / Q / R; contains .PR or .WS |
| tradeable_mid_large | ≥ $2B | ≥ 500K | ≥ 60 bars | Same |
The scheduled sweep uses the same $2B / 500K split only to label tickers mid-large versus the rest; both groups are swept. The batch path ranks its own universe, floored at $1B market cap under the same symbol rule. Trading-halt handling is not a universe filter: it sits in the Auto-Pilot engine.
How a ticker is excluded in practice
The live run path goes audit-lock check → price-freshness refresh → predict_one. A ticker is
excluded by the sweep's universe query, by the scanner queue's symbol filter, or by the pipeline returning no prediction.
There is no separate engine-side gate on the run path at backend 07c641de.
Verdicts and predicates
MapVerdict turns one (horizon, predicted % return) pair into BUY, SELL or HOLD with a LARGE / MID / SMALL
intensity. Magnitude bands widen with the horizon, negatives mirror positives, and a prediction of exactly zero is HOLD.
The band values are not published here. One horizon per run is tagged primary (the largest weighted absolute prediction).
The persisted row keeps the numeric predictions; the direction and intensity columns are no longer populated, so consumers
act on the numbers through the predicates below.
buy_now Stock feed · push- Latest, unexpired 60d row.
- 60d prediction above a floor, and the 1d / 5d / 20d predictions each above their own looser floor.
- Market cap above a floor.
- Stance and confidence fields remain in saved filter configs but are not evaluated.
sell_now Stock feed · push- Latest, unexpired 60d row whose 60d prediction falls below a bottom-percentile cutoff of the live 60d cross-section.
- The per-user sell slider is still stored as an absolute ceiling; it is mapped onto a percentile band at query time.
- No market-cap floor; callers scope it to the user's holdings and watchlist.
- The Sell tab additionally drops names the 20d horizon still ranks strongly.
Floors, ceilings and band sizes are tunable per user and are deliberately not printed. The model's output scale shifts between retrains, which is why the sell side moved from an absolute ceiling to a percentile.
- Auto-Pilot exits use a different predicate. The paper books resolve their own cross-sectional exit per strategy, not the user-facing
sell_nowconfiguration. See the Auto-Pilot page. - Auto-Pilot entry ranking is not a verdict. Books rank candidates separately from
MapVerdict. - Push. After a run,
maybeFirePushCrossingcompares the new primary row with the prior one and notifies only on a false→true crossing ofbuy_noworsell_now, and only when the primary row is the 60d horizon. It is best-effort and never blocks the decision write.
Risk flagger gate
risk_flags JSON column on the ticker's decision row: category, severity, text. An empty array means evaluated and clean.- The gate (
FreshRiskFlagsGateSQL) reads flags across all of a ticker's recent rows, because the row a flag was written on stops being the latest at the next refresh. - Without a qualifying fresh evaluation, the gate fails closed. A failed refresh does not invalidate an earlier evaluation that remains within the freshness window.
- The flagger is not graded against forward returns; coverage is measured, precision is not.
- It gates Auto-Pilot buys. It does not change a verdict or the Stock feed predicates.
Display-only risk / quality score
Each row also carries risk_score and quality_score: recent catalyst emissions (14-day lookback)
blended with a term derived from the spread of the horizon predictions. They are computed after the verdicts and stamped
on the row. They are not a verdict input, and no feed, Auto-Pilot, web or iOS path reads them; the one reader is a backtest diagnostic.
Catalysts
| Extractor | State | Notes |
|---|---|---|
| News | Live | Event-driven from the scanner's news clustering. One structured emission per (headline, ticker): event type, direction, magnitude bucket, horizon |
| 8-K filings | Dormant | Registered, but has produced no emissions since a May 2026 refactor |
| Earnings | Dormant | Declared dormant in the agent-role registry |
- Emissions are stored in
llm_catalyst_emissionswith the model name and the price at emit time. - A resolver (at boot and every six hours) grades each emission once its horizon has passed, writing the realized move, a directional hit and a magnitude hit back onto the row.
- Emissions feed the risk flagger's prompt context, the display-only risk / quality score, ticker-detail and scanner cards, and the public catalyst-accuracy endpoint.
- They are not an input to verdicts. Their only route to a trading gate is two hops: emission → risk-flagger context → high-severity flag.
Decision row schema
Columns the current engine path writes on each (ticker, horizon) row:
| Column | Meaning |
|---|---|
| ticker, horizon, decided_at | Row identity; unique together |
| target_date | The Nth trading day after the decision, matching how training labels are built |
| expires_at | Safety-net expiry at twice the horizon if nothing supersedes the row |
| trigger_type | One of the five triggers |
| baseline_pct, adjusted_pt_pct | This horizon's predicted % return (both carry the same value) |
| ai_price_target | Live price × (1 + prediction), when a live price exists |
| y_pred_60d, pred_1d, pred_5d, pred_20d | All four horizon predictions, denormalized onto every row of the run |
| y_cal_60d | Magnitude-calibrated 60d value, when the pipeline has a healthy calibration fit; otherwise NULL |
| current_price_at_decision | Spot price at decision time |
| entry_price | Suggested buy-limit: the lowest of spot and the short-horizon projected prices |
| rationale | A literal string of the form LGBM y_pred=…; no generated prose |
| scoring_weights | JSON holding the primary flag and the engine version |
| risk_score, quality_score | Display-only scores (above) |
| risk_flags | Written later by the risk-flagger sweep, not at insert |
| engine_run_id, inference_snapshot_id | Run identity and a reference to the stored inference snapshot |
| market_state_at_signal, market_cap_at_signal | Substrate for later research. Market cap is stamped on scheduled_batch rows only |
| is_latest, superseded_by_id | Lineage (below) |
| is_audit, audit_expires_at | Audit lock, set on on_demand rows only |
| triggered_by_user_id | Set on on_demand rows |
| triggered_by_catalyst_id | Set only when a caller supplies a catalyst id; the one caller that would is the unwired dispatcher |
Present but not populated by the current path: direction, intensity, size_pct (dropped from the write path in May 2026),
confidence, confidence_60d, opus_model, opus_prompt_hash, tagged_facts, n_models, cohorts,
and the reviewer's v2_* columns (not written since the reviewer was removed).
Caching & lineage
- In-memory, per process, keyed by ticker, with single-flight: concurrent taps on one ticker collapse into one run.
- Invalidated by a catalyst-mode scanner dispatch (the mode recorded in the dispatch ledger), by the price-gap monitor after it refreshes a ticker, and by an admin model override.
- Insert and supersede happen in one transaction.
- The prior latest row for the same (ticker, horizon) gets
is_latest = falseandsuperseded_by_idpointing at the new row. - History stays in place for grading and the offline backtester.
- Rows from
on_demandare protected from worker overwrites until the lock expires. - While locked, the four worker triggers return the locked row and refresh only its risk / quality scores.
- Override: a live price more than 5% away from the decision-time price lets a worker run replace it.
- The lock represents user attention; scanner fires never take it.
Verified against vibebullish-docs@0a4edb1, vibebullish-backend@07c641de,
vibebullish-data-pipeline@ee230ea, vibebullish-web@ce8ec92 on 2026-09-29.
Runtime observations are dated inline; a source sha does not vouch for deployed configuration or database contents.