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/apexcharts@3.46.0/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?"

Checkpoint 3 Readiness

By Friday May 1 at 3:30pm:

Helpful Resources