Fund Analyser — Architecture

This document covers this repository's own architecture: its components, data flow, and data stores, as they exist right now. For the wider Growthvine Capital product suite this app is one piece of, see README.md.

1. Component overview

[Diagram]

2. Backend: FastAPI (app.py)

Single-process FastAPI app. In production it also serves the built frontend as static files (StaticFiles(html=True) mounted at /, registered last so it never shadows an API route — see the comment at app.py:1136). In dev, Vite serves the UI and proxies /api/* to http://127.0.0.1:8000.

Key routes:

Route Purpose
GET /api/run (SSE) Runs the pipeline for one category, streaming progress
GET /api/run_multi_category Same, fanned out across several categories
POST /api/rank Computes a live ranking (FundRanker.rank_funds) without persisting
GET /api/categories, GET /api/categories/grouped Category list for the picker — unions Growthvine's live categories, custom subcategories (src/subcategory_mapper.py), and whatever's already in the DB
GET /api/capture_comparison Old-vs-new Up/Down Capture comparison (src/capture_comparison.py)
POST /api/upload_benchmark, GET /api/get_benchmark_columns Manual benchmark file upload path (still used for ad-hoc/one-off benchmarks not in the external DB)
POST /api/export_excel Re-generates the Excel workbook (src/exporter.py)
POST /api/overrides, POST /api/overrides/reset Manual per-fund score overrides
GET /api/fund_history, POST /api/calculate_sip Tear-sheet NAV history + SIP return calculator

3. Pipeline core (main.py::run_pipeline)

One call = one category. Steps, in order:

  1. Fetch AMFI master list — builds an ISIN → (scheme code, full name, NAV date) map.
  2. Fetch Growthvine schemes for the category (or, if Growthvine is unreachable, falls back to whatever schemes are already known in the DB for that category).
  3. Filter schemes — maps the local category name to Growthvine's category (or, for a custom subcategory, pulls from all its declared source categories and filters via SubcategoryMapper.classify); drops Direct plans; dedupes Regular/Growth variants.
  4. Consolidated per-fund metadata fetch — AUM, TER, PE/PB, Sharpe ratio, etc., from Growthvine.
  5. Fetch historical NAV dataGrowthvineNAVDownloader, with MFAPIDownloader (mfapi.in) as a per-scheme fallback for schemes Growthvine can't serve, including a hardcoded rebrand/merger map (PREDECESSOR_MAP, e.g. IDBI → LIC MF schemes).
  6. Fetch Investwell metadata (optional — needs credentials) — benchmark index name, expense ratio corroboration, etc.
  7. Resolve the benchmark — either read benchmark_prices straight from Postgres (the normal path, including the external-DB-sourced series — see §5) or fall back to a manually uploaded file, which then gets persisted into benchmark_prices and extended via src/benchmark_updater.py (BharatFinTrack) so subsequent runs don't need the file again.
  8. Compute analytics (AnalyticsEngine.calculate_monthly_rolling_returns) — rolling returns, Sharpe, Up/Down Capture, quartiles; DB-cached so only genuinely new dates are recomputed.
  9. PersistDatabaseManager.save_pipeline_to_db (raw + analytics tables) and, after that commits, FundRanker.rank_funds + DatabaseManager.save_ranking_to_db (fund_rankings — the table the tax-harvesting app's recommendation engine reads cross-database, read-only).
  10. Export to Excel (ExcelExporter).

Sequence: one GET /api/run call

[Diagram]

4. Ranking engine (src/ranking.py::FundRanker)

Decile composite score, default equity weights — see README.md's full calculation methodology for every formula (decile scoring, time-decay, per-category weight tables, coverage/age/AUM penalties, stars/borderline) verified line-by-line against this file:

[Diagram]

Debt/Hybrid categories use different weight profiles (_DEBT_CATEGORY_WEIGHT_OVERRIDES, plus Gilt/Credit Risk-specific overrides); the Configure Weights tab also offers these as one-click presets. An in-process TTL cache (_PEER_METADATA_CACHE, 5 min) avoids re-authenticating to Growthvine/Investwell on every /api/rank call.

5. Benchmark data: two independent sources

6. Frontend (frontend/, Vite + vanilla JS, no framework)

Single page, tab-based (frontend/src/tabs.js), each tab backed by its own module:

Tab Module Backs
📊 Run Pipeline pipeline.js /api/run, /api/run_multi_category
📥 Upload Data Sheets files.js /api/upload_benchmark, /api/get_benchmark_columns
🗄️ Database Preview database.js /api/get_results
🏆 Fund Rankings rankings.js, capture-comparison.js /api/rank, /api/capture_comparison, /api/aum_curve
🔧 Configure Weights weights.js weight-preset state, persisted client-side

category-picker.js and state.js are shared across tabs; tearsheet.js drives the per-fund NAV/SIP modal; api.js is the single fetch wrapper every module calls through.

7. Data stores

mutual_funds (Postgres, this app's own DB)historical_data, rolling_returns_3y, fund_rankings, benchmark_prices, absolute_capture_details/absolute_capture_summary, performance_summary. See database-schema.md for full column-level detail.

External benchmark DB (separate Postgres instance)nifty_equity_index_data, nifty_fi_index_data, nifty_dashboard_runs (tracks source PDF URLs). Read-only from this app's perspective.

Cross-app consumer — the tax-harvesting app reads fund_rankings read-only, cross-database, as its sole source of fund ranking data. It never calls /api/rank or triggers a pipeline run itself; freshness of fund_rankings is exactly whatever the last pipeline run for that category left behind.

8. SEBI-readiness audit (vs. Growthvine Capital target architecture)

Growthvine Capital SEBI-Ready System Architecture.pdf (v1.0, 2026-07-01) defines a target architecture for all five Growthvine Capital products. This repo is that document's "Fund Analysis Dashboard" product (§4): internal-team-only, Medium/Medium sensitivity/criticality, named risks "unauthorized internal access, stale data, ranking integrity". This section audits the current code against every control that document requires for this specific product — line-referenced, so it stays a checklist rather than prose. CAS/PII-specific controls (§8.3, §19 retention) are out of scope: this component never touches client CAS/transaction data, only fund-universe and ranking data.

# Target requirement (PDF §) Current state Severity
1 MFA + authenticated roles for internal users (§10.1, Roadmap Priority 5); "MFA for team users" is a named required control for this exact product (§13.3) No authentication of any kind. app.py registers every route (/api/run, /api/rank, /api/overrides, /api/export_excel, …) with no auth dependency, no session, no role check. Anyone with network reach to the app can trigger pipeline runs, export data, or mutate rankings. Critical
2 Ranking-parameter changes and dashboard exports must be audited (§13.3); structured, append-only business audit events for admin/config changes (§3.3, §14.2) No audit trail exists anywhere. Logging (logger.info/.error throughout app.py) goes to console/SSE only — not structured event_id/actor_id/correlation_id records, and isn't append-only. /api/overrides (app.py:1100-1117) and /api/export_excel perform exactly the two actions §13.3 requires be audited, and neither is. Critical
3 Ranking integrity — the PDF's own named top risk for this product (§4) manual_score_overrides (database.py:262-268) stores only scheme_code, metric_key, override_value, updated_at — no actor/user column. Combined with #1 (no auth), any override to fund_rankings — the table the separate tax-harvesting app reads read-only as its sole ranking source (§6) — is silently unattributed and unattributable after the fact. Critical
4 Edge protection — rate limiting, WAF, bot controls even for internal-only routes (§6.2, §11) No rate limiting or WAF anywhere. IS_DEV CORS (app.py:73-79) is allow_origins=["*"] — acceptable only because it's dev-gated, but there is no edge-control layer in prod either, just same-origin static serving. High
5 Secrets in Secrets Manager/SSM, never in .env/code (§15.2, Roadmap Priority 10) GROWTHVINE_*_CLIENT_SECRET, INVESTWELL_PASSWORD, DATABASE_URL are all loaded via python-dotenv from a flat .env file (app.py:25-26). docker-compose.yml also hardcodes POSTGRES_PASSWORD=postgres for local dev — fine locally, but confirm the same pattern isn't mirrored into the RDS deployment's actual secret handling. High
6 Cache versioned by upstream sync version, invalidated only after validated sync (§12.1-12.2); staged/validated sync into production tables (§8.2, Roadmap Priority 11) — "stale data" is this product's own named risk Caching is three separate in-process TTL dicts (_PEER_METADATA_CACHE 5 min in ranking.py:23-24, _GV_SCHEMES_CACHE 30 min and _AMFI_ISIN_MAP_CACHE in app.py:57-63) — wall-clock expiry only, no tie to Growthvine/Investwell sync version, and not shared across processes/workers. save_pipeline_to_db also writes straight into production tables per category (delete-then-append) with no staging table, checksum, or row-count validation step first. Medium
7 Batch jobs need job_id, idempotency key, per-item status, dead-letter/replay state (§13.7); "Weekly fund-ranking computation" is explicitly named as an in-scope batch workload run_all_mapped_categories.py:159-167 loops categories with a bare try/except, logging success/fail to console only. No persisted job execution record, no job_id/attempt_count/status fields, no dead-letter queue — a crash mid-run leaves no queryable record of which categories actually completed. HTTP-level retry (urllib3.Retry w/ backoff in investwell_api.py/growthvine_api.py) is solid and matches §13.7's transient-failure baseline, but that's transport-level, not job-level. Medium
8 Least-privilege DB access per service (§3.1, §8.1) Positive finding. Production RDS (docs/rds_schema_fix.sql) already scopes the app to a dedicated fund_app user with only SELECT/INSERT/UPDATE/DELETE — no ALTER/CREATE/ownership. This is exactly the least-privilege posture §3.1 asks for and should be preserved (not "fixed" by the broad GRANT ALL option that script's own comments offer as an alternative). — (keep as-is)
9 Cloud/DB resilience evidence — Multi-AZ, PITR, encryption, deletion protection (§8.1, Roadmap Priority 7) Not documented anywhere in this repo either way. Not necessarily absent — just not verifiable from the codebase, and this repo isn't where that evidence would live (RDS console/IaC is). Flagged as an open item for whoever owns the RDS instance, not a code fix. Info / needs owner confirmation
10 Trust boundary between this app and cross-database consumers (§3.2) tax-harvesting reads fund_rankings cross-database, described as "read-only" in this doc's own §7, but nothing in this repo shows a DB-grant-level read-only role enforcing that — it may just be convention today. Medium

Remediation order for this repo specifically

Unlike the PDF's company-wide 15-item roadmap, this product's own risk profile (no CAS/PII, but real ranking-integrity exposure) means the highest-value fixes here are, in order:

  1. Add auth + role check (Analyst/Viewer/Admin per §10.1) in front of every /api/* route — closes findings #1 and (mostly) #3.
  2. Add actor_id to manual_score_overrides and emit a structured audit event on every override write/reset and every /api/export_excel call — closes #2 and the rest of #3.
  3. Stage + checksum-validate save_pipeline_to_db writes before they land in production tables, and tie the three in-process caches' invalidation to a validated-sync marker instead of wall-clock TTL — closes #6.
  4. Give run_all_mapped_categories.py a persisted per-category job record (job_id, status, attempt_count) instead of console-only logging — closes #7.
  5. Move .env secrets to Secrets Manager/SSM for the RDS deployment — closes #5.

9. Planned architecture changes (open decisions)

Three changes have been decided in principle but not yet implemented, captured here so the target shape is documented ahead of the work.

9.1 Replace scraper-dependent data sources (BharatFinTrack / NSE / yfinance-style)

src/benchmark_updater.py and src/capture_comparison.py depend on BharatFinTrack's NSETRI client, which scrapes the NSE indices website rather than calling a licensed API. Sources in this shape (this includes yfinance-style unofficial scrapers generally, even where not currently used in this repo) are unreliable from production infrastructure — cloud/datacenter IPs get rate-limited or blocked by NSE in ways that don't show up from a developer's home connection, so failures only surface after deploy, not in local testing.

Decision: move off scraper-based sources for anything running in production. The external-benchmark-database path (src/external_benchmark_db.py, §5 above — sourced from NSE's published Index Dashboard PDFs rather than live scraping) is already the primary path for exactly this reason and should keep absorbing responsibility; BharatFinTrack/NSETRI should shrink to a manual/dev-only fallback (or be replaced with a licensed data vendor) rather than something the daily/weekly production pipeline depends on.

Open: which paid/licensed provider replaces it is not yet chosen — this is a data-sourcing decision for whoever owns the vendor relationship, not a code change to make speculatively.

9.2 Frontend framework mismatch: Vite/vanilla JS vs. the org's React/Node stack

Per §6, this app's frontend is a single-page Vite + vanilla JS app with no framework (frontend/index.html, frontend/src/*.js, tab modules). The main Growthvine Capital website is built on Node.js + React. Shipping the Fund Analysis Dashboard in a different stack than the rest of the company's web properties means no shared components, no shared build tooling, and every contributor needs to context-switch between two different frontend approaches.

Decision: flagged as a mismatch worth resolving — likely a migration of frontend/ to React (kept on Vite as the bundler, since Vite-with-React is standard and the issue is vanilla-JS-vs-React, not Vite-vs-something-else) so this product shares tooling and component patterns with the rest of the org's Node.js/React properties.

Open: this is a rewrite, not a patch — needs its own scoping (incremental tab-by-tab port vs. full rewrite) before work starts; not undertaken as part of this audit.

9.3 Extract data processing/calculation out of this app into a dedicated data layer

Today, main.py::run_pipeline, src/analytics.py::AnalyticsEngine, src/ranking.py::FundRanker, and all the ingestion clients (src/growthvine_api.py, src/growthvine_nav.py, src/investwell_api.py, src/mfapi.py, src/amfi.py) run inside this FastAPI backend — the same process that serves the dashboard UI and its API. Fetching, computing, and persisting fund analytics is a distinct concern from presenting them, and already has its own downstream consumer (tax-harvesting, reading fund_rankings cross-database) that has nothing to do with this app's UI.

Decision: pull data ingestion, analytics computation, and ranking out of this repo into a separate data-layer service/library. This app's backend should become a thin consumer of that layer's output (via its DB tables or an internal API) rather than the thing that runs the pipeline itself. This also directly helps §8's audit findings above: a dedicated data layer is a natural place to add the staged/validated sync (#6) and batch job tracking (#7) this document already flags as missing, without those concerns living inside a dashboard's web backend.

Open: target shape (shared library vs. standalone service with its own API, and whether run_all_mapped_categories.py/scheduled jobs move with it) needs its own design pass — not resolved here.

10. Running locally

docker-compose.yml bundles a Postgres 16 instance (db, host port 5433) and the app itself (app, host port 8000 by default). DATABASE_URL is overridden inside Compose to always point at the bundled db service; set it yourself in .env only for a non-Docker run. See README.md §6 for exact commands.