DEV Community

Richard Fu
Richard Fu

Posted on • Originally published at richardfu.net on

I Built a Free AI Portfolio Assistant That Emails Me Every Morning

I wanted to start building my investment portfolio by the end of the year. The problem? I don’t have time to monitor stock prices, read financial news, and analyze market data every day — and I don’t have the expertise to do it well.

What I wanted was simple: wake up, check my email, and know exactly what’s happening with my portfolio and what I should buy. And if something changes during the day — a price dip, a strengthening signal — I want to know before I miss it.

I looked at a lot of open-source projects. Some did portfolio tracking. Some did news aggregation. Some did basic alerts. But none did everything I wanted, and none were flexible enough to customize the output. I wanted full control over what data gets analyzed, how the AI reasons about it, and exactly what gets sent to me.

Most importantly — I wanted it free. And it turns out that’s entirely achievable with Yahoo Finance, Gemini’s free tier, and GitHub Actions.

So I built Richfolio — a zero-maintenance AI portfolio assistant — with Claude in a few sessions.

What It Does

Richfolio is a single TypeScript pipeline. It runs once, produces a report, and exits. No API server, no database, no background processes.

Every morning at 8am , GitHub Actions triggers the pipeline:

  1. Fetches live prices, P/E ratios, 52-week ranges, ETF holdings (Yahoo Finance)
  2. Computes technical indicators — SMA50, SMA200, RSI, momentum (Yahoo Finance chart data)
  3. Pulls news headlines per ticker (NewsAPI)
  4. Analyzes allocation gaps, P/E signals, ETF overlap against my target portfolio
  5. Sends everything to Gemini, which returns ranked buy recommendations with confidence scores, reasoning, and limit order prices
  6. Delivers via email and Telegram

The daily morning brief email

Every 2–3 hours during market hours , it re-runs in intraday mode — comparing against the morning baseline and only alerting me when signals strengthen. No change = no message.

Every Monday , a weekly rebalancing report shows what’s drifted from target — BUY, TRIM, or OK.

Daily brief, intraday alert, weekly rebalance

The Pipeline

The whole system is one index.ts that wires independent modules together:


const tickers = allUniqueTickers();
const prices = await fetchAllPrices(tickers);
const report = runAnalysis(prices);

// Daily mode: full brief with news + AI + technicals
const [news, technicals] = await Promise.all([
  fetchNews(tickers),
  fetchTechnicals(tickers),
]);
const aiRecs = await aiAnalyze(report, prices, news, technicals);

// Save morning baseline for intraday comparison
saveBaseline({
  timestamp: new Date().toISOString(),
  recommendations: aiRecs,
  prices: priceMap,
});

await sendBrief(report, news, aiRecs, technicals);
await sendTelegramBrief(report, news, aiRecs, technicals);

Enter fullscreen mode Exit fullscreen mode

Each module does one thing — fetch prices, compute technicals, run AI, send email. They communicate through typed interfaces and don’t know about each other. This makes it trivial to add new data sources or delivery channels.

Technical Indicators From Scratch

I didn’t want to add a charting library dependency for what’s essentially just math. Richfolio fetches 250 days of daily price data from Yahoo Finance and computes everything directly:


function computeSMA(prices: number[], period: number): number | null {
  if (prices.length < period) return null;
  const slice = prices.slice(-period);
  return slice.reduce((sum, p) => sum + p, 0) / period;
}

function computeRSI(prices: number[], period: number = 14): number | null {
  if (prices.length < period + 1) return null;

  const recent = prices.slice(-(period + 1));
  let avgGain = 0;
  let avgLoss = 0;

  for (let i = 1; i < recent.length; i++) {
    const change = recent[i] - recent[i - 1];
    if (change > 0) avgGain += change;
    else avgLoss += Math.abs(change);
  }

  avgGain /= period;
  avgLoss /= period;

  if (avgLoss === 0) return 100;
  const rs = avgGain / avgLoss;
  return 100 - 100 / (1 + rs);
}

Enter fullscreen mode Exit fullscreen mode

From these primitives, I classify each ticker’s momentum:


let momentumSignal: "bullish" | "bearish" | "neutral" = "neutral";

if (currentPrice > sma50 && (sma200 == null || sma50 > sma200) && rsi14 > 40) {
  momentumSignal = "bullish";
} else if (currentPrice < sma50 && sma200 != null && sma50 < sma200 && rsi14 < 60) {
  momentumSignal = "bearish";
}

Enter fullscreen mode Exit fullscreen mode

It also tracks 7-day and 30-day lows as support levels, and detects golden/death crosses (SMA50 crossing SMA200). All of this feeds into the AI prompt for smarter recommendations.

The AI Prompt

The AI layer is where everything comes together. Gemini receives the full context for every ticker — fundamentals, technicals, allocation data, and news — in a structured prompt:


function buildPrompt(report, priceData, news, technicals) {
  const tickerSummaries = report.items.map((item) => {
    const quote = priceData[item.ticker];
    const tech = technicals[item.ticker];

    const lines = [
      `${item.ticker}:`,
      ` Price: $${item.price.toFixed(2)}`,
      ` Trailing P/E: ${quote?.trailingPE?.toFixed(1) ?? "N/A"}`,
      ` 52-week position: ${(item.fiftyTwoWeekPercent * 100).toFixed(0)}%`,
      ` Current allocation: ${item.currentPct.toFixed(1)}% (target: ${item.targetPct.toFixed(1)}%, gap: ${item.gapPct.toFixed(1)}%)`,
    ];

    if (tech) {
      lines.push(` Technical indicators:`);
      lines.push(` 50-day MA: $${tech.sma50} (price ${tech.priceVsSma50}% vs MA)`);
      lines.push(` RSI(14): ${tech.rsi14}`);
      lines.push(` Momentum: ${tech.momentumSignal}`);
      lines.push(` 7-day low: $${tech.recentLow7d}, 30-day low: $${tech.recentLow30d}`);
    }

    return lines.join("\n");
  });
  // ... instructions follow
}

Enter fullscreen mode Exit fullscreen mode

The instructions tell Gemini to consider allocation need AND valuation together — a small gap with great valuation should rank above a large gap with poor valuation. It also asks for limit order prices based on nearby support levels:


8. For STRONG BUY and BUY tickers, suggest a limit order price slightly below
   current market. Base it on the nearest support level: 50-day MA, recent
   7d/30d low, or a round number.
9. Use technical indicators (MA, RSI, momentum) to refine confidence. A bullish
   momentum signal with oversold RSI strengthens a buy case.

Enter fullscreen mode Exit fullscreen mode

Gemini returns structured JSON using a defined schema, so parsing is reliable:


const response = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: prompt,
  config: {
    responseMimeType: "application/json",
    responseSchema,
  },
});

const recommendations = JSON.parse(response.text ?? "[]");

Enter fullscreen mode Exit fullscreen mode

Each recommendation includes an action (STRONG BUY/BUY/HOLD/WAIT), confidence score, reasoning, suggested dollar amount, limit order price, and the reasoning behind the limit price. No free-text parsing needed.

If Gemini is down or quota is exhausted, the system falls back to gap-based recommendations. The brief still gets delivered.

ETF Overlap Detection

One feature I’m particularly proud of: if I hold individual stocks that are also inside my ETFs, the system detects the overlap and adjusts buy suggestions.


// ETF overlap discount
if (quote.holdings && suggestedBuyValue > 0) {
  for (const h of quote.holdings) {
    const heldShares = currentHoldings[h.symbol] ?? 0;
    const heldQuote = priceData[h.symbol];
    if (heldShares > 0 && heldQuote) {
      const heldValue = heldShares * heldQuote.price;
      const etfExposure = h.holdingPercent * suggestedBuyValue;
      overlapDiscount += Math.min(etfExposure, heldValue);
    }
  }
  suggestedBuyValue -= overlapDiscount;
}

Enter fullscreen mode Exit fullscreen mode

Example: VOO contains ~7% AAPL. If I hold $8,000 in AAPL and VOO’s suggested buy is $10,000, the overlap is min(7% × $10,000, $8,000) = $700. VOO’s suggestion drops to $9,300. This prevents over-concentrating in stocks I already hold directly.

Intraday Alerts: Don’t Miss the Dip

The morning brief is great, but markets move. The intraday system saves the morning AI recommendations as a baseline, then re-runs every 2–3 hours and compares:


const ACTION_RANK = { WAIT: 0, HOLD: 1, BUY: 2, "STRONG BUY": 3 };

for (const rec of currentRecs) {
  const morning = baselineMap.get(rec.ticker);
  const confidenceDelta = rec.confidence - (morning?.confidence ?? 0);
  const actionUpgraded = ACTION_RANK[rec.action] > ACTION_RANK[morning?.action ?? "WAIT"];

  // Trigger 1: Confidence jumped significantly
  if (confidenceDelta >= config.confidenceIncreaseThreshold &&
      rec.confidence >= config.minConfidenceToAlert) {
    triggerType = "confidence_increase";
  }

  // Trigger 2: Action upgraded (e.g., BUY → STRONG BUY)
  if (actionUpgraded && rec.confidence >= config.minConfidenceToAlert) {
    triggerType = "action_upgrade";
  }

  // Trigger 3: New signal that wasn't in morning recs
  if (!morning && rec.confidence >= config.minConfidenceToAlert) {
    triggerType = "new_signal";
  }
}

Enter fullscreen mode Exit fullscreen mode

An alert fires only when something actually improved — confidence increased by 5+ points, an action upgraded, or a new strong signal appeared. All thresholds are configurable. No alert = no message. I only hear from it when it matters.

An intraday alert when SMH's signal strengthened during the day

The Stack (All Free)

This was a key constraint. I wanted zero recurring costs. Here’s what makes it possible:

Service Purpose Free Tier
Yahoo Finance (yahoo-finance2) Prices, fundamentals, technicals, ETF holdings Unlimited
Google Gemini 2.5 Flash AI recommendations + limit prices 250 req/day
NewsAPI Headlines per ticker 100 req/day
Resend HTML email delivery 3,000/month
Telegram Bot API Mobile alerts Unlimited
GitHub Actions Scheduled cron 2,000 min/month

The runtime is TypeScript + Node.js , executed with tsx — no build step, no compilation. Each module is independent and the whole pipeline runs in ~30 seconds.

For technical indicators, I computed SMA, RSI, and momentum from raw chart data — no charting library needed. For Telegram, I use native fetch instead of adding an npm package. The whole project has only 4 runtime dependencies: yahoo-finance2, @google/genai, resend, and dotenv.

Architecture


src/
├── config.ts # Typed loader for config.json + .env
├── index.ts # Entry point — wires everything together
├── fetchPrices.ts # Yahoo Finance: price, P/E, 52w, beta, dividends, ETF holdings
├── fetchTechnicals.ts # Yahoo Finance chart: SMA50, SMA200, RSI, momentum
├── fetchNews.ts # NewsAPI with ticker-to-company-name mapping
├── analyze.ts # Allocation gaps, P/E signals, ETF overlap, portfolio metrics
├── aiAnalysis.ts # Gemini prompt builder + JSON schema + response parser
├── state.ts # Morning baseline save/load for intraday comparison
├── intradayCompare.ts # Compare current vs morning, detect strengthening
├── email.ts # Daily HTML email template + Resend delivery
├── intradayEmail.ts # Intraday alert email (triggered only)
├── weeklyEmail.ts # Weekly rebalancing email + Resend
└── telegram.ts # Telegram Bot API (daily + intraday + weekly formatters)

Enter fullscreen mode Exit fullscreen mode

Every module exports typed interfaces (QuoteData, TechnicalData, AllocationItem, AIBuyRecommendation, IntradayAlert) and communicates through them. No shared state, no side effects. If a new data source or delivery channel is needed, it’s just a new file that plugs into index.ts.

Configuration

Your portfolio is defined in a single config.json:


{
  "targetPortfolio": {
    "VOO": 20,
    "QQQ": 15,
    "GLD": 10,
    "BSV": 20,
    "BTC": 1.5
  },
  "currentHoldings": {
    "AAPL": 30,
    "VOO": 1,
    "BTC": 0.0002
  },
  "totalPortfolioValueUSD": 50000
}

Enter fullscreen mode Exit fullscreen mode

Target allocations are percentages. Current holdings include stocks not in your target (like AAPL above) — these are tracked for ETF overlap detection. Crypto tickers like BTC are automatically converted to BTC-USD for Yahoo Finance.

In GitHub Actions, this file is stored as a repository variable (CONFIG_JSON) so your portfolio data stays private.

What I Learned

You don’t need a Bloomberg terminal. Yahoo Finance freely provides everything from real-time prices to earnings history to ETF holdings to 250 days of chart data. The AI layer just synthesizes it into actionable recommendations.

Scheduled pipelines are underrated. No server, no uptime concerns, no costs. GitHub Actions runs the cron, the pipeline executes in ~30 seconds, and I get my email. If it fails, it just retries next time.

Free tiers are generous for personal tools. I use ~3 of 3,000 monthly Resend emails and ~10 of 250 daily Gemini requests. The whole stack runs comfortably within free limits.

Technical data makes the AI smarter. Adding SMA, RSI, and momentum to the Gemini prompt noticeably improved recommendations. Instead of just “the allocation gap is large,” the AI now says things like “price near 50-day MA support with RSI at 38 — good entry point for a limit order at $217.”

Structured JSON output from Gemini is a game-changer. Using responseSchema means the AI returns exactly the fields I need — no regex parsing, no “oops it returned markdown this time.” Every response is type-safe and immediately usable.

Graceful degradation matters. Every optional service (Gemini, NewsAPI, Telegram) can be missing or down, and the system still works. The brief adapts to what’s available instead of failing entirely.

Try It

Richfolio is open source. Fork the repo, add your config and API keys, and you’ll have your own AI portfolio assistant by tomorrow morning. Setup takes about 10 minutes.

GitHub: github.com/furic/richfolio

Docs: furic.github.io/richfolio

It’s been running daily and I genuinely look forward to checking my email every morning now — which is probably the first time I’ve ever said that.

The post I Built a Free AI Portfolio Assistant That Emails Me Every Morning appeared first on Richard Fu.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.