All posts

Full-stack engineering · 11 min read

Five investors, one stock engine

Lens is my attempt to turn five very different investing philosophies into one coherent product: a FastAPI backend, a React dashboard, and a scoring engine that is honest about where the math is crisp and where the data absolutely is not.

I built Lens because most retail stock tools flatten everything into one leaderboard. That is useful if you want a screener. It is less useful if you care about why a company looks attractive. A cheap cyclical, a Buffett-style compounder, and a high-momentum breakout can all be good ideas for completely different reasons.

So the product premise became: score the same stock through five lenses - Graham, Buffett, Lynch, O'Neil, and Dividend - then show the result as a radar chart and a per-profile metric breakdown. The repo is split cleanly: frontend/ is a Vite + React + TypeScript app,backend/ is a FastAPI service with a light hexagonal architecture, and terraform/ plus GitHub Actions deploy the whole thing onto GCP.

The architecture is smaller than it looks

The backend is opinionated but not overbuilt. The domain model lives inbackend/app/domain/entities/__init__.py; the ports sit indomain/ports; the concrete adapters talk to Alpha Vantage, Polygon.io and PostgreSQL. The main orchestration happens inStockAnalysisService, which does exactly three things: check the cache, fetch fresh data if needed, and compute the five profile scores.

cached = await self._repo.get_analysis(ticker)
if cached and self._is_fresh(cached):
    return cached

(overview, fundamentals), market_data = await _gather(
    self._fundamentals.get_overview_and_fundamentals(ticker),
    self._market.get_quote(ticker),
)

profile_scores = [
    compute_profile_score(pt, fundamentals, market_data)
    for pt in InvestorProfileType
]

That _gather() call matters. Fundamentals come from Alpha Vantage, market data comes from Polygon, and they are fetched in parallel. The service then persists the resulting analysis in Postgres with a 24-hour TTL. That cache is not a micro-optimisation; it is the thing that makes the product economically viable when one of the upstream providers has an aggressively small free tier.

I also like the repository choice. Instead of normalising every metric, score and nested explanation into ten tables, Lens stores each analysis as a JSONB payload in stock_analyses. That keeps writes simple while still letting me rank by profile using a JSONB path query.

SELECT * FROM stock_analyses
ORDER BY (
    SELECT elem->>'total_score'
    FROM jsonb_array_elements(payload->'profile_scores') AS elem
    WHERE elem->>'profile_type' = :profile_type
    LIMIT 1
)::float DESC NULLS LAST

The tradeoff is obvious: JSONB is flexible, but it is not a great fit if I ever want serious historical analytics. For the current product shape - one latest snapshot per ticker - it is the right kind of laziness.

Financial APIs are messy in boring ways

The most realistic part of the repo is not the radar chart. It is all the small defensive code around data quality. Alpha Vantage returns a lot of values as strings, sometimes percentages mean 15.5%, sometimes they mean 0.155, and some ratios are simply absent. So the adapter inalpha_vantage_client.py spends a surprising amount of time coercing, normalising and deriving secondary metrics.

def _pct_field_to_decimal(value):
    v = _f(value)
    if v is None:
        return None
    if v > 1.0:
        return v / 100.0
    return v


def _payout_ratio_resolved(overview):
    pr = _pct_field_to_decimal(overview.get("PayoutRatio"))
    if pr is not None:
        return pr
    dps = _f(overview.get("DividendPerShare"))
    eps = _f(overview.get("EPS")) or _f(overview.get("DilutedEPS"))
    if dps is not None and eps is not None and eps != 0:
        return abs(dps / eps)

A few examples: debt-to-equity can come from the overview endpoint or be reconstructed from the balance sheet; earnings stability is inferred by walking up to five annual income statements; free cash flow is derived as operating cash flow minus capex; dividend growth is approximated from cash-flow statements. None of this is glamorous, but this is where a lot of finance side projects quietly fail.

The gotcha I would document first now

Lens has a field named roic, but today it is populated from Alpha Vantage's ReturnOnAssetsTTM as a proxy because the provider does not expose a clean ROIC metric. That is defensible for a prototype and not defensible enough for a serious investment product. The code is honest about it; the UI should be too.

The repo is also explicit about rate limits. Alpha Vantage free tier errors are detected by inspecting the response body for Noteor Information, then converted into a clear 429-style user message. Polygon search does the same for invalid or under-scoped API keys. The result is not perfect resilience, but it does at least fail loudly and specifically instead of returning a quietly broken score.

The scoring engine is opinionated, not predictive

The scoring logic in backend/app/services/scoring.py is the core of the project. Each profile is a list of weighted metric scorers. A scorer produces a MetricResult; the weighted sum becomes aProfileScore; the grade is mapped with simple thresholds from A through F.

profilewhat it rewardssignature constraint
Valuecheap assets and balance-sheet safetyP/E < 15, P/B < 1.5
Qualityhigh capital returns and marginsROE > 15%, strong FCF
GARPgrowth without paying too muchPEG < 1.0
Momentumfundamentals plus price strengthRS ≥ 70, volume surge
Dividendincome that looks sustainableyield band, payout discipline

Two helper functions do most of the work: _ratio_score()compares a value to a target, and _range_score() rewards landing inside a preferred band. That gives the system a nice property: metrics degrade smoothly instead of flipping from 100 to 0 at a single cutoff.

_make_scorer(
    "dividend_yield", "Dividend Yield", "2% – 6%", 0.25,
    "The 2–6% band is often attractive without usually signalling distress.",
    lambda f, m: (
        f.dividend_yield,
        0.02 <= (f.dividend_yield or 0) <= 0.06,
        _range_score(f.dividend_yield, 0.02, 0.06),
    ),
)

I like this because it keeps the engine interpretable. If a stock gets a weak Dividend score, I can point to the exact failing metrics. The frontend reinforces that by showing every metric, its benchmark, whether it passed, and a tooltip explaining why that metric matters.

The honest downside is that this is a hand-built heuristic model, not a backtested alpha signal. The weights are understandable, which is good. They are also subjective, which is unavoidable. Lens is closer to a structured checklist than a prediction engine, and I think that is a healthier promise to make.

Momentum was the least pure profile

Graham, Buffett, Lynch and Dividend mostly live on accounting and cash flow. O'Neil does not. The Momentum profile needs recent price action, relative strength and abnormal volume, so the Polygon adapter computes a rough 52-week relative-strength score by comparing a ticker's 52-week change to SPY and mapping that spread onto a 0-100 scale.

ticker_perf, spy_perf = await asyncio.gather(
    self._get_52w_change(ticker),
    self._get_52w_change("SPY"),
)

if ticker_perf is None or spy_perf is None:
    return None

diff = ticker_perf - spy_perf
rs = 50 + (diff * 100)   # rough mapping; refine with universe data

The comment in the code says what needs saying: this is a rough mapping. A real relative-strength percentile should be computed against a broad universe, not one ETF. The current implementation is directionally useful - it tells me whether a stock is materially outperforming the market - but it is not a faithful reproduction of institutional RS.

The same is true for volume. Lens compares current volume with the trailing 30-day average and awards points if it is at least 40% above that baseline. Reasonable? Yes. Deeply nuanced? No. For a personal product, this is a good example of where to stop: accurate enough to be informative, still simple enough to explain on one screen.

The frontend is a workbench, not a brochure

The frontend is where the project becomes more than a scoring script. The dashboard combines StockSearch, StockHeader, a Recharts radar visual in ProfileRadarChart.tsx, and a tab-like deep dive in ProfileDashboard.tsx. The search box is backed by TanStack Query, the auth token lives in a persisted Zustand store, and Axios injects the JWT into every request via an interceptor.

One design choice I like is that the metrics table does not just show numbers. It shows benchmark, pass/fail state, and an info tooltip for every row. That makes the product usable by someone who does not already know what FCF coverage or PEG means.

There is also an optional AI layer. If GEMINI_API_KEY is configured, the dashboard exposes an AI Analyze button. The backend formats the existing analysis into a prompt and asksgemini-2.5-flash-lite for a short narrative. I like that the AI output is downstream of the deterministic scoring engine rather than a replacement for it.

The most interesting product compromise is in ProfileLabPage. The “top stocks” view only ranks companies that have already been looked up and cached. The UI even says so: search for a ticker on the dashboard to populate the cache. That is a sensible shortcut for an MVP, but it means Lens is not yet a true market-wide screener. Right now the cache is doubling as the dataset.

Deploying it on GCP was mostly IAM and packaging

The deployment story is more mature than I expected for a personal project. The backend and frontend are both multi-stage Docker builds. The frontend compiles to static assets served by Nginx; the backend runs Uvicorn with two workers. GitHub Actions builds both images, pushes them to Artifact Registry, then runs Terraform.

Infrastructure-wise, Lens uses Cloud Run for both services, Cloud SQL for Postgres, and Workload Identity Federation so GitHub can authenticate to GCP without a long-lived service-account key. The backend connects to Cloud SQL through the Cloud Run mounted /cloudsql socket, while the Terraform module keeps the Cloud Run services at zero minimum instances to avoid idle cost.

env_vars = {
  DATABASE_URL = "postgresql+asyncpg://lens:${var.db_password}@/lens?host=/cloudsql/${module.cloud_sql.connection_name}"
  CORS_ORIGINS = module.frontend.url
  USE_MOCK_DATA = "false"
}

Two comments in the infra code tell the real story. First: there is a note about allUsers access and an organisation policy aroundiam.allowedPolicyMemberDomains. Second: the reusable deploy workflow has a specific step to clear Cloud SQL deletion protection before Terraform replacements. That is exactly the kind of operational scar tissue I trust more than a README diagram.

A nice little deployment detail

The frontend build tries to discover the current backend Cloud Run URL at deploy time and injects it as VITE_API_URL. If the backend does not exist yet, the workflow warns and builds without it. That is a small thing, but it avoids hard-coding environment URLs in the frontend repo and keeps the pipeline largely self-bootstrapping.

What I would change

The first thing I would change is the data model. Today Lens stores one latest analysis per ticker. That is enough for a dashboard, but it throws away history. I cannot ask how a company's Buffett score evolved over time, or whether a Momentum signal preceded a drawdown, because there is no time series behind the snapshot.

The second change would be a proper universe-ingestion pipeline. The current design only analyses stocks users explicitly search for. That is fine for keeping API costs contained, and honestly probably the right first constraint, but it means the “top by profile” endpoint is really “top among whatever happened to be cached recently”. A scheduled batch pipeline with watchlists, quotas and freshness policies would make the product much more truthful.

Third: I would tighten the semantics of the metrics. roicshould be real ROIC, relative strength should be universe-based, and the UI should expose which fields are directly provider-sourced versus derived. Financial software earns trust by being explicit about its approximations, not by hiding them.

The stack

Lens is not trying to be Bloomberg in miniature. It is a deliberately constrained product: one stock, five philosophies, transparent scoring, and just enough infrastructure discipline that I can trust it when I come back to it in six months.