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.

LLM calls in the verdict path
0
Per-decision reviewer removed 2026-06-14; last Opus caller removed 2026-09-14
Trigger types
5
All run the same engine code path
Production cohorts
2
tradeable_v2, tradeable_mid_large
Live trading
None
Paper books only

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.

Who reads action_decisions
ConsumerWhat it reads
Stock feed (Buy / Sell tabs)The latest 60d row per ticker through the buy_now / sell_now predicates
Auto-Pilot paper booksLatest rows for entry ranking and its own exit predicate (see Auto-Pilot)
Tripwire monitorBuy recommendations and their decision-time prices, to watch live trades against them
Push notificationsA predicate crossing between the previous and the new primary row
DiagnosticsBacktest, 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.

TriggerFired byScope
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.

A. Per-ticker predict_one every engine run
  • The backend calls the pipeline's POST /lightgbm/predict_one for 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.
B. Gated batch 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-daily run (due 16:40 ET) reports ready_for_batch; otherwise the job is deferred as dq_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.

CohortMarket capAvg volumeHistorySymbol exclusions
tradeable_v2≥ $300M—≥ 60 barsTrailing W / Q / R; contains .PR or .WS
tradeable_mid_large≥ $2B≥ 500K≥ 60 barsSame

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_now configuration. See the Auto-Pilot page.
  • Auto-Pilot entry ranking is not a verdict. Books rank candidates separately from MapVerdict.
  • Push. After a run, maybeFirePushCrossing compares the new primary row with the prior one and notifies only on a false→true crossing of buy_now or sell_now, and only when the primary row is the 60d horizon. It is best-effort and never blocks the decision write.

Risk flagger gate

1
Sweep
After each scheduled refresh, an LLM extracts observable risk events (lawsuits, guidance cuts, restatements) for a bounded pool of top candidates per risk-flagged strategy.
2
Write
Results land in the risk_flags JSON column on the ticker's decision row: category, severity, text. An empty array means evaluated and clean.
3
Gate
For strategies that require risk flags, a buy needs a non-NULL evaluation on a decision row from the last 24 hours and no high-severity flag on any decision row within that window. The Insider strategy does not apply this gate.
  • 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

ExtractorStateNotes
NewsLiveEvent-driven from the scanner's news clustering. One structured emission per (headline, ticker): event type, direction, magnitude bucket, horizon
8-K filingsDormantRegistered, but has produced no emissions since a May 2026 refactor
EarningsDormantDeclared dormant in the agent-role registry
  • Emissions are stored in llm_catalyst_emissions with 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:

ColumnMeaning
ticker, horizon, decided_atRow identity; unique together
target_dateThe Nth trading day after the decision, matching how training labels are built
expires_atSafety-net expiry at twice the horizon if nothing supersedes the row
trigger_typeOne of the five triggers
baseline_pct, adjusted_pt_pctThis horizon's predicted % return (both carry the same value)
ai_price_targetLive price × (1 + prediction), when a live price exists
y_pred_60d, pred_1d, pred_5d, pred_20dAll four horizon predictions, denormalized onto every row of the run
y_cal_60dMagnitude-calibrated 60d value, when the pipeline has a healthy calibration fit; otherwise NULL
current_price_at_decisionSpot price at decision time
entry_priceSuggested buy-limit: the lowest of spot and the short-horizon projected prices
rationaleA literal string of the form LGBM y_pred=…; no generated prose
scoring_weightsJSON holding the primary flag and the engine version
risk_score, quality_scoreDisplay-only scores (above)
risk_flagsWritten later by the risk-flagger sweep, not at insert
engine_run_id, inference_snapshot_idRun identity and a reference to the stored inference snapshot
market_state_at_signal, market_cap_at_signalSubstrate for later research. Market cap is stamped on scheduled_batch rows only
is_latest, superseded_by_idLineage (below)
is_audit, audit_expires_atAudit lock, set on on_demand rows only
triggered_by_user_idSet on on_demand rows
triggered_by_catalyst_idSet 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

On-demand cache 4h TTL
  • 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.
Latest-row roll
  • Insert and supersede happen in one transaction.
  • The prior latest row for the same (ticker, horizon) gets is_latest = false and superseded_by_id pointing at the new row.
  • History stays in place for grading and the offline backtester.
Audit lock 24h default
  • Rows from on_demand are 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.