Portfolio_Analysis — Architecture & Production Readiness

What this is

Reporting tools for GrowthVine Capital, a mutual fund distributor (MFD). Two independent report pipelines that both end the same way: Jinja2 → HTML → Playwright (headless Chromium) → PDF.

  1. Monthly Portfolio Report (main.py) — full client report driven by the live InvestWell API.
  2. Prospect Diagnostics Report (prospect_report/) — a lead-gen funnel: upload a CAS PDF → instant teaser in-browser → email-gated 15-page detailed PDF, deployed as a Flask web app.

These share no runtime code path — main.py is a standalone CLI script; prospect_report/ is the deployed web service. They only share the general pattern (Jinja2/Playwright) and some data-fetching idioms.


Architecture diagram

[Diagram]

Components

Component Role Key dependency
main.py Monthly report CLI pipeline InvestWell API, yfinance, Anthropic, Postgres
mock_main.py / mock_data.py Same report, static mock data, no external calls
investwell_api.py Thin REST client, JWT auth (15-min token, no refresh loop) requests
prospect_report/server.py Flask app — upload, teaser, email-gated PDF, mock/preview routes Flask, gunicorn
prospect_report/cas_parser.py Parses CAMS/KFintech/MF Central CAS PDFs (detailed & holdings-only) pdfplumber
prospect_report/prospect_math.py Risk/return math: XIRR, Sharpe, Sortino, Beta, Alpha, capture ratios, overlap, tax numpy, mfapi.in, yfinance
prospect_report/gv_db.py Read-only queries against GrowthVine's Postgres warehouse psycopg2
prospect_report/growthvine_api.py Client for GrowthVine's own first-party NAV API requests
prospect_report/email_sender.py Sends the final PDF via Gmail SMTP smtplib
templates/*.html, prospect_report/templates/*.html Jinja2 templates rendered to HTML then PDF via Playwright Jinja2, Playwright
render.yaml / Procfile Deploy config for Render.com gunicorn

Production readiness assessment

Overall: prototype / early-access, not production-hardened. It works end-to-end and is deployed, but several choices are fine for a low-traffic lead-gen demo and would break under real production load or scrutiny.

🔴 Will break under real usage

🟡 Reliability / operability gaps

🟢 Reasonable for its scope

Recommendations, roughly in priority order

  1. Replace _SESSIONS and leads.csv with a real datastore (even SQLite on a persistent disk, or the existing Postgres instance) — this is the biggest correctness risk for a lead-gen product where losing a lead is the worst-case outcome.
  2. Move off Render's free tier (or add a paid always-on plan) to remove cold-starts and the single-worker bottleneck.
  3. Replace the fire-and-forget email thread with a real task queue (RQ/Celery, or even a simple retry-with-backoff wrapper) so failures aren't silent.
  4. Add basic automated tests around prospect_math.py's calculations (XIRR, overlap, tax) — this is the highest-value, most testable logic in the repo and currently has zero coverage.
  5. Add structured logging + an error tracker so failures in production are visible instead of living only in Render's ephemeral log tail.

Audit: Portfolio Review vs. the SEBI-Ready System Architecture target doc

Scoped to just the Portfolio Review product — prospect_report/ (CAS upload → teaser → email-gated detailed audit). This is the only product from Growthvine Capital SEBI-Ready System Architecture (v1.0, 2026-07-01) that's actually implemented, and it's the one the doc itself flags as highest-stakes: Data Sensitivity: High, Criticality: High, Primary Risks: CAS data leakage, unauthorized report access, external API abuse (doc §4).

The doc defines an exact target workflow for this product (§13.1): Prospect → WAF/ALB → Public API → Consent Store → mfcentral API → Data API → portfolio_review DB → Private S3 → Audit Log, plus 5 required controls. Below is that workflow and those controls checked against the real code.

Workflow step-by-step: target vs. actual

Doc's target step What actually happens
WAF/ALB validates request before it reaches the app Nothing — Flask receives the raw request directly on Render's edge (render.yaml:7). No WAF, no rate limiting, no request-size cap.
Consent stored (version + purpose) before any processing No consent store. upload.html:136-138 is an informational blurb, not a checkbox, and nothing is persisted.
CAS fetched live via mfcentral API Doesn't happen — the doc assumes the app pulls the CAS itself; this app instead has the prospect self-download it from mfcentral.com and upload the PDF (upload.html:118-127). A real architectural difference, not just a missing control — there's no mfcentral integration to audit here at all.
Fund details enriched via a separated Data API prospect_math.py calls gv_db.py directly with app-level Postgres credentials in the same process — functionally similar, but no service boundary, no read-only-credential isolation the doc's split architecture assumes.
Normalized portfolio data stored in a portfolio_review DB (doc §8.1: High sensitivity, required CAS retention policy + encryption + audit logs) No such database exists. Parsed holdings/teaser results live only in the in-process _SESSIONS dict (server.py:42) — no persistence layer at all for this product's core data.
Encrypted PDF uploaded to private S3 PDF written to local disk (server.py:273, main.py:585). No S3, no boto3 in requirements.txt, no encryption-at-rest control.
Every processing/report-generation step recorded to an audit log Only lead capture is logged (_log_lead, server.py:397-411) — timestamp, name, email, health_score, portfolio_value, flags. No event_id/actor/result/correlation_id, no record of parsing, PDF generation, or downloads.
Prospect gets back a short-lived download token Teaser access is gated by a signed Flask session cookie (session["sid"]) — a reasonable de facto token for the teaser page. But the detailed audit PDF is emailed as a raw attachment (email_sender.py), not delivered via any token/link at all.

The doc's own 4 required controls for this workflow — checked

  1. "Consent stored before external API call." 🔴 Fails — no consent store exists (see above).
  2. "Raw CAS retained only if approved by retention policy." 🟢 Passes in practice, though not by policy — the uploaded PDF is deleted immediately after parsing (server.py:210-214), so the effective retention is zero, which is stricter than the doc's own 7–30 day baseline (§19). No written policy exists, but the behavior already clears the bar.
  3. "Report access uses short-lived token." 🟠 Half-true — the in-browser teaser is cookie-gated, but the emailed detailed PDF has no token/expiry/revocation at all; anyone with the inbox (now or years later) can re-share the attachment indefinitely.
  4. "Every report download is logged." 🔴 Fails — /download-teaser-pdf and the emailed detailed report both go out with zero logging of who downloaded what, when.
  5. "CAS processing failures are recoverable without exposing raw errors." 🔴 Fails concretelyserver.py:207-208:
    else:
        error = f"Processing error: {e}"
    return render_template("upload.html", error=error)
    The raw Python exception string is rendered straight into the user-facing error page. Any pdfplumber/parsing internals, stack-trace fragments, or file-path details leak directly to the browser. This is the one finding here that's a direct, literal violation of an explicit sentence in the target doc — not an inferred gap.

Additional findings tied to this product's own named risks (doc §4)

What's already right for this product

Bottom line for Portfolio Review

Against the doc's own 5-step control checklist for this exact product, 2 of 5 pass or half-pass, 3 fail outright — and one failure (the raw exception leaked to the user on parse errors) is a direct violation of an explicit requirement, not an interpretation. The bigger structural gap is that there's no portfolio_review database at all: everything the doc wants governed (retention policy, encryption, audit logs) has no table to attach to yet, because this product currently has no persistence layer beyond an in-memory dict and a CSV file. Fixing the exception-leak and the unverified-email-recipient issue are both same-day fixes; standing up real persistence + audit logging is the actual prerequisite for anything else in this product's SEBI-readiness roadmap.


Proposed target architecture

Four changes came out of reviewing the above, each addressing a concrete production-failure mode rather than a style preference.

1. Headless Chromium comes off the web dyno

Playwright/Chromium isn't wrong for this job — the templates (landscape_template.html, detailed_proposal.html) render charts via Chart.js, so a pure-CSS PDF engine (WeasyPrint, etc.) would silently drop every chart unless they're rebuilt as server-side images first. The actual problem is that a 300MB+ browser binary runs inside the same process that serves live HTTP requests, competing for memory with everything else on a small/free Render instance — that's what fails under load, not the rendering approach itself.

Target: move PDF generation to a separate worker, triggered by a queue instead of an inline function call inside the request handler. Two viable paths:

2. yfinance replaced with a licensed data feed

yfinance scrapes Yahoo's unofficial endpoints and is known to rate-limit or silently block requests from data-center IP ranges (AWS/Render included) — there's no SLA, and a failure here degrades the AI commentary and market-pulse sections of client-facing reports with no warning beyond a print(). Replace with a contracted data source: NSE/BSE official feeds for SENSEX/NIFTY, a paid vendor (Alpha Vantage, Twelve Data) for S&P 500/NASDAQ. This also closes a gap from the SEBI audit's vendor-register requirement (§18) — an unofficial scraper isn't something you can put in a vendor contract.

3. Flask stops being the frontend

server.py currently does three unrelated jobs in one process: serve HTML pages, run CAS parsing/math, and generate PDFs. Since the main GrowthVine site is already Node/React, the upload form and teaser results page belong there instead — served with the same branding, session, and auth infrastructure as the rest of the site, calling the Python side purely as a JSON API. Flask (or a successor) keeps no Jinja2-rendered user-facing pages at all.

4. Data/calculation layer separated out

prospect_math.py, cas_parser.py, gv_db.py, investwell_api.py, and growthvine_api.py currently get import-ed directly into whatever process happens to be serving the request. This is also what the SEBI target doc's own architecture calls for (a dedicated internal "Data API" instance, doc §7.1) and was flagged as missing in the audit above. Pulling this into its own internally-network-only service means:

Target architecture diagram

[Diagram]

What this doesn't solve by itself

This re-architecture fixes the infrastructure-shaped problems (heavy in-process rendering, unreliable data vendor, mismatched frontend stack, tangled data layer) but doesn't automatically close the SEBI-readiness gaps from the audit above — consent capture, structured audit logging, retention policy, and the unverified-email-recipient issue on report delivery all still need to be built deliberately into the new Node BFF / Data API boundary, not assumed to fall out of the split for free.