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.mddocs/superpowers/specs/2026-07-16-watchlist-intelligence-final-corrections.mddocs/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_ENABLEDis unset — their Telegram delivery will simply land inDeliveryAttempt.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_quantityis 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(default30); - 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
CRITICALandPORTFOLIO_INTELLIGENCE_BLOCK_ON_CRITICAL_EVENT=true(default); - instrument mapping is unresolved (
resolve_instrumentreturnedNone).
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:
ACTIONABLEbeforeWATCH/BLOCKED/EXPIRED;overall_scoredescending;entry_scoredescending;portfolio_fit_scoredescending;- mandate tier or watchlist tier ascending;
symbolascending.
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:
- Today's Opportunities — the top five ranked cards from the latest
completed eligible
ENTRY_SCANorPORTFOLIO_FITrun. 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). - 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.
- 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.
- Recommendation History — paginated: run date and type, scores, suggested quantity, status, disposition, expiry, and evidence coverage.
- 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")forCORPORATE_ACTION_DIGESTand 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, inventedheadline/category/ news_idmappings, and generic-news substitutes are explicitly disallowed; if the live verification step cannot be performed in the deployed environment, the adapter is committed as aFAILEDreporting 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
CRITICALevents affecting a holding, mandate, or watchlist item: an immediate Telegram notification during configured waking hours (PORTFOLIO_INTELLIGENCE_WAKING_HOURS_IST, default07: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
DeliveryAttemptrow carries adedup_key(unique onchannel,destination_fingerprint,artifact_type,artifact_id, andcalculation_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:
accumulation_mandatesintelligence_runswatchlist_recommendationsrecommendation_dispositionsportfolio_digestscorporate_action_eventsdelivery_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 viautc_now() = datetime.now(timezone.utc)— neverdatetime.utcnow();NUMERIC(18, 4)(SQLModelField(sa_column=Column(Numeric(18, 4)))) for monetary and share quantities;JSONBfor JSON columns;- named
CheckConstraints and partial unique indexes in SQLModel metadata, re-added by migration050via idempotentALTER TABLE ... ADD CONSTRAINTguarded byDO $$ ... $$blocks (Postgres has noADD CONSTRAINT IF NOT EXISTS); - regular
INTEGER/SERIALIDs everywhere — matching the existingportfolio.portfolios.idwidth 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
nowis 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
UNAVAILABLEdigest is never reported asSUCCEEDED. recommended quantity > remaining quantityis 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.