Build a Stock Portfolio Tracker API
Build a REST API with FastAPI to manage stock portfolios and holdings.
Introduction
30 Second Summary
Every stock trading app you've ever used has a backend that tracks your portfolios, records your trades, and calculates whether you're up or down. That backend is a REST API.
In this project, you will build a stock portfolio tracker API using FastAPI and SQLModel. Your API will manage portfolios and stock holdings, persist data in a SQLite database, and serve interactive documentation automatically.
What You'll Build
A stock portfolio tracker REST API with full CRUD operations, database persistence, and auto-generated Swagger UI documentation.
By the end of this project, you'll have:
- A working FastAPI application with 7+ endpoints for creating, reading, updating, and deleting portfolios and stock holdings.
- A SQLite database with SQLModel ORM models that persists portfolio data between server restarts.
- A performance calculation endpoint that computes total invested value, current value, and gain/loss percentages for a portfolio.
- Secret Mission: A search and filtering endpoint that finds holdings by ticker symbol across all portfolios.
Are there any prerequisites?
You need basic Python knowledge including functions, classes, and type hints. No prior web development experience is required.
Before We Start
Before We Start...
Set Up Your Development Environment
Before building anything, you need a working Python environment with the right packages installed.
In this step, you'll install Python, set up a code editor, create a virtual environment, and confirm that FastAPI runs successfully on your machine.
In this step, get ready to:
- Install Python 3.10+ and a code editor.
- Create a project directory with a virtual environment and install dependencies.
- Create project files and verify FastAPI runs with a hello world endpoint.
Install Python and your code editor
This project requires Python 3.10 or higher. Let's check whether it's already installed on your machine.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
macOS
python3 --version
Windows
python --version
✔️ I see version 3.10 or higher
You're all set. Move on to installing your code editor below.
ⓧ I see an older version
Your system has Python installed, but it's below 3.10. You need to install a newer version.
- Go to python.org/downloads and download the latest Python 3 release for your operating system.
- Run the installer. On Windows, make sure to check Add Python to PATH during installation.
- Close and reopen your terminal, then run the version check command again to confirm you see 3.10 or higher.
macOS may still show the old version
macOS can keep its bundled Python as the default. If python3 --version still shows the old version after installing, try running python3.14 --version (or whichever version you installed) to confirm the new install succeeded.
ⓧ Command not found
Python is not installed yet. Let's fix that.
- Go to python.org/downloads and download the latest Python 3 release for your operating system.
- Run the installer. On Windows, make sure to check Add Python to PATH during installation.
- Close and reopen your terminal, then run the version check command again to confirm you see a version number of 3.10 or higher.
Now let's install a code editor. This project recommends Cursor or VS Code.
- If you don't have one installed, download Cursor or VS Code and run the installer.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor or VS Code and press Enter to open it.
Create your project and install dependencies
Every Python project should live in its own folder with its own virtual environment. This keeps your dependencies isolated from other projects on your machine.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir portfolio-tracker
cd portfolio-tracker
- Create a Python virtual environment by running:
macOS
python3 -m venv venv
Windows
python -m venv venv
- Activate the virtual environment:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python space on your computer. Packages you install inside it stay separate from your system Python, preventing version conflicts between projects.
- Install the project dependencies by running:
pip install "fastapi[standard]" sqlmodel==0.0.38
What does this install?
This command installs two packages. fastapi[standard] gives you FastAPI plus its recommended extras like the development server and automatic documentation. sqlmodel==0.0.38 is the ORM that combines database models and data validation into one.
Installing SQLModel automatically pulls in Pydantic and SQLAlchemy as dependencies, so you don't need to install those separately.
You should see output ending with Successfully installed followed by a list of packages.
Seeing a permission error or install failure?
- Make sure (venv) is showing in your terminal prompt. If it's not, run the activate command again.
- If you see an error about pip not being found, try python -m pip install instead of pip install.
Still stuck? Help me fix my pip install error.
Create project files and verify FastAPI runs
Your project needs three Python files. Let's create them and confirm FastAPI can serve a basic endpoint.
- Create the three project files by running:
macOS
touch main.py models.py database.py
Windows
New-Item main.py, models.py, database.py
- Confirm the files exist by running:
ls
You should see main.py, models.py, and database.py in the output (along with the venv folder).
- Open the portfolio-tracker folder in your code editor. In Cursor or VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the portfolio-tracker folder on your Desktop, then click Open.
- In the file explorer sidebar, click main.py to open it.
- Add the following hello world code to main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello World"}
What does this code do?
- from fastapi import FastAPI imports the framework.
- app = FastAPI() creates your API application instance.
- @app.get("/") tells FastAPI to handle GET requests to the root URL.
- The read_root() function returns a JSON response with a greeting message.
- Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Back in your terminal, start the FastAPI development server by running:
fastapi dev main.py
You should see output indicating the server is running, including a line showing http://127.0.0.1:8000.
- Open your web browser and go to http://127.0.0.1:8000.
You should see a JSON response: {"message":"Hello World"}.
Don't see the JSON response?
- Make sure your virtual environment is active ((venv) in the prompt) before running fastapi dev main.py.
- Check for typos in main.py. The decorator @app.get("/") must be on the line directly above the function definition.
- If you see a port-in-use error, another process is using port 8000. Close it or try stopping and restarting the command.
Still stuck? Help me get my FastAPI server running.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and leave the server running.
ⓧ I'd like to double check the full code
Here's the complete main.py at this point:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello World"}
Your development environment is ready and FastAPI is serving requests. Next up, you'll define the database models that power your portfolio tracker.
Define Your Database Models with SQLModel
Before your API can store portfolios and stock holdings, it needs a clear definition of what that data looks like. This is where database models come in.
In this step, you'll use SQLModel to define your data models. SQLModel is unique because each model class acts as both a Pydantic validation schema and a database table definition at the same time. One class, two jobs.
In this step, get ready to:
- Define Portfolio and Holding database models with relationships.
- Create response models for partial updates and performance metrics.
- Configure the SQLite database engine and session management.
Create the Portfolio model
A portfolio is the top-level container that groups stock holdings together. You'll define it as a SQLModel table class so it creates a real database table while also validating data from API requests.
- Open models.py in your editor and add the following code:
from datetime import datetime
from sqlmodel import Field, Relationship, SQLModel
# Portfolio is the top-level container for stock holdings
class Portfolio(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
description: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)
# One portfolio has many holdings
holdings: list["Holding"] = Relationship(back_populates="portfolio")
What does this code do?
The table=True parameter tells SQLModel to create a real database table from this class. Without it, the class would only be a data validation schema.
- Field(default=None, primary_key=True) makes id auto-generated by the database.
- Field(index=True) on name speeds up searches by portfolio name.
- Relationship(back_populates="portfolio") creates a link between Portfolio and Holding so you can access a portfolio's holdings directly.
- Save models.py.
You won't see a visible change yet. The next chunk adds the Holding model that completes the relationship.
Create the Holding model and response schemas
A holding represents a specific stock position within a portfolio. It tracks which ticker was bought, how many shares, and at what price.
- In models.py, add the Holding model below the Portfolio class:
class Holding(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
portfolio_id: int = Field(foreign_key="portfolio.id")
ticker: str = Field(index=True)
shares: float
purchase_price: float
purchased_at: datetime = Field(default_factory=datetime.utcnow)
# Link back to the parent portfolio
portfolio: Portfolio | None = Relationship(back_populates="holdings")
What does this code do?
The foreign_key="portfolio.id" on portfolio_id tells the database that each holding belongs to exactly one portfolio. If you delete a portfolio later, you'll need to handle its holdings too.
The Relationship(back_populates="holdings") completes the two-way link. From a portfolio you can access portfolio.holdings, and from a holding you can access holding.portfolio.
You also need two non-table models. These are pure Pydantic schemas that validate API request and response data without creating database tables.
- Add these two classes below the Holding class in models.py:
# Schema for partial updates (all fields optional)
class HoldingUpdate(SQLModel):
ticker: str | None = None
shares: float | None = None
purchase_price: float | None = None
# Response schema for portfolio performance calculations
class PortfolioPerformance(SQLModel):
portfolio_name: str
total_invested: float
current_value: float
total_gain_loss: float
gain_loss_percentage: float
holdings_count: int
Why are these models different?
Notice there's no table=True on these classes. They're data shapes only.
HoldingUpdate makes every field optional so the API can accept partial updates (e.g., change just the share count without touching the ticker). PortfolioPerformance defines the shape of data your performance endpoint will return later.
- Save models.py.
- Verify your models load correctly by running this in your terminal:
python3 -c "from models import Portfolio, Holding, HoldingUpdate, PortfolioPerformance; print('All models loaded successfully')"
You should see All models loaded successfully printed in your terminal.
Seeing an ImportError?
- Make sure you saved models.py before running the command.
- Check that your virtual environment is active. You should see (venv) or similar at the start of your terminal prompt.
- Confirm the class names match exactly. Python is case-sensitive.
Ask for help: Why is my models.py import failing?
✔️ Awesome, I've got everything!
Great. Double check you've saved models.py before moving on.
ⓧ I'd like to double check the full code
Here's the complete models.py file for reference:
from datetime import datetime
from sqlmodel import Field, Relationship, SQLModel
class Portfolio(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
description: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)
holdings: list["Holding"] = Relationship(back_populates="portfolio")
class Holding(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
portfolio_id: int = Field(foreign_key="portfolio.id")
ticker: str = Field(index=True)
shares: float
purchase_price: float
purchased_at: datetime = Field(default_factory=datetime.utcnow)
portfolio: Portfolio | None = Relationship(back_populates="holdings")
class HoldingUpdate(SQLModel):
ticker: str | None = None
shares: float | None = None
purchase_price: float | None = None
class PortfolioPerformance(SQLModel):
portfolio_name: str
total_invested: float
current_value: float
total_gain_loss: float
gain_loss_percentage: float
holdings_count: int
Set up the database engine and session
Your models define the shape of data, but you still need a database engine to store it. SQLite is a lightweight database that stores everything in a single file. No server process to install or manage.
- Open database.py and add the following code:
from contextlib import asynccontextmanager
from sqlmodel import SQLModel, Session, create_engine
# Database file will be created in your project folder
sqlite_file_name = "portfolio.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
# Required for SQLite with FastAPI's multi-threaded request handling
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
def create_db_and_tables():
# Creates all tables defined by SQLModel classes with table=True
SQLModel.metadata.create_all(engine)
def get_session():
# Yields a database session, then closes it after the request finishes
with Session(engine) as session:
yield session
What does this code do?
- create_engine connects to portfolio.db. If the file doesn't exist, SQLite creates it automatically.
- check_same_thread: False is required because FastAPI handles requests across multiple threads, but SQLite's default only allows access from the thread that created the connection.
- get_session() is a generator function that provides a fresh database session for each API request. FastAPI's dependency injection system calls it automatically via Depends().
- Save database.py.
You won't see a visible change yet. The next chunk connects this database to your FastAPI app.
Now you need to wire up the database to your FastAPI application. The lifespan event runs your table-creation logic once when the server starts.
- Open main.py and replace the entire contents with this code:
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import (
Holding,
HoldingUpdate,
Portfolio,
PortfolioPerformance,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create database tables when the server starts
create_db_and_tables()
yield
app = FastAPI(
title="Portfolio Tracker API",
description="A stock portfolio management API built with FastAPI and SQLModel",
version="1.0.0",
lifespan=lifespan,
)
What does this code do?
The lifespan function is an async context manager that runs create_db_and_tables() once when the server starts up. The yield separates startup code from shutdown code.
The imports at the top pull in everything you'll need for the endpoints you build in later steps. The app instance now has a title, description, and version that display in the auto-generated docs.
- Save main.py.
- Start the development server by running:
fastapi dev main.py
- Open http://127.0.0.1:8000/docs in your browser.
You should see the Swagger UI page with the title "Portfolio Tracker API" at the top. There are no endpoints listed yet, but the interactive docs are live.
Seeing an import error in the terminal?
- Check that models.py, database.py, and main.py are all in the same folder.
- Verify your virtual environment is active and sqlmodel is installed. Run pip list and look for sqlmodel in the output.
- Make sure you replaced the entire main.py file. The old hello world route should be gone.
Ask for help: Why is my FastAPI server failing to start after adding models?
✔️ Awesome, I've got everything!
Great. Make sure you've saved both database.py and main.py before moving on.
ⓧ I'd like to double check the full code
Here's the complete database.py:
from contextlib import asynccontextmanager
from sqlmodel import SQLModel, Session, create_engine
sqlite_file_name = "portfolio.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
And here's the complete main.py:
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import (
Holding,
HoldingUpdate,
Portfolio,
PortfolioPerformance,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="Portfolio Tracker API",
description="A stock portfolio management API built with FastAPI and SQLModel",
version="1.0.0",
lifespan=lifespan,
)
Your data models are locked in and the database is wired up. Next up, you'll build the CRUD endpoints that let users create, read, and delete portfolios through the API.
Build Portfolio CRUD Endpoints
Your database models define the shape of your data, but without endpoints, there's no way for anyone to interact with them. REST API endpoints are the doors that let clients create, read, and delete data through HTTP requests.
In this step, you'll build four endpoints that handle the full lifecycle of a portfolio. Each endpoint follows the same pattern: receive a request, interact with the database session, and return a response.
In this step, get ready to:
- Create endpoints to add and list portfolios.
- Create an endpoint to retrieve a single portfolio with error handling.
- Create an endpoint to delete a portfolio and its holdings.
Create the POST endpoint for new portfolios
A POST endpoint accepts data from a client and saves it to the database. FastAPI automatically validates the incoming request body against your Portfolio model before your function even runs.
- In main.py, add the following code below the app = FastAPI(...) block:
@app.post("/portfolios/", response_model=Portfolio)
def create_portfolio(portfolio: Portfolio, session: Session = Depends(get_session)):
# Add the portfolio object to the database session
session.add(portfolio)
# Save changes to the database
session.commit()
# Refresh to get the auto-generated id and created_at
session.refresh(portfolio)
return portfolio
What does this code do?
The @app.post decorator registers this function to handle POST requests at /portfolios/. The response_model=Portfolio tells FastAPI to validate and serialize the response as a Portfolio.
The session: Session = Depends(get_session) parameter uses dependency injection. FastAPI calls your get_session() function automatically and passes the database session into your endpoint.
- Save main.py.
- Refresh your browser at http://127.0.0.1:8000/docs. You should see a green POST /portfolios/ entry in the Swagger UI.
Don't see the new endpoint?
Make sure your dev server is still running in the terminal. If it stopped, run fastapi dev main.py again.
Check the terminal for error messages. A common cause is an indentation error or a missing colon after the function definition.
Help me debug why my FastAPI endpoint isn't showing up in Swagger UI.
Create the GET endpoint to list all portfolios
Clients need a way to see what portfolios exist. A GET endpoint retrieves data without modifying anything in the database.
- In main.py, add this endpoint below the create_portfolio function:
@app.get("/portfolios/", response_model=list[Portfolio])
def list_portfolios(session: Session = Depends(get_session)):
# Query all portfolios from the database
portfolios = session.exec(select(Portfolio)).all()
return portfolios
What does this code do?
The select(Portfolio) builds a SQL query that fetches all rows from the Portfolio table. Calling .all() executes the query and returns every result as a list.
The response_model=list[Portfolio] tells FastAPI that this endpoint returns a list of Portfolio objects, so the Swagger docs display the correct schema.
- Save main.py and refresh http://127.0.0.1:8000/docs.
- You should now see both a POST and a GET entry under /portfolios/ in the Swagger UI.
Seeing an import error in the terminal?
If you see ImportError for select, confirm the import line at the top of main.py reads from sqlmodel import Session, select.
Help me fix an import error in my FastAPI project.
Create the GET endpoint to retrieve a single portfolio
Listing all portfolios is useful, but clients also need to fetch one specific portfolio by its ID. This endpoint introduces path parameters and error handling for when a portfolio doesn't exist.
- In main.py, add this endpoint below the list_portfolios function:
@app.get("/portfolios/{portfolio_id}", response_model=Portfolio)
def get_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
# Look up the portfolio by its primary key
portfolio = session.get(Portfolio, portfolio_id)
# Return a 404 error if the portfolio doesn't exist
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
return portfolio
What does this code do?
The {portfolio_id} in the path is a path parameter. FastAPI extracts it from the URL and passes it to your function as the portfolio_id argument, automatically converting it to an integer.
The session.get(Portfolio, portfolio_id) looks up a record by primary key. If no record exists, it returns None, and your code raises an HTTPException with a 404 status code.
What is HTTPException?
HTTPException is FastAPI's built-in way to return error responses. When you raise one, FastAPI stops processing and immediately sends the error status code and message to the client.
A 404 status code means "not found." The client asked for a resource that doesn't exist in the database.
- Save main.py and refresh http://127.0.0.1:8000/docs.
- You should see a new GET /portfolios/{portfolio_id} entry that shows a portfolio_id parameter field.
Seeing a validation error?
If FastAPI shows a validation error in the terminal, make sure portfolio_id: int has the type hint. Without the int type, FastAPI cannot validate the path parameter correctly.
Help me fix a validation error on my FastAPI path parameter.
Create the DELETE endpoint to remove a portfolio
Deleting a portfolio isn't just removing one row. Each portfolio can have multiple holdings associated with it. The delete endpoint needs to remove both the portfolio and all of its holdings to avoid orphaned data.
- In main.py, add this endpoint below the get_portfolio function:
@app.delete("/portfolios/{portfolio_id}")
def delete_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
# Look up the portfolio by its primary key
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
# Delete all holdings that belong to this portfolio
for holding in portfolio.holdings:
session.delete(holding)
# Delete the portfolio itself
session.delete(portfolio)
session.commit()
return {"detail": "Portfolio deleted"}
What does this code do?
The loop for holding in portfolio.holdings iterates over all holdings linked to this portfolio through the relationship you defined in models.py. Each holding is deleted individually before the portfolio itself is removed.
Notice there's no response_model on this endpoint. It returns a simple dictionary with a confirmation message instead of a model object.
- Save main.py and refresh http://127.0.0.1:8000/docs.
- You should see four portfolio endpoints listed: POST, GET (list), GET (single), and DELETE.
Server crashed after saving?
Check that the for loop is indented inside the function body. Also confirm you have session.commit() after deleting both the holdings and the portfolio.
Help me fix a crash in my delete endpoint.
Let's quickly test the endpoints you've built so far. You can do this directly in the Swagger UI.
- In the Swagger UI, click on the POST /portfolios/ endpoint to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{
"name": "Tech Stocks",
"description": "My technology sector portfolio"
}
- Click Execute.
You should see a 200 response with the portfolio data, including an auto-generated id and created_at timestamp.
- Now expand the GET /portfolios/ endpoint, click Try it out, then click Execute.
You should see a JSON array containing the "Tech Stocks" portfolio you just created.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and your dev server is still running.
ⓧ I'd like to double check the full code
Here's the full main.py as it should look at this point:
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import (
Holding,
HoldingUpdate,
Portfolio,
PortfolioPerformance,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="Portfolio Tracker API",
description="A stock portfolio management API built with FastAPI and SQLModel",
version="1.0.0",
lifespan=lifespan,
)
@app.post("/portfolios/", response_model=Portfolio)
def create_portfolio(portfolio: Portfolio, session: Session = Depends(get_session)):
session.add(portfolio)
session.commit()
session.refresh(portfolio)
return portfolio
@app.get("/portfolios/", response_model=list[Portfolio])
def list_portfolios(session: Session = Depends(get_session)):
portfolios = session.exec(select(Portfolio)).all()
return portfolios
@app.get("/portfolios/{portfolio_id}", response_model=Portfolio)
def get_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
return portfolio
@app.delete("/portfolios/{portfolio_id}")
def delete_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
for holding in portfolio.holdings:
session.delete(holding)
session.delete(portfolio)
session.commit()
return {"detail": "Portfolio deleted"}
You now have a full set of portfolio CRUD endpoints. Next up, you'll build the holdings endpoints so users can add actual stock positions to their portfolios.
Build Holdings CRUD Endpoints
Your portfolio endpoints let users create and manage collections, but a portfolio without any stocks in it is just an empty container. Now you need endpoints that let users add, view, update, and remove individual stock holdings within each portfolio.
Holdings are a nested resource. They always belong to a specific portfolio. This means some endpoints will use the portfolio's ID in the URL path, while others operate directly on a holding by its own ID.
In this step, get ready to:
- Create an endpoint to add stock holdings to a portfolio.
- Create endpoints to list and delete holdings.
- Create an endpoint to partially update a holding.
Create the endpoint to add a holding to a portfolio
When a user adds a stock holding, the API needs to confirm the target portfolio actually exists before saving anything. This prevents orphan holdings pointing to a non-existent portfolio.
- In main.py, find the delete_portfolio function (the last endpoint in your file).
- Add the following endpoint below it:
@app.post("/portfolios/{portfolio_id}/holdings/", response_model=Holding)
def create_holding(
portfolio_id: int, holding: Holding, session: Session = Depends(get_session)
):
# Check that the target portfolio exists
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
# Assign the holding to this portfolio and save
holding.portfolio_id = portfolio_id
session.add(holding)
session.commit()
session.refresh(holding)
return holding
What does this code do?
The URL path /portfolios/{portfolio_id}/holdings/ captures the portfolio ID from the URL. FastAPI automatically converts it to an integer for you.
Before saving the holding, the function checks that the portfolio exists. If it does not, the API returns a 404 error. If it does, the function sets holding.portfolio_id to link the holding to that portfolio, then commits it to the database.
- Save main.py.
- Switch to Swagger UI at http://127.0.0.1:8000/docs and refresh the page. You should see a new green POST entry for /portfolios/{portfolio_id}/holdings/.
Don't see the new endpoint?
Make sure you saved main.py. The dev server auto-reloads on save, but unsaved changes won't appear.
Check your terminal for error messages. A syntax error (missing colon, unmatched parentheses) will prevent the server from reloading.
help me debug why my new holdings endpoint isn't showing in Swagger UI
Create endpoints to list and delete holdings
Users need to see what stocks are in a portfolio and remove individual holdings they no longer want to track.
- In main.py, add the following two endpoints below the create_holding function:
@app.get("/portfolios/{portfolio_id}/holdings/", response_model=list[Holding])
def list_holdings(portfolio_id: int, session: Session = Depends(get_session)):
# Verify the portfolio exists before listing its holdings
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
# Query all holdings that belong to this portfolio
holdings = session.exec(
select(Holding).where(Holding.portfolio_id == portfolio_id)
).all()
return holdings
@app.delete("/holdings/{holding_id}")
def delete_holding(holding_id: int, session: Session = Depends(get_session)):
# Find the holding by its own ID
holding = session.get(Holding, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
session.delete(holding)
session.commit()
return {"detail": "Holding deleted"}
What does this code do?
- list_holdings uses a select query with a .where() filter to return only holdings that match the given portfolio ID.
- delete_holding operates on a holding by its own ID (not nested under a portfolio URL). This is a common pattern for delete operations where you already know the exact resource ID.
- Both endpoints return a 404 if the target resource does not exist. Consistent error handling across all endpoints makes your API predictable for clients.
- Save main.py.
- Refresh Swagger UI. You should now see a GET entry for /portfolios/{portfolio_id}/holdings/ and a red DELETE entry for /holdings/{holding_id}.
Seeing a server error in the terminal?
Check that select is imported at the top of main.py. It should already be there from the previous step: from sqlmodel import Session, select.
Make sure the .where() clause uses a double equals sign (==) for comparison, not a single equals sign.
help me fix a server error in my holdings list or delete endpoint
Create the endpoint to update a holding
Sometimes a user wants to correct a typo in the ticker or update the number of shares without deleting and re-creating the entire holding. A PATCH endpoint handles partial updates where only the fields the client sends get changed.
- In main.py, add the following endpoint below the delete_holding function:
@app.patch("/holdings/{holding_id}", response_model=Holding)
def update_holding(
holding_id: int,
holding_update: HoldingUpdate,
session: Session = Depends(get_session),
):
# Find the existing holding
holding = session.get(Holding, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
# Only update fields that were explicitly sent in the request
update_data = holding_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(holding, key, value)
session.add(holding)
session.commit()
session.refresh(holding)
return holding
Why use model_dump(exclude_unset=True)?
The HoldingUpdate model has all optional fields. When a client sends only {"shares": 50}, the ticker and purchase_price fields default to None.
Using model_dump(exclude_unset=True) returns only the fields the client explicitly included. This way you update shares without accidentally wiping out the existing ticker or price.
- Save main.py.
- Refresh Swagger UI. You should see a new orange PATCH entry for /holdings/{holding_id}.
PATCH endpoint not appearing?
Confirm that HoldingUpdate is imported at the top of main.py. It should already be in your imports from the previous step.
Check for indentation errors. The @app.patch decorator and the function must be at the module level (no extra indentation).
help me figure out why my PATCH endpoint isn't working
Let's quickly test the full flow in Swagger UI to confirm everything works together.
- In Swagger UI, expand the POST /portfolios/{portfolio_id}/holdings/ endpoint and click Try it out.
- Set portfolio_id to 1 (or the ID of a portfolio you created earlier).
- Replace the request body with this JSON and click Execute:
{
"ticker": "AAPL",
"shares": 10,
"purchase_price": 150.00
}
- You should see a 200 response with the holding data including an auto-generated id and purchased_at timestamp.
- Now test the PATCH endpoint. Expand PATCH /holdings/{holding_id}, click Try it out, set holding_id to 1, and send this body:
{
"shares": 25
}
- The response should show shares updated to 25 while ticker and purchase_price remain unchanged.
Why PATCH instead of PUT?
PUT replaces the entire resource. The client must send all fields, even the ones that haven't changed. PATCH is for partial updates where the client only sends the fields they want to modify.
For a holding with three editable fields, PATCH is more convenient. The client can update just the share count without needing to resend the ticker and price.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and that all four holdings endpoints appear in Swagger UI.
ⓧ I'd like to double check the full code
Here is the complete main.py as it should look at the end of this step:
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import (
Holding,
HoldingUpdate,
Portfolio,
PortfolioPerformance,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="Portfolio Tracker API",
description="A stock portfolio management API built with FastAPI and SQLModel",
version="1.0.0",
lifespan=lifespan,
)
@app.post("/portfolios/", response_model=Portfolio)
def create_portfolio(portfolio: Portfolio, session: Session = Depends(get_session)):
session.add(portfolio)
session.commit()
session.refresh(portfolio)
return portfolio
@app.get("/portfolios/", response_model=list[Portfolio])
def list_portfolios(session: Session = Depends(get_session)):
portfolios = session.exec(select(Portfolio)).all()
return portfolios
@app.get("/portfolios/{portfolio_id}", response_model=Portfolio)
def get_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
return portfolio
@app.delete("/portfolios/{portfolio_id}")
def delete_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
for holding in portfolio.holdings:
session.delete(holding)
session.delete(portfolio)
session.commit()
return {"detail": "Portfolio deleted"}
@app.post("/portfolios/{portfolio_id}/holdings/", response_model=Holding)
def create_holding(
portfolio_id: int, holding: Holding, session: Session = Depends(get_session)
):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
holding.portfolio_id = portfolio_id
session.add(holding)
session.commit()
session.refresh(holding)
return holding
@app.get("/portfolios/{portfolio_id}/holdings/", response_model=list[Holding])
def list_holdings(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
holdings = session.exec(
select(Holding).where(Holding.portfolio_id == portfolio_id)
).all()
return holdings
@app.delete("/holdings/{holding_id}")
def delete_holding(holding_id: int, session: Session = Depends(get_session)):
holding = session.get(Holding, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
session.delete(holding)
session.commit()
return {"detail": "Holding deleted"}
@app.patch("/holdings/{holding_id}", response_model=Holding)
def update_holding(
holding_id: int,
holding_update: HoldingUpdate,
session: Session = Depends(get_session),
):
holding = session.get(Holding, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
update_data = holding_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(holding, key, value)
session.add(holding)
session.commit()
session.refresh(holding)
return holding
Your API now has full CRUD for both portfolios and holdings. Next up, you'll add the most exciting endpoint: a performance calculator that shows total value and gain/loss across all holdings in a portfolio.
Add Portfolio Performance Calculations
Your API can create, read, update, and delete both portfolios and holdings. But raw data alone isn't very useful to an investor who wants to know whether they're making money.
In this step, you'll build a performance calculation endpoint that computes total invested value, simulated current value, and gain/loss percentage. You'll also take the full API for a test drive through Swagger UI.
In this step, get ready to:
- Review the PortfolioPerformance response model.
- Build a price simulation helper and performance endpoint.
- Test the complete API through Swagger UI.
Review the PortfolioPerformance response model
Before building the endpoint, let's look at the response model you already defined in models.py.
The PortfolioPerformance class tells FastAPI exactly what shape the performance response should take:
class PortfolioPerformance(SQLModel):
portfolio_name: str
total_invested: float
current_value: float
total_gain_loss: float
gain_loss_percentage: float
holdings_count: int
What do these fields represent?
- total_invested is the sum of (shares x purchase_price) across all holdings.
- current_value is the sum of (shares x simulated current price) across all holdings.
- total_gain_loss is the difference between current value and total invested.
- gain_loss_percentage expresses the gain or loss as a percentage of total invested.
Why simulate prices instead of calling a real API?
Real stock price APIs require API keys, network calls, and rate limit handling. By simulating prices deterministically from the ticker name, the project stays self-contained and focused on FastAPI patterns.
You could swap in a real price API later without changing the endpoint structure.
Build the performance endpoint
The performance endpoint needs a way to get a "current price" for each holding. You'll write a helper function first, then the endpoint that uses it.
- In main.py, scroll to the bottom of the file after the update_holding function.
- Add the price simulation helper by pasting this code:
def simulate_current_price(ticker: str, purchase_price: float) -> float:
"""Simulate a current price based on ticker hash for demo purposes."""
# Use the sum of character codes as a seed for repeatable results
seed = sum(ord(c) for c in ticker)
# Generate a multiplier between 0.8 and 1.29 (simulates -20% to +29% moves)
multiplier = 0.8 + (seed % 50) / 100
return round(purchase_price * multiplier, 2)
What does this function do?
It takes a ticker symbol and the purchase price, then produces a fake "current price" that stays consistent every time you call it with the same ticker. The same ticker always gets the same multiplier, so results are predictable for testing.
You won't see a change yet. The next chunk uses this helper inside the performance endpoint.
- Directly below simulate_current_price, add the performance endpoint:
@app.get("/portfolios/{portfolio_id}/performance", response_model=PortfolioPerformance)
def get_portfolio_performance(
portfolio_id: int, session: Session = Depends(get_session)
):
# Verify the portfolio exists
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
# Fetch all holdings for this portfolio
holdings = session.exec(
select(Holding).where(Holding.portfolio_id == portfolio_id)
).all()
# Calculate totals by looping through each holding
total_invested = 0.0
current_value = 0.0
for holding in holdings:
invested = holding.shares * holding.purchase_price
total_invested += invested
current_price = simulate_current_price(holding.ticker, holding.purchase_price)
current_value += holding.shares * current_price
# Derive gain/loss from the totals
total_gain_loss = current_value - total_invested
gain_loss_percentage = (
(total_gain_loss / total_invested * 100) if total_invested > 0 else 0.0
)
return PortfolioPerformance(
portfolio_name=portfolio.name,
total_invested=round(total_invested, 2),
current_value=round(current_value, 2),
total_gain_loss=round(total_gain_loss, 2),
gain_loss_percentage=round(gain_loss_percentage, 2),
holdings_count=len(holdings),
)
What does this endpoint do?
- It fetches the portfolio and all its holdings from the database.
- For each holding, it multiplies shares by purchase price (total invested) and shares by simulated current price (current value).
- It computes the difference as gain/loss and expresses it as a percentage.
- The if total_invested > 0 guard prevents a division-by-zero error when a portfolio has no holdings.
- Save main.py.
- Switch back to your terminal. The dev server auto-reloads, so you should see a log line confirming the reload.
Seeing an import error or server crash?
- Make sure simulate_current_price is defined ABOVE the get_portfolio_performance function. Python reads top to bottom, so the helper must come first.
- Check that PortfolioPerformance is still in your imports at the top of main.py.
Why won't my performance endpoint load?
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py before moving on to testing.
ⓧ I'd like to double check the full code
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import (
Holding,
HoldingUpdate,
Portfolio,
PortfolioPerformance,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="Portfolio Tracker API",
description="A stock portfolio management API built with FastAPI and SQLModel",
version="1.0.0",
lifespan=lifespan,
)
@app.post("/portfolios/", response_model=Portfolio)
def create_portfolio(portfolio: Portfolio, session: Session = Depends(get_session)):
session.add(portfolio)
session.commit()
session.refresh(portfolio)
return portfolio
@app.get("/portfolios/", response_model=list[Portfolio])
def list_portfolios(session: Session = Depends(get_session)):
portfolios = session.exec(select(Portfolio)).all()
return portfolios
@app.get("/portfolios/{portfolio_id}", response_model=Portfolio)
def get_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
return portfolio
@app.delete("/portfolios/{portfolio_id}")
def delete_portfolio(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
for holding in portfolio.holdings:
session.delete(holding)
session.delete(portfolio)
session.commit()
return {"detail": "Portfolio deleted"}
@app.post("/portfolios/{portfolio_id}/holdings/", response_model=Holding)
def create_holding(
portfolio_id: int, holding: Holding, session: Session = Depends(get_session)
):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
holding.portfolio_id = portfolio_id
session.add(holding)
session.commit()
session.refresh(holding)
return holding
@app.get("/portfolios/{portfolio_id}/holdings/", response_model=list[Holding])
def list_holdings(portfolio_id: int, session: Session = Depends(get_session)):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
holdings = session.exec(
select(Holding).where(Holding.portfolio_id == portfolio_id)
).all()
return holdings
@app.delete("/holdings/{holding_id}")
def delete_holding(holding_id: int, session: Session = Depends(get_session)):
holding = session.get(Holding, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
session.delete(holding)
session.commit()
return {"detail": "Holding deleted"}
@app.patch("/holdings/{holding_id}", response_model=Holding)
def update_holding(
holding_id: int,
holding_update: HoldingUpdate,
session: Session = Depends(get_session),
):
holding = session.get(Holding, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
update_data = holding_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(holding, key, value)
session.add(holding)
session.commit()
session.refresh(holding)
return holding
def simulate_current_price(ticker: str, purchase_price: float) -> float:
"""Simulate a current price based on ticker hash for demo purposes."""
seed = sum(ord(c) for c in ticker)
multiplier = 0.8 + (seed % 50) / 100
return round(purchase_price * multiplier, 2)
@app.get("/portfolios/{portfolio_id}/performance", response_model=PortfolioPerformance)
def get_portfolio_performance(
portfolio_id: int, session: Session = Depends(get_session)
):
portfolio = session.get(Portfolio, portfolio_id)
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
holdings = session.exec(
select(Holding).where(Holding.portfolio_id == portfolio_id)
).all()
total_invested = 0.0
current_value = 0.0
for holding in holdings:
invested = holding.shares * holding.purchase_price
total_invested += invested
current_price = simulate_current_price(holding.ticker, holding.purchase_price)
current_value += holding.shares * current_price
total_gain_loss = current_value - total_invested
gain_loss_percentage = (
(total_gain_loss / total_invested * 100) if total_invested > 0 else 0.0
)
return PortfolioPerformance(
portfolio_name=portfolio.name,
total_invested=round(total_invested, 2),
current_value=round(current_value, 2),
total_gain_loss=round(total_gain_loss, 2),
gain_loss_percentage=round(gain_loss_percentage, 2),
holdings_count=len(holdings),
)
Test all endpoints in Swagger UI
Your API now has 9 endpoints. Let's take them for a spin using the interactive docs that FastAPI generates automatically.
- Return to your browser and open http://127.0.0.1:8000/docs.
- Click the POST /portfolios/ row to expand it.
- Click Try it out.
- Replace the request body with this JSON and click Execute:
{
"name": "Tech Stocks",
"description": "My technology portfolio"
}
- You should see a 200 response with your new portfolio and its id (likely 1).
- Now expand POST /portfolios/{portfolio_id}/holdings/ and click Try it out.
- Enter 1 for the portfolio_id path parameter.
- Replace the request body with this JSON and click Execute:
{
"ticker": "AAPL",
"shares": 10,
"purchase_price": 150.00
}
- Add a second holding using the same endpoint with this JSON:
{
"ticker": "GOOGL",
"shares": 5,
"purchase_price": 140.00
}
- Expand GET /portfolios/{portfolio_id}/performance and click Try it out.
- Enter 1 for the portfolio_id and click Execute.
You should see a JSON response containing portfolio_name, total_invested, current_value, total_gain_loss, gain_loss_percentage, and holdings_count.
What should I see?
The response shows your portfolio's simulated performance. Because the price multiplier is deterministic, the same ticker always produces the same gain or loss percentage.
The holdings_count field should show 2 if you added both AAPL and GOOGL holdings.
Your data persists between restarts
The portfolio.db file stores all your data. If you stop and restart the dev server, your portfolios and holdings will still be there.
Getting a 404 on the performance endpoint?
- Make sure you're using the correct portfolio_id. Check by calling GET /portfolios/ first to see which IDs exist.
- If you deleted your database file while experimenting, your old IDs no longer exist. Create a fresh portfolio and use its new ID.
Help me debug my 404 error on the performance endpoint.
Secret mission
Add Search and Filtering
Your API can manage portfolios and holdings, but what if a user owns the same stock across multiple portfolios? Build a cross-portfolio search endpoint with optional filters for shares and price range.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- FastAPI development server (running process).
- Project folder containing main.py, models.py, and database.py.
- Python virtual environment (inside your project folder).
- SQLite database file portfolio.db (inside your project folder).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with your API endpoints.
- Your FastAPI dev server continues running at http://127.0.0.1:8000.
- Your portfolio.db file keeps all the portfolio and holding data you added.
- You can keep testing endpoints in Swagger UI at http://127.0.0.1:8000/docs.
Pause - I'll come back to this later
Shut down the running server to free up your terminal. Your project files and database stay intact for next time.
- Switch to the terminal where your FastAPI server is running.
- Press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
When you come back, navigate to your project folder, reactivate the virtual environment, and start the server again.
Delete - I don't want to use this again
Remove all project files, the database, and the virtual environment. This cannot be undone.
- Switch to the terminal where your FastAPI server is running.
- Press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
- Navigate up one level out of your project folder by running:
cd ..
- Delete the entire project folder (macOS/Linux) by running:
rm -rf portfolio-tracker
- On Windows, run this instead:
rmdir /s /q portfolio-tracker
This removes all Python files, the virtual environment, and the portfolio.db database.
Nice Work!
Nice Work!
You just built a production-style REST API from scratch. Your portfolio tracker is a fully functional backend application with persistent data, validated inputs, and auto-generated documentation.
You've learned how to:
- Build a FastAPI application with full CRUD endpoints for portfolios and stock holdings, including data validation via Pydantic models and error handling with HTTPException.
- Design SQLModel database models with relationships, foreign keys, and automatic table creation backed by a persistent SQLite database.
- Create a portfolio performance calculation endpoint and test all your routes interactively through FastAPI's auto-generated Swagger UI documentation.
- Secret Mission: Add a search endpoint with multiple optional query parameter filters to find holdings by ticker symbol across all portfolios.
Ready to quiz yourself?