Nate's Project Guide
Project: Portfolio Tracker Category: Web App (Flask) + Data Science Last updated: April 23
Note: This guide reflects the latest state of your project repo. It may not match the most up-to-date version if you've worked since.
Feature 1 — Charts on the dashboard
Your app.py already hands the template alloc (dict of industry → percent) and gains (list of {ticker, gain, percent}). That's exactly the shape charting libraries want. We'll add Flowbite/ApexCharts — Tailwind styling on top of the open-source ApexCharts library — and render two charts.
No changes to portfolio.py or app.py are needed. This is all templates/index.html.
Phase 1: Add ApexCharts via CDN
Agent-assisted OK. One
<script>tag.
Objective
Get ApexCharts loaded on the page so you can call new ApexCharts(...) from your own script tag.
Instructions
Hints
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.js"></script>
The Flowbite docs page has lots of copy/paste examples: https://flowbite.com/docs/plugins/charts/#data-series. Flowbite = Tailwind classes around ApexCharts. You don't need the Flowbite npm package — just the Tailwind classes on the wrapper and the ApexCharts script above.
Phase 2: Donut chart for allocation
Agent-assisted OK. Replacing a Jinja loop with a single div + chart options.
Objective
Replace the "Allocation by industry" bar list with a donut chart rendered from alloc.
Instructions
Hints
Keep the section header, swap the list:
<section
class="mt-6 rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-xl shadow-slate-950/50"
>
<h2 class="mb-4 text-lg font-semibold text-white">Allocation by industry</h2>
<div id="allocation-chart"></div>
</section>
Script at the bottom of index.html (before </body>):
<script>
const alloc = {{ alloc|tojson }};
const allocOptions = {
chart: { type: "donut", height: 320, fontFamily: "Inter, sans-serif" },
series: Object.values(alloc),
labels: Object.keys(alloc),
colors: ["#8b5cf6", "#06b6d4", "#f59e0b", "#ec4899", "#10b981", "#f43f5e", "#6366f1"],
legend: { position: "bottom", labels: { colors: "#cbd5e1" } },
dataLabels: { enabled: true, formatter: (val) => val.toFixed(1) + "%" },
stroke: { colors: ["#0f172a"] },
};
new ApexCharts(document.getElementById("allocation-chart"), allocOptions).render();
</script>
What {{ alloc|tojson }} does: Jinja serializes your Python dict to a JSON literal inline in the HTML. The browser reads it as a real JS object — no parsing on your end.
Optional — get help from your agent:
In templates/index.html, replace the "Allocation by industry" <ul> loop with a Flowbite/ApexCharts donut using the alloc dict passed from app.py. Put the render script right before </body>. Don't touch app.py or portfolio.py. Match the dark-slate card styling of the other sections.
Phase 3: Column chart for gain/loss
Agent-assisted OK.
Objective
Add a column chart showing percent gain/loss per ticker, green for gains, rose for losses.
Instructions
Hints
New section:
<section
class="mt-6 rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-xl shadow-slate-950/50"
>
<h2 class="mb-4 text-lg font-semibold text-white">Gain / Loss by ticker</h2>
<div id="gains-chart"></div>
</section>
Render script:
<script>
const gains = {{ gains|tojson }};
const gainsOptions = {
chart: { type: "bar", height: 320, toolbar: { show: false }, fontFamily: "Inter, sans-serif" },
series: [{ name: "Return %", data: gains.map((g) => g.percent.toFixed(2)) }],
xaxis: {
categories: gains.map((g) => g.ticker),
labels: { style: { colors: "#cbd5e1" } },
},
yaxis: {
labels: {
style: { colors: "#cbd5e1" },
formatter: (val) => val + "%",
},
},
plotOptions: { bar: { borderRadius: 4, columnWidth: "50%" } },
colors: ["#10b981"],
dataLabels: { enabled: false },
grid: { borderColor: "#1e293b" },
};
new ApexCharts(document.getElementById("gains-chart"), gainsOptions).render();
</script>
Red-for-losses polish (optional): ApexCharts lets you pass a function as colors:
colors: [
({ value }) => (value >= 0 ? "#10b981" : "#f43f5e"),
],
Optional — get help from your agent:
Add a second ApexCharts bar chart under my donut chart, using the gains list passed to the template. Categories are tickers, values are percent. Use emerald for positive bars and rose for negative. Match my existing card styling.
Feature 2 — Stretch: "Ask your portfolio" chatbot
Only start this if Feature 1 is shipped. This is a real stretch goal — skip it if you're tight on time for Demo Day.
The idea: a chat box at the bottom of the dashboard where you can type "What's my biggest concentration risk?" or "Which holding is dragging me down?" and an LLM answers using your actual portfolio as context.
Same pattern Makayla is using for Victor Paladin — see makayla-c's April 21 guide Phase 3 for a worked example of OpenAI + Pydantic structured output.
Phase 1: Set up ai.py
Handwrite this yourself. Small file, but this is where the LLM wiring lives.
Objective
Create ai.py with an OpenAI client and one function that takes a portfolio summary + a question and returns a structured answer.
Instructions
Hints
ai.py:
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel
load_dotenv()
client = OpenAI()
class PortfolioAnswer(BaseModel):
message: str
tickers_mentioned: list[str]
SYSTEM_PROMPT = """
You are a concise financial assistant looking at a user's stock portfolio.
Answer in 1-3 sentences. Reference specific tickers or industries from the
portfolio when relevant. Do NOT give personalized financial advice or
buy/sell recommendations — describe what is in the data only.
""".strip()
def get_portfolio_answer(portfolio_summary: str, question: str) -> PortfolioAnswer:
response = client.responses.parse(
model="gpt-4o-mini",
input=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Portfolio:\n{portfolio_summary}\n\nQuestion: {question}"},
],
text_format=PortfolioAnswer,
)
return response.output_parsed
if __name__ == "__main__":
summary = "AAPL: 10 shares, tech, up 12%\nXOM: 5 shares, energy, down 3%"
answer = get_portfolio_answer(summary, "What's my biggest winner?")
print(answer.message)
print("Mentioned:", answer.tickers_mentioned)
Why Pydantic? responses.parse(..., text_format=PortfolioAnswer) forces the API to return an object that matches your schema. No JSON string wrangling.
Phase 2: Add the /ask route + chat UI
Agent-assisted OK on the HTML/JS. The portfolio summary builder is the interesting part — handwrite that.
Objective
Add a /ask POST route that takes a question, builds a short text summary of the portfolio, calls get_portfolio_answer, and returns JSON. Add a small chat box on the dashboard that hits it.
Instructions
Hints
portfolio.py — new function (handwrite):
def summarize(portfolio):
"""Plain-text portfolio summary for the LLM to read."""
lines = [f"Total holdings: {len(portfolio)}"]
for s in portfolio:
value = s["shares"] * s["current_price"]
lines.append(
f"- {s['ticker']}: {s['shares']} shares @ ${s['current_price']:.2f} "
f"(industry: {s['industry']}, value: ${value:,.2f})"
)
return "\n".join(lines)
app.py — new route:
from flask import request, jsonify
from ai import get_portfolio_answer
from portfolio import summarize
@app.route("/ask", methods=["POST"])
def ask():
data = request.get_json()
question = data.get("question", "").strip()
if not question:
return jsonify({"message": "Ask me a question about your portfolio."})
portfolio = load_portfolio("data/portfolio.csv")
answer = get_portfolio_answer(summarize(portfolio), question)
return jsonify({"message": answer.message, "tickers": answer.tickers_mentioned})
templates/index.html — floating chat widget (drop this in right before </body>, outside your main container):
This is a floating chat window fixed to the bottom-right corner of the page. A round button toggles it open/closed so it stays out of the way until you want to ask something.
<div id="chat-widget" class="fixed bottom-6 right-6 z-50">
<button
id="chat-toggle"
class="flex h-14 w-14 items-center justify-center rounded-full bg-violet-500 text-white shadow-lg shadow-violet-500/40 hover:bg-violet-400"
aria-label="Open chat"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<div
id="chat-panel"
class="hidden absolute bottom-16 right-0 flex h-[28rem] w-80 flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/95 shadow-2xl shadow-slate-950/60 backdrop-blur"
>
<div class="flex items-center justify-between border-b border-slate-800 px-4 py-3">
<h2 class="text-sm font-semibold text-white">Ask your portfolio</h2>
<button id="chat-close" class="text-slate-400 hover:text-white" aria-label="Close chat">✕</button>
</div>
<div id="chat-log" class="flex-1 space-y-3 overflow-y-auto px-4 py-3 text-sm"></div>
<form id="chat-form" class="flex gap-2 border-t border-slate-800 p-3">
<input
id="chat-input"
type="text"
placeholder="What's my biggest risk?"
class="flex-1 rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-violet-500 focus:outline-none"
/>
<button
type="submit"
class="rounded-lg bg-violet-500 px-3 py-2 font-semibold text-white hover:bg-violet-400"
>
Ask
</button>
</form>
</div>
</div>
<script>
const chatToggle = document.getElementById("chat-toggle")
const chatPanel = document.getElementById("chat-panel")
const chatClose = document.getElementById("chat-close")
const chatForm = document.getElementById("chat-form")
const chatInput = document.getElementById("chat-input")
const chatLog = document.getElementById("chat-log")
chatToggle.addEventListener("click", () => {
chatPanel.classList.toggle("hidden")
if (!chatPanel.classList.contains("hidden")) chatInput.focus()
})
chatClose.addEventListener("click", () => chatPanel.classList.add("hidden"))
chatForm.addEventListener("submit", async (e) => {
e.preventDefault()
const question = chatInput.value.trim()
if (!question) return
chatLog.innerHTML += `<div class="text-slate-400">You: ${question}</div>`
chatInput.value = ""
chatLog.scrollTop = chatLog.scrollHeight
const res = await fetch("/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
})
const data = await res.json()
chatLog.innerHTML += `<div class="text-violet-300">Portfolio: ${data.message}</div>`
chatLog.scrollTop = chatLog.scrollHeight
})
</script>
Why floating? Keeps your dashboard (charts + tables) as the main view, and the chat is one click away without pushing content down. The fixed bottom-6 right-6 z-50 on the outer wrapper pins it to the bottom-right corner above everything else.
Optional — get help from your agent (after the route works with curl/Postman):
Here's my /ask route and the floating chat widget in index.html. Keep the fetch behavior and the fixed bottom-right positioning the same, but improve the chat log styling — user messages right-aligned, assistant messages left-aligned in bubbles, add a loading "…" while waiting for the response. Don't change app.py or ai.py.
Phase 3: Tune the system prompt
Handwrite this yourself. The prompt IS the product for this feature.
Objective
Iterate SYSTEM_PROMPT in ai.py until answers are useful for your portfolio.
Instructions
- "What's my biggest concentration risk?"
- "Which sector am I lightest in?"
- "Which holding gained the most and which lost the most?"