Agentic Stock Tasks

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.

Authority: advisory + dry-run backtest only, always. 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.

TemplateTierPurpose
screenfastScan a universe for a criterion; keep the strongest candidate.
momentum_scanfastMomentum entry/exit signal candidates.
deep_divepremiumDeep research on one symbol into a coherent thesis.
earnings_checkpremiumPre/post earnings summary (figures vs expectations, guidance).
thesis_driftpremiumReview a holding against its recorded thesis (holds / drifting / broken).
price_movepremiumAnswer 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.pycompile_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.pyStockTaskRunner — bounds every execution:

  • Per-user concurrency: a bounded semaphore caps in-flight runs at concurrency = 3 (constructor default).
  • Per-task budget: each StockTaskTemplate declares timeout_seconds = 60 (120 for price_move, the one web-searching template), max_model_calls = 4, and max_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 to direction: "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 (queuedrunningcompleted/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 through StockTaskRunner.run_task.
  • completed — the runner returned an AdvisorySignal; the signal is persisted into result.
  • failed — the runner raised; the exception type and message (truncated to 1000 chars) are persisted into result.

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 with 400), or from a natural-language goal compiled to a template (422 when 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; 404 when the run is not the caller's.
  • DELETE /{task_id} — delete one task and every run it owns; 404 when the task is not the caller's.
  • POST /{task_id}/backtest — read-only handoff (below); 404 when 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.pybuild_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).
  • pricelast close, change_30d_pct, trailing_return_pct (1/3/5/10/30 sessions), and sessions: up to MAX_SESSIONS = 40 individual daily bars (date, OHLC, volume, pct_change) (get_price_history).
  • events — point-in-time corporate events; empty when the provider does not support them.
The individual sessions matter. A 30-day aggregate hides a single-session move: a stock that fell 20% on one day and drifted otherwise reports roughly the same 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.
Cited dates are enforced, not merely requested. Asked why a stock "fell for 3-4 sessions" when only one session actually fell, a model will manufacture the missing bars — inventing weekend dates carrying plausible volumes to match the number the question presumes. The prompts forbid this (every cited date must appear verbatim in 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).