Portfolio Watchlist Intelligence


title: Portfolio Watchlist Intelligence description: Accumulation mandates, deterministic technical + portfolio-fit scoring, tranche sizing, top-five opportunities, official BSE corporate actions, and the always-on intelligence scheduler.

Portfolio Watchlist Intelligence

The Watchlist Intelligence layer turns the watchlist from a static note table into a decision-support workflow. The user declares what they want to accumulate (an accumulation mandate); the system derives how much is already owned, how much remains, whether current technicals support a buy, and whether buying would improve or worsen the portfolio's diversification — and then persists ranked recommendations, brief digests, and corporate-action alerts that the operator can review without ever placing an order.

This page is the public documentation. Source-of-truth spec and corrections:

  • docs/superpowers/specs/2026-07-16-watchlist-intelligence-design.md
  • docs/superpowers/specs/2026-07-16-watchlist-intelligence-final-corrections.md
  • docs/superpowers/plans/2026-07-16-watchlist-intelligence-final-corrections.md

The two *-final-corrections.md documents are authoritative wherever any of the older plan/spec text conflicts with them.

Disabled by default, on purpose. Every intelligence scheduler starts unconditionally (it does not depend on Telegram being enabled), but the deliverables themselves are overridable. The opening brief, closing brief, and the 09:30 entry scan will all run and persist even when PORTFOLIO_TELEGRAM_ENABLED is unset — their Telegram delivery will simply land in DeliveryAttempt.status = "SKIPPED" rows so the UI surfaces calculation results while delivery is off. The mandate form is exposed at the watchlist row level so an operator can never create a mandate outside the approved candidate universe.

Mandate — the user-authoritative concept

An accumulation mandate (portfolio.accumulation_mandates) is one row per active accumulation goal. It is the only place where target quantity, maximum acceptable price, and concentration limits are stored — no other table edits them.

| Field | Purpose | |---|---| | symbol | Uppercase; must already exist in the user's watchlist and resolve through the instrument registry | | portfolio_id | Destination portfolio (must exist; immutable after create) | | target_quantity | Whole shares the operator wants to end up holding | | max_acceptable_price | Hard gate: a recommendation can never be ACTIONABLE above this | | max_total_budget | Optional cap on total target-position cost basis | | deadline | Optional end date for accumulation | | preferred_tranches | Default 5; ceiling for the base ceil(remaining/remaining_tranches) sizing | | max_tranche_quantity | Optional per-tranche ceiling | | max_stock_allocation_pct | Hard stock-concentration cap | | max_sector_allocation_pct | Hard sector-concentration cap | | min_entry_score | Default 65; threshold a candidate must clear to be ACTIONABLE | | min_portfolio_fit_score | Default 50; threshold a candidate must clear to be ACTIONABLE | | status | ACTIVE, PAUSED, COMPLETED, or CANCELLED |

Mandates are immutable in identity: symbol and portfolio_id cannot be changed. To redirect a mandate, cancel it and create a new one. Updates are optimistic-concurrency-controlled via a version column (PATCH must send if_version). Only one ACTIVE or PAUSED mandate may exist for a (portfolio_id, symbol) pair at a time — enforced by a partial unique index, not by an in-memory check.

Mandate creation is rejected (422) if the symbol is not already on the watchlist; the dashboard opens the mandate form from the watchlist row itself. There is no free-text symbol entry that bypasses the approved candidate universe.

Derived progress — owned, remaining, completion

The mandate row never stores an editable accumulated quantity. Three derived fields are recomputed at read time from the canonical persisted portfolio.holdings projection:

owned_quantity           = Σ Holding.quantity for (symbol, portfolio.name)
remaining_quantity       = max(target_quantity - owned_quantity, 0)
estimated_remaining_cost = remaining_quantity × current_price (FX-aware)

projection_observed_at is the successful holdings-sync or ledger-rebuild observation time — never Holding.updated_at, never a quote-fetch time, never the intelligence job's start time. For a broker-linked portfolio, a stale / failed / skipped / OTP-required / empty / never-completed sync means the quantity is reported as UNAVAILABLE (not a confident zero). For a manually maintained portfolio with no broker connection, an absent canonical holding row may be treated as a genuine zero.

A mandate becomes COMPLETED only when the reconciliation step observes an owned quantity ≥ target with valid evidence. The internal complete_if_target_reached(...) is idempotent — re-running it against an already-COMPLETED mandate is a no-op — and refuses to act on CANCELLED mandates. There is no manual-complete endpoint, ever.

Entry scoring — deterministic, coverage-aware

The entry score (intelligence/score_entry) is a 0–100 weighted blend of seven factors. Each factor exposes its value, weight, status, reason, source, and observed_at inside WatchlistRecommendation.technical_json — a missing factor is reported as unavailable, never as zero.

| Factor | Weight | |---|---:| | Price discipline within the user maximum | 20 | | Support-zone proximity and confidence | 25 | | Trend health | 15 | | Momentum reset using RSI-14 | 10 | | Volume confirmation | 10 | | Reward-to-risk | 10 | | Data quality and freshness | 10 | | Total | 100 |

The score is normalized to the available weight only:

available_weight      = Σ weights of available factors
raw_weighted_score    = Σ (factor_score / factor_max × factor_weight)
normalized_score      = raw_weighted_score / available_weight × 100
evidence_coverage_pct = available_weight / 100 × 100

An ACTIONABLE recommendation requires evidence_coverage_pct ≥ 80. A normalized score below coverage is labelled PARTIAL and is not actionable.

Hard gates that block the score outright

A candidate is BLOCKED (or UNAVAILABLE when the data isn't there yet) when any of these apply — and none of them can be overridden by the AI narrator:

  • mandate is not ACTIVE;
  • remaining_quantity is zero;
  • current price is unavailable, non-finite, zero, or negative;
  • current price is above max_acceptable_price;
  • quote freshness exceeds PORTFOLIO_INTELLIGENCE_QUOTE_STALE_MINUTES (default 30);
  • technical history has fewer than 30 daily bars;
  • evidence coverage is below 80%;
  • the suggested executable quantity is zero;
  • the resulting position violates a hard stock or sector cap;
  • a corporate event is CRITICAL and PORTFOLIO_INTELLIGENCE_BLOCK_ON_CRITICAL_EVENT=true (default);
  • instrument mapping is unresolved (resolve_instrument returned None).

A price above the maximum acceptable price is an absolute block. AI cannot override it.

Portfolio-fit scoring

The portfolio-fit score (intelligence/score_portfolio_fit) measures how buying a candidate would change the user's portfolio. It uses the shared base-currency portfolio valuation snapshot (a single backend service that combines holdings, selected price, selected FX, and calculate_dashboard_totals); it never sums raw Holding.current_value across currencies.

| Factor | Weight | |---|---:| | Stock concentration headroom | 25 | | Sector diversification and headroom | 25 | | Target-allocation gap | 25 | | Look-through overlap through funds/ETFs | 15 | | Historical correlation/duplication | 10 | | Total | 100 |

Missing sector is reported as unavailable, never bucketed as "Other". Correlation is only computed when at least 60 overlapping trading days are available; otherwise the factor is unavailable, and sector equality is not substituted as a numeric correlation value.

When diversification analysis identifies a portfolio gap for which no approved candidate exists, the system reports it honestly:

Healthcare is underweight, but no approved healthcare candidate exists in the watchlist.

It does not invent a stock suggestion.

Ranking and tranche sizing

The overall score is a deterministic blend:

overall_score = round(0.60 × entry_score + 0.40 × portfolio_fit_score)

A candidate is ACTIONABLE only when both entry_score ≥ mandate.min_entry_score and portfolio_fit_score ≥ mandate.min_portfolio_fit_score. Plain watchlist symbols with no active mandate are also ranked but capped at WATCH — they never receive a suggested tranche and their maximum-price evidence uses the manually configured buy-zone ceiling only when present and valid.

Sort order is fully deterministic:

  1. ACTIONABLE before WATCH / BLOCKED / EXPIRED;
  2. overall_score descending;
  3. entry_score descending;
  4. portfolio_fit_score descending;
  5. mandate tier or watchlist tier ascending;
  6. symbol ascending.

The top five are persisted; every evaluated candidate is also persisted so later history queries can show the full ranking.

Tranche sizing

size_tranche(...) returns an advisory suggested quantity — the operator never has to take it.

remaining_quantity   = target_quantity − owned_quantity
base_tranche        = ceil(remaining_quantity / remaining_tranches)

The base is then constrained by, in order: remaining_quantity, max_tranche_quantity, the remaining budget (using the current canonical cost basis for already-owned quantity — not a hardcoded zero), the maximum acceptable price, the stock-allocation cap, the sector-allocation cap, and the whole-share rule (v1 only ranks Indian-listed equities, so fractional shares are out of scope). Every limiting reason is persisted in reasons_json / warnings_json; the suggested quantity is never greater than remaining quantity.

A standing cash_constraint_unavailable warning is attached to every tranche in v1: the portfolio runtime does not yet persist an authoritative "available cash" concept, so the cash constraint is intentionally omitted from the hard gates rather than guessed.

The four conceptual surfaces

The Watchlist page (/portfolio/watchlist) is composed of five back-end-owned sections, each rendering already-persisted rows:

  1. Today's Opportunities — the top five ranked cards from the latest completed eligible ENTRY_SCAN or PORTFOLIO_FIT run. Every card includes generation time (IST), valid-until time, the three scores, current price with quote observation time, the suggested tranche, remaining target, maximum acceptable price, availability, top reasons, warnings, AI-explanation status, and the disposition actions (VIEWED, SNOOZED, ACCEPTED, SKIPPED).
  2. Accumulation Mandates — target / owned / remaining quantity, progress percentage, maximum price, destination portfolio, caps, status, last evaluated time, and edit / pause / resume / cancel controls via a drawer-based mandate editor.
  3. Watchlist table — current price + observed time, manual buy zone, computed support zone, technical status, active-mandate indicator, last recommendation, and the existing tier / rationale. This table is rendered with the responsive table primitive so it survives 360px mobile cards.
  4. Recommendation History — paginated: run date and type, scores, suggested quantity, status, disposition, expiry, and evidence coverage.
  5. Intelligence Health — last/next scheduled runs, corporate-action source status, Telegram delivery status, errors, partial coverage, and unresolved symbol mappings.

The Portfolio Overview (/portfolio) inserts compact cards after the existing attention strip showing the latest opening/closing brief, the top-five latest opportunities, failed/partial intelligence jobs, and corporate actions that need attention. It renders the persisted recommendation/digest fields as-is — it never recalculates scores or totals.

The five scheduled jobs

A new, always-on PortfolioScheduler instance (start_portfolio_intelligence_in_thread) starts regardless of whether PORTFOLIO_TELEGRAM_ENABLED is set — the calculation and persistence layer is independent of Telegram delivery. Timezone is Asia/Kolkata; weekends and exchange holidays are persisted as IntelligenceRun.status="SKIPPED".

| Slot (IST) | run_type | What it does | |---|---|---| | 09:20 | OPENING_BRIEF | Builds the opening portfolio brief from the shared valuation snapshot, picks top movers by rupee impact, includes active mandates near an eligible zone, and writes a PortfolioDigest regardless of Telegram state. | | 09:30 | ENTRY_SCAN | Evaluates every active mandate and eligible watchlist item, persists the full ranking, persists the top-five WatchlistRecommendations, sends a Telegram alert only when at least one actionable signal exists or an existing signal materially changes. | | 10:00 | PORTFOLIO_FIT | Re-evaluates concentration, sector, target-allocation, and look-through factors; emits the top-five from the approved universe; reports diversification gaps honestly. | | 16:00 | CLOSING_BRIEF | Final portfolio value, today's market gain and percentage, top contributors/detractors, sector contribution, watchlist signals generated during the day, important corporate events, and data-health limitations. | | 16:05 | CORPORATE_ACTION_DIGEST | Persists relevant corporate events for holdings / active mandates / watchlist items, with deterministic classification and deduplication. |

The existing support_alert_monitor cron is also tightened to the actual 09:15–15:30 IST market window via an OrTrigger of three sub-triggers (instead of a single hour="9-21" cron) so price alerts and support alerts fire only when the market is actually open.

Slot identity and concurrency

Each scheduled callback computes its canonical slot as canonical_slot(run_type, market_date) — a fixed IST time-of-day table:

OPENING_BRIEF           09:20
ENTRY_SCAN              09:30
PORTFOLIO_FIT           10:00
CLOSING_BRIEF           16:00
CORPORATE_ACTION_DIGEST 16:05

A 09:31 callback for ENTRY_SCAN still persists scheduled_for = 09:30, and two processes starting the scheduler at slightly different wall times share the same run via the database single-flight constraint. The IntelligenceRun.idempotency_key (f"{run_type}:{market_date}:{scheduled_for.isoformat()}") is UNIQUE; concurrent attempts INSERT ... ON CONFLICT DO NOTHING RETURNING and the loser reads the existing row. Manual recompute uses a database single-flight constraint and a per-request UUID-derived retry key — never count(existing_retries) + 1, which would race under concurrent retries.

Retry semantics

A genuine retry (triggered_by="RETRY") always creates a new linked row referencing the failed run via retry_of_run_id — it never silently dedupes against the original failed run. The orchestrator returns run_repo.get_by_id(run.id) at the end; it never calls get_latest(), which would race against a concurrent newer run.

Corporate-action ingestion

The corporate-action package (arb_bot/portfolio_app/corporate_actions/) ships with one adapter: a BSE announcements adapter (BseCorporateActionSource) that implements the CorporateActionSource Protocol. The adapter:

  • identifies itself and its source URL;
  • uses a bounded timeout and retry policy, respects rate limits, and sets a clear user agent;
  • persists fetch status, source timestamps, and sanitized metadata;
  • fails closed — a source failure is persisted as IntelligenceRun(status="FAILED") for CORPORATE_ACTION_DIGEST and is visibly distinct from a successful run with zero events.

Mandatory verification gate. Before any field mapping is implemented, a real browser network trace of BSE's public announcements page is captured, the response shape and field names are recorded, and a sanitized fixture under tests/portfolio_app/fixtures/corporate_actions/ is retained. Placeholder BSE endpoints, invented headline/category/ news_id mappings, and generic-news substitutes are explicitly disallowed; if the live verification step cannot be performed in the deployed environment, the adapter is committed as a FAILED reporting path with explicit disclosure rather than a fake-success path.

Event types and materiality

Event types (DIVIDEND, BONUS, STOCK_SPLIT, BUYBACK, RIGHTS_ISSUE, RESULTS, BOARD_MEETING, FUNDRAISING, ORDER_WIN, ORDER_CANCELLATION, CREDIT_RATING, PROMOTER_PLEDGE, PROMOTER_PLEDGE_RELEASE, INSIDER_TRANSACTION, BULK_DEAL, BLOCK_DEAL, MANAGEMENT_CHANGE, AUDITOR_CHANGE, MERGER_ACQUISITION, DEMERGER, REGULATORY_LEGAL, INVESTOR_PRESENTATION, EARNINGS_CALL, OPERATIONAL_DISRUPTION, OTHER) are determined deterministically at ingestion time by classify_event(raw) -> (event_type, materiality, classification_rule_version), where classification_rule_version starts at "v1". The AI narrator may summarize likely impact but may not change the deterministic event type or materiality.

Materiality bands:

  • CRITICAL — trading suspension, insolvency, fraud/regulatory action, major operational shutdown, auditor resignation with adverse context, severe credit downgrade, promoter-pledge crisis.
  • HIGH — merger/acquisition, large dilution, buyback, rights issue, major order, major management change, material legal matter.
  • MEDIUM — results, dividend, bonus, split, board meeting, rating change, pledge movement.
  • LOW — routine presentation, earnings-call scheduling, minor disclosure.
  • INFORMATIONAL — duplicate or administrative notice.

Symbol resolution and relevance

Symbol resolution uses, in order: (1) ISIN via instruments.py::resolve_instrument; (2) BSE scrip code mapped in instrument_aliases; (3) normalized official symbol/name mapping; (4) explicit unresolved state (CorporateActionEvent.symbol = None, surfaced in the Intelligence Health panel as "unresolved mappings"). The ingestion pipeline never guesses a similar-looking company name.

Relevance is persisted with a reason (DIRECT_HOLDING, ACTIVE_MANDATE, WATCHLIST). Opening briefs, closing briefs, immediate alerts, and the 16:05 digest only include events relevant to current holdings, active or paused mandates, or watchlist symbols — unrelated BSE announcements are not dumped into user reports.

Watermark semantics

The ingestion watermark is the last SUCCEEDED source fetch's confirmed source boundary. The current RUNNING row is never used as the previous watermark — RunRepository.get_latest_succeeded_before(...) is the dedicated query. A PARTIAL parse does not advance the success watermark; the next fetch overlaps safely so malformed or late records are not skipped. Deduplication makes overlap safe.

Notification policy

  • CRITICAL events affecting a holding, mandate, or watchlist item: an immediate Telegram notification during configured waking hours (PORTFOLIO_INTELLIGENCE_WAKING_HOURS_IST, default 07:00-22:00).
  • Non-critical events: included in the 16:05 digest.
  • Events that arrive after the digest: included in the next opening brief and the next corporate-action digest.
  • Dedup: every DeliveryAttempt row carries a dedup_key (unique on channel, destination_fingerprint, artifact_type, artifact_id, and calculation_version) so the same artifact is never re-sent without an explicit operator-initiated retry.

AI narrator

The narrator (intelligence/narrator.py::IntelligenceNarrator) explains persisted deterministic facts; it does not invent them. It is backed by a single shared CodexRunnerNarrator wrapping arb_bot.ai.codex_runner.CodexRunner — one directional shared-utility import, not a duplicate LLM-calling implementation.

Three Protocol methods:

summarize_recommendation(evidence)   → RecommendationNarrative
summarize_digest(evidence)           → DigestNarrative
summarize_corporate_action(evidence) → CorporateActionNarrative

RecommendationNarrative carries only summary, key_reasons, risk_note, and confidence_label. Its Pydantic model sets extra="forbid" and the JSON schema sets additionalProperties: false, so an invented numeric field (price, score, quantity, target) fails validation outright. Numeric facts always render from persisted deterministic data — the narrator can only describe them.

Failure behaviour

AI disabled (PORTFOLIO_AI_NARRATION_ENABLED=0), timed out (PORTFOLIO_AI_NARRATION_TIMEOUT_SEC, default 20), returning malformed JSON, or failing schema validation: the run persists ai_status = FAILED (or UNAVAILABLE / NOT_REQUESTED), the recommendation and digest render a deterministic fallback text, the calculation status is unchanged, and the UI discloses AI unavailability. AI failure never fails the deterministic calculation.

One narrator per run is built and shared across the evaluation set — never a fresh CodexRunner per candidate.

Telegram delivery

Telegram delivery is async end-to-end:

async def deliver(...) -> None: ...
async def retry_delivery(...) -> None: ...

Scheduled callbacks await deliver(...); the FastAPI retry route is async and awaits the real retry. No loop.run_until_complete() is called on the running scheduler loop; no nested event loops exist in scheduler or request paths. pytest.mark.asyncio tests use an async fake bot.

All times labelled IST in Telegram output are converted through ZoneInfo("Asia/Kolkata") first. Every message chunk respects the Telegram safe size limit, splitting oversized sections on line or safe-text boundaries while preserving valid HTML. Critical events use a dedicated escaped formatter — symbols, headlines, and source URLs are escaped before HTML interpolation.

When PORTFOLIO_TELEGRAM_ENABLED is unset, the calculation still runs and a DeliveryAttempt row with status="SKIPPED" is created so the operator sees the calculation succeeded but delivery was disabled. When Telegram is enabled but the send raises, the calculation stays SUCCEEDED/PARTIAL and the delivery row becomes FAILED; a real retry via POST /api/portfolio/intelligence/deliveries/{delivery_id}/retry re-renders the persisted artifact and uses its original generation time, not DeliveryAttempt.last_attempt_at, as the report time.

Database and migration

All seven new tables live in arb_bot/portfolio_app/db/intelligence_models.py plus its paired arb_bot/portfolio_app/db/intelligence_repository.py, following the existing db/trust_models.py / db/trust_repository.py precedent. db/engine.py::init_db() imports the new module so a clean install gets every table from SQLModel.metadata.create_all. The schema-qualified migration is db/050_watchlist_intelligence.sql.

Tables, in dependency order:

  1. accumulation_mandates
  2. intelligence_runs
  3. watchlist_recommendations
  4. recommendation_dispositions
  5. portfolio_digests
  6. corporate_action_events
  7. delivery_attempts

The corporate_action_events table is created before delivery_attempts because delivery_attempts.corporate_action_event_id references it. This ordering supersedes the order in the original plan.

Every ORM column matches the migration exactly:

  • TIMESTAMPTZ (DateTime(timezone=True)) for timestamps, populated via utc_now() = datetime.now(timezone.utc) — never datetime.utcnow();
  • NUMERIC(18, 4) (SQLModel Field(sa_column=Column(Numeric(18, 4)))) for monetary and share quantities;
  • JSONB for JSON columns;
  • named CheckConstraints and partial unique indexes in SQLModel metadata, re-added by migration 050 via idempotent ALTER TABLE ... ADD CONSTRAINT guarded by DO $$ ... $$ blocks (Postgres has no ADD CONSTRAINT IF NOT EXISTS);
  • regular INTEGER / SERIAL IDs everywhere — matching the existing portfolio.portfolios.id width so foreign keys resolve consistently on clean install and upgrade.

Migration parity is verified four ways: clean install, upgrade, init_db()-then-migration, and migration-then-init_db(). The migration is applied twice in tests to prove idempotency. watchlist_items is left untouched.

API surface

All new routes are mounted in arb_bot/portfolio_app/api/router.py alongside the existing route list, under the same APIRouter(prefix="/api/portfolio", ...) parent. Static routes are registered before dynamic ones to avoid FastAPI route shadowing. Auth is enforced at mount time, not per route. Routes are versioned; missing records return 404, conflicting state returns 409, validation failures return 422.

Mandates

GET    /api/portfolio/watchlist/mandates
POST   /api/portfolio/watchlist/mandates
GET    /api/portfolio/watchlist/mandates/{mandate_id}
PATCH  /api/portfolio/watchlist/mandates/{mandate_id}
POST   /api/portfolio/watchlist/mandates/{mandate_id}/pause
POST   /api/portfolio/watchlist/mandates/{mandate_id}/resume
POST   /api/portfolio/watchlist/mandates/{mandate_id}/cancel

Creation validates, in order: portfolio exists (404), watchlist item exists (422 with an explicit "add it to the watchlist first" instruction), instrument resolves unambiguously (422), no conflicting active mandate (409). PATCH requires if_version; a stale version returns 409. There is no manual-complete endpoint.

Opportunities

GET  /api/portfolio/watchlist/opportunities/latest
GET  /api/portfolio/watchlist/opportunities/history
GET  /api/portfolio/watchlist/runs
GET  /api/portfolio/watchlist/runs/{run_id}
POST /api/portfolio/watchlist/recompute
POST /api/portfolio/watchlist/recommendations/{id}/disposition

latest selects the latest completed eligible run, not the most recently inserted recommendation row; a newer failed run never silently displaces a previous successful one. recompute is a user-triggered ENTRY_SCAN under database single-flight; an already-RUNNING equivalent returns 409.

Digests and deliveries

GET  /api/portfolio/intelligence/digests/latest
GET  /api/portfolio/intelligence/digests
GET  /api/portfolio/intelligence/runs
POST /api/portfolio/intelligence/deliveries/{delivery_id}/retry

The retry performs a real send; PENDING flipping in isolation is not a retry.

Corporate actions

GET /api/portfolio/corporate-actions
GET /api/portfolio/corporate-actions/{event_id}
GET /api/portfolio/corporate-actions/status

Filters: symbol, portfolio relevance, event type, materiality, date range, and unresolved mapping.

Configuration

The new intelligence knobs live alongside the existing PortfolioRuntimeConfig keys:

| Variable | Default | Purpose | |---|---|---| | PORTFOLIO_AI_NARRATION_ENABLED | 0 | Master switch for the AI narrator | | PORTFOLIO_AI_NARRATION_TIMEOUT_SEC | 20 | Per-narration timeout | | PORTFOLIO_INTELLIGENCE_QUOTE_STALE_MINUTES | 30 | Maximum quote age for ACTIONABLE | | PORTFOLIO_INTELLIGENCE_BLOCK_ON_CRITICAL_EVENT | true | Block ACTIONABLE when a critical event is open | | PORTFOLIO_INTELLIGENCE_BLOCK_ON_STOP_BREAK | true | Block when price has broken the technical stop | | PORTFOLIO_INTELLIGENCE_DEFAULT_STOCK_CAP_PCT | 10 | Project default stock-concentration cap | | PORTFOLIO_INTELLIGENCE_FUND_LOOKTHROUGH_STALE_DAYS | 30 | Constituent snapshots older than this are flagged | | PORTFOLIO_INTELLIGENCE_WAKING_HOURS_IST | 07:00-22:00 | Window for immediate critical-event delivery |

All defaults are safe: the system is decision-support only, and AI_* is off by default so a default install never shells out to codex exec.

Persistence invariants

A few invariants are worth calling out because they directly determine whether the numbers on screen are truthful:

  • All new persisted timestamps are timezone-aware UTC.
  • Quote observation time is captured per provider call; a shared scheduler now is never a quote time.
  • A non-INR holding without selected FX is excluded from aggregate totals — it is not silently converted or dropped to zero.
  • A failed BSE parse is never represented as zero events.
  • An UNAVAILABLE digest is never reported as SUCCEEDED.
  • recommended quantity > remaining quantity is rejected at every layer (sizing, recommendation row constraint, API validation).
  • Concentration is calculated before the suggested quantity, not after.
  • Frontend never recalculates entry / portfolio-fit / overall scores; it renders persisted fields only.

Interaction with live trading

The intelligence layer is part of the portfolio runtime (arb_bot/portfolio_app), which must never import arb_bot.bot, arb_bot.execution.engine, arb_bot.command_handler, or arb_bot.client. It has no broker order-placement code path. No scanner, executor, research, or policy file is touched by this feature.

The Telegram delivery path reuses the existing portfolio-side python-telegram-bot Application (telegram_app.py::build_application()) and never the trading-bot send_telegram shim — the two are intentionally separate channels.