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, orarb_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
- Apply migration 051 (
ai_research_*+ai_thesis_*tables) and 052 (Trust Center event kinds). - Deploy with all AI Research flags off.
- Verify normal Portfolio Planner smoke tests.
- Enable manual AI Research capability.
- Enable worker only after capability health succeeds. In the production
Docker stack the
dashboardservice is the single durable queue owner;arb-botexplicitly disables its duplicate worker loop. - Run controlled dossiers for a small symbol set; inspect evidence, cost, source quality, historical cutoffs.
- Enable thesis schedule and outcome evaluation separately.
- 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:
PARTIALdossiers (any unfinished stage, or stages that did not produce a persistedAIResearchReport) are rejected with409 Conflict.- The response body starts with
"Run is …"— for exampleRun is PARTIAL; only COMPLETED runs can be pinned as a baseline.orRun is FAILED; only COMPLETED runs can be pinned as a baseline. - The frontend types this as
PinBaselineUnavailableErrorand surfaces the message via a destructive toast through theonPinErrorcallback wired inHoldingDetail. 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, orWEAKENING, and the comparison is persisted toai_thesis_comparisonsplus emitted asAI_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:
- Dashboard — what is the rating, what action is implied, how confident is the model.
- Active baseline banner — am I looking at the active thesis or a superseded one.
- 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.
- Bull / bear / catalysts / risks / invalidations — the full case.
- Claims + sources — provenance for every material claim.
- Stages — what ran and where the budget went.
- Thesis baselines — the full pin history, not just the active one.
- Outcomes — 5/20/60-day realised returns once the schedule has evaluated them.