Agentic Stock Tasks
The stock-task builder (arb_bot/portfolio_app/stock_tasks/) turns
parametrized, fixed-shape stock-analysis jobs into advisory
signals — and, when the signal is directional, an optional
dry-run backtest. A user picks one of six approved
templates and supplies parameters — or writes a natural-language goal that is
compiled to one of those templates; the runner executes it through the
per-user/tiered LLM chain and persists an AdvisorySignal. No
task ever places an order or mutates live-trading state.
arb_bot/portfolio_app/stock_tasks/ must never import
arb_bot.bot, arb_bot.execution.engine, or
arb_bot.command_handler; never place, modify, or cancel an
order; and never touch live trades or go-live state. Every template prompt
opens with "Never place orders", and the isolation contract is pinned by
tests/portfolio_app/stock_tasks/test_stock_tasks_import_isolation.py (a
fresh-subprocess import-direction guard plus a static source scan). The
backtest handoff only enqueues a run on the isolated
arb_bot/research/ engine — it never reaches the broker.
Templates
Six fixed templates live in arb_bot/portfolio_app/stock_tasks/templates.py
(prompts in arb_bot/portfolio_app/stock_tasks/prompts/*.md). Each
template pins a tier so a user cannot author an unbounded task; a request
naming an unknown template is rejected with 400 before anything
runs.
| Template | Tier | Purpose |
|---|---|---|
screen | fast | Scan a universe for a criterion; keep the strongest candidate. |
momentum_scan | fast | Momentum entry/exit signal candidates. |
deep_dive | premium | Deep research on one symbol into a coherent thesis. |
earnings_check | premium | Pre/post earnings summary (figures vs expectations, guidance). |
thesis_drift | premium | Review a holding against its recorded thesis (holds / drifting / broken). |
price_move | premium | Answer params.question about a recent price move, attributing it to dated sessions, corporate events, and news. |
fast templates route through the opencode-go chain
(deepseek-v4-flash, system key); premium templates
stay on the codex chain — the same two-tier routing used elsewhere
(resolve_default_provider(tier=...)).
price_move is the only template with
web_search = True. Explaining why a stock moved needs
news the market-data snapshot does not carry, so this one template may search;
every other template answers from market_data alone. The
restriction is pinned by
tests/portfolio_app/stock_tasks/test_templates.py.
Natural-language authoring
A user can create a task without naming a template: POST /
accepts an optional goal string alongside
template_name/params. When goal is
present, arb_bot/portfolio_app/stock_tasks/nl_compiler.py —
compile_goal(goal) — makes a single fast-tier LLM
call (the per-user chain when present, else
resolve_default_provider(tier="fast")) under a strict output
schema whose template_name enum is exactly the six templates
above, plus the extracted symbols/universe/
criterion/timeframe/question params.
question is what carries a user's actual words through
to the template. Without it a goal phrased as a question ("why did X
fall over the last 3 sessions?") lost its text at this boundary and arrived
as a bare symbol, which is why such a goal used to come back as a generic
thesis. When the model omits the field the raw goal is kept
verbatim, so the question can never be silently dropped. The compiler is also
told to route "why did it move" goals to price_move rather than
deep_dive, which builds a thesis and structurally cannot answer
such a question. The compiled result is
persisted in agent_tasks exactly as an explicit-template task is
today — same columns, same bounded runner.
Fail-closed: a failed model call, malformed or non-object
output, or a template_name that is not one of the six known
templates raises a clear error and the route returns 422 — the
model cannot invent a template or author an unbounded task. When
goal is absent the explicit template+params path is used
unchanged (400 for an unknown template_name).
Contracts
arb_bot/portfolio_app/stock_tasks/contracts.py defines the two
Pydantic contracts. StockTaskParams carries
symbols, universe, criterion,
timeframe, and question (all optional).
question is free text, but it is an input to a fixed
template — it never lets a user author an unbounded task, and only
question-answering templates (price_move) read it.
AdvisorySignal is the only output: symbol,
direction (long | short | none),
thesis, confidence (0–1), evidence
(list of strings), an optional answer (the direct reply to
params.question, rendered in the run-result drawer;
None for thesis-producing templates), and an optional
strategy_descriptor. Prompts forbid inventing evidence
and require direction: "none" + confidence: 0.0 when
no candidate qualifies.
Runner bounds
arb_bot/portfolio_app/stock_tasks/runner.py —
StockTaskRunner — bounds every execution:
- Per-user concurrency: a bounded semaphore caps in-flight
runs at
concurrency = 3(constructor default). - Per-task budget: each
StockTaskTemplatedeclarestimeout_seconds = 60(120 forprice_move, the one web-searching template),max_model_calls = 4, andmax_tool_calls = 12, so a template can never request an unbounded run. - Fail-closed: an unknown template raises
ValueError; a failed provider call or a non-dict result raises instead of fabricating a signal. A directional result with insufficient data degrades todirection: "none", never a guessed long/short.
Persistence
db/096_stock_tasks.sql creates two tenant-scoped tables. Both
are keyed by user_id and read/written only through
arb_bot/portfolio_app/stock_tasks/repository.py, which scopes
every query by the authenticated int(user.id):
agent_tasks— one row per task:template_name,params(JSONB),created_at.agent_task_runs— one row per execution:task_id(cascade),status(queued→running→completed/failed),result(JSONB),created_at,finished_at.
Async execution and worker
Runs are asynchronous. POST /{task_id}/run only enqueues: it
verifies ownership, inserts a queued row, and returns
{"run_id", "status": "queued"} immediately — it never invokes the
runner. A background worker
(arb_bot/portfolio_app/stock_tasks/worker.py) drains the queue
through the existing bounded runner, so the HTTP request never blocks on an
LLM call. Each run's lifecycle is:
queued— row inserted by the run route; not yet claimed.running— the worker claimed the row (atomically, in the same transaction that flips its status) and is executing it throughStockTaskRunner.run_task.completed— the runner returned anAdvisorySignal; the signal is persisted intoresult.failed— the runner raised; the exception type and message (truncated to 1000 chars) are persisted intoresult.
StockTaskWorker.loop() polls for the next claimable run every
poll_interval_sec = 5 seconds (only sleeping when a cycle found
nothing). The claim query uses FOR UPDATE ... SKIP LOCKED so
concurrent workers never double-claim the same row, and it is tenant-aware:
agent_task_runs is one queue per user, so the worker iterates
active users and claims each user's oldest queued row (mirroring
the V2/V3 research workers). Provider resolution is best-effort per owner —
resolve_user_provider(owner_user_id), falling back to the system
default, then to the runner's own per-tier default — and never raises, so a
resolution failure cannot stop the drain loop.
Resume/restart: a run is only ever claimed out of
queued, so a worker that dies mid-run leaves its row
running forever. There is no updated_at heartbeat;
the reclaim sweep treats a running row whose
created_at is more than one hour old as orphaned and re-claims
it. An hour is far beyond any real advisory run and far below "forever".
Budgets (template timeout, max model/tool calls, per-user concurrency
semaphore) are unchanged — the worker still executes through the same bounded
StockTaskRunner.
The worker starts at dashboard/API startup in
dashboard_server.py, gated by
Config.STOCK_TASK_WORKER_ENABLED (default true).
A module-level flag guards against double-start if the startup hook fires more
than once, and a failed start is logged but never raised — the dashboard keeps
serving. The trading bot already starts its own workers; dashboard-only
deployments need this boot-time start so queued stock tasks keep being
processed without the bot process. Setting
STOCK_TASK_WORKER_ENABLED=false disables the drain loop, leaving
runs in queued until it is re-enabled.
API surface
Mounted under /api/portfolio/stock-tasks/
(arb_bot/portfolio_app/stock_tasks/routes.py), authenticated via
get_current_user:
POST /— create a task from a known template (rejects unknown with400), or from a natural-languagegoalcompiled to a template (422when the goal doesn't map).GET /— list the caller's tasks.POST /{task_id}/run— enqueue a run (queued).GET /runs— list the caller's runs.DELETE /runs/{run_id}— delete one run, keeping its task;404when the run is not the caller's.DELETE /{task_id}— delete one task and every run it owns;404when the task is not the caller's.POST /{task_id}/backtest— read-only handoff (below);404when no backtestable result exists.
Deleting tasks and runs
Both deletes are scoped in the SQL statement itself
(WHERE id = %s AND user_id = %s), so another tenant's id matches
no row and returns 404 rather than deleting anything. Deleting a
task cascades to its runs through
agent_task_runs.task_id … ON DELETE CASCADE
(db/096_stock_tasks.sql); deleting a run leaves its task alone.
Effect on the worker: deleting a queued run
cancels it, because claim_next_queued_run only ever claims rows
that are still present. Deleting a running run does not
abort the model call already in flight — the worker finishes, its
mark_run update matches no row, and the result is discarded.
In the dashboard both deletes are two-step: the first click arms the button (it re-labels to “Confirm?”) and only the second fires the request, with blur disarming it. Deletion is irreversible and a task takes its whole run history with it, so a single stray click must not be enough.
Backtest handoff
arb_bot/portfolio_app/stock_tasks/backtest_handoff.py maps an
AdvisorySignal to a normalized strategy descriptor
(strategy: "advisory", symbol, direction, market entry,
signal-reverse exit). POST /{task_id}/backtest then
enqueues a real research run from it via
arb_bot.research.worker.enqueue() and returns the
research_run_id alongside the descriptor. The run is picked up by
ResearchRunWorker and reports the same cost / tax / drawdown
metrics every other research backtest does; progress and results are read
through the normal /api/research/runs/* endpoints. An optional
body {start_date, end_date} pins the window, defaulting to the
trailing 365 days.
Only long signals actually run.
to_strategy_descriptor() returns None (and the route
404s) for a none direction or a missing result, and
a short direction is rejected with 400 because
BacktestEngine is a long-only portfolio engine — backtesting a
short through it would report long returns under a short label.
The run never reaches a broker: it executes the advisory research
strategy (arb_bot/research/strategies/advisory.py), which holds
every advised symbol for the window and carries no signal logic of its own —
the model made the call, and the backtest measures what that call would have
returned. Because an advised symbol has no tracked index membership,
RunConfig.advisory_symbols is resolved by
StaticUniverseManager
(arb_bot/research/universe/static.py) instead of the normal
UniverseManager, which would resolve it to nothing and leave the
engine with no prices to fetch. advisory is deliberately excluded
from all_strategy_names() / registry_entries(): it
only works with advisory_symbols set by the handoff, so listing
it would offer a guaranteed-to-fail option in the dashboard strategy picker
and in the live-policy promotion validator.
Market-data enrichment
Every symbol-bearing task is enriched with a read-only market-data snapshot
before the LLM call, so the model can form a thesis from real figures rather
than refusing with "insufficient data".
arb_bot/portfolio_app/stock_tasks/market_data.py —
build_market_snapshot(symbols) — builds a per-symbol dict via the
existing read-only YFinanceProvider from
arb_bot/research/providers/yfinance_provider.py:
profile— sector, industry, exchange, longName (get_profile).consensus— forward PE, consensus target (mean/high/low), forward EPS, analyst count (get_consensus_estimates).statements— annual + quarterly normalized rows (revenue, ebitda, operating_income, net_income, diluted_eps, debt, equity, cash, FCF, …) (get_financial_statements).price—lastclose,change_30d_pct,trailing_return_pct(1/3/5/10/30 sessions), andsessions: up toMAX_SESSIONS = 40individual daily bars (date, OHLC,volume,pct_change) (get_price_history).events— point-in-time corporate events; empty when the provider does not support them.
change_30d_pct as one that bled slowly
for a month. While the snapshot carried only that aggregate, any question
about "the last N sessions" was unanswerable, and the model would answer with
the 30-day figure instead. Every prompt now instructs the model to read the
dated bars for claims about recent price action and never to substitute
change_30d_pct for a shorter window.
sessions, and the
question's premise must yield to the bars), and
runner._reject_unsupplied_dates enforces it: a result citing a
trading date inside the supplied window that is not one of the supplied
session dates raises and the run is recorded failed. Templates
with web_search are exempt, since they can legitimately cite a
source published on a non-trading day.
Symbol → yfinance ticker resolution:
resolve_provider_symbol(symbol) reads
Instrument.provider_symbol from the portfolio DB when a matching
instrument exists; otherwise it appends .NS to a bare symbol (NSE
default). Already-suffixed symbols are returned unchanged. Every per-symbol
fetch is wrapped in try/except, so one failing symbol yields an
empty dict for that symbol and never fails the whole task — the LLM is told
to report exactly which data is missing.
StockTaskRunner._run_single injects the snapshot into the LLM
user_message as market_data (alongside
params) whenever params.symbols is non-empty. The
persisted AdvisorySignal shape is unchanged —
market_data is input-only and never stored. Each template prompt
instructs the model to cite figures from market_data in every
evidence claim and to return direction: "none" with
an explicit "missing data" note when a symbol's snapshot is empty.
Results viewer
The Stock Tasks page renders a View action on each run row that
opens a drawer showing the run's AdvisorySignal result: symbol,
direction badge (long/success, short/danger, none/neutral), a confidence bar,
the thesis paragraph, the bulleted evidence list, and the
strategy_descriptor (when a backtest handoff exists). The action
is disabled while a run has no result (queued / running / failed
without a result).