> Source URL: /unit-3/project-paths/nate-m/nate-m-2026-04-23.guide
# 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](https://flowbite.com/docs/plugins/charts/#data-series) — Tailwind styling on top of the open-source [ApexCharts](https://apexcharts.com/) 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

- [ ] Open `templates/index.html`
- [ ] In the `<head>`, right after the Tailwind CDN `<script>`, add the ApexCharts CDN tag (below)
- [ ] Refresh the page — nothing should look different yet. Open the browser devtools console and type `ApexCharts` — you should see the class, not `undefined`

#### Hints

```html
<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

- [ ] In `templates/index.html`, find the `<section>` that contains `"Allocation by industry"` and loops over `alloc.items()`
- [ ] Replace the `<ul>...</ul>` inside that section with `<div id="allocation-chart"></div>`
- [ ] At the bottom of the file, just before `</body>`, add a `<script>` block that reads `alloc` via Jinja and renders a donut (snippet below)
- [ ] Refresh — you should see a donut with one wedge per industry and a legend underneath

#### Hints

**Keep the section header, swap the list:**

```html
<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>`):**

```html
<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:**
>
> ```text
> 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

- [ ] Add a new `<section>` above or below the donut with `<div id="gains-chart"></div>`
- [ ] Add another `<script>` block (or extend the one from Phase 2) that reads `gains` via Jinja and renders a bar chart (snippet below)
- [ ] Refresh — you should see one column per ticker, colored by sign

#### Hints

**New section:**

```html
<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:**

```html
<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`:

```js
colors: [
  ({ value }) => (value >= 0 ? "#10b981" : "#f43f5e"),
],
```

> **Optional — get help from your agent:**
>
> ```text
> 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](../makayla-c/makayla-c-2026-04-21.guide.md) 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

- [ ] Run `uv add openai pydantic`
- [ ] Get your OpenAI API key and put it in a `.env` file at the project root: `OPENAI_API_KEY=sk-...`
- [ ] Run `uv add python-dotenv` (so the key loads automatically)
- [ ] Create `ai.py` at the project root with the code below
- [ ] Run `uv run python ai.py` — it should print a short answer to the hardcoded test question

#### Hints

**`ai.py`:**

```python
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

- [ ] In `portfolio.py`, add `summarize(portfolio)` — returns a short plain-text summary the LLM can read (one line per holding plus totals). Keep this with the other business logic.
- [ ] In `app.py`, add `from portfolio import summarize` and `from ai import get_portfolio_answer`
- [ ] Add a `/ask` route (POST, JSON in, JSON out) — see hint below
- [ ] At the bottom of `templates/index.html`, add a chat form + `<div id="chat-log">` + a small `<script>` that POSTs to `/ask` via `fetch` and appends replies

#### Hints

**`portfolio.py` — new function (handwrite):**

```python
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:**

```python
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.

```html
<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):
>
> ```text
> 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

- [ ] With the app running, try these 3 questions and read the answers:
  - "What's my biggest concentration risk?"
  - "Which sector am I lightest in?"
  - "Which holding gained the most and which lost the most?"
- [ ] If answers are too vague, tighten the system prompt (ask for ticker + number in every answer, cap at 2 sentences, etc.)
- [ ] If answers start giving advice ("you should buy more X"), add an explicit "do not recommend trades" line

---

## Checkpoint 3 Readiness

By Friday May 1 at 3:30pm:

- [ ] Allocation donut chart renders from real data
- [ ] Gain/loss column chart renders from real data
- [ ] `README.md` updated with a screenshot of the dashboard
- [ ] _If you built the chatbot:_ `OPENAI_API_KEY` is loaded from `.env`, not hardcoded, and `.env` is in `.gitignore` (add a `.env.example` for reference)
- [ ] Checkpoint 3 entry in `project.journal.md`
- [ ] Committed and pushed

## Helpful Resources

- [Checkpoint 3 Instructions](../../projects/final-project-checkpoint-3.project.md)
- [Flowbite Charts docs](https://flowbite.com/docs/plugins/charts/#data-series)
- [ApexCharts options reference](https://apexcharts.com/docs/options/)
- [Jinja `|tojson` filter](https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.tojson)
- [OpenAI Structured Outputs (Responses API + Pydantic)](https://platform.openai.com/docs/guides/structured-outputs)


---

## Backlinks

The following sources link to this document:

- [April 23 — Charts + chatbot stretch](/unit-3/project-paths/projects.path.llm.md)
- [April 23 -- Charts + chatbot stretch](/unit-3/project-paths/nate-m/nate-m.path.llm.md)
- [April 23 - Charts + chatbot stretch](/unit-3/projects/showcase/nate-m.project.llm.md)
