AI Portfolio Research


title: "AI Portfolio Research" section: "portfolio-ai-research" slug: "portfolio-ai-research"

AI Portfolio Research and Thesis Intelligence

A first-party, advisory-only subsystem inspired by the multi-agent research workflow in TauricResearch/TradingAgents. Portfolio Planner keeps authoritative ownership of accounting, valuation, allocation, reconciliation, policy approval, and broker execution.

Capability

Disabled by default. Enable in three layers:

| Flag | Effect | |---|---| | PORTFOLIO_AI_RESEARCH_ENABLED=1 | capability on; API live | | PORTFOLIO_AI_RESEARCH_WORKER_ENABLED=1 | background worker polls for queued runs | | PORTFOLIO_AI_RESEARCH_SCHEDULE_ENABLED=1 | thesis rechecks at 09:30 IST | | PORTFOLIO_AI_RESEARCH_OUTCOME_ENABLED=1 | 5/20/60-day outcome evaluation at 16:00 IST |

The app must start normally with all flags off. No AI provider failure may break Watchlist Intelligence, holdings, reconciliation, policies, research backtests, or the dashboard.

Cost & limits

PORTFOLIO_AI_RESEARCH_MAX_COST_USD_PER_RUN=1.0, PORTFOLIO_AI_RESEARCH_DAILY_RUN_LIMIT=25, PORTFOLIO_AI_RESEARCH_MAX_INPUT_TOKENS=48000, PORTFOLIO_AI_RESEARCH_MAX_OUTPUT_TOKENS=8000, PORTFOLIO_AI_RESEARCH_TIMEOUT_SEC=60, PORTFOLIO_AI_RESEARCH_MAX_RETRIES=1.

Workflow

The compatibility lifecycle runs 13 sequential stages: CONTEXT → MARKET → FUNDAMENTALS → NEWS → SENTIMENT → BULL_CASE → BEAR_CASE → RESEARCH_SYNTHESIS → AGGRESSIVE_RISK → NEUTRAL_RISK → CONSERVATIVE_RISK → FINAL_SYNTHESIS → THESIS_COMPARISON. Stages checkpoint after every completion; the worker resumes from the first unfinished stage on restart. New runs then execute the 19-stage institutional-publication graph, including specialist, adversarial, reconciliation, committee, editor, and QA stages. A run reaches COMPLETED only after publication QA explicitly passes.

Institutional publication v2

New publication artifacts use a strict, versioned InstitutionalResearchPublicationV2 payload. The fact ledger performs deterministic calculations and preserves source conflicts; publication quality checks block unsupported claims and missing core modules. The PDF route renders the persisted payload with a deterministic A4 renderer and never invokes a model during download. Existing v1 reports remain readable and use the legacy renderer. A specialist may mark an unavailable evidence item as a disclosed data gap and continue; it must not invent a replacement value. Fact-check, committee, editor, and QA failures remain blocking.

Evidence & provenance

Every source is classified as VERIFIED_INTERNAL, VERIFIED_FETCHED, DISCOVERED, or UNAVAILABLE. Material claims carry one of VERIFIED_FACT, CALCULATED_FACT, SOURCE_REPORTED, AI_INFERENCE, UNSUPPORTED. Claim text is stored separately from prose; unsupported factual claims are removed or downgraded.

Historical mode

Historical runs (mode=HISTORICAL) require an explicit cutoff timestamp. The cutoff filter runs in provider-side code before prompts are built; future-dated evidence is rejected and unknown-publication-time evidence is downgraded from VERIFIED_* to DISCOVERED.

Trust Center

Ten event kinds are appended to the existing evidence.events table under pillar=research:

AI_RESEARCH_REQUESTED, AI_RESEARCH_COMPLETED, AI_RESEARCH_PARTIAL, AI_RESEARCH_FAILED, AI_RESEARCH_CANCELLED, AI_RESEARCH_BASELINE_SET, AI_RESEARCH_BASELINE_REPLACED, AI_RESEARCH_DRIFT_DETECTED, AI_RESEARCH_OUTCOME_EVALUATED, AI_RESEARCH_PROMOTION_EVIDENCE_ATTACHED.

PAPER evidence

PromotionRequest accepts an explicit ai_research_dossier_ids list. The selected IDs contribute to the request fingerprint and are recorded with the promotion row. AI evidence cannot change validation, eligibility, or initial capital.

Safety boundary

  • The AI Research package never imports arb_bot.bot, arb_bot.execution, or arb_bot.command_handler (enforced by a static AST scan in tests).
  • The orchestrator is exercised end-to-end with patched exploding services for transactions, holdings, broker sync, snapshots, reconciliation, approvals, policy go-live, and orders; none are invoked.
  • Missing financial values stay unavailable — never coerced to zero (enforced by a tokenize-based code scan).

Deployment

  1. Apply migration 051 (ai_research_* + ai_thesis_* tables) and 052 (Trust Center event kinds).
  2. Deploy with all AI Research flags off.
  3. Verify normal Portfolio Planner smoke tests.
  4. Enable manual AI Research capability.
  5. Enable worker only after capability health succeeds. In the production Docker stack the dashboard service is the single durable queue owner; arb-bot explicitly disables its duplicate worker loop.
  6. Run controlled dossiers for a small symbol set; inspect evidence, cost, source quality, historical cutoffs.
  7. Enable thesis schedule and outcome evaluation separately.
  8. Keep all broker and policy execution flags unchanged.

Run leases use the latest durable stage progress rather than the original run start time, so a healthy long publication is not reclaimed after five minutes. Evidence-source persistence is an idempotent PostgreSQL upsert, making resume and crash recovery safe under the (run_id, source_key) uniqueness rule.

Rollback

Feature disablement, not destructive migration rollback. Existing AI Research rows remain available for audit while new execution is disabled.

Pinning a thesis baseline

A thesis baseline is the dossier snapshot that subsequent runs are measured against. Pinning is a deliberate, two-step operator action — it overwrites whatever baseline was previously active for the same instrument (and optional portfolio) and is logged in the Trust Center as AI_RESEARCH_BASELINE_SET / AI_RESEARCH_BASELINE_REPLACED.

Two-click confirmation flow

Inside a dossier on the Holding Detail page, the Pin as thesis baseline button does not place the pin immediately. The first click arms the action — the button label switches to Confirm pin and adopts the accent-3 hover style. The second click posts to POST /api/portfolio/ai-research/runs/{run_id}/set-baseline and, on success, the dossier is refetched so the new baseline row appears in thesis_baselines. The ActiveBaselineBanner then renders and the pin button is hidden (the active-baseline indicator replaces it). On a non-2xx response the button returns to its un-armed state and the operator surfaces the failure through the existing onPinError callback. A click outside the armed button (or any document-level mousedown) cancels the confirm state so a stray click cannot pin by accident. While the pin is in flight the "Run new research" button is also disabled, so the two actions cannot race.

Partial dossiers cannot be pinned

POST /runs/{run_id}/set-baseline is strict about run state:

  • PARTIAL dossiers (any unfinished stage, or stages that did not produce a persisted AIResearchReport) are rejected with 409 Conflict.
  • The response body starts with "Run is …" — for example Run is PARTIAL; only COMPLETED runs can be pinned as a baseline. or Run is FAILED; only COMPLETED runs can be pinned as a baseline.
  • The frontend types this as PinBaselineUnavailableError and surfaces the message via a destructive toast through the onPinError callback wired in HoldingDetail. There is no retry path — finish or replace the dossier first, then pin.

Drift comparisons only become meaningful with a baseline

compare_to_active_baseline returns INSUFFICIENT_EVIDENCE whenever there is no active AIThesisBaseline row or the candidate run has no persisted report. In practice this means:

  • With zero pinned baselines, every comparison is INSUFFICIENT_EVIDENCE.
  • Once an operator pins their first COMPLETED dossier, subsequent runs can be classified against it as STRENGTHENING, UNCHANGED, or WEAKENING, and the comparison is persisted to ai_thesis_comparisons plus emitted as AI_RESEARCH_DRIFT_DETECTED.
  • Each new pin replaces the previous active baseline for the same instrument context (one active baseline per instrument, optionally scoped by portfolio_id); old comparisons remain for audit.

Reading a dossier

A COMPLETED dossier renders a layered view: a decision dashboard at the top, the pinned-baseline banner (when present), the bull/bear/catalyst-and-risk column block, and three optional sections (segment breakdown, peer comparison, valuation-basis chip). Every component is rendered from the same ResearchReport payload and degrades gracefully when the optional fields are absent — legacy dossiers that pre-date the new fields continue to display the rating, action, confidence, thesis, bull/bear, catalysts, risks, and invalidation conditions exactly as before.

Decision dashboard

DossierDashboard is the top card. It shows the rating pill, the portfolio action pill, and the confidence meter in a single flex row, followed by a "Why this rating" header and one-or-two sentence summary. The summary prefers report.why_this_rating when the model client populates it (typically a curated ≤ 60-word paragraph that complements, rather than repeats, the thesis). When why_this_rating is null — for example on a dossier generated before the field existed — the dashboard falls back to the first sentence of report.thesis, so the "Why this rating" UX is preserved for legacy reports. The card uses the shared AIRatingBadge / AIPortfolioActionBadge / AIConfidenceBar from AIResearchBadges, so colour, icon, and tooltip treatment are identical to the inline badges that appeared before.

Active baseline banner

When the dossier's thesis_baselines list contains an active baseline (is_active === true), an ActiveBaselineBanner is rendered immediately beneath the dashboard. The banner shows the pinned run id in a monospace font and the timestamp the baseline was pinned (created_at, em dash if absent). The banner and the DossierActions "Pin as thesis baseline" button are mutually exclusive — when an active baseline exists the button is hidden (DossierActions returns null), and the banner is the single source of truth for "this dossier is the active baseline". When no active baseline exists the banner is hidden and the pin button is the action surface. This pairing avoids ambiguity when the dossier body was auto-loaded from an older run while a fresh run is still in progress.

Catalyst timeline

Inside the catalysts/risks/invalidations column block, CatalystTimeline renders catalysts with a best-effort date column. The component scans each catalyst string for a quarter+fiscal-year notation (Q1 FY27, Q3FY2026) or a month-name + 2- or 4-digit year (Mar 2027, Jul-2026). When at least one catalyst mentions a parseable date, the catalysts render as a vertical <ol> with the matched date fragment in a fixed-width left column (w-28) and the catalyst text on the right; entries without a parsed date show TBD. When no catalyst contains a parseable date, the component falls back to a flat bullet list (the timeline column is hidden in that case so the UI does not show an empty TBD stack). An empty catalysts array is rendered as null and the column shows the No catalysts recorded. fallback. Risks and invalidation conditions keep their original bullet lists — the timeline treatment only applies to catalysts.

Segment breakdown

When report.segment_views is a non-empty array, SegmentBreakdown renders a two-column grid (one column on mobile) of segment cards. Each card shows the segment name, an optional N% revenue chip when revenue_share_pct is present, the segment thesis, and (when populated) catalysts and risks sub-lists with accent2 / danger section headers respectively. The card uses the standard rounded-border / surface treatment that the rest of the dossier uses. The component returns null when the array is empty or null, so legacy dossiers without the field display nothing in its place.

Peer comparison

When report.peer_set is a non-empty array, PeerTable renders a five-column table: Symbol (monospace), Name, Rating (an AIRatingBadge so the colour treatment matches the dashboard), 1Y return (em dash when the field is null), and Rationale. The table is horizontally scrollable on narrow viewports, the peer-table data-testid anchors it for selector queries, and each row uses the same border treatment as the segment cards. The candidate universe is bounded by arb_bot/portfolio_app/ai_research/peer_set.py — a JSON-backed tier map (RELIANCE → {IOC, BPCL, HINDPETRO, BHARTIARTL, AVENUESUPER} plus comparable maps for HDFCBANK and INFY) — which the orchestrator passes into the FINAL_SYNTHESIS prompt as the peer_symbols input. The model emits only those peers that are supported by the supplied evidence. Unknown symbols return an empty peer list from peer_set.lookup, the prompt passes nothing, the model returns nothing, and the dossier simply omits the table.

Valuation basis chip

When report.valuation_basis is a non-empty string, ValuationBasisChip renders an accent-tinted pill with the label Valuation basis: <basis>. The basis is one of a constrained set emitted by the FINAL_SYNTHESIS prompt scaffold: PEG vs sector, DCF stub, SOTP via segment EBITDA, Cyclical P/E band, or Structural re-rating thesis. The chip is hidden when the field is null or empty, so legacy dossiers without a valuation basis show nothing in its place. The chip sits below the peer table and above the claims section, so a single glance communicates why the operator is being asked to look at this number.

Field provenance

The four new dossier fields are all additive and optional on FinalResearchReport (why_this_rating, valuation_basis, segment_views, peer_set). They are produced by the FINAL_SYNTHESIS prompt scaffold and wired through AIResearchOrchestrator._to_final_report into the persisted FinalResearchReport, then carried in AIResearchReport.payload_json. The dossier route promotes them from payload_json to top-level keys in the response so the frontend parser sees the same shape it has always validated against. SegmentView and PeerRef Pydantic models live in arb_bot/portfolio_app/ai_research/contracts.py and validate extra fields as forbid, so malformed payloads surface as a ValidationError upstream of the dashboard rather than rendering partial UI. The frontend parser in frontend/src/portfolio/api/aiResearch.ts mirrors the same shapes — parseSegmentView, parsePeerRef, and the four new parseReport branches — so a backend regression surfaces as an AiResearchValidationError and a destructive state instead of a silent fallback. parseSegmentView enforces the same 0..100 bound on revenue_share_pct that the backend enforces, and rejects present-but- malformed catalysts / risks arrays rather than silently coercing them to [].

Reading order

In practice an operator should read a dossier in this order:

  1. Dashboard — what is the rating, what action is implied, how confident is the model.
  2. Active baseline banner — am I looking at the active thesis or a superseded one.
  3. Segment breakdown + peer table + valuation basis chip — does the thesis hold up across the disclosed segments and peers, and what lens am I being asked to apply.
  4. Bull / bear / catalysts / risks / invalidations — the full case.
  5. Claims + sources — provenance for every material claim.
  6. Stages — what ran and where the budget went.
  7. Thesis baselines — the full pin history, not just the active one.
  8. Outcomes — 5/20/60-day realised returns once the schedule has evaluated them.