Stock Portfolio Analytics
A 6-table PostgreSQL schema fed by a yfinance ETL pipeline, with 15+ advanced SQL queries computing returns, volatility, Sharpe ratio and position-level P&L, surfaced through an interactive Streamlit dashboard.
- Python
- PostgreSQL
- ETL Pipeline
- Streamlit
- yfinance

The full project report is here as a PDF, and the code is on GitHub.
Over a few weeks I built a relational database in MySQL that models a real investment platform: investors, their portfolios, the trades they make, daily market prices and dividends. I then wrote 12 analytical queries against it, grouped into three tiers of difficulty.
The goal was to practise SQL on the kind of questions a fintech data analyst actually gets asked. How much capital is deployed? What's the realised profit on this trade? Who was owed a dividend?
The schema
Six normalised tables:
| Table | Primary key | Foreign keys | Holds |
|---|---|---|---|
investors |
investor_id |
— | Platform users |
portfolios |
portfolio_id |
investor_id |
Accounts owned by investors |
stocks |
stock_id |
— | Tradeable equities |
trades |
trade_id |
portfolio_id, stock_id |
Every BUY and SELL (the main fact table) |
daily_prices |
price_id |
stock_id |
Historical OHLCV prices |
dividends |
dividend_id |
stock_id |
Dividend payouts, with ex-dates |
erDiagram
INVESTORS ||--o{ PORTFOLIOS : owns
PORTFOLIOS ||--o{ TRADES : contains
STOCKS ||--o{ TRADES : "traded in"
STOCKS ||--o{ DAILY_PRICES : "priced by"
STOCKS ||--o{ DIVIDENDS : pays
The design follows a star schema, as financial databases usually do. The
high-volume event tables (trades, daily_prices) are the fact tables, and
the descriptive ones (investors, portfolios, stocks) are the
dimensions.
Some deliberate choices:
trade_typeis anENUM('BUY','SELL'). AVARCHARwould accept "buy", "Buy " or anything else.DATErather thanDATETIMEfor trade, price and ex-dates. Storing a time on date-only data wastes space and makes comparisons awkward.UNIQUE (stock_id, price_date)ondaily_prices, so the same stock can't have two prices on the same day.- Indexes on
trade_date,portfolio_idandprice_date, so the biggest tables aren't scanned in full. - Foreign keys live on the child table. A trade without a portfolio means nothing, while a stock is complete on its own.
The data: synthetic users, real prices
| Table | Rows | Source |
|---|---|---|
investors |
50 | Faker (en_IN) |
portfolios |
80 | Faker (en_IN) |
stocks |
15 | Real tickers, hard-coded |
trades |
500 | Faker + random |
daily_prices |
~11,000 | yfinance (real data) |
dividends |
~50 | Faker + random |
The 15 stocks are split across NSE and NASDAQ. Their prices are real historical OHLCV data from January 2022 to December 2024, pulled with yfinance. Combining synthetic users with real prices means the price analysis (moving averages, trends) reflects actual market behaviour, while no real person's data is involved.
Tier 1: joins and aggregations
Capital invested per portfolio (assets under management):
SELECT p.portfolio_id, p.portfolio_name,
ROUND(SUM(t.quantity * t.price_per_share) + SUM(t.fees), 2) AS total_invested
FROM portfolios p
JOIN trades t ON p.portfolio_id = t.portfolio_id
WHERE t.trade_type = 'BUY'
GROUP BY p.portfolio_id;
BUY and SELL counts side by side. Conditional aggregation puts both counts
on the same row, which GROUP BY trade_type can't do:
SELECT i.full_name,
COUNT(CASE WHEN t.trade_type = 'BUY' THEN 1 END) AS buy_trades,
COUNT(CASE WHEN t.trade_type = 'SELL' THEN 1 END) AS sell_trades
FROM investors i
JOIN portfolios p ON i.investor_id = p.investor_id
JOIN trades t ON p.portfolio_id = t.portfolio_id
GROUP BY i.investor_id, i.full_name;
Current position sizes. BUYs count as positive, SELLs as negative, and
HAVING removes positions that have been fully sold:
SELECT p.portfolio_id, s.ticker,
SUM(CASE WHEN t.trade_type = 'BUY' THEN t.quantity
WHEN t.trade_type = 'SELL' THEN -t.quantity END) AS shares_held
FROM trades t
JOIN portfolios p ON p.portfolio_id = t.portfolio_id
JOIN stocks s ON s.stock_id = t.stock_id
GROUP BY p.portfolio_id, s.stock_id
HAVING shares_held > 0;
This tier also includes the five most-traded stocks by volume and the total fees paid per investor.
Tier 2: subqueries and CTEs
Portfolios above the platform average. A nested subquery totals each portfolio, then averages those totals to get one benchmark number:
SELECT p.portfolio_id,
ROUND(SUM(t.quantity * t.price_per_share), 2) AS total_invested
FROM portfolios p
JOIN trades t ON p.portfolio_id = t.portfolio_id
GROUP BY p.portfolio_id
HAVING total_invested > (
SELECT AVG(portfolio_total) FROM (
SELECT SUM(quantity * price_per_share) AS portfolio_total
FROM trades GROUP BY portfolio_id
) AS sub
);
Realised P&L on every sale. If someone bought the same stock several times
at different prices, a simple average of those prices is wrong. The correct cost
basis is the weighted average, SUM(quantity × price) / SUM(quantity):
WITH avg_buy_cost AS (
SELECT portfolio_id, stock_id,
SUM(quantity * price_per_share) / SUM(quantity) AS avg_buy_price
FROM trades
WHERE trade_type = 'BUY'
GROUP BY portfolio_id, stock_id
)
SELECT t.portfolio_id, s.ticker,
ROUND((t.price_per_share - a.avg_buy_price) * t.quantity - t.fees, 2) AS net_pnl
-- plus a CASE WHEN labelling each row PROFIT / LOSS / BREAK EVEN
FROM trades t
JOIN avg_buy_cost a ON a.portfolio_id = t.portfolio_id
AND a.stock_id = t.stock_id
JOIN stocks s ON s.stock_id = t.stock_id
WHERE t.trade_type = 'SELL';
Dividend income, respecting the ex-date. You only receive a dividend if you held the shares before the ex-date. The CTE nets BUYs against SELLs, counting only trades made before each ex-date:
WITH holdings AS (
SELECT t.portfolio_id, t.stock_id, d.dividend_id,
SUM(CASE WHEN t.trade_type = 'BUY' THEN t.quantity ELSE 0 END)
- SUM(CASE WHEN t.trade_type = 'SELL' THEN t.quantity ELSE 0 END) AS shares_held
FROM trades t
JOIN dividends d ON d.stock_id = t.stock_id
WHERE t.trade_date < d.ex_date
GROUP BY t.portfolio_id, t.stock_id, d.dividend_id
HAVING shares_held > 0
)
SELECT i.full_name,
ROUND(SUM(h.shares_held * d.amount_per_share), 2) AS dividend_income
FROM holdings h
JOIN portfolios p ON p.portfolio_id = h.portfolio_id
JOIN investors i ON i.investor_id = p.investor_id
JOIN dividends d ON d.dividend_id = h.dividend_id
GROUP BY i.investor_id;
Tier 3: window functions
A window function calculates across a group of rows without collapsing them
into one. The pattern is FUNCTION() OVER (PARTITION BY … ORDER BY … ROWS BETWEEN …):
PARTITION BYsets the group.ORDER BYsets the order of rows within the group.ROWS BETWEENsets how many rows the calculation looks at.
| Function | Query | Business use |
|---|---|---|
RANK() |
Stocks ranked by volume within each sector | Sector leaderboard |
SUM() OVER |
Running total invested per portfolio | When capital was deployed |
AVG() OVER + ROWS |
7-day moving average price | Smoothing out daily noise |
NTILE(4) |
Investors split into quartiles | Segmentation (Standard to Platinum) |
7-day moving average on real prices:
SELECT s.ticker, dp.price_date, dp.close_price,
ROUND(AVG(dp.close_price) OVER (
PARTITION BY dp.stock_id
ORDER BY dp.price_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_7day
FROM daily_prices dp
JOIN stocks s ON s.stock_id = dp.stock_id
ORDER BY s.ticker, dp.price_date;
6 PRECEDING AND CURRENT ROW makes a 7-row window. For the first few days, when
fewer than 7 rows exist, MySQL averages whatever rows are available instead of
failing.
Running total per portfolio. This uses the same pattern, with the window set
to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Investor tiers with NTILE(4):
WITH investor_totals AS (
SELECT i.investor_id, i.full_name,
ROUND(SUM(t.quantity * t.price_per_share), 2) AS total_invested
FROM trades t
JOIN portfolios p ON p.portfolio_id = t.portfolio_id
JOIN investors i ON i.investor_id = p.investor_id
GROUP BY i.investor_id
)
SELECT full_name, total_invested,
NTILE(4) OVER (ORDER BY total_invested) AS quartile,
CASE NTILE(4) OVER (ORDER BY total_invested)
WHEN 1 THEN 'Standard' WHEN 2 THEN 'Silver'
WHEN 3 THEN 'Gold' WHEN 4 THEN 'Platinum'
END AS investor_tier
FROM investor_totals
ORDER BY total_invested DESC;
RANK() gives tied rows the same rank and then skips the next one (1, 1, 3), so
the sector leaderboard stays honest when two stocks are tied.
What I learned beyond the syntax
WHERE vs HAVING is about more than execution order. WHERE filters
data: individual rows, before any aggregation exists. HAVING filters
insights: the aggregated results. Any condition involving SUM, COUNT or
AVG belongs in HAVING.
Window functions don't replace GROUP BY. They answer a different question.
GROUP BY asks "what is the total per group?" A window function asks "what is
this row's place within its group?" Running totals, rankings and moving averages
need both the individual row and its group at the same time.
Errors I hit along the way
Learning to read MySQL's error codes is part of learning SQL. These are the ones I ran into and how I fixed them:
| Code | Error | Cause | Fix |
|---|---|---|---|
| 1054 | Unknown column | Typo in a column or table name | Check it exists in the referenced table |
| 1052 | Ambiguous column | The same column name in two joined tables | Prefix it: table.column |
| 1055 | ONLY_FULL_GROUP_BY |
Selected column not grouped or aggregated | Add it to GROUP BY or aggregate it |
| 1064 | Syntax error | Missing keyword, comma or bracket | Check the structure; a CTE must be followed by a SELECT |
| 1066 | Not unique table/alias | Same table joined twice without aliases | Add aliases |
| 1109 | Unknown table in field | CTE or subquery alias out of scope | Define the CTE before the SELECT that uses it |
| 1111 | Invalid group function | Nested aggregate like MAX(SUM()) |
Pre-aggregate in a subquery or CTE |
| 1140 | Non-aggregated column | Mixing aggregated and plain columns | Add GROUP BY, or use a window function |
Wrapping up
The three tiers follow the progression a data analyst goes through: basic aggregation, then business logic in CTEs, then window-function analytics. The patterns here apply directly to fintech and financial data roles: weighted cost basis for P&L, ex-date-aware dividend income, moving averages on real market data, and NTILE segmentation.